SPB Git

Repository Public

mcp.ts 12.2 KB 256 lines typescript

1/**2 * MCP IPC (runtime-fs): GET /mcp, POST /mcp/add, /mcp/remove, /mcp/test.3 *4 * Listing merges the configuration files (user + local scopes from the global `.claude.json`,5 * project scope from `<root>/.mcp.json`) with the live status printed by `claude mcp list`.6 * Env values, header values, URL credentials and query strings never leave this module, and7 * the CLI's own output (which echoes header/env values) is never forwarded or logged.8 */9import { promises as fsp } from 'node:fs';10import { z } from 'zod';11import { AddMcpServer, type McpServerInfo } from '@cr/shared';12import type { RuntimeConfig } from './config.js';13import { RtError, type RuntimePlugin } from './plugins.js';14import { assertRootAllowed, resolveInsideEx } from './paths.js';15import { defaultClaudeEnv, runClaude, type ClaudeEnv } from './fsx-claude-env.js';16import {17  buildAddArgv,18  buildGetArgv,19  buildRemoveArgv,20  parseClaudeJson,21  parseMcpGet,22  parseMcpList,23  serversFrom,24  validateAdd,25  type LiveStatus,26} from './fsx-mcp-config.js';2728import {29  buildCodexAddArgv,30  buildCodexGetArgv,31  buildCodexListArgv,32  buildCodexRemoveArgv,33  describeCodexServer,34  isCodexMcpNotFound,35  parseCodexMcpList,36  validateCodexAdd,37} from './codex-mcp.js';3839const LIST_TTL_MS = 60_000;40const LIST_TIMEOUT_MS = 45_000;41const WRITE_TIMEOUT_MS = 30_000;42const MAX_CONFIG_BYTES = 64 * 1024 * 1024;43const MAX_PROJECT_CONFIG_BYTES = 1024 * 1024;44const SCOPE_RANK: Record<McpServerInfo['scope'], number> = { local: 0, project: 1, user: 2 };4546export interface McpOptions {47  env?: ClaudeEnv;48  listTtlMs?: number;49}5051const ListQuery = z.object({52  root: z.string().min(1).optional(),53  live: z.enum(['0', '1']).optional(), // live=0 → configuration only, no CLI call54  refresh: z.enum(['0', '1']).optional(), // refresh=1 → bypass the 60 s status cache55});56const AddBody = AddMcpServer.extend({ root: z.string().min(1).optional() });57// Stricter than the shared schema: a leading "-" would be read as an option by the CLI.58const ServerName = z.string().regex(/^[A-Za-z0-9_][A-Za-z0-9_-]{0,39}$/);59const RemoveBody = z.object({60  name: ServerName,61  scope: z.enum(['user', 'project', 'local']).default('user'),62  root: z.string().min(1).optional(),63});64const TestBody = z.object({ name: ServerName, root: z.string().min(1).optional() });6566function parse<S extends z.ZodType>(schema: S, input: unknown): z.infer<S> {67  const r = schema.safeParse(input);68  // Issue messages may quote received values; only the field path is reported.69  if (!r.success) throw new RtError(400, 'bad_request', `invalid ${r.error.issues[0]?.path.join('.') || 'request'}`);70  return r.data;71}7273async function readJson(file: string, maxBytes: number): Promise<unknown> {74  try {75    const st = await fsp.stat(file);76    if (!st.isFile() || st.size > maxBytes) return null;77    return JSON.parse(await fsp.readFile(file, 'utf8')) as unknown;78  } catch {79    return null; // missing or malformed configuration = no servers from this source80  }81}8283interface StatusSnapshot {84  at: number;85  checkedAt: string | null;86  statuses: Map<string, LiveStatus>;87}8889export function createMcpPlugin(opts: McpOptions = {}): RuntimePlugin {90  return async (app, { config }) => {91    const env = opts.env ?? defaultClaudeEnv();92    const ttl = opts.listTtlMs ?? LIST_TTL_MS;93    const cache = new Map<string, Promise<StatusSnapshot>>();9495    const cwdFor = async (rootReal: string | null): Promise<string> => {96      if (rootReal) return rootReal;97      const home = await fsp.stat(env.home).catch(() => null);98      return home?.isDirectory() ? env.home : '/';99    };100101    const liveStatuses = async (cwd: string, refresh: boolean): Promise<StatusSnapshot> => {102      const hit = cache.get(cwd);103      if (hit && !refresh) {104        const snap = await hit;105        if (Date.now() - snap.at <= ttl) return snap;106      }107      const fresh = (async (): Promise<StatusSnapshot> => {108        const r = await runClaude(config, env, ['mcp', 'list'], { cwd, timeoutMs: LIST_TIMEOUT_MS, maxStdout: 1024 * 1024 });109        if (!r || r.spawnError || r.timedOut || r.code !== 0) return { at: Date.now(), checkedAt: null, statuses: new Map() };110        return { at: Date.now(), checkedAt: new Date().toISOString(), statuses: parseMcpList(r.stdout) };111      })();112      cache.set(cwd, fresh);113      return fresh;114    };115116    const requireRoot = async (root: string | undefined, scope: string): Promise<string | null> => {117      if (root) return assertRootAllowed(config, root);118      if (scope === 'user') return null;119      throw new RtError(400, 'bad_request', `root is required for ${scope} scope`);120    };121122    const claude = async (argv: string[], cwd: string, timeoutMs: number): Promise<{ code: number | null; text: string; stdout: string }> => {123      const r = await runClaude(config, env, argv, { cwd, timeoutMs, maxStdout: 256 * 1024 });124      if (!r || r.spawnError) throw new RtError(503, 'claude_unavailable', 'the claude binary could not be started');125      if (r.timedOut) throw new RtError(504, 'claude_timeout', 'claude did not answer in time');126      return { code: r.code, text: `${r.stdout}\n${r.stderr}`, stdout: r.stdout };127    };128129    // ---------------------------------------------------------------- codex profile130    // One global scope, read through `codex mcp list --json` (no TOML parsing here, and the CLI131    // does not health-check: statuses stay `unknown` unless the configuration says otherwise).132    const codex = config.agent === 'codex';133    let codexList: { at: number; value: Promise<McpServerInfo[]> } | null = null;134    const codexServers = (refresh: boolean): Promise<McpServerInfo[]> => {135      if (codexList && !refresh && Date.now() - codexList.at <= ttl) return codexList.value;136      const value = (async (): Promise<McpServerInfo[]> => {137        const r = await claude(buildCodexListArgv(), await cwdFor(null), LIST_TIMEOUT_MS);138        const list = r.code === 0 ? parseCodexMcpList(r.stdout, new Date().toISOString()) : null;139        if (!list) throw new RtError(502, 'mcp_list_failed', `codex mcp list failed (exit ${r.code ?? 'signal'})`);140        return list;141      })();142      const entry = { at: Date.now(), value };143      codexList = entry;144      value.catch(() => {145        if (codexList === entry) codexList = null;146      });147      return value;148    };149150    app.get('/mcp', async (req): Promise<McpServerInfo[]> => {151      const q = parse(ListQuery, req.query);152      if (codex) {153        if (q.root) await assertRootAllowed(config, q.root);154        // Copies: the cached objects are never handed out.155        return (await codexServers(q.refresh === '1')).map((s) => ({ ...s, envKeys: [...s.envKeys] }));156      }157      const rootReal = q.root ? await assertRootAllowed(config, q.root) : null;158      const doc = await readJson(env.claudeJson, MAX_CONFIG_BYTES);159      const servers = parseClaudeJson(doc, rootReal ? [...new Set([rootReal, q.root as string])] : []);160      if (rootReal) {161        const file = await resolveInsideEx(config, rootReal, '.mcp.json');162        if (file.exists && file.stats?.isFile()) servers.push(...serversFrom(await readJson(file.abs, MAX_PROJECT_CONFIG_BYTES), 'project'));163      }164      servers.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : SCOPE_RANK[a.scope] - SCOPE_RANK[b.scope]));165      if (q.live === '0' || servers.length === 0) return servers;166      const snap = await liveStatuses(await cwdFor(rootReal), q.refresh === '1');167      const applied = new Set<string>();168      for (const s of servers) {169        // The CLI reports one status per name: it belongs to the highest-precedence scope.170        if (applied.has(s.name)) continue;171        applied.add(s.name);172        const live = snap.statuses.get(s.name);173        if (live) Object.assign(s, { status: live.status, lastError: live.lastError, checkedAt: snap.checkedAt });174      }175      return servers;176    });177178    app.post('/mcp/add', async (req) => {179      const body = parse(AddBody, req.body);180      const spec = { name: body.name, transport: body.transport, target: body.target, args: body.args, env: body.env, headers: body.headers, scope: body.scope };181      const reason = validateAdd(spec);182      if (reason) throw new RtError(400, 'bad_request', reason);183      if (codex) {184        const unsupported = validateCodexAdd(spec);185        if (unsupported) throw new RtError(400, 'unsupported', unsupported);186        // `codex mcp add` silently REPLACES an existing server: check first, like the Claude CLI does.187        if ((await codexServers(true)).some((s) => s.name === spec.name)) {188          throw new RtError(409, 'exists', 'an MCP server with this name already exists');189        }190        const r = await claude(buildCodexAddArgv(spec), await cwdFor(null), WRITE_TIMEOUT_MS);191        codexList = null;192        if (r.code !== 0) throw new RtError(502, 'mcp_add_failed', `codex mcp add failed (exit ${r.code ?? 'signal'})`);193        return { ok: true, name: body.name, scope: body.scope };194      }195      const rootReal = await requireRoot(body.root, body.scope);196      const cwd = await cwdFor(body.scope === 'project' ? rootReal : null);197      const r = await claude(buildAddArgv(spec), cwd, WRITE_TIMEOUT_MS);198      cache.clear();199      if (r.code !== 0) {200        if (/already exists/i.test(r.text)) throw new RtError(409, 'exists', 'an MCP server with this name already exists in this scope');201        throw new RtError(502, 'mcp_add_failed', `claude mcp add failed (exit ${r.code ?? 'signal'})`);202      }203      return { ok: true, name: body.name, scope: body.scope };204    });205206    app.post('/mcp/remove', async (req) => {207      const body = parse(RemoveBody, req.body);208      if (codex) {209        if (body.scope !== 'user') throw new RtError(400, 'unsupported', 'Codex has a single (user) MCP scope');210        const r = await claude(buildCodexRemoveArgv(body.name), await cwdFor(null), WRITE_TIMEOUT_MS);211        codexList = null;212        // Exit code 0 in both cases: the message tells whether something was removed.213        if (isCodexMcpNotFound(r.text)) throw new RtError(404, 'not_found', 'no MCP server with this name');214        if (r.code !== 0) throw new RtError(502, 'mcp_remove_failed', `codex mcp remove failed (exit ${r.code ?? 'signal'})`);215        return { ok: true, name: body.name, scope: body.scope };216      }217      const rootReal = await requireRoot(body.root, body.scope);218      const r = await claude(buildRemoveArgv(body.name, body.scope), await cwdFor(body.scope === 'user' ? null : rootReal), WRITE_TIMEOUT_MS);219      cache.clear();220      if (r.code !== 0) {221        if (/no mcp server|not found/i.test(r.text)) throw new RtError(404, 'not_found', 'no MCP server with this name in this scope');222        throw new RtError(502, 'mcp_remove_failed', `claude mcp remove failed (exit ${r.code ?? 'signal'})`);223      }224      return { ok: true, name: body.name, scope: body.scope };225    });226227    app.post('/mcp/test', async (req) => {228      const body = parse(TestBody, req.body);229      if (codex) {230        // `codex mcp get` only proves the server is configured; nothing connects to it.231        const r = await claude(buildCodexGetArgv(body.name), await cwdFor(null), LIST_TIMEOUT_MS);232        if (isCodexMcpNotFound(r.text)) throw new RtError(404, 'not_found', 'no MCP server with this name');233        if (r.code !== 0) throw new RtError(502, 'mcp_test_failed', `codex mcp get failed (exit ${r.code ?? 'signal'})`);234        let doc: unknown = null;235        try {236          doc = JSON.parse(r.stdout.slice(r.stdout.indexOf('{'), r.stdout.lastIndexOf('}') + 1));237        } catch {238          /* not JSON: status stays unknown */239        }240        const info = describeCodexServer(doc, null);241        return { status: info?.status ?? 'unknown', lastError: info?.lastError ?? null, checkedAt: new Date().toISOString() };242      }243      const rootReal = body.root ? await assertRootAllowed(config, body.root) : null;244      const r = await claude(buildGetArgv(body.name), await cwdFor(rootReal), LIST_TIMEOUT_MS);245      if (r.code !== 0) {246        if (/no mcp server/i.test(r.text)) throw new RtError(404, 'not_found', 'no MCP server with this name');247        throw new RtError(502, 'mcp_test_failed', `claude mcp get failed (exit ${r.code ?? 'signal'})`);248      }249      const live = parseMcpGet(r.text);250      return { status: live.status, lastError: live.lastError, checkedAt: new Date().toISOString() };251    });252  };253}254255export default createMcpPlugin();256
Files main
Menu

Theme