serveMcp

tcmts.cli.mcp.serveMcp · #xdmgmbuo · term · runs on the server

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

used by