Repository Public
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