serveMcp
uses node:child_process
Serve MCP on stdin/stdout. Each tool runs a tcm command (this same program: bin) in root, so a tool does
exactly what the command does at a terminal, without its output reaching the protocol channel. One that finishes
soon (reading and editing the codebase: WORKER_COMMANDS) runs in a worker that stays up, with no tcm to start;
the rest, and any while the worker is busy, in a tcm of their own. They report briefly (TCM_BRIEF=1): an agent
reads every line, so what it can ask for (dependents by name, lint warnings) is a count. A call with an option its
tool doesn't have is refused, naming the build (build, the hash of tcm's code) and the options it has.
import { sessionDirFor } from "#codebase/tcmts/cli/codebase"; import { BRANCH_TOOLS, branchOption, briefOutput, callLive, INSTRUCTIONS, jobsDirFor, LIVE_TOOLS, liveArgv, liveMcpServers, liveOption, MAX, mcpWorker, moveSession, next, onBranch, optionsProblem, plain, restoreSessionBranch, startJob, TOOLS, WORKER_COMMANDS, } from "#codebase/tcmts/cli/mcp"; import type { Ctx, Result, Tool } from "#codebase/tcmts/cli/mcp"; import { IR_VERSION } from "#codebase/tcmts/core/hash"; import { spawn } from "node:child_process"; import path_2 from "node:path"; export function serveMcp( root: string, bin: string[], version: string, branch?: string, build?: string, ) { // what the supervisor told this server, not the commands it runs: a test of `tcm mcp` they run is a server of its // own, not this one's child const direct = !!process.env.TCM_MCP_DIRECT; delete process.env.TCM_MCP_DIRECT; delete process.env.TCM_MCP_SERVER; // a server answering a `live` call (`callLive`): every command it runs is the codebase's own tcm's const liveServer = !!process.env.TCM_MCP_LIVE; delete process.env.TCM_MCP_LIVE; // the supervisor (`tcm mcp`), whose record says which branch the session is on; and whether it went on with the // branch of an earlier session from the same directory const supervisor = Number(process.env.TCM_MCP_PID) || undefined; const resumed = !!process.env.TCM_MCP_RESUMED; delete process.env.TCM_MCP_PID; delete process.env.TCM_MCP_RESUMED; const worker = mcpWorker(bin, root); process.stdin.on("end", () => worker.close()); const tcm = async ( argv: string[], onLine?: (line: string) => void, env: Record<string, string> = {}, ) => liveServer ? await own(liveArgv(argv), onLine, env) : ((WORKER_COMMANDS.has(argv[0] ?? "") ? await worker.run(argv, env, onLine) : undefined) ?? (await own(argv, onLine, env))); const own = (argv: string[], onLine?: (line: string) => void, env: Record<string, string> = {}) => new Promise<{ code: number; out: string }>((resolve) => { const child = spawn(bin[0]!, [...bin.slice(1), ...argv], { cwd: root, env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0", TCM_BRIEF: "1", ...env }, stdio: ["ignore", "pipe", "pipe"], }); let out = ""; let heard = 0; const hear = (d: Buffer) => { out += d; if (!onLine) return; const end = out.lastIndexOf("\n"); if (end < heard) return; for (const line of plain(out.slice(heard, end)).split("\n")) if (line.trim()) onLine(line); heard = end + 1; }; child.stdout.on("data", hear); child.stderr.on("data", hear); child.on("close", (code) => resolve({ code: code ?? 1, out: plain(out).trim() })); }); const ctx: Ctx = { root, tcm, ...(branch ? { branch } : {}) }; // a command started in the background: a process of its own (`startJob`), its output kept in the session's // directory as it comes, for tcm_job, here or in a server started after this one stops const startCommandJob = (argv: string[]) => startJob( jobsDirFor(root, branch), { argv }, { cmd: [...bin, ...argv], cwd: root, env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0", TCM_BRIEF: "1" }, }, ); // a tool that runs several commands (tcm_merge), or one the codebase's own tcm answers (`live`), in the // background: the call made of a server of its own (`tcm mcp`, run by the job's process), what it prints as it goes // kept, then what it says const startToolJob = ( name: string, argv: string[], args: Record<string, unknown>, live: boolean, ) => startJob( jobsDirFor(root, branch), { argv, tool: live ? `${name} (live)` : name }, { cmd: [...bin, ...(live ? liveArgv(["mcp"]) : ["mcp"])], cwd: root, env: { ...process.env, ...(direct ? { TCM_MCP_DIRECT: "1" } : {}), ...(live ? { TCM_MCP_LIVE: "1" } : {}), TCM_MCP_SERVER: "1", NO_COLOR: "1", }, call: { name, arguments: args }, }, ); // the session moved onto another branch: every command from now on is on it (the worker started again for it), and // a server the supervisor starts again is too (its record says so) const switchTo = (to: string | undefined): Result => { if (!to) return { text: 'tcm_cli ["switch", "<branch>"] moves this session onto that branch (tcm_status lists the ones holding work main doesn\'t have)', isError: true, }; const record = supervisor ? liveMcpServers(root).find((s) => s.pid === supervisor) : undefined; if (!record) return { text: "this server runs without `tcm mcp`'s supervisor, which says which branch a session is on: start it with `tcm mcp --branch <it>` instead", isError: true, }; const moved = moveSession(root, record, to); if (moved.moved) { branch = to; ctx.branch = to; process.env.TCM_BRANCH = to; worker.close(); } return { text: moved.text, isError: !moved.moved }; }; const send = (msg: unknown) => process.stdout.write(JSON.stringify(msg) + "\n"); // a tool's options as listed (the ones that only read take `branch`, and the ones the codebase's own tcm can answer // `live`) const inputOf = ({ name, inputSchema }: Tool): Record<string, unknown> => ({ ...inputSchema, properties: { ...(inputSchema.properties as Record<string, unknown>), ...(BRANCH_TOOLS.has(name) ? { branch: branchOption } : {}), ...(LIVE_TOOLS.has(name) ? { live: liveOption } : {}), }, }); const handle = async (m: { id?: number | string; method?: string; params?: Record<string, unknown>; }) => { const reply = (result: unknown) => m.id !== undefined && send({ jsonrpc: "2.0", id: m.id, result }); const fail = (code: number, message: string) => m.id !== undefined && send({ jsonrpc: "2.0", id: m.id, error: { code, message } }); switch (m.method) { case "initialize": return reply({ protocolVersion: (m.params?.protocolVersion as string | undefined) ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "tcm", version: `${version} (hash format v${IR_VERSION})` }, instructions: branch ? `${INSTRUCTIONS}\n\nThis is a session on its own branch, ${branch}: your changes stay on it, and out of the repository's files, while other agents work on other branches of the same codebase. Its scratch files are its own, in ${path_2.relative(root, sessionDirFor(root, branch)).split(path_2.sep).join("/")}/ (the branch's name with each character other than a letter, digit, _, . or - made _): a scratch file named by itself (scratch.ts) is there, and tcm_edit says the path of the one it writes. When your change is done and its tests pass, say so and ask the user whether to land it: it stays on this branch until they say to, as work on a git branch does. Only once they've told you to land it do you call tcm_merge (with approved: true), which lands it on main (merging main in first, then testing); never land work on your own initiative. A tcm you run yourself, outside these tools (in a shell), works on the codebase's own branch: give it TCM_BRANCH=${branch} to work on this one.${resumed ? " The branch is the one this directory's last session left holding its work (its server stopped: a restart, or a reconnect), so you go on with that work: tcm_status says what the branch has that main doesn't." : ""} To look at another branch (another session's work, or a side of a conflict) without moving onto it, give the tools that only read \`branch\` (tcm_status, tcm_view, tcm_search, tcm_find, tcm_history, tcm_todo, tcm_deps, tcm_dependents, tcm_docs; the CLI's \`--branch\`): they read it as it is and change nothing, this session and its files staying as they are. If your work is on another branch (an earlier session's, which tcm_status lists), tcm_cli ["switch", "<branch>"] moves this session onto it, if no other session's server is on it.` : direct ? `${INSTRUCTIONS}\n\nThis server works on the codebase's own branch directly (\`tcm mcp --direct\`), not a session: your changes land on it as you save them (and reach the files it writes), with no merging to do.` : INSTRUCTIONS, }); case "ping": return reply({}); case "tools/list": return reply({ tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: inputOf(t), })), }); case "tools/call": { const tool = TOOLS.find((t) => t.name === m.params?.name); if (!tool) return fail(-32602, `no tool ${String(m.params?.name)}`); const args = (m.params?.arguments ?? {}) as Record<string, unknown>; // an option it doesn't have isn't ignored: the call would do something else (a `live` one is the codebase's // own tcm's to check, as it may know options this one doesn't) const problem = args.live === true && LIVE_TOOLS.has(tool.name) && !liveServer ? undefined : optionsProblem(tool.name, inputOf(tool), args, build); if (problem) return reply({ content: [{ type: "text", text: problem }], isError: true }); // `branch`: a tool that only reads reads that branch (`--branch`), the session staying on its own const reading = BRANCH_TOOLS.has(tool.name) && typeof args.branch === "string" && args.branch ? args.branch : undefined; try { // a client that asked for progress hears the command's output as it comes: some take minutes const token = (m.params?._meta as { progressToken?: string | number } | undefined) ?.progressToken; let progress = 0; const onLine = token === undefined ? undefined : (line: string) => send({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: ++progress, message: line }, }); const answer = async (): Promise<Result> => { let r: Result; // `live`: answered by the codebase's own tcm, as it is now, the whole tool (a server of its own) if (args.live === true && LIVE_TOOLS.has(tool.name) && !liveServer) { // (in the background, a job: the live server is the job's, and ends once it's answered) const { live: _, background: later, ...rest } = args; if (later === true) { const id = startToolJob(tool.name, tool.argv?.(rest) ?? [], rest, true); r = { text: `started job ${id}: ${tool.name} (live)\n\nIt runs on while you do other things: tcm_job {"id": ${id}} says how it's going, and what it has printed; tcm_job {"id": ${id}, "wait": 300} waits for it to end (up to 300 seconds), rather than asking again and again.`, }; } else r = await callLive( bin, root, tool.name, rest, direct ? { TCM_MCP_DIRECT: "1" } : {}, onLine, ); } else if (args.background === true && tool.argv) { const argv = onBranch(tool.argv(args), reading); const id = startCommandJob(argv); r = { text: `started job ${id}: tcm ${argv.join(" ")}\n\nIt runs on while you do other things: tcm_job {"id": ${id}} says how it's going, and what it printed; tcm_job {"id": ${id}, "wait": 300} waits for it to end (up to 300 seconds), rather than asking again and again.`, }; } else if ( args.background === true && tool.run && "background" in (tool.inputSchema.properties as Record<string, unknown>) ) { // (the call as given, `branch` and all, answered by the job's own server: the job is the background) const { background: _, ...rest } = args; const id = startToolJob(tool.name, [], rest, false); r = { text: `started job ${id}: ${tool.name}\n\nIt runs on while you do other things: tcm_job {"id": ${id}} says how it's going, and what it has printed; tcm_job {"id": ${id}, "wait": 300} waits for it to end (up to 300 seconds), rather than asking again and again.`, }; } else if (tool.run) r = await tool.run(args, { ...ctx, tcm: ( argv: string[], heard?: (line: string) => void, env?: Record<string, string>, ) => tcm(onBranch(argv, reading), heard ?? onLine, env), }); else { const command = onBranch(tool.argv!(args), reading); const { code, out } = await tcm(command, onLine); const hint = next(command, out, code); r = { text: [ briefOutput(command, out) || (code === 0 ? "done" : `tcm ${command[0]} failed`), hint, ] .filter(Boolean) .join("\n\n"), isError: code !== 0, }; } return r; }; let r: Result; // in a session, `switch` moves it onto another branch (one it owns: no other server's on it) const argv = Array.isArray(args.args) ? (args.args as unknown[]).map(String) : []; if (tool.name === "tcm_cli" && branch && !args.cwd && argv[0] === "switch") r = switchTo(argv[1]); else r = await answer(); // a session whose branch is gone (deleted under it, by a tcm from before that couldn't happen) has it // made again, and the call made again, rather than every call failing const gone = branch; if ( gone && r.text.includes(`no branch named '${gone}' (TCM_BRANCH)`) && restoreSessionBranch(root, gone) ) { const again = await answer(); r = { ...again, text: `(this session's branch, ${gone}, was gone: it's made again from main, its scratch files as they were, and the call made again. tcm_status lists branches holding work main doesn't have, and tcm_cli ["switch", "<branch>"] goes on with one.)\n\n${again.text}`, }; } const text = r.text.length > MAX ? `${r.text.slice(0, MAX)}\n… (${r.text.length - MAX} more characters)` : r.text; return reply({ content: [{ type: "text", text }], isError: !!r.isError }); } catch (e) { return reply({ content: [{ type: "text", text: (e as Error).message }], isError: true }); } } default: if (m.method?.startsWith("notifications/")) return; return fail(-32601, `no method ${String(m.method)}`); } }; let buffer = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk: string) => { buffer += chunk; for (let nl = buffer.indexOf("\n"); nl >= 0; nl = buffer.indexOf("\n")) { const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1); if (!line) continue; let m: Parameters<typeof handle>[0]; try { m = JSON.parse(line) as Parameters<typeof handle>[0]; } catch { send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); continue; } void handle(m); } }); return new Promise<void>((resolve) => process.stdin.on("end", () => resolve())); }
uses
- tcmts.cli.codebase.sessionDirFor
- tcmts.cli.mcp.BRANCH_TOOLS
- tcmts.cli.mcp.Ctx
- tcmts.cli.mcp.INSTRUCTIONS
- tcmts.cli.mcp.LIVE_TOOLS
- tcmts.cli.mcp.MAX
- tcmts.cli.mcp.Result
- tcmts.cli.mcp.TOOLS
- tcmts.cli.mcp.Tool
- tcmts.cli.mcp.WORKER_COMMANDS
- tcmts.cli.mcp.branchOption
- tcmts.cli.mcp.briefOutput
- tcmts.cli.mcp.callLive
- tcmts.cli.mcp.jobsDirFor
- tcmts.cli.mcp.liveArgv
- tcmts.cli.mcp.liveMcpServers
- tcmts.cli.mcp.liveOption
- tcmts.cli.mcp.mcpWorker
- tcmts.cli.mcp.moveSession
- tcmts.cli.mcp.next
- tcmts.cli.mcp.onBranch
- tcmts.cli.mcp.optionsProblem
- tcmts.cli.mcp.plain
- tcmts.cli.mcp.restoreSessionBranch
- tcmts.cli.mcp.startJob
- tcmts.core.hash.IR_VERSION