diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5b471..57916c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Versions before 0.8.0 are in the [git tags](https://github.com/leemour/cli-core/ Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## 0.16.0 — 02.10.2026 + +### Added + +- **`@leemour/cli-core/mcp` installs and checks local stdio MCP entries.** A CLI can add its existing + MCP server to Codex or Claude Code through those clients' own commands and probe the handshake and + tool list without calling a tool. Existing entries are refused, so callers must remove one in the + client before changing it. + Client commands use `cross-spawn` so npm's Windows `.cmd` shims can be started. + ## 0.15.0 — 01.10.2026 ### Added diff --git a/README.md b/README.md index 6650f07..aa6ec35 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ streams.stderr // [] | `/codegen` | build time: an official API description → types, Valibot schemas, an operation manifest and a coverage page — see below | | `/release` | release time: `releaseCheck` and the checks it runs — changelog shape, links, package contents, version in step — see below | | `/skill` | the CLI's SKILL.md for coding agents: `skillCommand` (`show`, `install`), `skillHint`, `skillResource` — see below | +| `/mcp` | local stdio MCP setup for Codex and Claude Code, plus a read-only handshake and tool-list probe | **Nothing in the root export is HTTP.** Status classification, `Retry-After` parsing and the fetch seam live in `@leemour/cli-core/http`, so a CLI that speaks a socket never depends on a stack it diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index bd9b491..a670e75 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -29,6 +29,7 @@ architecture proposal ([private repository](https://github.com/leemour/max-cli-p | `/codegen/runtime` | the number helpers generated schemas import | `valibot` | | `/release` | release checks a CLI runs before it publishes: `releaseCheck` and the checks it runs (changelog shape, document links, versions in step, package contents) | Node built-ins only | | `/skill` | the CLI's SKILL.md for agents: the `skill` command, the daily hint, the MCP resource | Commander **at run time** (the only entry point that is), `/update`'s state file, the root's `Renderer` and `Streams` types | +| `/mcp` | local stdio MCP entry setup and a handshake probe | Node child processes; no messenger or MCP SDK dependency | | `/testing` | `captureStreams`, `memoryKeyring`, `brokenKeyring`, `fakeClock` | the root | Each is a separate `exports` entry in [`package.json`](../../package.json), built from diff --git a/package.json b/package.json index 1ede394..b5e151b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@leemour/cli-core", - "version": "0.15.0", + "version": "0.16.0", "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks", "license": "MIT", "author": "Viacheslav Ptsarev", @@ -63,6 +63,10 @@ "types": "./dist/skill/index.d.ts", "default": "./dist/skill/index.js" }, + "./mcp": { + "types": "./dist/mcp/index.d.ts", + "default": "./dist/mcp/index.js" + }, "./biome": "./config/biome.base.json", "./tsconfig.base.json": "./config/tsconfig.base.json", "./tsconfig.test.json": "./config/tsconfig.test.json", @@ -87,6 +91,7 @@ }, "dependencies": { "cli-table3": "^0.6.5", + "cross-spawn": "7.0.6", "env-paths": "^4.0.0", "picocolors": "^1.1.1", "pino": "^10.3.1", @@ -97,6 +102,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", + "@types/cross-spawn": "6.0.6", "@types/node": "^22.20.4", "@vitest/coverage-v8": "^5.0.2", "commander": "^15.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1f8be6..96f404b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: cli-table3: specifier: ^0.6.5 version: 0.6.5 + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 env-paths: specifier: ^4.0.0 version: 4.0.0 @@ -27,6 +30,9 @@ importers: '@biomejs/biome': specifier: ^2.3.14 version: 2.5.14 + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/node': specifier: ^22.20.4 version: 22.20.4 @@ -532,6 +538,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -719,6 +728,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -763,6 +776,9 @@ packages: resolution: {integrity: sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==} engines: {node: '>=20'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -839,6 +855,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -882,6 +902,14 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1021,6 +1049,11 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@3.2.2: resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} engines: {node: '>=20.11'} @@ -1305,6 +1338,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 22.20.4 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -1424,6 +1461,12 @@ snapshots: commander@15.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + emoji-regex@8.0.0: {} env-paths@4.0.0: @@ -1478,6 +1521,8 @@ snapshots: is-safe-filename@0.1.1: {} + isexe@2.0.0: {} + js-tokens@10.0.0: {} lefthook-darwin-arm64@2.1.14: @@ -1539,6 +1584,8 @@ snapshots: on-exit-leak-free@2.1.2: {} + path-key@3.1.1: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -1611,6 +1658,12 @@ snapshots: safe-stable-stringify@2.5.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -1709,4 +1762,8 @@ snapshots: transitivePeerDependencies: - msw + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@3.2.2: {} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 1c37666..a27bace 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -31,10 +31,16 @@ import { resolvePaths, saveConfigFile, } from "../src/index.js" +import { setupArguments } from "../src/mcp/index.js" import { fakeClock } from "../src/testing/index.js" import { installerOf, isNewer } from "../src/update/index.js" const ESCAPE = String.fromCharCode(27) +checkMcpImport() +function checkMcpImport() { + const args = setupArguments("codex", "smoke", { type: "stdio", command: "/bin/node", args: ["mcp"] }) + if (args.join(" ") !== "mcp add smoke -- /bin/node mcp") throw new Error("MCP setup export failed") +} const runtime = typeof (globalThis as { Bun?: unknown }).Bun === "undefined" ? "node" : "bun" const failures: string[] = [] diff --git a/src/mcp/index.test.ts b/src/mcp/index.test.ts new file mode 100644 index 0000000..6d55090 --- /dev/null +++ b/src/mcp/index.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" +import { installStdioEntry, probeStdio, type StdioEntry, setupArguments } from "./index.js" + +const entry: StdioEntry = { + type: "stdio", + command: "/usr/bin/node", + args: ["/opt/max/dist/bin/max.js", "work", "mcp"], + env: { MAX_CONFIG_DIR: "/tmp/work config" }, +} + +describe("local MCP setup", () => { + it("uses the clients' own commands and preserves paths and environment values as arguments", () => { + expect(setupArguments("codex", "max-work", entry)).toEqual([ + "mcp", + "add", + "max-work", + "--env", + "MAX_CONFIG_DIR=/tmp/work config", + "--", + "/usr/bin/node", + "/opt/max/dist/bin/max.js", + "work", + "mcp", + ]) + expect(setupArguments("claude-code", "max-work", entry)).toEqual([ + "mcp", + "add", + "--scope", + "user", + "max-work", + "--env", + "MAX_CONFIG_DIR=/tmp/work config", + "--", + "/usr/bin/node", + "/opt/max/dist/bin/max.js", + "work", + "mcp", + ]) + }) + + it("refuses an existing entry without removing it", () => { + const calls: string[][] = [] + expect(() => + installStdioEntry("codex", "max-work", entry, { + run: (_file, args) => { + calls.push(args) + return { status: 0, stdout: "{}", stderr: "" } + }, + }), + ).toThrow("already configured") + expect(calls).toEqual([["mcp", "get", "max-work", "--json"]]) + }) + + it("adds a missing entry without touching other entries", () => { + const calls: string[][] = [] + const installed = installStdioEntry("claude-code", "tg", entry, { + run: (_file, args) => { + calls.push(args) + return calls.length === 1 + ? { status: 1, stdout: 'No MCP server named "tg"', stderr: "" } + : { status: 0, stdout: "Added", stderr: "" } + }, + }) + expect(installed.name).toBe("tg") + expect(calls.map((args) => args.slice(0, 2))).toEqual([ + ["mcp", "get"], + ["mcp", "add"], + ]) + }) +}) + +describe("local MCP doctor", () => { + it("initializes a stdio server and lists tools without calling them", async () => { + const program = [ + "const rl = require('node:readline').createInterface({input: process.stdin});", + "rl.on('line', line => { const m = JSON.parse(line);", + "if (m.method === 'initialize') process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result:{protocolVersion:'2025-11-25',capabilities:{tools:{}},serverInfo:{name:'fake',version:'1'}}})+'\\n');", + "if (m.method === 'tools/list') process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result:{tools:[{name:'max_status',annotations:{readOnlyHint:true}},{name:'max_messages_send',annotations:{readOnlyHint:false}}]}})+'\\n');", + "if (m.method === 'tools/call') process.exit(10);", + "});", + ].join("") + await expect(probeStdio({ type: "stdio", command: process.execPath, args: ["-e", program] })).resolves.toEqual({ + tools: ["max_status", "max_messages_send"], + potentialWrites: ["max_messages_send"], + }) + }) +}) diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..0f6807c --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,136 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process" +import crossSpawn from "cross-spawn" + +export type McpClient = "codex" | "claude-code" + +export interface StdioEntry { + type: "stdio" + command: string + args: string[] + env?: Record +} + +export interface CommandResult { + status: number | null + error?: Error + stdout: string + stderr: string +} + +export type RunCommand = (file: string, args: string[]) => CommandResult + +const realRun: RunCommand = (file, args) => { + const result = crossSpawn.sync(file, args, { encoding: "utf8", timeout: 10_000, maxBuffer: 256_000 }) + return { status: result.status, error: result.error, stdout: result.stdout ?? "", stderr: result.stderr ?? "" } +} + +const executable = (client: McpClient): string => (client === "codex" ? "codex" : "claude") + +export const setupArguments = (client: McpClient, name: string, entry: StdioEntry): string[] => { + const variables = Object.entries(entry.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]) + if (client === "codex") return ["mcp", "add", name, ...variables, "--", entry.command, ...entry.args] + return ["mcp", "add", "--scope", "user", name, ...variables, "--", entry.command, ...entry.args] +} + +export const installStdioEntry = ( + client: McpClient, + name: string, + entry: StdioEntry, + { run = realRun }: { run?: RunCommand } = {}, +): { client: McpClient; name: string; command: string[] } => { + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) throw new Error("the MCP name must be letters, digits, _ or -") + if (!entry.command || entry.args.some((arg) => arg.includes("\0"))) throw new Error("invalid MCP command") + const file = executable(client) + const existing = run(file, ["mcp", "get", name, ...(client === "codex" ? ["--json"] : [])]) + if (existing.error) throw new Error(`${file} is not installed or could not be started`) + if (existing.status === 0) throw new Error(`${name} is already configured in ${file}; remove it there first`) + if (existing.status !== 0 && !/No MCP server named/i.test(existing.stdout + existing.stderr)) { + throw new Error(`${file} could not check its MCP configuration`) + } + const args = setupArguments(client, name, entry) + const added = run(file, args) + if (added.status !== 0 || added.error) throw new Error(`${file} could not add ${name}`) + return { client, name, command: [entry.command, ...entry.args] } +} + +export interface ProbeResult { + tools: string[] + potentialWrites: string[] +} + +export const probeStdio = async ( + entry: StdioEntry, + { + timeoutMs = 10_000, + start = (file: string, args: string[], env: NodeJS.ProcessEnv) => spawn(file, args, { env, stdio: "pipe" }), + }: { + timeoutMs?: number + start?: (file: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcessWithoutNullStreams + } = {}, +): Promise => { + const child = start(entry.command, entry.args, { ...process.env, ...entry.env }) + let buffer = "" + let timer: NodeJS.Timeout | undefined + const pending = new Map< + number, + { resolve: (value: Record) => void; reject: (reason: Error) => void } + >() + const request = (id: number, method: string, params: object) => + new Promise>((resolve, reject) => { + pending.set(id, { resolve, reject }) + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`) + }) + const fail = (error: Error) => { + for (const waiter of pending.values()) waiter.reject(error) + pending.clear() + } + child.on("error", () => fail(new Error("the MCP server could not be started"))) + child.on("exit", () => fail(new Error("the MCP server exited before answering"))) + child.stdin.on("error", () => fail(new Error("the MCP server closed its input"))) + child.stdout.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + buffer += chunk + if (buffer.length > 1_000_000) return fail(new Error("the MCP server sent too much output")) + for (let newline = buffer.indexOf("\n"); newline !== -1; newline = buffer.indexOf("\n")) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + try { + const value = JSON.parse(line) as { id?: unknown; result?: Record; error?: unknown } + if (typeof value.id !== "number") continue + const waiter = pending.get(value.id) + if (!waiter) continue + pending.delete(value.id) + if (value.error) waiter.reject(new Error("the MCP server rejected the handshake")) + else waiter.resolve(value.result ?? {}) + } catch { + fail(new Error("the MCP server sent invalid JSON")) + } + } + }) + try { + timer = setTimeout(() => fail(new Error("the MCP server did not answer in time")), timeoutMs) + await request(1, "initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "cli-core-mcp-doctor", version: "1" }, + }) + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`) + const result = await request(2, "tools/list", {}) + const tools = result.tools + if (!Array.isArray(tools)) throw new Error("the MCP server returned no tool list") + const named = tools.filter( + (item): item is { name: string; annotations?: { readOnlyHint?: boolean } } => + item !== null && typeof item === "object" && "name" in item && typeof item.name === "string", + ) + if (named.length !== tools.length) throw new Error("the MCP server returned an invalid tool list") + return { + tools: named.map((item) => item.name), + potentialWrites: named.filter((item) => item.annotations?.readOnlyHint !== true).map((item) => item.name), + } + } finally { + if (timer) clearTimeout(timer) + pending.clear() + child.stdin.end() + child.kill() + } +}