src/server/mcp.ts
Rama main · réplica de lectura del repositorio original.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { dataPath, APP_ROOT } from './paths.ts';
import { dataSync } from './data-sync.ts';
import { getGlobalMcpsFromConfig } from './shraga-config.ts';
const MCP_DIR = dataPath('mcps');
export interface McpStdioServerConfig {
/** When false, server stays in config for UI / sync but is omitted from the Claude SDK. Default: true. */
enabled?: boolean;
/** Claude Code CLI expects stdio transports to declare type (see --mcp-config validation). */
type?: 'stdio';
command: string;
args?: string[];
env?: Record<string, string>;
}
export interface McpHttpServerConfig {
enabled?: boolean;
type: 'http';
url: string;
headers?: Record<string, string>;
}
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
export type McpConfig = Record<string, McpServerConfig>;
function loadJson(file: string): McpConfig {
if (!existsSync(file)) return {};
try {
return JSON.parse(readFileSync(file, 'utf-8'));
} catch {
return {};
}
}
function mcpPath(uid: string) {
return path.join(MCP_DIR, `${uid}.json`);
}
export function getGlobalMcpConfig(): McpConfig {
return getGlobalMcpsFromConfig();
}
export function getUserMcpConfig(uid: string): McpConfig {
return loadJson(mcpPath(uid));
}
function isHttpConfig(s: McpServerConfig): s is McpHttpServerConfig {
return s.type === 'http';
}
/** `$VAR` / `${VAR}` placeholders inside a string. Same dialect as stdio `env` values, but also
* substitutes when EMBEDDED (`Bearer $TOKEN`) — a header value is never the bare variable. */
const PLACEHOLDER_RE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
/** Expand placeholders; unset var → '' (same as stdio env resolution). `found` records each hit. */
function expandPlaceholders(value: string, found: string[]): string {
return value.replace(PLACEHOLDER_RE, (_m, braced, bare) => {
const v = process.env[braced || bare] || '';
found.push(v);
return v;
});
}
/** Resolve `$VAR` placeholders in an http entry's headers and url.
* Without this, an authenticated remote MCP can only be configured by INLINING the secret into
* data/mcps/*.json — which shraga auto-commits and pushes to the data repo (see saveMcpConfig →
* dataSync.trackWrite). Entries with no placeholders are returned untouched. */
function resolveHttpConfig(name: string, server: McpHttpServerConfig): McpHttpServerConfig | null {
const found: string[] = [];
const out: McpHttpServerConfig = { ...server };
if (typeof server.url === 'string') out.url = expandPlaceholders(server.url, found);
if (server.headers) {
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(server.headers)) {
headers[k] = typeof v === 'string' ? expandPlaceholders(v, found) : String(v ?? '');
}
out.headers = headers;
}
// Env-gate, mirroring stdio: an entry that DECLARES placeholders but resolves them all empty isn't
// configured for this deployment — skip it rather than mounting with a broken `Bearer ` credential.
if (found.length > 0 && found.every((v) => !v)) {
console.log(`[mcp] ${name}: skipped — required env placeholders not set in this deployment`);
return null;
}
return out;
}
/** Resolve env values: empty → process.env[same key], "$VAR" → process.env[VAR] */
function resolveEnv(config: McpConfig): McpConfig {
const resolved: McpConfig = {};
for (const [name, server] of Object.entries(config)) {
if (isHttpConfig(server)) {
const http = resolveHttpConfig(name, server);
if (http) resolved[name] = http;
continue;
}
if (!server.env) { resolved[name] = server; continue; }
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(server.env)) {
// env values can be null/undefined/non-string if a config was edited by hand
// or synced from another instance — coerce defensively so resolution never throws.
if (v == null || v === '') {
env[k] = process.env[k] || '';
} else if (typeof v !== 'string') {
env[k] = String(v);
} else if (v.startsWith('$')) {
env[k] = process.env[v.slice(1)] || '';
} else {
env[k] = v;
}
}
// Env-gate: a server that DECLARES env keys but resolves them all empty isn't configured for
// this deployment (e.g. an MCP that needs STRIPE_SECRET_KEY when none is set) — skip it instead
// of mounting → failing every turn. Servers with no declared env, or any value present, pass.
if (Object.keys(env).length > 0 && Object.values(env).every((v) => !v)) {
console.log(`[mcp] ${name}: skipped — required env (${Object.keys(env).join(', ')}) not set in this deployment`);
continue;
}
resolved[name] = { ...server, env };
}
return resolved;
}
const MASK_CHAR = '••••';
function maskValue(v: string): string {
if (!v || v.length <= 8) return MASK_CHAR;
return `${v.slice(0, 4)}${MASK_CHAR}${v.slice(-4)}`;
}
function isMasked(v: string): boolean {
return v.includes(MASK_CHAR);
}
/** Mask env values for client display */
export function maskEnvValues(config: McpConfig): McpConfig {
const masked: McpConfig = {};
for (const [name, server] of Object.entries(config)) {
if (isHttpConfig(server)) { masked[name] = server; continue; }
if (!server.env) { masked[name] = server; continue; }
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(server.env)) {
env[k] = v ? maskValue(v) : '';
}
masked[name] = { ...server, env };
}
return masked;
}
/** Merge incoming config, preserving original values for masked fields */
export function mergeWithOriginal(incoming: McpConfig, original: McpConfig): McpConfig {
const merged: McpConfig = {};
for (const [name, server] of Object.entries(incoming)) {
if (isHttpConfig(server)) { merged[name] = server; continue; }
if (!server.env) { merged[name] = server; continue; }
const orig = original[name];
const origEnv = (orig && !isHttpConfig(orig) ? orig.env : undefined) || {};
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(server.env)) {
if (!isMasked(v)) {
env[k] = v;
continue;
}
const saved = origEnv[k];
env[k] =
saved != null && saved !== ''
? saved
: (process.env[k] || '');
}
merged[name] = { ...server, env };
}
return merged;
}
/** Global MCPs merged with per-user overrides (raw, no env resolution) */
export function getRawMcpConfig(uid: string): McpConfig {
return { ...getGlobalMcpConfig(), ...getUserMcpConfig(uid) };
}
/** Ensure stdio MCP entries match CLI schema (avoids silent drops / validation issues). */
function withStdioType(config: McpConfig): McpConfig {
const out: McpConfig = {};
for (const [name, server] of Object.entries(config)) {
if (isHttpConfig(server)) { out[name] = server; continue; }
if (server.command && !server.type) out[name] = { type: 'stdio', ...server };
else out[name] = server;
}
return out;
}
/** Canonical path baked into MCP env when prod has the file deployed (cwd = app dir). */
const GOOGLE_SA_DEPLOY_REL = './secrets/google-service-account.json';
/**
* Resolve GOOGLE_SERVICE_ACCOUNT for any MCP that declares it in env.
* - Inline JSON when env starts with `{`.
* - Fall back to `secrets/google-service-account.json` when the path is bogus.
*/
function finalizeGoogleServiceAccountCredentials(config: McpConfig): McpConfig {
const jsonFromEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON?.trim();
const defaultAbs = path.join(APP_ROOT, 'secrets/google-service-account.json');
const defaultExists = existsSync(defaultAbs);
let result = { ...config };
for (const [name, entry] of Object.entries(result)) {
if (isHttpConfig(entry)) continue;
if (!entry.env || !('GOOGLE_SERVICE_ACCOUNT' in entry.env)) continue;
const accountFromResolve = entry.env.GOOGLE_SERVICE_ACCOUNT?.trim() ?? '';
if (jsonFromEnv?.startsWith('{')) {
result = { ...result, [name]: { ...entry, env: { ...entry.env, GOOGLE_SERVICE_ACCOUNT: jsonFromEnv } } };
continue;
}
if (accountFromResolve.startsWith('{')) continue;
const raw = accountFromResolve;
const pathOk =
raw &&
!raw.includes('${') &&
existsSync(path.isAbsolute(raw) ? raw : path.resolve(APP_ROOT, raw.replace(/^\.\//, '')));
if (pathOk) continue;
if (!defaultExists) continue;
result = { ...result, [name]: { ...entry, env: { ...entry.env, GOOGLE_SERVICE_ACCOUNT: GOOGLE_SA_DEPLOY_REL } } };
}
return result;
}
/**
* For MCP entries that declare FIREBASE_DATABASE_URL + FIREBASE_SERVICE_ACCOUNT_JSON in env:
* resolve from FIREBASE_CONFIG_* JSON blobs when the dedicated env vars are empty.
*/
function finalizeFirebaseCredentials(config: McpConfig): McpConfig {
let result = { ...config };
for (const [name, entry] of Object.entries(result)) {
if (isHttpConfig(entry)) continue;
if (!entry.env || !('FIREBASE_DATABASE_URL' in entry.env)) continue;
const patches: Record<string, string> = {};
const isEmpty = (v?: string) => !v || v.startsWith('${');
if (isEmpty(entry.env.FIREBASE_DATABASE_URL)) {
const suffix = name.includes('lab') ? 'LAB' : name.includes('prod') ? 'PROD' : '';
const configKeys = suffix
? [`FIREBASE_CONFIG_${suffix}`, `VITE_FIREBASE_CONFIG_${suffix}`, 'FIREBASE_CONFIG']
: ['FIREBASE_CONFIG'];
const configJson = configKeys.map(k => process.env[k]).find(v => v?.trim());
if (configJson) try {
const parsed = JSON.parse(configJson);
if (parsed.databaseURL) patches.FIREBASE_DATABASE_URL = parsed.databaseURL;
} catch { /* not valid JSON */ }
}
if (isEmpty(entry.env.FIREBASE_SERVICE_ACCOUNT_JSON)) {
const suffix = name.includes('lab') ? 'LAB' : name.includes('prod') ? 'PROD' : '';
const saKeys = suffix
? [`FIREBASE_SERVICE_ACCOUNT_JSON_${suffix}`, 'FIREBASE_SERVICE_ACCOUNT_JSON']
: ['FIREBASE_SERVICE_ACCOUNT_JSON'];
const sa = saKeys.map(k => process.env[k]).find(v => v?.trim());
if (sa) patches.FIREBASE_SERVICE_ACCOUNT_JSON = sa;
}
if (Object.keys(patches).length) {
result = { ...result, [name]: { ...entry, env: { ...entry.env, ...patches } } };
}
}
return result;
}
/** Full merged MCP config (includes `enabled: false` entries) for API/UI. */
export function getResolvedMcpConfig(uid: string): McpConfig {
return finalizeFirebaseCredentials(
finalizeGoogleServiceAccountCredentials(
resolveEnv(
withStdioType(
getRawMcpConfig(uid),
),
),
),
);
}
/** Strip disabled servers and drop `enabled` before passing to Claude Code (unknown keys can break validation). */
function activeMcpConfigForSdk(config: McpConfig): McpConfig {
const out: McpConfig = {};
for (const [name, server] of Object.entries(config)) {
if (server.enabled === false) continue;
const sdkServer = { ...server };
delete (sdkServer as { enabled?: boolean }).enabled;
out[name] = sdkServer;
}
return out;
}
/** Active MCPs only — used by agent, schedulers, webhooks, CLI sync. */
export function getMcpConfig(uid: string): McpConfig {
return activeMcpConfigForSdk(getResolvedMcpConfig(uid));
}
/** Resolved, SDK-ready GLOBAL mcp config (no per-user overlay) — for boot/background catalog warm-up.
* The slow/shared servers are global, so warming this
* populates their catalog entries off the turn path. Per-user-only servers warm on first use. */
export function getGlobalMcpConfigForSdk(): McpConfig {
return activeMcpConfigForSdk(
finalizeFirebaseCredentials(
finalizeGoogleServiceAccountCredentials(
resolveEnv(
withStdioType(
getGlobalMcpConfig(),
),
),
),
),
);
}
export function saveMcpConfig(uid: string, config: McpConfig): void {
mkdirSync(MCP_DIR, { recursive: true });
writeFileSync(mcpPath(uid), JSON.stringify(config, null, 2));
dataSync.trackWrite(`mcps/${uid}.json`);
}