From 5aa516d794d7398c9ca70e5870d97c3f0b2052f4 Mon Sep 17 00:00:00 2001 From: moyun Date: Sat, 27 Jun 2026 19:35:30 +0800 Subject: [PATCH 1/7] feat(shared,server): merge MCP tool schemas in SaaS chat route CLI sends discovered MCP tool definitions in the chat submit body; the server deserializes them into dynamicTool records alongside built-in contracts without running MCP on the server, preserving Phase 11 local-only execution. Co-authored-by: Cursor --- packages/server/src/routes/chat.ts | 25 +++++++++++++--- packages/shared/src/index.ts | 7 ++++- packages/shared/src/mcp-tools.ts | 48 ++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/mcp-tools.ts diff --git a/packages/server/src/routes/chat.ts b/packages/server/src/routes/chat.ts index d47ed9f..9932ef4 100644 --- a/packages/server/src/routes/chat.ts +++ b/packages/server/src/routes/chat.ts @@ -20,6 +20,11 @@ * - Session is not persisted until all tool calls in the response have output * ({@link hasPendingToolCalls} gate in `onFinish`) * + * Phase 02 MCP (D-06): + * - CLI sends `mcpTools` JSON schemas in submit body (from McpManager.getToolDefinitions) + * - Server merges via {@link deserializeMcpToolsToDynamic} — no MCP SDK on server + * - MCP tool calls execute only in CLI `executeMcpToolCall` + * * Persists USER / ASSISTANT rows to the database. Interrupted streams save partial * ASSISTANT content. Resume replays generation when the last stored message is USER-only. */ @@ -38,7 +43,8 @@ import { db } from "@mocode/database/client"; import type { Prisma } from "@mocode/database"; import { getToolContracts, - modeSchema, + modeSchema, + deserializeMcpToolsToDynamic, type ModeType, type ToolContracts } from "@mocode/shared"; @@ -58,6 +64,13 @@ type ChatMessageMetadata = { type MocodeUIMessage = UIMessage>; +/** Wire payload for one MCP tool schema — mirrors CLI SerializedMcpTool (no execute fn). */ +const mcpToolSchema = z.object({ + name: z.string(), + description: z.string().optional(), + inputSchema: z.unknown().optional(), +}); + const submitSchema = z.object({ id: z.string(), messages: z @@ -69,6 +82,7 @@ const submitSchema = z.object({ .min(1), mode: modeSchema, model: z.string().refine(isSupportedChatModel, "Unsupported model"), + mcpTools: z.array(mcpToolSchema).optional(), }); const submitValidator = zValidator("json", submitSchema, (result, c) => { @@ -96,7 +110,7 @@ const app = new Hono() submitValidator, async (c) => { const userId = c.get("userId"); - const { id, messages, mode, model } = c.req.valid("json"); + const { id, messages, mode, model, mcpTools } = c.req.valid("json"); const session = await db.session.findUnique({ where: { id, userId }, @@ -107,8 +121,11 @@ const app = new Hono() } const startTime = Date.now(); - // Tool contracts only — executors live in CLI `executeLocalTool` (Phase 11). - const tools = getToolContracts(mode); + // Tool contracts only — executors live in CLI (Phase 11). MCP schemas merged from CLI wire payload (D-06). + const tools = { + ...getToolContracts(mode), + ...deserializeMcpToolsToDynamic(mcpTools), + }; const resolvedModel = resolveChatModel(model); const previousMessages = Array.isArray(session.messages) ? (session.messages as unknown as MocodeUIMessage[]) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index df2c58a..73b50ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -17,4 +17,9 @@ export { getToolContracts, type ToolContracts, type ModeType, -} from "./schemas"; \ No newline at end of file +} from "./schemas"; + +export { + deserializeMcpToolsToDynamic, + type SerializedMcpTool, +} from "./mcp-tools"; \ No newline at end of file diff --git a/packages/shared/src/mcp-tools.ts b/packages/shared/src/mcp-tools.ts new file mode 100644 index 0000000..8fd7391 --- /dev/null +++ b/packages/shared/src/mcp-tools.ts @@ -0,0 +1,48 @@ +/** + * MCP tool schema deserialization for server-side streamText merge (Phase 02, D-06). + * + * Lives in @mocode/shared so packages/server can rebuild dynamicTool records without + * importing packages/cli or @modelcontextprotocol/sdk. + * + * Intentionally duplicates `jsonSchemaToInputSchema` from packages/cli/src/mcp/tools.ts — + * shared package must stay MCP-SDK-free. Both sides produce schema-only dynamicTool entries; + * execution always happens on the CLI. + */ +import { dynamicTool, jsonSchema } from "ai"; +import { z } from "zod"; +import type { ToolSet } from "ai"; + +export type SerializedMcpTool = { + name: string; + description?: string; + inputSchema?: unknown; +}; + +function jsonSchemaToInputSchema(schema: unknown) { + if (schema && typeof schema === "object") { + try { + return jsonSchema(schema as Record); + } catch { + // Fall through to permissive Zod object. + } + } + return z.object({}).passthrough(); +} + +/** Converts CLI wire-format MCP tools to AI SDK dynamicTool records without execute fn. */ +export function deserializeMcpToolsToDynamic(mcpTools?: SerializedMcpTool[]): ToolSet { + if (!mcpTools?.length) { + return {}; + } + + const result: ToolSet = {}; + + for (const tool of mcpTools) { + result[tool.name] = dynamicTool({ + description: tool.description ?? tool.name, + inputSchema: jsonSchemaToInputSchema(tool.inputSchema), + }); + } + + return result; +} From 58cdc8b0aca408daedec2a693fd717f614e72d1c Mon Sep 17 00:00:00 2001 From: moyun Date: Sat, 27 Jun 2026 19:35:38 +0800 Subject: [PATCH 2/7] feat(cli): add MCP client stack and write approval gate Introduce mcp.json merge loader, stdio/HTTP/SSE transports, McpManager lifecycle with hot-reload, mcp__ tool naming, and executeMcpToolCall with TUI write approval. Co-authored-by: Cursor --- bun.lock | 156 ++++++++ packages/cli/package.json | 8 + packages/cli/src/lib/mcp-approval-ui.ts | 54 +++ packages/cli/src/lib/mcp-approval.test.ts | 29 ++ packages/cli/src/lib/mcp-tool-call.ts | 130 +++++++ packages/cli/src/lib/truncate-path.test.ts | 17 + packages/cli/src/lib/truncate-path.ts | 7 + packages/cli/src/mcp/config-schema.ts | 49 +++ packages/cli/src/mcp/config.test.ts | 184 +++++++++ packages/cli/src/mcp/config.ts | 188 ++++++++++ packages/cli/src/mcp/heuristics.test.ts | 61 +++ packages/cli/src/mcp/heuristics.ts | 103 ++++++ packages/cli/src/mcp/integration.test.ts | 59 +++ packages/cli/src/mcp/manager.test.ts | 233 ++++++++++++ packages/cli/src/mcp/manager.ts | 411 +++++++++++++++++++++ packages/cli/src/mcp/session-init.test.ts | 47 +++ packages/cli/src/mcp/session-mcp.ts | 37 ++ packages/cli/src/mcp/tools.test.ts | 33 ++ packages/cli/src/mcp/tools.ts | 212 +++++++++++ packages/cli/src/mcp/transports.test.ts | 60 +++ packages/cli/src/mcp/transports.ts | 51 +++ packages/cli/src/mcp/types.ts | 58 +++ packages/cli/src/mcp/watcher.test.ts | 100 +++++ packages/cli/src/mcp/watcher.ts | 117 ++++++ 24 files changed, 2404 insertions(+) create mode 100644 packages/cli/src/lib/mcp-approval-ui.ts create mode 100644 packages/cli/src/lib/mcp-approval.test.ts create mode 100644 packages/cli/src/lib/mcp-tool-call.ts create mode 100644 packages/cli/src/lib/truncate-path.test.ts create mode 100644 packages/cli/src/lib/truncate-path.ts create mode 100644 packages/cli/src/mcp/config-schema.ts create mode 100644 packages/cli/src/mcp/config.test.ts create mode 100644 packages/cli/src/mcp/config.ts create mode 100644 packages/cli/src/mcp/heuristics.test.ts create mode 100644 packages/cli/src/mcp/heuristics.ts create mode 100644 packages/cli/src/mcp/integration.test.ts create mode 100644 packages/cli/src/mcp/manager.test.ts create mode 100644 packages/cli/src/mcp/manager.ts create mode 100644 packages/cli/src/mcp/session-init.test.ts create mode 100644 packages/cli/src/mcp/session-mcp.ts create mode 100644 packages/cli/src/mcp/tools.test.ts create mode 100644 packages/cli/src/mcp/tools.ts create mode 100644 packages/cli/src/mcp/transports.test.ts create mode 100644 packages/cli/src/mcp/transports.ts create mode 100644 packages/cli/src/mcp/types.ts create mode 100644 packages/cli/src/mcp/watcher.test.ts create mode 100644 packages/cli/src/mcp/watcher.ts diff --git a/bun.lock b/bun.lock index c222722..edf8d79 100644 --- a/bun.lock +++ b/bun.lock @@ -14,12 +14,20 @@ "mocode": "./bin/mocode", }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.85", + "@ai-sdk/cerebras": "^2.0.51", + "@ai-sdk/google": "^3.0.30", + "@ai-sdk/groq": "^3.0.21", + "@ai-sdk/openai": "^3.0.72", "@ai-sdk/react": "^3.0.210", "@mocode/shared": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "@openrouter/ai-sdk-provider": "^2.9.1", "@opentui/core": "^0.4.1", "@opentui/react": "^0.4.1", "@vscode/ripgrep": "^1.18.0", "ai": "^6.0.208", + "chokidar": "^5.0.0", "date-fns": "^4.4.0", "hono": "^4.12.25", "open": "^11.0.0", @@ -194,6 +202,8 @@ "@mocode/shared": ["@mocode/shared@workspace:packages/shared"], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.9.1", "https://registry.npmmirror.com/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.9.1.tgz", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-okgq07Vdkro4CB5INbfhwa0e6VR1HS7sidNcfHN/MeXLJvX1JmQCff/vem6tcxwT9r1avyFrXSlfv9B28D/Pag=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -336,6 +346,8 @@ "@vscode/ripgrep-win32-x64": ["@vscode/ripgrep-win32-x64@1.18.0", "https://registry.npmmirror.com/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg=="], + "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.17.0", "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "https://registry.npmmirror.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -344,20 +356,30 @@ "ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "better-result": ["better-result@2.9.2", "https://registry.npmmirror.com/better-result/-/better-result-2.9.2.tgz", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="], + "body-parser": ["body-parser@2.3.0", "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.3", "https://registry.npmmirror.com/bun-ffi-structs/-/bun-ffi-structs-0.2.3.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="], "bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "c12": ["c12@3.3.4", "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chart.js": ["chart.js@4.5.1", "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], "chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -368,8 +390,16 @@ "confbox": ["confbox@0.2.4", "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -390,6 +420,8 @@ "denque": ["denque@2.1.0", "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -398,18 +430,40 @@ "dotenv": ["dotenv@17.4.2", "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.20.0", "https://registry.npmmirror.com/effect/-/effect-3.20.0.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "empathic": ["empathic@2.0.0", "https://registry.npmmirror.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "esbuild": ["esbuild@0.28.1", "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "express": ["express@5.2.1", "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.5.2.tgz", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "exsolve": ["exsolve@1.0.8", "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "fast-check": ["fast-check@3.23.2", "https://registry.npmmirror.com/fast-check/-/fast-check-3.23.2.tgz", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], @@ -420,40 +474,68 @@ "fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "generate-function": ["generate-function@2.3.1", "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], "get-east-asian-width": ["get-east-asian-width@1.6.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-port-please": ["get-port-please@3.2.0", "https://registry.npmmirror.com/get-port-please/-/get-port-please-3.2.0.tgz", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "giget": ["giget@3.3.0", "https://registry.npmmirror.com/giget/-/giget-3.3.0.tgz", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "grammex": ["grammex@3.1.12", "https://registry.npmmirror.com/grammex/-/grammex-3.1.12.tgz", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], "graphmatch": ["graphmatch@1.1.1", "https://registry.npmmirror.com/graphmatch/-/graphmatch-1.1.1.tgz", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hono": ["hono@4.12.25", "https://registry.npmmirror.com/hono/-/hono-4.12.25.tgz", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "http-errors": ["http-errors@2.0.1", "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-status-codes": ["http-status-codes@2.3.0", "https://registry.npmmirror.com/http-status-codes/-/http-status-codes-2.3.0.tgz", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "import-in-the-middle": ["import-in-the-middle@3.0.2", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ=="], + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-docker": ["is-docker@3.0.0", "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-in-ssh": ["is-in-ssh@1.0.0", "https://registry.npmmirror.com/is-in-ssh/-/is-in-ssh-1.0.0.tgz", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-property": ["is-property@1.0.2", "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], "is-wsl": ["is-wsl@3.1.1", "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.1.tgz", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], @@ -462,18 +544,32 @@ "jiti": ["jiti@2.7.0", "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-cookie": ["js-cookie@3.0.7", "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.7.tgz", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], "json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "lru.min": ["lru.min@1.1.4", "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], "marked": ["marked@17.0.1", "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "https://registry.npmmirror.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -482,16 +578,30 @@ "named-placeholders": ["named-placeholders@1.1.6", "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "open": ["open@11.0.0", "https://registry.npmmirror.com/open/-/open-11.0.0.tgz", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "opentui-spinner": ["opentui-spinner@0.0.7", "https://registry.npmmirror.com/opentui-spinner/-/opentui-spinner-0.0.7.tgz", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], "parse-ms": ["parse-ms@4.0.0", "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "perfect-debounce": ["perfect-debounce@2.1.0", "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], @@ -512,6 +622,8 @@ "pgpass": ["pgpass@1.0.5", "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-types": ["pkg-types@2.3.1", "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "postgres": ["postgres@3.4.7", "https://registry.npmmirror.com/postgres/-/postgres-3.4.7.tgz", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], @@ -532,8 +644,16 @@ "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pure-rand": ["pure-rand@6.1.0", "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.15.3", "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "range-parser": ["range-parser@1.3.0", "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc9": ["rc9@3.0.1", "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], "react": ["react@19.2.7", "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], @@ -556,22 +676,38 @@ "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-applescript": ["run-applescript@7.1.0", "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "seq-queue": ["seq-queue@0.0.5", "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "serve-static": ["serve-static@2.2.1", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shell-quote": ["shell-quote@1.8.4", "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.4.tgz", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], + "side-channel": ["side-channel@1.1.1", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-git": ["simple-git@3.36.0", "https://registry.npmmirror.com/simple-git/-/simple-git-3.36.0.tgz", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.3", "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" } }, "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q=="], @@ -582,6 +718,8 @@ "standardwebhooks": ["standardwebhooks@1.0.0", "https://registry.npmmirror.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -592,22 +730,32 @@ "throttleit": ["throttleit@2.1.0", "https://registry.npmmirror.com/throttleit/-/throttleit-2.1.0.tgz", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.22.4", "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "valibot": ["valibot@1.2.0", "https://registry.npmmirror.com/valibot/-/valibot-1.2.0.tgz", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "https://registry.npmmirror.com/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.0", "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.3.1", "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -618,6 +766,8 @@ "zod": ["zod@4.4.3", "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@mocode/database/typescript": ["typescript@6.0.3", "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-7.8.0.tgz", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], @@ -626,10 +776,16 @@ "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "https://registry.npmmirror.com/@prisma/debug/-/debug-7.2.0.tgz", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "body-parser/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "express/cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "pg-types/postgres-array": ["postgres-array@2.0.0", "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "react-devtools-core/ws": ["ws@7.5.11", "https://registry.npmmirror.com/ws/-/ws-7.5.11.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + + "type-is/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], } } diff --git a/packages/cli/package.json b/packages/cli/package.json index b23e919..0244143 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,12 +20,20 @@ "typescript": "^5.4" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.85", + "@ai-sdk/cerebras": "^2.0.51", + "@ai-sdk/google": "^3.0.30", + "@ai-sdk/groq": "^3.0.21", + "@ai-sdk/openai": "^3.0.72", "@ai-sdk/react": "^3.0.210", "@mocode/shared": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "@openrouter/ai-sdk-provider": "^2.9.1", "@opentui/core": "^0.4.1", "@opentui/react": "^0.4.1", "@vscode/ripgrep": "^1.18.0", "ai": "^6.0.208", + "chokidar": "^5.0.0", "date-fns": "^4.4.0", "hono": "^4.12.25", "open": "^11.0.0", diff --git a/packages/cli/src/lib/mcp-approval-ui.ts b/packages/cli/src/lib/mcp-approval-ui.ts new file mode 100644 index 0000000..fddb544 --- /dev/null +++ b/packages/cli/src/lib/mcp-approval-ui.ts @@ -0,0 +1,54 @@ +/** + * Bridge from async `onToolCall` to the modal DialogProvider for MCP write approval (D-15). + * + * Mirrors `requestBashApproval` UX: Approve once / Reject / Allow for session. + * Esc / backdrop dismiss → reject (same as bash approval). Session allowlist keyed by + * full `mcp____` string is owned by `use-chat` `sessionMcpAllowRef`. + */ +import { createElement } from "react"; +import { McpApprovalDialog } from "../components/dialogs/mcp-approval-dialog"; +import type { DialogContextValue } from "../providers/dialog"; + +/** User choice from McpApprovalDialog — maps 1:1 to bash approval verdicts. */ +export type McpApprovalVerdict = "approve-once" | "reject" | "allow-session"; + +/** + * Opens McpApprovalDialog and resolves when the user picks an action or dismisses. + * `settled` prevents double-resolve if both a button and `onClose` fire. + */ +export function requestMcpApproval( + dialog: DialogContextValue, + toolName: string, + input: unknown, +): Promise { + return new Promise((resolve) => { + let settled = false; + + const settle = (verdict: McpApprovalVerdict) => { + if (settled) return; + settled = true; + resolve(verdict); + }; + + dialog.open({ + title: "Approve MCP tool call", + onClose: () => settle("reject"), + children: createElement(McpApprovalDialog, { + toolName, + input, + onApproveOnce: () => { + settle("approve-once"); + dialog.close(); + }, + onReject: () => { + settle("reject"); + dialog.close(); + }, + onAllowSession: () => { + settle("allow-session"); + dialog.close(); + }, + }), + }); + }); +} diff --git a/packages/cli/src/lib/mcp-approval.test.ts b/packages/cli/src/lib/mcp-approval.test.ts new file mode 100644 index 0000000..b72ccfa --- /dev/null +++ b/packages/cli/src/lib/mcp-approval.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { requiresMcpWriteApproval } from "../mcp/heuristics"; + +const emptyAllowlist = new Set(); + +describe("MCP write approval gate", () => { + const writeTools = [ + { name: "write_file", tool: "write_file" }, + { name: "delete_item", tool: "delete_item" }, + { name: "create_record", tool: "create_record" }, + { name: "update_config", tool: "update_config" }, + ]; + + test.each(writeTools)("$name triggers write approval requirement", ({ tool }) => { + expect(requiresMcpWriteApproval(tool, emptyAllowlist)).toBe(true); + }); + + test("read-only tools do not trigger write approval", () => { + expect(requiresMcpWriteApproval("get_foo", emptyAllowlist)).toBe(false); + expect(requiresMcpWriteApproval("list_items", emptyAllowlist)).toBe(false); + expect(requiresMcpWriteApproval("read_file", emptyAllowlist)).toBe(false); + }); + + test("session allowlist skips approval for full mcp__server__tool key", () => { + const allowlist = new Set(["mcp__filesystem__write_file"]); + expect(requiresMcpWriteApproval("mcp__filesystem__write_file", allowlist)).toBe(false); + expect(requiresMcpWriteApproval("mcp__filesystem__write_file", emptyAllowlist)).toBe(true); + }); +}); diff --git a/packages/cli/src/lib/mcp-tool-call.ts b/packages/cli/src/lib/mcp-tool-call.ts new file mode 100644 index 0000000..f0a1244 --- /dev/null +++ b/packages/cli/src/lib/mcp-tool-call.ts @@ -0,0 +1,130 @@ +/** + * MCP tool execution handler for `use-chat` `onToolCall` (Phase 02, D-06, D-13). + * + * Flow: + * 1. Normalize tool name (`Mcp__` → `mcp__`) and route only `mcp__server__tool` names + * 2. PLAN mode — reject write tools with output-error (read-only MCP still allowed) + * 3. BUILD write tools — `requestMcpApproval` TUI gate unless session-allowed + * 4. `McpManager.callTool` — actual MCP SDK invocation (with auto-reconnect) + * + * Returns `false` when the name is not an MCP tool so the caller can fall through to + * builtin local tools (readFile, bash, etc.). + */ +import { Mode, type ModeType } from "@mocode/shared"; +import type { McpManager } from "../mcp/manager"; +import { loadMergedMcpConfig } from "../mcp/config"; +import { + isMcpReadOnlyTool, + isMcpToolName, + parseMcpToolName, + requiresMcpWriteApproval, +} from "../mcp/heuristics"; +import type { DialogContextValue } from "../providers/dialog"; +import type { McpApprovalVerdict } from "./mcp-approval-ui"; + +/** Model-facing guidance when the user clicks Reject in McpApprovalDialog (mirrors bash reject pattern). */ +const MCP_REJECT_ERROR_TEXT = + "User rejected this MCP tool call in the TUI approval dialog — this is not a runtime failure. " + + "Do not retry the same tool call unless the user explicitly requests it again in a new message."; + +const PLAN_MODE_ERROR_TEXT = "Tool not available in PLAN mode"; + +/** Minimal shape passed from AI SDK `onToolCall` into this handler. */ +export type McpToolCallInput = { + toolName: string; + toolCallId: string; + input: unknown; +}; + +/** Injected dependencies so unit tests can mock manager, dialog, and `addToolOutput`. */ +export type ExecuteMcpToolCallDeps = { + getMcpManager: () => McpManager; + requestMcpApproval: ( + dialog: DialogContextValue, + toolName: string, + input: unknown, + ) => Promise; + sessionMcpAllowRef: Set; + mode: ModeType; + dialog: DialogContextValue; + addToolOutput: (params: { + toolCallId: string; + output?: unknown; + state?: "output-error"; + errorText?: string; + }) => void; +}; + +/** Returns true when the tool call was handled as an MCP tool. */ +export async function executeMcpToolCall( + toolCall: McpToolCallInput, + deps: ExecuteMcpToolCallDeps, +): Promise { + const { toolName: rawToolName, toolCallId, input } = toolCall; + // Some free models emit `Mcp__filesystem__read_file` — normalize to lowercase prefix. + const toolName = isMcpToolName(rawToolName) + ? rawToolName + : rawToolName.toLowerCase().startsWith("mcp__") + ? rawToolName.toLowerCase() + : rawToolName; + + if (!isMcpToolName(toolName)) { + return false; + } + + const { server, tool } = parseMcpToolName(toolName); + const config = loadMergedMcpConfig(process.cwd()); + const toolConfig = config.mcpServers[server]?.tools?.[tool]; + + const { + getMcpManager, + requestMcpApproval, + sessionMcpAllowRef, + mode, + dialog, + addToolOutput, + } = deps; + + // D-08: PLAN strips write MCP tools from contracts; this is a second guard at execution time. + if (mode === Mode.PLAN && !isMcpReadOnlyTool(tool, toolConfig)) { + addToolOutput({ + toolCallId, + state: "output-error", + errorText: PLAN_MODE_ERROR_TEXT, + }); + return true; + } + + // D-13/D-15: write tools pause for TUI approval unless "Allow for this session" was chosen. + if (requiresMcpWriteApproval(toolName, sessionMcpAllowRef, toolConfig)) { + const verdict = await requestMcpApproval(dialog, toolName, input); + if (verdict === "reject") { + addToolOutput({ + toolCallId, + state: "output-error", + errorText: MCP_REJECT_ERROR_TEXT, + }); + return true; + } + if (verdict === "allow-session") { + sessionMcpAllowRef.add(toolName); + } + } + + try { + const args = + input && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : {}; + const output = await getMcpManager().callTool(server, tool, args); + addToolOutput({ toolCallId, output }); + } catch (error) { + addToolOutput({ + toolCallId, + state: "output-error", + errorText: error instanceof Error ? error.message : String(error), + }); + } + + return true; +} diff --git a/packages/cli/src/lib/truncate-path.test.ts b/packages/cli/src/lib/truncate-path.test.ts new file mode 100644 index 0000000..9374535 --- /dev/null +++ b/packages/cli/src/lib/truncate-path.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { truncatePathForDisplay } from "./truncate-path"; + +describe("truncatePathForDisplay", () => { + test("returns path unchanged when it fits", () => { + expect(truncatePathForDisplay("/short", 20)).toBe("/short"); + }); + + test("keeps the tail with a leading ellipsis", () => { + const path = "/Users/moyun/code/mocode/.mocode/mcp.json"; + expect(truncatePathForDisplay(path, 24)).toBe("…mocode/.mocode/mcp.json"); + }); + + test("handles maxWidth of 1", () => { + expect(truncatePathForDisplay("/long/path", 1)).toBe("…"); + }); +}); diff --git a/packages/cli/src/lib/truncate-path.ts b/packages/cli/src/lib/truncate-path.ts new file mode 100644 index 0000000..c8c7abf --- /dev/null +++ b/packages/cli/src/lib/truncate-path.ts @@ -0,0 +1,7 @@ +/** Leading-ellipsis truncation for long paths in terminal UI (DESIGN.md). */ +export function truncatePathForDisplay(path: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + if (path.length <= maxWidth) return path; + if (maxWidth === 1) return "…"; + return `…${path.slice(-(maxWidth - 1))}`; +} diff --git a/packages/cli/src/mcp/config-schema.ts b/packages/cli/src/mcp/config-schema.ts new file mode 100644 index 0000000..389adf8 --- /dev/null +++ b/packages/cli/src/mcp/config-schema.ts @@ -0,0 +1,49 @@ +/** + * Zod validation for ~/.mocode/mcp.json and project .mocode/mcp.json (Phase 02, HARNESS-04). + */ +import { z } from "zod"; + +const mcpToolOverrideSchema = z.object({ + readOnly: z.boolean(), +}); + +const mcpServerStdioSchema = z.object({ + enabled: z.boolean().default(true), + transport: z.literal("stdio"), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + timeoutMs: z.number().positive().default(60000), + tools: z.record(z.string(), mcpToolOverrideSchema).optional(), +}); + +const mcpServerHttpSchema = z.object({ + enabled: z.boolean().default(true), + transport: z.literal("http"), + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + timeoutMs: z.number().positive().default(60000), + tools: z.record(z.string(), mcpToolOverrideSchema).optional(), +}); + +const mcpServerSseSchema = z.object({ + enabled: z.boolean().default(true), + transport: z.literal("sse"), + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + timeoutMs: z.number().positive().default(60000), + tools: z.record(z.string(), mcpToolOverrideSchema).optional(), +}); + +export const mcpServerEntrySchema = z.discriminatedUnion("transport", [ + mcpServerStdioSchema, + mcpServerHttpSchema, + mcpServerSseSchema, +]); + +export const mcpConfigSchema = z.object({ + mcpServers: z.record(z.string(), mcpServerEntrySchema), +}); + +export type McpConfig = z.infer; +export type McpServerEntry = z.infer; diff --git a/packages/cli/src/mcp/config.test.ts b/packages/cli/src/mcp/config.test.ts new file mode 100644 index 0000000..f3992ce --- /dev/null +++ b/packages/cli/src/mcp/config.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mcpConfigSchema } from "./config-schema"; +import { getEnabledServers, loadMergedMcpConfig } from "./config"; + +function writeMcpJson(dir: string, data: unknown): string { + const mocodeDir = join(dir, ".mocode"); + mkdirSync(mocodeDir, { recursive: true }); + const path = join(mocodeDir, "mcp.json"); + writeFileSync(path, JSON.stringify(data), "utf-8"); + return path; +} + +describe("mcpConfigSchema", () => { + test("schema: stdio without command fails parse", () => { + const result = mcpConfigSchema.safeParse({ + mcpServers: { + bad: { transport: "stdio" }, + }, + }); + + expect(result.success).toBe(false); + }); + + test("schema: enabled:false stdio entry parses", () => { + const result = mcpConfigSchema.safeParse({ + mcpServers: { + fs: { transport: "stdio", command: "npx", enabled: false }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mcpServers.fs.enabled).toBe(false); + } + }); + + test("schema: timeoutMs defaults to 60000 when omitted", () => { + const result = mcpConfigSchema.safeParse({ + mcpServers: { + fs: { transport: "stdio", command: "npx" }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mcpServers.fs.timeoutMs).toBe(60000); + } + }); + + test("schema: http without url fails parse", () => { + const result = mcpConfigSchema.safeParse({ + mcpServers: { + remote: { transport: "http" }, + }, + }); + + expect(result.success).toBe(false); + }); +}); + +describe("loadMergedMcpConfig", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + function makeFixturePair(globalData: unknown, projectData: unknown) { + const globalDir = mkdtempSync(join(tmpdir(), "mocode-mcp-global-")); + const projectDir = mkdtempSync(join(tmpdir(), "mocode-mcp-project-")); + tempDirs.push(globalDir, projectDir); + + const globalPath = writeMcpJson(globalDir, globalData); + const projectPath = writeMcpJson(projectDir, projectData); + + return { globalPath, projectPath, projectDir }; + } + + test("merge: global mcpServers merged with project", () => { + const { globalPath, projectPath, projectDir } = makeFixturePair( + { + mcpServers: { + globalServer: { transport: "stdio", command: "echo", args: ["global"] }, + }, + }, + { + mcpServers: { + projectServer: { transport: "stdio", command: "echo", args: ["project"] }, + }, + }, + ); + + const config = loadMergedMcpConfig(projectDir, { + globalPath, + projectPath, + }); + + expect(config.mcpServers.globalServer).toBeDefined(); + expect(config.mcpServers.projectServer).toBeDefined(); + }); + + test("override: same server name in project overrides global", () => { + const { globalPath, projectPath, projectDir } = makeFixturePair( + { + mcpServers: { + shared: { transport: "stdio", command: "echo", args: ["global"] }, + }, + }, + { + mcpServers: { + shared: { transport: "stdio", command: "echo", args: ["project"] }, + }, + }, + ); + + const config = loadMergedMcpConfig(projectDir, { + globalPath, + projectPath, + }); + + expect(config.mcpServers.shared.args).toEqual(["project"]); + }); + + test("override: project http replaces global stdio for same name", () => { + const { globalPath, projectPath, projectDir } = makeFixturePair( + { + mcpServers: { + alpha: { transport: "stdio", command: "a" }, + }, + }, + { + mcpServers: { + beta: { transport: "stdio", command: "b" }, + alpha: { transport: "http", url: "http://x" }, + }, + }, + ); + + const config = loadMergedMcpConfig(projectDir, { + globalPath, + projectPath, + }); + + expect(Object.keys(config.mcpServers).sort()).toEqual(["alpha", "beta"]); + expect(config.mcpServers.alpha.transport).toBe("http"); + expect(config.mcpServers.alpha.url).toBe("http://x"); + expect(config.mcpServers.beta.command).toBe("b"); + }); + + test("missing files returns empty config", () => { + const emptyDir = mkdtempSync(join(tmpdir(), "mocode-mcp-empty-")); + tempDirs.push(emptyDir); + + const config = loadMergedMcpConfig(emptyDir, { + globalPath: join(emptyDir, "missing-global.json"), + projectPath: join(emptyDir, "missing-project.json"), + }); + + expect(config.mcpServers).toEqual({}); + }); +}); + +describe("getEnabledServers", () => { + test("enabled: disabled server (enabled:false) excluded", () => { + const config = mcpConfigSchema.parse({ + mcpServers: { + active: { transport: "stdio", command: "echo", args: [], enabled: true }, + disabled: { transport: "stdio", command: "echo", args: [], enabled: false }, + }, + }); + + const enabled = getEnabledServers(config); + + expect(enabled.active).toBeDefined(); + expect(enabled.disabled).toBeUndefined(); + expect(Object.keys(enabled)).toEqual(["active"]); + }); +}); diff --git a/packages/cli/src/mcp/config.ts b/packages/cli/src/mcp/config.ts new file mode 100644 index 0000000..751496a --- /dev/null +++ b/packages/cli/src/mcp/config.ts @@ -0,0 +1,188 @@ +/** + * MCP config loader — union merge of global and project mcp.json (Phase 02, D-01). + * + * Merge rule: project `mcpServers` entries override global entries with the same name. + * `enabled: false` keeps the server in config but excludes it from connectAll. + * `setServerEnabled` writes back to project file when the server is defined there. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { mcpConfigSchema, type McpConfig, type McpServerEntry } from "./config-schema"; + +const CONFIG_DIR = ".mocode"; +const MCP_FILE = "mcp.json"; + +export type McpConfigSource = "global" | "project"; + +export type McpConfigPaths = { + global: string; + project: string; +}; + +export type LoadMergedMcpConfigOptions = { + globalPath?: string; + projectPath?: string; +}; + +/** Returns filesystem paths for global and project MCP config files. */ +export function getMcpConfigPaths(cwd: string): McpConfigPaths { + return { + global: join(homedir(), CONFIG_DIR, MCP_FILE), + project: join(cwd, CONFIG_DIR, MCP_FILE), + }; +} + +function readMcpJsonFile(path: string): Record { + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw) as Record; + return parsed; + } catch { + return {}; + } +} + +function mergeRawConfig( + globalRaw: Record, + projectRaw: Record, +): McpConfig { + const globalServers = + globalRaw.mcpServers && typeof globalRaw.mcpServers === "object" + ? (globalRaw.mcpServers as Record) + : {}; + const projectServers = + projectRaw.mcpServers && typeof projectRaw.mcpServers === "object" + ? (projectRaw.mcpServers as Record) + : {}; + + const merged = { + mcpServers: { + ...globalServers, + ...projectServers, + }, + }; + + const result = mcpConfigSchema.safeParse(merged); + if (!result.success) { + throw new Error(`Invalid MCP config: ${result.error.message}`); + } + + return result.data; +} + +/** + * Loads and validates MCP config by merging global ~/.mocode/mcp.json with + * project .mocode/mcp.json. Project entries override global entries by server name. + */ +export function loadMergedMcpConfig( + cwd: string, + options?: LoadMergedMcpConfigOptions, +): McpConfig { + const paths = + options?.globalPath && options?.projectPath + ? { global: options.globalPath, project: options.projectPath } + : getMcpConfigPaths(cwd); + + const globalRaw = readMcpJsonFile(paths.global); + const projectRaw = readMcpJsonFile(paths.project); + return mergeRawConfig(globalRaw, projectRaw); +} + +/** Returns only servers with enabled !== false (default enabled is true). */ +export function getEnabledServers(config: McpConfig): Record { + const enabled: Record = {}; + + for (const [name, server] of Object.entries(config.mcpServers)) { + if (server.enabled !== false) { + enabled[name] = server; + } + } + + return enabled; +} + +function readRawMcpServers(path: string): Record { + const raw = readMcpJsonFile(path); + const merged = mergeRawConfig(raw, {}); + return merged.mcpServers; +} + +/** Returns whether a server entry is stored in project or global mcp.json. */ +export function getServerConfigSource( + serverName: string, + cwd: string, + options?: LoadMergedMcpConfigOptions, +): McpConfigSource { + const paths = + options?.globalPath && options?.projectPath + ? { global: options.globalPath, project: options.projectPath } + : getMcpConfigPaths(cwd); + + const projectRaw = readMcpJsonFile(paths.project); + const projectServers = + projectRaw.mcpServers && typeof projectRaw.mcpServers === "object" + ? (projectRaw.mcpServers as Record) + : {}; + + if (serverName in projectServers) { + return "project"; + } + + return "global"; +} + +/** + * Writes updated server entries back to global or project mcp.json. + * The updater receives only servers defined in the target file. + */ +export function saveMcpConfig( + target: McpConfigSource, + cwd: string, + updater: (servers: Record) => Record, + options?: LoadMergedMcpConfigOptions, +): void { + const paths = + options?.globalPath && options?.projectPath + ? { global: options.globalPath, project: options.projectPath } + : getMcpConfigPaths(cwd); + + const path = target === "project" ? paths.project : paths.global; + const dir = dirname(path); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const currentServers = readRawMcpServers(path); + const updatedServers = updater(currentServers); + const validated = mcpConfigSchema.parse({ mcpServers: updatedServers }); + + writeFileSync(path, `${JSON.stringify(validated, null, 2)}\n`, { mode: 0o600 }); +} + +/** Persists enabled flag for a server and returns the config file that was updated. */ +export function setServerEnabled( + serverName: string, + enabled: boolean, + cwd: string, +): McpConfigSource { + const source = getServerConfigSource(serverName, cwd); + + saveMcpConfig(source, cwd, (servers) => { + const entry = servers[serverName]; + if (!entry) { + throw new Error(`MCP server "${serverName}" not found in ${source} config`); + } + + return { + ...servers, + [serverName]: { + ...entry, + enabled, + }, + }; + }); + + return source; +} diff --git a/packages/cli/src/mcp/heuristics.test.ts b/packages/cli/src/mcp/heuristics.test.ts new file mode 100644 index 0000000..b22a278 --- /dev/null +++ b/packages/cli/src/mcp/heuristics.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { isMcpReadOnlyTool, requiresMcpWriteApproval } from "./heuristics"; + +describe("isMcpReadOnlyTool", () => { + const readOnlyTools = [ + { name: "get_foo", tool: "get_foo" }, + { name: "list_items", tool: "list_items" }, + { name: "read_file", tool: "read_file" }, + { name: "fetch_data", tool: "fetch_data" }, + { name: "search_docs", tool: "search_docs" }, + ]; + + test.each(readOnlyTools)("$name is read-only", ({ tool }) => { + expect(isMcpReadOnlyTool(tool)).toBe(true); + }); + + const writeTools = [ + { name: "write_file", tool: "write_file" }, + { name: "delete_item", tool: "delete_item" }, + { name: "create_record", tool: "create_record" }, + { name: "update_config", tool: "update_config" }, + ]; + + test.each(writeTools)("$name is not read-only", ({ tool }) => { + expect(isMcpReadOnlyTool(tool)).toBe(false); + }); + + test("list_items is PLAN-visible (read-only)", () => { + expect(isMcpReadOnlyTool("list_items")).toBe(true); + }); + + test("delete_item is not PLAN-visible", () => { + expect(isMcpReadOnlyTool("delete_item")).toBe(false); + }); + + test("config readOnly:true forces read-only on write tool", () => { + expect(isMcpReadOnlyTool("write_file", { readOnly: true })).toBe(true); + }); + + test("config readOnly:false forces write on read prefix tool", () => { + expect(isMcpReadOnlyTool("get_weather", { readOnly: false })).toBe(false); + }); +}); + +describe("requiresMcpWriteApproval", () => { + const writeTools = [ + { name: "write_file", tool: "write_file" }, + { name: "delete_item", tool: "delete_item" }, + { name: "create_record", tool: "create_record" }, + { name: "update_config", tool: "update_config" }, + { name: "push_changes", tool: "push_changes" }, + ]; + + test.each(writeTools)("$name requires write approval", ({ tool }) => { + expect(requiresMcpWriteApproval(tool, new Set())).toBe(true); + }); + + test("get_foo does not require write approval", () => { + expect(requiresMcpWriteApproval("get_foo", new Set())).toBe(false); + }); +}); diff --git a/packages/cli/src/mcp/heuristics.ts b/packages/cli/src/mcp/heuristics.ts new file mode 100644 index 0000000..e06b4c1 --- /dev/null +++ b/packages/cli/src/mcp/heuristics.ts @@ -0,0 +1,103 @@ +/** + * MCP tool read/write heuristics (Phase 02, D-08, D-13). + * + * Read-only detection mirrors PLAN mode local tools: prefix heuristic plus + * optional per-tool `readOnly` override from mcp.json. + */ +import { Mode, type ModeType } from "@mocode/shared"; + +const MCP_PREFIX = "mcp__"; +const READ_ONLY_PREFIX = /^(get|list|read|fetch|search)_/i; + +export type McpToolConfigOverride = { + readOnly?: boolean; +}; + +/** True when the tool name uses Claude Code MCP naming (`mcp__server__tool`). */ +export function isMcpToolName(name: string): boolean { + return name.startsWith(MCP_PREFIX); +} + +/** Splits `mcp____` into server and raw MCP tool segments. */ +export function parseMcpToolName(fullName: string): { server: string; tool: string } { + if (!isMcpToolName(fullName)) { + throw new Error(`Invalid MCP tool name: ${fullName}`); + } + + const rest = fullName.slice(MCP_PREFIX.length); + const separator = rest.indexOf("__"); + if (separator === -1) { + throw new Error(`Invalid MCP tool name: ${fullName}`); + } + + return { + server: rest.slice(0, separator), + tool: rest.slice(separator + 2), + }; +} + +function rawToolName(toolNameOrFullName: string): string { + return isMcpToolName(toolNameOrFullName) + ? parseMcpToolName(toolNameOrFullName).tool + : toolNameOrFullName; +} + +/** + * True when an MCP tool is read-only by prefix heuristic or config override. + * Evaluates the raw MCP tool name (not the full `mcp__` name). + */ +export function isMcpReadOnlyTool( + rawToolName: string, + toolConfig?: McpToolConfigOverride, +): boolean { + if (toolConfig?.readOnly === true) { + return true; + } + if (toolConfig?.readOnly === false) { + return false; + } + return READ_ONLY_PREFIX.test(rawToolName); +} + +/** + * In PLAN mode write tools are omitted from contracts, so this always returns false. + * In BUILD mode (or when called with a session allowlist Set) write tools require approval + * unless session-allowed or classified read-only. + * + * Overload: pass `Set` from use-chat (session allowlist) or `ModeType` when + * filtering tool registration only (no session context). + */ +export function requiresMcpWriteApproval( + toolNameOrFullName: string, + sessionAllowedOrMode: Set | ModeType, + toolConfig?: McpToolConfigOverride, +): boolean { + let mode: ModeType | undefined; + let sessionAllowed: Set; + + if (sessionAllowedOrMode instanceof Set) { + sessionAllowed = sessionAllowedOrMode; + } else { + mode = sessionAllowedOrMode; + sessionAllowed = new Set(); + } + + if (mode === Mode.PLAN) { + return false; + } + + const rawName = rawToolName(toolNameOrFullName); + if (isMcpReadOnlyTool(rawName, toolConfig)) { + return false; + } + + const approvalKey = isMcpToolName(toolNameOrFullName) + ? toolNameOrFullName + : toolNameOrFullName; + + if (sessionAllowed.has(approvalKey)) { + return false; + } + + return true; +} diff --git a/packages/cli/src/mcp/integration.test.ts b/packages/cli/src/mcp/integration.test.ts new file mode 100644 index 0000000..8eee099 --- /dev/null +++ b/packages/cli/src/mcp/integration.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { McpManager } from "./manager"; + +async function isNpxAvailable(): Promise { + try { + const proc = Bun.spawn(["npx", "--version"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} + +describe("MCP stdio integration", () => { + test("connectAll discovers tools from filesystem MCP server", async () => { + if (!(await isNpxAvailable())) { + console.warn("SKIP: npx unavailable — MCP stdio integration test skipped"); + return; + } + + const tempRoot = mkdtempSync(join(tmpdir(), "mocode-mcp-integration-")); + const serverDir = mkdtempSync(join(tempRoot, "server-root-")); + const projectDir = mkdtempSync(join(tempRoot, "project-")); + const configDir = join(projectDir, ".mocode"); + mkdirSync(configDir, { recursive: true }); + const manager = new McpManager(); + + writeFileSync( + join(configDir, "mcp.json"), + JSON.stringify({ + mcpServers: { + filesystem: { + enabled: true, + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", serverDir], + timeoutMs: 60000, + }, + }, + }), + ); + + try { + await manager.connectAll(projectDir); + + const discovered = manager.getDiscoveredTools(); + expect(discovered.length).toBeGreaterThan(0); + expect(discovered[0]?.serverName).toBe("filesystem"); + expect(discovered[0]?.tools.length).toBeGreaterThan(0); + expect(manager.getStatus()[0]?.status).toBe("connected"); + } finally { + await manager.disconnectAll(); + rmSync(tempRoot, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/packages/cli/src/mcp/manager.test.ts b/packages/cli/src/mcp/manager.test.ts new file mode 100644 index 0000000..5df2e59 --- /dev/null +++ b/packages/cli/src/mcp/manager.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { McpServerEntry } from "./config-schema"; +import { McpConnectionStatus } from "./types"; +import { McpManager } from "./manager"; + +type ScheduledTimer = { + delay: number; + callback: () => void; +}; + +function createMockClient(overrides: Partial = {}): Client { + return { + connect: mock(async () => {}), + close: mock(async () => {}), + callTool: mock(async () => ({ content: [] })), + listTools: mock(async () => ({ tools: [{ name: "list_files", description: "List files" }] })), + ...overrides, + } as unknown as Client; +} + +function createTimerHarness() { + const scheduled: ScheduledTimer[] = []; + + return { + scheduled, + setTimer: (callback: () => void, delay: number) => { + scheduled.push({ delay, callback }); + return scheduled.length as unknown as ReturnType; + }, + clearTimer: () => {}, + runNext: async () => { + const next = scheduled.shift(); + next?.callback(); + await new Promise((resolve) => setImmediate(resolve)); + }, + }; +} + +const httpServer: McpServerEntry = { + enabled: true, + transport: "http", + url: "http://127.0.0.1:9999/mcp", + timeoutMs: 60000, +}; + +const stdioServer: McpServerEntry = { + enabled: true, + transport: "stdio", + command: "echo", + timeoutMs: 45000, +}; + +describe("McpManager", () => { + afterEach(() => { + mock.restore(); + }); + + test("connectAll sets status connected for mock server", async () => { + const client = createMockClient(); + const manager = new McpManager({ + loadConfig: () => ({ mcpServers: { fs: stdioServer } }), + createClient: () => client, + createTransportFn: () => ({ type: "stdio" }) as ReturnType, + }); + + await manager.connectAll("/tmp/project"); + + expect(client.connect).toHaveBeenCalledTimes(1); + expect(client.listTools).toHaveBeenCalledTimes(1); + expect(manager.getStatus()).toEqual([ + { + name: "fs", + transport: "stdio", + status: McpConnectionStatus.CONNECTED, + enabled: true, + error: undefined, + toolCount: 1, + }, + ]); + }); + + test("HTTP failure schedules reconnect with doubling delay up to 5 attempts", async () => { + const timers = createTimerHarness(); + let connectCalls = 0; + const client = createMockClient({ + connect: mock(async () => { + connectCalls += 1; + throw new Error("connection refused"); + }), + }); + + const manager = new McpManager({ + loadConfig: () => ({ mcpServers: { remote: httpServer } }), + createClient: () => client, + createTransportFn: () => ({ type: "http" }) as ReturnType, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + + await manager.connectAll("/tmp/project"); + expect(connectCalls).toBe(1); + expect(manager.getStatus()[0]?.status).toBe(McpConnectionStatus.PENDING); + + for (let retry = 1; retry <= 5; retry += 1) { + expect(timers.scheduled).toHaveLength(1); + expect(timers.scheduled[0]?.delay).toBe(1000 * 2 ** (retry - 1)); + await timers.runNext(); + expect(connectCalls).toBe(1 + retry); + } + + expect(timers.scheduled).toHaveLength(0); + expect(manager.getStatus()[0]?.status).toBe(McpConnectionStatus.FAILED); + }); + + test("stdio failure does not schedule reconnect", async () => { + const timers = createTimerHarness(); + const client = createMockClient({ + connect: mock(async () => { + throw new Error("spawn failed"); + }), + }); + + const manager = new McpManager({ + loadConfig: () => ({ mcpServers: { local: stdioServer } }), + createClient: () => client, + createTransportFn: () => ({ type: "stdio" }) as ReturnType, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + + await manager.connectAll("/tmp/project"); + + expect(timers.scheduled).toHaveLength(0); + expect(manager.getStatus()[0]?.status).toBe(McpConnectionStatus.FAILED); + }); + + test("callTool passes timeout from server timeoutMs", async () => { + const client = createMockClient(); + const manager = new McpManager({ + loadConfig: () => ({ mcpServers: { local: stdioServer } }), + createClient: () => client, + createTransportFn: () => ({ type: "stdio" }) as ReturnType, + }); + + await manager.connectAll("/tmp/project"); + await manager.callTool("local", "list_files", { path: "." }); + + expect(client.callTool).toHaveBeenCalledWith( + { name: "list_files", arguments: { path: "." } }, + undefined, + { timeout: 45000, resetTimeoutOnProgress: true }, + ); + }); + + test("reconnect resets attempts and retries connect once", async () => { + let shouldFail = true; + const client = createMockClient({ + connect: mock(async () => { + if (shouldFail) { + throw new Error("temporary failure"); + } + }), + }); + const timers = createTimerHarness(); + + const manager = new McpManager({ + loadConfig: () => ({ mcpServers: { remote: httpServer } }), + createClient: () => client, + createTransportFn: () => ({ type: "http" }) as ReturnType, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + + await manager.connectAll("/tmp/project"); + expect(manager.getStatus()[0]?.status).toBe(McpConnectionStatus.PENDING); + + shouldFail = false; + await manager.reconnect("remote"); + + expect(client.connect).toHaveBeenCalledTimes(2); + expect(manager.getStatus()[0]?.status).toBe(McpConnectionStatus.CONNECTED); + expect(manager.getStatus()[0]?.toolCount).toBe(1); + }); + + test("applyServerEnabledChange only affects the toggled server", async () => { + let fsEnabled = true; + let connectCalls = 0; + const client = createMockClient({ + connect: mock(async () => { + connectCalls += 1; + }), + }); + + const manager = new McpManager({ + loadConfig: () => ({ + mcpServers: { + fs: { ...stdioServer, enabled: fsEnabled }, + other: stdioServer, + }, + }), + createClient: () => client, + createTransportFn: () => ({ type: "stdio" }) as ReturnType, + }); + + await manager.connectAll("/tmp/project"); + expect(connectCalls).toBe(2); + + fsEnabled = false; + await manager.applyServerEnabledChange("fs", false, "/tmp/project"); + + expect(manager.getStatus().find((server) => server.name === "fs")).toEqual({ + name: "fs", + transport: "stdio", + status: McpConnectionStatus.DISABLED, + enabled: false, + error: undefined, + toolCount: undefined, + }); + expect(manager.getStatus().find((server) => server.name === "other")?.status).toBe( + McpConnectionStatus.CONNECTED, + ); + expect(connectCalls).toBe(2); + + fsEnabled = true; + await manager.applyServerEnabledChange("fs", true, "/tmp/project"); + + expect(connectCalls).toBe(3); + expect(manager.getStatus().find((server) => server.name === "fs")?.status).toBe( + McpConnectionStatus.CONNECTED, + ); + }); +}); diff --git a/packages/cli/src/mcp/manager.ts b/packages/cli/src/mcp/manager.ts new file mode 100644 index 0000000..6eca450 --- /dev/null +++ b/packages/cli/src/mcp/manager.ts @@ -0,0 +1,411 @@ +/** + * MCP connection manager — connect, reconnect, tool calls (Phase 02, D-14, D-16). + * + * Owns the lifecycle of every MCP server declared in merged `mcp.json` (global + + * project). The CLI is the sole MCP client: servers never run on MoCode server. + * + * Architecture: + * - `connectAll` / `connectServer` — stdio child processes or HTTP/SSE transports + * - `getRegisteredTools` — tool schemas fed to the model (stable across brief disconnects) + * - `callTool` — invoked from `executeMcpToolCall` after optional TUI write approval + * - `getMcpManager` — process singleton; SIGINT/SIGTERM disconnects all servers + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { ModeType } from "@mocode/shared"; +import { getEnabledServers, loadMergedMcpConfig } from "./config"; +import type { McpServerEntry } from "./config-schema"; +import { createTransport } from "./transports"; +import { getMcpToolDefinitions } from "./tools"; +import type { SerializedMcpTool } from "./tools"; +import { + McpConnectionStatus, + McpTransport, + type McpConnectionStatusType, + type McpServerConfig, + type McpTransportType, +} from "./types"; + +const CLIENT_NAME = "mocode"; +const CLIENT_VERSION = "1.0.0"; +const DEFAULT_TIMEOUT_MS = 60_000; +const INITIAL_RECONNECT_DELAY_MS = 1_000; +const MAX_RECONNECT_ATTEMPTS = 5; + +/** Row shape for `/mcp` dialog and status polling. */ +export type McpServerStatus = { + name: string; + transport: McpTransportType; + status: McpConnectionStatusType; + enabled: boolean; + error?: string; + toolCount?: number; +}; + +/** `listTools` result grouped by configured server name. */ +export type DiscoveredMcpTool = { + serverName: string; + tools: Tool[]; +}; + +type ManagedServer = { + client: Client | null; + config: McpServerEntry; + status: McpConnectionStatusType; + reconnectAttempts: number; + reconnectTimer?: ReturnType; + error?: string; + tools: Tool[]; +}; + +/** Injectable deps for unit tests (timers, transport factory, config loader). */ +export type McpManagerDeps = { + loadConfig?: typeof loadMergedMcpConfig; + createClient?: () => Client; + createTransportFn?: (entry: McpServerConfig) => ReturnType; + setTimer?: typeof setTimeout; + clearTimer?: typeof clearTimeout; +}; + +function sanitizeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/(authorization|api-key|x-api-key):\s*\S+/gi, "$1: [REDACTED]"); +} + +function toMcpServerConfig(entry: McpServerEntry): McpServerConfig { + return entry as McpServerConfig; +} + +let exitHandlersRegistered = false; +let managerInstance: McpManager | null = null; + +function registerExitHandlers(manager: McpManager): void { + if (exitHandlersRegistered) { + return; + } + exitHandlersRegistered = true; + + const cleanup = () => { + void manager.disconnectAll(); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); +} + +export class McpManager { + private readonly servers = new Map(); + /** Prevents duplicate in-flight `connectServer` for the same name (e.g. rapid `/mcp` toggles). */ + private readonly connecting = new Set(); + private cwd = process.cwd(); + private readonly loadConfig: typeof loadMergedMcpConfig; + private readonly createClient: () => Client; + private readonly createTransportFn: (entry: McpServerConfig) => ReturnType; + private readonly setTimer: typeof setTimeout; + private readonly clearTimer: typeof clearTimeout; + + constructor(deps: McpManagerDeps = {}) { + this.loadConfig = deps.loadConfig ?? loadMergedMcpConfig; + this.createClient = deps.createClient ?? (() => new Client({ name: CLIENT_NAME, version: CLIENT_VERSION })); + this.createTransportFn = deps.createTransportFn ?? createTransport; + this.setTimer = deps.setTimer ?? setTimeout; + this.clearTimer = deps.clearTimer ?? clearTimeout; + } + + /** Loads merged config and connects all enabled MCP servers. */ + async connectAll(cwd: string): Promise { + this.cwd = cwd; + const config = this.loadConfig(cwd); + const enabled = getEnabledServers(config); + + await Promise.all( + Object.entries(enabled).map(([name, serverConfig]) => this.connectServer(name, serverConfig)), + ); + } + + /** Connects or disconnects a single server after its enabled flag changed in config. */ + async applyServerEnabledChange(serverName: string, enabled: boolean, cwd: string): Promise { + this.cwd = cwd; + + if (!enabled) { + await this.disconnect(serverName); + const entry = this.servers.get(serverName); + if (entry) { + entry.status = McpConnectionStatus.DISABLED; + } + return; + } + + const config = this.loadConfig(cwd); + const serverConfig = config.mcpServers[serverName]; + if (!serverConfig) { + throw new Error(`MCP server "${serverName}" not found in config`); + } + + await this.connectServer(serverName, serverConfig); + } + + /** Returns connection status for every server in merged config. */ + getStatus(): McpServerStatus[] { + const config = this.loadConfig(this.cwd); + return Object.entries(config.mcpServers).map(([name, serverConfig]) => { + const state = this.servers.get(name); + const disabled = serverConfig.enabled === false; + const connected = state?.status === McpConnectionStatus.CONNECTED; + + return { + name, + transport: serverConfig.transport, + status: disabled + ? McpConnectionStatus.DISABLED + : (state?.status ?? McpConnectionStatus.PENDING), + enabled: !disabled, + error: disabled ? undefined : state?.error, + toolCount: connected ? state.tools.length : undefined, + }; + }); + } + + /** + * Disconnects one MCP server and clears its reconnect timer. + * Intentionally does not clear `entry.tools` — last-known schemas stay available + * for `getRegisteredTools` until the next successful `listTools`. + */ + async disconnect(serverName: string): Promise { + const entry = this.servers.get(serverName); + if (!entry) { + return; + } + + if (entry.reconnectTimer) { + this.clearTimer(entry.reconnectTimer); + entry.reconnectTimer = undefined; + } + + if (entry.client) { + await entry.client.close().catch(() => {}); + entry.client = null; + } + + entry.status = McpConnectionStatus.PENDING; + } + + /** Disconnects all MCP servers. */ + async disconnectAll(): Promise { + await Promise.all([...this.servers.keys()].map((name) => this.disconnect(name))); + } + + /** + * Invokes a tool on a connected MCP server with per-server timeout (D-16). + * Calls `ensureConnected` first so a briefly disconnected but enabled server + * can be brought back before the tool loop retries the same call. + */ + async callTool( + serverName: string, + toolName: string, + args: Record, + options?: { timeout?: number }, + ) { + await this.ensureConnected(serverName); + + const entry = this.servers.get(serverName); + if (!entry?.client || entry.status !== McpConnectionStatus.CONNECTED) { + throw new Error(`MCP server "${serverName}" is not connected`); + } + + const timeout = options?.timeout ?? entry.config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return entry.client.callTool( + { name: toolName, arguments: args }, + undefined, + { timeout, resetTimeoutOnProgress: true }, + ); + } + + /** On-demand reconnect when `callTool` runs while the transport is down but server is enabled. */ + private async ensureConnected(serverName: string): Promise { + const entry = this.servers.get(serverName); + if (entry?.client && entry.status === McpConnectionStatus.CONNECTED) { + return; + } + + const config = this.loadConfig(this.cwd); + const serverConfig = config.mcpServers[serverName]; + if (!serverConfig || serverConfig.enabled === false) { + return; + } + + await this.connectServer(serverName, serverConfig); + } + + /** Resets reconnect attempts and retries connection once (for /mcp manual reconnect). */ + async reconnect(serverName: string): Promise { + const entry = this.servers.get(serverName); + if (!entry) { + return; + } + + if (entry.reconnectTimer) { + this.clearTimer(entry.reconnectTimer); + entry.reconnectTimer = undefined; + } + + entry.reconnectAttempts = 0; + await this.connectServer(serverName, entry.config); + } + + /** Only connected servers — use `getRegisteredTools` when the model needs schemas. */ + getDiscoveredTools(): DiscoveredMcpTool[] { + const discovered: DiscoveredMcpTool[] = []; + + for (const [serverName, entry] of this.servers) { + if (entry.status === McpConnectionStatus.CONNECTED && entry.tools.length > 0) { + discovered.push({ serverName, tools: entry.tools }); + } + } + + return discovered; + } + + /** + * Tool schemas for model registration — uses live connection when available, + * otherwise last-known schemas for enabled servers (keeps tool-loop turns stable). + */ + getRegisteredTools(): DiscoveredMcpTool[] { + const config = this.loadConfig(this.cwd); + const registered: DiscoveredMcpTool[] = []; + + for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) { + if (serverConfig.enabled === false) { + continue; + } + + const entry = this.servers.get(serverName); + const liveTools = + entry?.status === McpConnectionStatus.CONNECTED && entry.tools.length > 0 + ? entry.tools + : entry?.tools.length + ? entry.tools + : undefined; + + if (liveTools && liveTools.length > 0) { + registered.push({ serverName, tools: liveTools }); + } + } + + return registered; + } + + /** Mode-filtered MCP tool schemas for SaaS/BYOK registration (schemas only, no execution). */ + getToolDefinitions(mode: ModeType): SerializedMcpTool[] { + return getMcpToolDefinitions(this, mode, this.cwd); + } + + /** + * Connects one server: create SDK client → transport → listTools → cache schemas. + * + * Reconnect policy (D-14): + * - HTTP/SSE: `scheduleReconnect` with exponential backoff on failure + * - stdio: no auto-reconnect — child exit is usually config/user action; user retries via `/mcp` + * + * On disconnect, `entry.tools` is retained (see `disconnect`) so `getRegisteredTools` + * can still expose schemas while status is pending/failed. + */ + private async connectServer(name: string, config: McpServerEntry): Promise { + if (this.connecting.has(name)) { + return; + } + this.connecting.add(name); + const previous = this.servers.get(name); + if (previous?.reconnectTimer) { + this.clearTimer(previous.reconnectTimer); + } + if (previous?.client) { + await previous.client.close().catch(() => {}); + } + + const entry: ManagedServer = { + client: null, + config, + status: McpConnectionStatus.PENDING, + reconnectAttempts: previous?.reconnectAttempts ?? 0, + tools: [], + }; + this.servers.set(name, entry); + + try { + const client = this.createClient(); + const transport = this.createTransportFn(toMcpServerConfig(config)); + await client.connect(transport); + + let tools: Tool[] = []; + try { + ({ tools } = await client.listTools()); + } catch (discoveryError) { + await client.close().catch(() => {}); + entry.status = McpConnectionStatus.FAILED; + entry.error = sanitizeErrorMessage(discoveryError); + return; + } + + entry.client = client; + entry.status = McpConnectionStatus.CONNECTED; + entry.reconnectAttempts = 0; + entry.error = undefined; + entry.tools = tools; + } catch (error) { + entry.status = McpConnectionStatus.FAILED; + entry.error = sanitizeErrorMessage(error); + entry.tools = []; + + const isRemoteTransport = + config.transport === McpTransport.HTTP || config.transport === McpTransport.SSE; + + if (isRemoteTransport) { + this.scheduleReconnect(name); + } + } finally { + this.connecting.delete(name); + } + } + + /** + * Exponential backoff reconnect for remote transports only. + * Sets status to PENDING during the wait so `/mcp` can show "pending…". + */ + private scheduleReconnect(serverName: string): void { + const entry = this.servers.get(serverName); + if (!entry) { + return; + } + + if (entry.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + entry.status = McpConnectionStatus.FAILED; + return; + } + + entry.reconnectAttempts += 1; + entry.status = McpConnectionStatus.PENDING; + const delay = INITIAL_RECONNECT_DELAY_MS * 2 ** (entry.reconnectAttempts - 1); + + entry.reconnectTimer = this.setTimer(() => { + entry.reconnectTimer = undefined; + void this.connectServer(serverName, entry.config); + }, delay); + } +} + +/** Returns the process-wide MCP manager singleton. */ +export function getMcpManager(): McpManager { + if (!managerInstance) { + managerInstance = new McpManager(); + registerExitHandlers(managerInstance); + } + return managerInstance; +} + +/** Clears the singleton — for unit tests only. */ +export function resetMcpManagerForTests(): void { + managerInstance = null; +} diff --git a/packages/cli/src/mcp/session-init.test.ts b/packages/cli/src/mcp/session-init.test.ts new file mode 100644 index 0000000..5dcea59 --- /dev/null +++ b/packages/cli/src/mcp/session-init.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { initMcpOnSessionMount } from "./session-mcp"; + +describe("initMcpOnSessionMount", () => { + const mockConnectAll = mock(async () => {}); + const mockDisconnectAll = mock(async () => {}); + const mockWatchMcpConfig = mock(() => {}); + const mockStopMcpWatcher = mock(() => {}); + + const deps = { + getManager: () => ({ + connectAll: mockConnectAll, + disconnectAll: mockDisconnectAll, + }), + watchConfig: mockWatchMcpConfig, + stopWatcher: mockStopMcpWatcher, + }; + + beforeEach(() => { + mockConnectAll.mockClear(); + mockDisconnectAll.mockClear(); + mockWatchMcpConfig.mockClear(); + mockStopMcpWatcher.mockClear(); + }); + + test("mount calls connectAll and watchMcpConfig once", () => { + const cleanup = initMcpOnSessionMount("/tmp/project", deps); + + expect(mockConnectAll).toHaveBeenCalledTimes(1); + expect(mockConnectAll).toHaveBeenCalledWith("/tmp/project"); + expect(mockWatchMcpConfig).toHaveBeenCalledTimes(1); + expect(mockWatchMcpConfig).toHaveBeenCalledWith("/tmp/project"); + + cleanup(); + }); + + test("cleanup calls stopMcpWatcher and disconnectAll", () => { + const cleanup = initMcpOnSessionMount("/tmp/project", deps); + mockConnectAll.mockClear(); + mockWatchMcpConfig.mockClear(); + + cleanup(); + + expect(mockStopMcpWatcher).toHaveBeenCalledTimes(1); + expect(mockDisconnectAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/mcp/session-mcp.ts b/packages/cli/src/mcp/session-mcp.ts new file mode 100644 index 0000000..d02cc6e --- /dev/null +++ b/packages/cli/src/mcp/session-mcp.ts @@ -0,0 +1,37 @@ +/** + * Session-scoped MCP initialization (Phase 02, D-06 partial). + * + * Called when a chat session screen mounts: + * 1. connectAll enabled servers for session cwd + * 2. watch mcp.json for hot-reload (chokidar, D-04) + * 3. cleanup on unmount — stop watcher, disconnectAll + * + * Partial connect failure is swallowed so a bad MCP server cannot block the TUI. + */ +import { getMcpManager } from "./manager"; +import { stopMcpWatcher, watchMcpConfig } from "./watcher"; + +export type SessionMcpDeps = { + getManager?: typeof getMcpManager; + watchConfig?: typeof watchMcpConfig; + stopWatcher?: typeof stopMcpWatcher; +}; + +/** Connects MCP servers and watches config; returns cleanup for session unmount. */ +export function initMcpOnSessionMount(cwd: string, deps: SessionMcpDeps = {}): () => void { + const getManager = deps.getManager ?? getMcpManager; + const watchConfig = deps.watchConfig ?? watchMcpConfig; + const stopWatcher = deps.stopWatcher ?? stopMcpWatcher; + const manager = getManager(); + + void manager.connectAll(cwd).catch(() => { + // Partial MCP failure must not block session render. + }); + + watchConfig(cwd); + + return () => { + stopWatcher(); + void manager.disconnectAll(); + }; +} diff --git a/packages/cli/src/mcp/tools.test.ts b/packages/cli/src/mcp/tools.test.ts new file mode 100644 index 0000000..a893a92 --- /dev/null +++ b/packages/cli/src/mcp/tools.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { mcpToolName, buildMcpToolSet } from "./tools"; +import { Mode } from "@mocode/shared"; + +describe("mcpToolName", () => { + test("formats tool name as mcp__server__tool per D-05", () => { + expect(mcpToolName("filesystem", "read_file")).toBe("mcp__filesystem__read_file"); + }); + + test("preserves server and tool segments", () => { + expect(mcpToolName("my-server", "list_items")).toBe("mcp__my-server__list_items"); + }); +}); + +describe("buildMcpToolSet", () => { + test("builds tool set with mcp__ naming prefix", () => { + const tools = buildMcpToolSet(Mode.BUILD, [ + { serverName: "filesystem", tool: { name: "read_file", description: "Read a file", inputSchema: {} } }, + ]); + + expect(Object.keys(tools)).toContain("mcp__filesystem__read_file"); + }); + + test("PLAN mode excludes write MCP tools", () => { + const tools = buildMcpToolSet(Mode.PLAN, [ + { serverName: "filesystem", tool: { name: "read_file", description: "Read", inputSchema: {} } }, + { serverName: "filesystem", tool: { name: "write_file", description: "Write", inputSchema: {} } }, + ]); + + expect(Object.keys(tools)).toContain("mcp__filesystem__read_file"); + expect(Object.keys(tools)).not.toContain("mcp__filesystem__write_file"); + }); +}); diff --git a/packages/cli/src/mcp/tools.ts b/packages/cli/src/mcp/tools.ts new file mode 100644 index 0000000..b6ae48b --- /dev/null +++ b/packages/cli/src/mcp/tools.ts @@ -0,0 +1,212 @@ +/** + * MCP tools → AI SDK dynamicTool bridge (Phase 02, D-05, D-07, D-08). + * + * Responsibilities: + * - Name tools `mcp____` (Claude Code convention, D-05) + * - Build schema-only dynamicTool entries (no execute — CLI runs MCP in onToolCall) + * - Filter write MCP tools in PLAN mode (D-08) + * - Serialize schemas for SaaS wire payload (`getMcpToolDefinitions` → chat submit body) + */ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { getToolContracts, Mode, type ModeType } from "@mocode/shared"; +import { dynamicTool, jsonSchema, type ToolSet } from "ai"; +import { z } from "zod"; +import type { McpConfig, McpServerEntry } from "./config-schema"; +import { loadMergedMcpConfig } from "./config"; +import { + isMcpReadOnlyTool, + isMcpToolName, + parseMcpToolName, + type McpToolConfigOverride, +} from "./heuristics"; +import type { McpManager } from "./manager"; + +export type McpToolDescriptor = { + serverName: string; + tool: { + name: string; + description?: string; + inputSchema?: unknown; + }; +}; + +export type SerializedMcpTool = { + name: string; + description?: string; + inputSchema?: unknown; +}; + +/** Formats MCP tool name per D-05: `mcp____`. */ +export function mcpToolName(serverName: string, toolName: string): string { + return `mcp__${serverName}__${toolName}`; +} + +function toolConfigOverride( + serverConfig: McpServerEntry | undefined, + toolName: string, +): McpToolConfigOverride | undefined { + return serverConfig?.tools?.[toolName]; +} + +function jsonSchemaToInputSchema(schema: unknown) { + if (schema && typeof schema === "object") { + try { + return jsonSchema(schema as Record); + } catch { + // Fall through to permissive Zod object. + } + } + return z.object({}).passthrough(); +} + +/** Maps listTools results to AI SDK dynamicTool entries (no execute fn). */ +export function mcpToolsToDynamicTools( + serverName: string, + tools: Array, + serverConfig?: McpServerEntry, +): ToolSet { + const result: ToolSet = {}; + + for (const tool of tools) { + const fullName = mcpToolName(serverName, tool.name); + result[fullName] = dynamicTool({ + description: tool.description ?? `MCP tool ${tool.name} from ${serverName}`, + inputSchema: jsonSchemaToInputSchema(tool.inputSchema), + }); + } + + return result; +} + +/** Removes write MCP tools from the tool set in PLAN mode (D-08). */ +export function filterMcpToolsForMode( + mode: ModeType, + toolSet: ToolSet, + config?: McpConfig, +): ToolSet { + if (mode === Mode.BUILD) { + return toolSet; + } + + const filtered: ToolSet = {}; + for (const [name, definition] of Object.entries(toolSet)) { + if (!isMcpToolName(name)) { + filtered[name] = definition; + continue; + } + + const { server, tool } = parseMcpToolName(name); + const override = toolConfigOverride(config?.mcpServers[server], tool); + if (isMcpReadOnlyTool(tool, override)) { + filtered[name] = definition; + } + } + + return filtered; +} + +/** Builds MCP-only dynamicTool set from descriptors with mode filtering. */ +export function buildMcpToolSet( + mode: ModeType, + descriptors: McpToolDescriptor[], + config?: McpConfig, +): ToolSet { + let combined: ToolSet = {}; + + for (const { serverName, tool } of descriptors) { + const serverConfig = config?.mcpServers[serverName]; + combined = { ...combined, ...mcpToolsToDynamicTools(serverName, [tool], serverConfig) }; + } + + return filterMcpToolsForMode(mode, combined, config); +} + +/** Merges local tool contracts with mode-filtered MCP dynamic tools. */ +export function buildMergedToolSet( + mode: ModeType, + mcpDynamicTools: ToolSet, + config?: McpConfig, +): ToolSet { + return { + ...getToolContracts(mode), + ...filterMcpToolsForMode(mode, mcpDynamicTools, config), + }; +} + +/** JSON-safe wire format for SaaS chat request `mcpTools` payload. */ +export function serializeMcpToolsForServer(toolSet: ToolSet): SerializedMcpTool[] { + return Object.entries(toolSet) + .filter(([name]) => isMcpToolName(name)) + .map(([name, definition]) => { + const tool = definition as { + description?: string; + inputSchema?: unknown; + }; + + return { + name, + description: tool.description, + inputSchema: tool.inputSchema, + }; + }); +} + +/** Builds dynamicTool map from all connected MCP servers. */ +export function buildMcpDynamicToolsFromManager( + manager: McpManager, + config?: McpConfig, +): ToolSet { + const mergedConfig = config ?? loadMergedMcpConfig(process.cwd()); + let combined: ToolSet = {}; + + for (const { serverName, tools } of manager.getRegisteredTools()) { + const serverConfig = mergedConfig.mcpServers[serverName]; + combined = { + ...combined, + ...mcpToolsToDynamicTools(serverName, tools, serverConfig), + }; + } + + return combined; +} + +/** Serialized MCP tool schemas for a session mode (schemas only, no execution). */ +export function getMcpToolDefinitions( + manager: McpManager, + mode: ModeType, + cwd = process.cwd(), +): SerializedMcpTool[] { + const config = loadMergedMcpConfig(cwd); + return serializeDiscoveredMcpTools(mode, manager.getRegisteredTools(), config); +} + +/** + * SaaS path: preserves raw MCP `inputSchema` from listTools for the server wire payload. + * Prefer this over `serializeMcpToolsForServer` when schemas must round-trip unchanged. + */ +export function serializeDiscoveredMcpTools( + mode: ModeType, + discovered: ReturnType, + config: McpConfig, +): SerializedMcpTool[] { + const serialized: SerializedMcpTool[] = []; + + for (const { serverName, tools } of discovered) { + const serverConfig = config.mcpServers[serverName]; + + for (const tool of tools) { + const override = toolConfigOverride(serverConfig, tool.name); + if (mode === Mode.PLAN && !isMcpReadOnlyTool(tool.name, override)) { + continue; + } + + serialized.push({ + name: mcpToolName(serverName, tool.name), + description: tool.description, + inputSchema: tool.inputSchema, + }); + } + } + + return serialized; +} diff --git a/packages/cli/src/mcp/transports.test.ts b/packages/cli/src/mcp/transports.test.ts new file mode 100644 index 0000000..e84a46b --- /dev/null +++ b/packages/cli/src/mcp/transports.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { createTransport } from "./transports"; +import type { McpServerConfigStdio } from "./types"; + +const baseStdio = (overrides: Partial = {}): McpServerConfigStdio => ({ + enabled: true, + transport: "stdio", + command: "echo", + timeoutMs: 60000, + ...overrides, +}); + +describe("createTransport", () => { + test("stdio transport type selects StdioClientTransport", () => { + const transport = createTransport( + baseStdio({ + args: ["hello"], + }), + ); + + expect(transport.type).toBe("stdio"); + expect(transport.constructor.name).toMatch(/Stdio/i); + expect((transport as { stderr: unknown }).stderr).toBeNull(); + }); + + test("stdio env merges process.env with entry.env", () => { + const transport = createTransport( + baseStdio({ + env: { MOCODE_TEST_ENV: "custom" }, + }), + ); + + expect(transport.type).toBe("stdio"); + expect(process.env.PATH).toBeDefined(); + }); + + test("http transport type selects StreamableHTTPClientTransport", () => { + const transport = createTransport({ + enabled: true, + transport: "http", + url: "http://localhost:3000/mcp", + timeoutMs: 60000, + }); + + expect(transport.type).toBe("http"); + expect(transport.constructor.name).toMatch(/HTTP/i); + }); + + test("sse transport type selects SSEClientTransport", () => { + const transport = createTransport({ + enabled: true, + transport: "sse", + url: "http://localhost:3000/sse", + timeoutMs: 60000, + }); + + expect(transport.type).toBe("sse"); + expect(transport.constructor.name).toMatch(/SSE/i); + }); +}); diff --git a/packages/cli/src/mcp/transports.ts b/packages/cli/src/mcp/transports.ts new file mode 100644 index 0000000..b36d99e --- /dev/null +++ b/packages/cli/src/mcp/transports.ts @@ -0,0 +1,51 @@ +/** + * MCP transport factory — stdio, HTTP (Streamable), and SSE (Phase 02, D-02). + * + * Stdio uses `stderr: "ignore"` so child process banner logs (e.g. npx MCP servers) + * do not paint through the OpenTUI dialog layer. + */ +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { McpTransport, type McpServerConfig, type McpTransportType } from "./types"; + +export type TaggedTransport = Transport & { type: McpTransportType }; + +function tagTransport(transport: Transport, type: McpTransportType): TaggedTransport { + return Object.assign(transport, { type }); +} + +/** Creates an MCP client transport for the given server config entry. */ +export function createTransport(entry: McpServerConfig): TaggedTransport { + switch (entry.transport) { + case McpTransport.STDIO: + return tagTransport( + new StdioClientTransport({ + command: entry.command, + args: entry.args ?? [], + env: { ...process.env, ...entry.env } as Record, + stderr: "ignore", + }), + McpTransport.STDIO, + ); + case McpTransport.HTTP: + return tagTransport( + new StreamableHTTPClientTransport(new URL(entry.url), { + requestInit: entry.headers ? { headers: entry.headers } : undefined, + }), + McpTransport.HTTP, + ); + case McpTransport.SSE: + return tagTransport( + new SSEClientTransport(new URL(entry.url), { + requestInit: entry.headers ? { headers: entry.headers } : undefined, + }), + McpTransport.SSE, + ); + default: { + const _exhaustive: never = entry; + throw new Error(`Unsupported MCP transport: ${(_exhaustive as McpServerConfig).transport}`); + } + } +} diff --git a/packages/cli/src/mcp/types.ts b/packages/cli/src/mcp/types.ts new file mode 100644 index 0000000..effe924 --- /dev/null +++ b/packages/cli/src/mcp/types.ts @@ -0,0 +1,58 @@ +/** + * MCP transport and connection types for MoCode CLI MCP client (Phase 02, D-02). + */ + +export const McpTransport = { + STDIO: "stdio", + HTTP: "http", + SSE: "sse", +} as const; + +export type McpTransportType = (typeof McpTransport)[keyof typeof McpTransport]; + +export const McpConnectionStatus = { + CONNECTED: "connected", + PENDING: "pending", + FAILED: "failed", + DISABLED: "disabled", +} as const; + +export type McpConnectionStatusType = + (typeof McpConnectionStatus)[keyof typeof McpConnectionStatus]; + +export type McpToolOverride = { + readOnly: boolean; +}; + +export type McpServerConfigStdio = { + enabled: boolean; + transport: typeof McpTransport.STDIO; + command: string; + args?: string[]; + env?: Record; + timeoutMs: number; + tools?: Record; +}; + +export type McpServerConfigHttp = { + enabled: boolean; + transport: typeof McpTransport.HTTP; + url: string; + headers?: Record; + timeoutMs: number; + tools?: Record; +}; + +export type McpServerConfigSse = { + enabled: boolean; + transport: typeof McpTransport.SSE; + url: string; + headers?: Record; + timeoutMs: number; + tools?: Record; +}; + +export type McpServerConfig = + | McpServerConfigStdio + | McpServerConfigHttp + | McpServerConfigSse; diff --git a/packages/cli/src/mcp/watcher.test.ts b/packages/cli/src/mcp/watcher.test.ts new file mode 100644 index 0000000..48eeea1 --- /dev/null +++ b/packages/cli/src/mcp/watcher.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { FSWatcher } from "chokidar"; +import type { McpManager } from "./manager"; +import { stopMcpWatcher, watchMcpConfig } from "./watcher"; + +type ChangeHandler = () => void; + +describe("watchMcpConfig", () => { + const changeHandlers: ChangeHandler[] = []; + const mockWatcherClose = mock(async () => {}); + const mockDisconnectAll = mock(async () => {}); + const mockConnectAll = mock(async () => {}); + const scheduledTimers: Array<{ delay: number; callback: () => void }> = []; + + const mockWatch = mock((_path: string) => ({ + on: mock((event: string, handler: ChangeHandler) => { + if (event === "change") { + changeHandlers.push(handler); + } + }), + close: mockWatcherClose, + })) as unknown as (path: string) => FSWatcher; + + const mockSetTimer = mock((callback: () => void, delay: number) => { + scheduledTimers.push({ delay, callback }); + return scheduledTimers.length as unknown as ReturnType; + }); + + const mockClearTimer = mock((_id: ReturnType) => {}); + const mockExists = mock(() => true); + + const mockManager = { + disconnectAll: mockDisconnectAll, + connectAll: mockConnectAll, + } as unknown as McpManager; + + const testDeps = { + watch: mockWatch, + setTimer: mockSetTimer, + clearTimer: mockClearTimer, + exists: mockExists, + getManager: () => mockManager, + getPaths: () => ({ + global: "/home/user/.mocode/mcp.json", + project: "/tmp/project/.mocode/mcp.json", + }), + }; + + beforeEach(() => { + stopMcpWatcher(testDeps); + changeHandlers.length = 0; + scheduledTimers.length = 0; + mockWatch.mockClear(); + mockWatcherClose.mockClear(); + mockDisconnectAll.mockClear(); + mockConnectAll.mockClear(); + mockSetTimer.mockClear(); + mockClearTimer.mockClear(); + }); + + afterEach(() => { + stopMcpWatcher(testDeps); + }); + + test("registers watchers on global and project mcp.json paths", () => { + watchMcpConfig("/tmp/project", undefined, testDeps); + + expect(mockWatch).toHaveBeenCalledTimes(2); + expect(mockWatch).toHaveBeenCalledWith("/home/user/.mocode/mcp.json", { ignoreInitial: true }); + expect(mockWatch).toHaveBeenCalledWith("/tmp/project/.mocode/mcp.json", { ignoreInitial: true }); + }); + + test("debounces rapid changes into one reload after 300ms", async () => { + watchMcpConfig("/tmp/project", undefined, testDeps); + + changeHandlers[0]?.(); + changeHandlers[0]?.(); + + expect(mockDisconnectAll).toHaveBeenCalledTimes(0); + expect(mockConnectAll).toHaveBeenCalledTimes(0); + expect(mockClearTimer).toHaveBeenCalledTimes(1); + + const lastTimer = scheduledTimers.at(-1); + expect(lastTimer?.delay).toBe(300); + + await lastTimer?.callback(); + + expect(mockDisconnectAll).toHaveBeenCalledTimes(1); + expect(mockConnectAll).toHaveBeenCalledTimes(1); + expect(mockConnectAll).toHaveBeenCalledWith("/tmp/project"); + }); + + test("stopMcpWatcher closes chokidar handles", async () => { + watchMcpConfig("/tmp/project", undefined, testDeps); + + stopMcpWatcher(testDeps); + + expect(mockWatcherClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/mcp/watcher.ts b/packages/cli/src/mcp/watcher.ts new file mode 100644 index 0000000..44c75d9 --- /dev/null +++ b/packages/cli/src/mcp/watcher.ts @@ -0,0 +1,117 @@ +/** + * chokidar hot-reload for mcp.json changes (Phase 02, D-04). + * + * Watches both global and project config paths; debounces 300ms then disconnectAll + + * connectAll so rapid saves coalesce. Module-level debounce state assumes one active + * session watcher per process (session unmount calls stopMcpWatcher). + */ +import { existsSync } from "node:fs"; +import { dirname } from "node:path"; +import chokidar, { type FSWatcher } from "chokidar"; +import { getMcpConfigPaths } from "./config"; +import { getMcpManager, type McpManager } from "./manager"; + +const DEBOUNCE_MS = 300; + +export type McpWatcherDeps = { + watch?: typeof chokidar.watch; + setTimer?: typeof setTimeout; + clearTimer?: typeof clearTimeout; + exists?: typeof existsSync; + getManager?: () => McpManager; + getPaths?: typeof getMcpConfigPaths; +}; + +type ActiveWatcher = { + handle: FSWatcher; +}; + +let activeWatchers: ActiveWatcher[] = []; +let debounceTimer: ReturnType | undefined; +let debounceCwd: string | undefined; +let debounceOnReload: (() => void) | undefined; +let timerHooks: Pick = {}; + +function resolveDeps(deps?: McpWatcherDeps): Required { + return { + watch: deps?.watch ?? chokidar.watch.bind(chokidar), + setTimer: deps?.setTimer ?? setTimeout, + clearTimer: deps?.clearTimer ?? clearTimeout, + exists: deps?.exists ?? existsSync, + getManager: deps?.getManager ?? getMcpManager, + getPaths: deps?.getPaths ?? getMcpConfigPaths, + }; +} + +function resolveWatchTarget(path: string, exists: typeof existsSync): string { + return exists(path) ? path : dirname(path); +} + +async function reloadMcp( + cwd: string, + onReload: (() => void) | undefined, + getManager: () => McpManager, +): Promise { + const manager = getManager(); + await manager.disconnectAll(); + await manager.connectAll(cwd); + onReload?.(); +} + +function scheduleReload(cwd: string, onReload: (() => void) | undefined, deps: Required): void { + debounceCwd = cwd; + debounceOnReload = onReload; + + if (debounceTimer !== undefined) { + deps.clearTimer(debounceTimer); + } + + debounceTimer = deps.setTimer(() => { + debounceTimer = undefined; + const targetCwd = debounceCwd ?? cwd; + const callback = debounceOnReload; + debounceCwd = undefined; + debounceOnReload = undefined; + void reloadMcp(targetCwd, callback, deps.getManager); + }, DEBOUNCE_MS); +} + +/** Watches global and project mcp.json; debounced reload reconnects all MCP servers. */ +export function watchMcpConfig(cwd: string, onReload?: () => void, deps?: McpWatcherDeps): void { + const resolved = resolveDeps(deps); + timerHooks = { setTimer: resolved.setTimer, clearTimer: resolved.clearTimer }; + + stopMcpWatcher(resolved); + + const paths = resolved.getPaths(cwd); + for (const configPath of [paths.global, paths.project]) { + const target = resolveWatchTarget(configPath, resolved.exists); + const handle = resolved.watch(target, { ignoreInitial: true }); + + const onConfigEvent = () => { + scheduleReload(cwd, onReload, resolved); + }; + + handle.on("change", onConfigEvent); + handle.on("add", onConfigEvent); + activeWatchers.push({ handle }); + } +} + +/** Stops all MCP config watchers and clears pending debounced reloads. */ +export function stopMcpWatcher(deps?: Pick): void { + const clearTimer = deps?.clearTimer ?? timerHooks.clearTimer ?? clearTimeout; + + if (debounceTimer !== undefined) { + clearTimer(debounceTimer); + debounceTimer = undefined; + } + + debounceCwd = undefined; + debounceOnReload = undefined; + + for (const { handle } of activeWatchers) { + void handle.close(); + } + activeWatchers = []; +} From 2cc5bf2b44ed0136668058923cd26a0c56bb9c86 Mon Sep 17 00:00:00 2001 From: moyun Date: Sat, 27 Jun 2026 19:35:49 +0800 Subject: [PATCH 3/7] feat(cli): add BYOK --local mode with keys and local sessions Support mocode --local with keys.json storage, /keys wizard triggers, per-project local session JSON, LocalChatTransport in-process streamText, and BYOK system prompt. Co-authored-by: Cursor --- .../cli/src/lib/keys-wizard-trigger.test.ts | 97 +++++++ packages/cli/src/lib/keys-wizard-trigger.ts | 44 ++++ packages/cli/src/lib/keys.test.ts | 58 +++++ packages/cli/src/lib/keys.ts | 75 ++++++ .../cli/src/lib/local-chat-transport.test.ts | 115 +++++++++ packages/cli/src/lib/local-chat-transport.ts | 177 +++++++++++++ packages/cli/src/lib/local-mode.test.ts | 17 ++ packages/cli/src/lib/local-mode.ts | 22 ++ packages/cli/src/lib/local-model.test.ts | 34 +++ packages/cli/src/lib/local-model.ts | 239 ++++++++++++++++++ packages/cli/src/lib/local-sessions.test.ts | 60 +++++ packages/cli/src/lib/local-sessions.ts | 150 +++++++++++ packages/cli/src/lib/system-prompt.ts | 206 +++++++++++++++ 13 files changed, 1294 insertions(+) create mode 100644 packages/cli/src/lib/keys-wizard-trigger.test.ts create mode 100644 packages/cli/src/lib/keys-wizard-trigger.ts create mode 100644 packages/cli/src/lib/keys.test.ts create mode 100644 packages/cli/src/lib/keys.ts create mode 100644 packages/cli/src/lib/local-chat-transport.test.ts create mode 100644 packages/cli/src/lib/local-chat-transport.ts create mode 100644 packages/cli/src/lib/local-mode.test.ts create mode 100644 packages/cli/src/lib/local-mode.ts create mode 100644 packages/cli/src/lib/local-model.test.ts create mode 100644 packages/cli/src/lib/local-model.ts create mode 100644 packages/cli/src/lib/local-sessions.test.ts create mode 100644 packages/cli/src/lib/local-sessions.ts create mode 100644 packages/cli/src/lib/system-prompt.ts diff --git a/packages/cli/src/lib/keys-wizard-trigger.test.ts b/packages/cli/src/lib/keys-wizard-trigger.test.ts new file mode 100644 index 0000000..89f2ee1 --- /dev/null +++ b/packages/cli/src/lib/keys-wizard-trigger.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_CHAT_MODEL_ID, findSupportedChatModel } from "@mocode/shared"; +import { saveKeys } from "./keys"; +import { setLocalMode } from "./local-mode"; +import { openKeysWizardIfNeeded, shouldAutoOpenKeysWizard } from "./keys-wizard-trigger"; + +const TEST_DIR = join(homedir(), ".mocode-test-keys-wizard"); + +function defaultProvider(): string { + return findSupportedChatModel(DEFAULT_CHAT_MODEL_ID)!.provider; +} + +describe("shouldAutoOpenKeysWizard", () => { + beforeEach(() => { + setLocalMode(false); + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true, mode: 0o700 }); + } + }); + + afterEach(() => { + setLocalMode(false); + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + }); + + test("returns true when local mode and keys missing", () => { + setLocalMode(true); + const provider = defaultProvider(); + expect(shouldAutoOpenKeysWizard({ keysDir: TEST_DIR, provider })).toBe(true); + }); + + test("returns false in SaaS mode", () => { + setLocalMode(false); + expect(shouldAutoOpenKeysWizard({ keysDir: TEST_DIR, provider: defaultProvider() })).toBe(false); + }); + + test("returns false when keys present", () => { + setLocalMode(true); + const provider = defaultProvider(); + saveKeys({ [provider]: { apiKey: "sk-test-key" } }, { keysDir: TEST_DIR }); + expect(shouldAutoOpenKeysWizard({ keysDir: TEST_DIR, provider })).toBe(false); + }); +}); + +describe("openKeysWizardIfNeeded", () => { + beforeEach(() => { + setLocalMode(false); + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true, mode: 0o700 }); + } + }); + + afterEach(() => { + setLocalMode(false); + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + }); + + test("opens dialog when local mode and keys missing", () => { + setLocalMode(true); + const provider = defaultProvider(); + let opened = false; + + const dialog = { + open: () => { + opened = true; + }, + close: () => {}, + }; + + expect(openKeysWizardIfNeeded(dialog, { keysDir: TEST_DIR, provider })).toBe(true); + expect(opened).toBe(true); + }); + + test("does not open dialog in SaaS mode", () => { + setLocalMode(false); + let opened = false; + + const dialog = { + open: () => { + opened = true; + }, + close: () => {}, + }; + + expect(openKeysWizardIfNeeded(dialog, { keysDir: TEST_DIR, provider: defaultProvider() })).toBe( + false, + ); + expect(opened).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/keys-wizard-trigger.ts b/packages/cli/src/lib/keys-wizard-trigger.ts new file mode 100644 index 0000000..28db0e7 --- /dev/null +++ b/packages/cli/src/lib/keys-wizard-trigger.ts @@ -0,0 +1,44 @@ +/** + * D-12 auto-trigger for the `/keys` setup wizard in BYOK mode. + * Never logs key values. + */ +import { createElement } from "react"; +import { DEFAULT_CHAT_MODEL_ID, findSupportedChatModel } from "@mocode/shared"; +import { KeysWizardDialogContent } from "../components/dialogs/keys-wizard-dialog"; +import type { DialogContextValue } from "../providers/dialog"; +import { hasRequiredKeys, type KeysOptions } from "./keys"; +import { isLocalMode } from "./local-mode"; + +export type KeysWizardTriggerOptions = KeysOptions & { + provider?: string; +}; + +function getDefaultProviderForKeysCheck(): string { + return findSupportedChatModel(DEFAULT_CHAT_MODEL_ID)?.provider ?? "anthropic"; +} + +/** Returns true when BYOK mode is active and the target provider has no API key. */ +export function shouldAutoOpenKeysWizard(options?: KeysWizardTriggerOptions): boolean { + if (!isLocalMode()) { + return false; + } + + const provider = options?.provider ?? getDefaultProviderForKeysCheck(); + return !hasRequiredKeys(provider, options); +} + +/** Opens the keys wizard when auto-trigger conditions are met. Returns whether it opened. */ +export function openKeysWizardIfNeeded( + dialog: DialogContextValue, + options?: KeysWizardTriggerOptions, +): boolean { + if (!shouldAutoOpenKeysWizard(options)) { + return false; + } + + dialog.open({ + title: "API Keys", + children: createElement(KeysWizardDialogContent), + }); + return true; +} diff --git a/packages/cli/src/lib/keys.test.ts b/packages/cli/src/lib/keys.test.ts new file mode 100644 index 0000000..8ad1f9c --- /dev/null +++ b/packages/cli/src/lib/keys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { saveKeys, getKeys, hasRequiredKeys } from "./keys"; + +const TEST_DIR = join(homedir(), ".mocode-test-keys"); +const KEYS_FILE = join(TEST_DIR, "keys.json"); + +describe("keys", () => { + beforeEach(() => { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true, mode: 0o700 }); + } + }); + + afterEach(() => { + if (existsSync(KEYS_FILE)) { + rmSync(KEYS_FILE); + } + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + }); + + test("saveKeys creates file with mode 0o600 per D-12", () => { + saveKeys({ openai: { apiKey: "sk-test-key" } }, { keysDir: TEST_DIR }); + + expect(existsSync(KEYS_FILE)).toBe(true); + const stat = statSync(KEYS_FILE); + expect(stat.mode & 0o777).toBe(0o600); + }); + + test("getKeys returns saved keys", () => { + const keys = { + anthropic: { apiKey: "sk-ant-test" }, + openai: { apiKey: "sk-test" }, + }; + saveKeys(keys, { keysDir: TEST_DIR }); + + expect(getKeys({ keysDir: TEST_DIR })).toEqual(keys); + }); + + test("getKeys returns null when file missing", () => { + expect(getKeys({ keysDir: TEST_DIR })).toBeNull(); + }); + + test("hasRequiredKeys returns true when provider has non-empty apiKey", () => { + saveKeys({ openai: { apiKey: "sk-test" } }, { keysDir: TEST_DIR }); + expect(hasRequiredKeys("openai", { keysDir: TEST_DIR })).toBe(true); + }); + + test("hasRequiredKeys returns false when provider missing or empty", () => { + saveKeys({ openai: { apiKey: "" } }, { keysDir: TEST_DIR }); + expect(hasRequiredKeys("openai", { keysDir: TEST_DIR })).toBe(false); + expect(hasRequiredKeys("anthropic", { keysDir: TEST_DIR })).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/keys.ts b/packages/cli/src/lib/keys.ts new file mode 100644 index 0000000..93e6d6d --- /dev/null +++ b/packages/cli/src/lib/keys.ts @@ -0,0 +1,75 @@ +/** + * BYOK API key persistence (D-12). + * + * Stores provider keys in `~/.mocode/keys.json` with owner-only permissions. + * Key values are never logged or included in thrown errors. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +const providerKeySchema = z.object({ + apiKey: z.string(), +}); + +export const keysSchema = z.record(z.string(), providerKeySchema); + +export type ProviderKeys = z.infer; + +export type KeysOptions = { + keysDir?: string; +}; + +const CONFIG_DIR = join(homedir(), ".mocode"); + +function resolveKeysFile(options?: KeysOptions): string { + const dir = options?.keysDir ?? CONFIG_DIR; + return join(dir, "keys.json"); +} + +function redactApiKeys(message: string, keys: ProviderKeys): string { + let redacted = message; + for (const entry of Object.values(keys)) { + if (entry.apiKey.length > 0) { + redacted = redacted.split(entry.apiKey).join("[REDACTED]"); + } + } + return redacted; +} + +/** Returns saved provider keys, or `null` when the file is missing or invalid. */ +export function getKeys(options?: KeysOptions): ProviderKeys | null { + const keysFile = resolveKeysFile(options); + try { + const data = readFileSync(keysFile, "utf-8"); + const parsed = keysSchema.safeParse(JSON.parse(data)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +/** Persists provider keys after validation. Creates `~/.mocode` with mode 0o700 when needed. */ +export function saveKeys(keys: ProviderKeys, options?: KeysOptions): void { + const dir = options?.keysDir ?? CONFIG_DIR; + const keysFile = join(dir, "keys.json"); + const parsed = keysSchema.safeParse(keys); + + if (!parsed.success) { + throw new Error(redactApiKeys(parsed.error.message, keys)); + } + + if (!existsSync(dir)) { + mkdirSync(dir, { mode: 0o700 }); + } + + writeFileSync(keysFile, JSON.stringify(parsed.data, null, 2), { mode: 0o600 }); +} + +/** Returns whether the given provider has a non-empty API key on disk. */ +export function hasRequiredKeys(provider: string, options?: KeysOptions): boolean { + const keys = getKeys(options); + const entry = keys?.[provider]; + return typeof entry?.apiKey === "string" && entry.apiKey.length > 0; +} diff --git a/packages/cli/src/lib/local-chat-transport.test.ts b/packages/cli/src/lib/local-chat-transport.test.ts new file mode 100644 index 0000000..38e11b8 --- /dev/null +++ b/packages/cli/src/lib/local-chat-transport.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { Mode } from "@mocode/shared"; +import type { ToolSet } from "ai"; +import type { McpManager } from "../mcp/manager"; + +let lastStreamTextTools: ToolSet | undefined; + +const streamTextMock = mock((args: { tools: ToolSet }) => { + lastStreamTextTools = args.tools; + return { + toUIMessageStream: () => + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }; +}); + +mock.module("ai", () => ({ + streamText: streamTextMock, + validateUIMessages: async ({ messages }: { messages: unknown[] }) => messages, + convertToModelMessages: async (messages: unknown[]) => messages, +})); + +const { LocalChatTransport, stripIncompleteAssistantMessages } = await import("./local-chat-transport"); + +function createMockManager(): McpManager { + return { + getDiscoveredTools: () => [ + { + serverName: "filesystem", + tools: [{ name: "read_file", description: "Read file", inputSchema: {} }], + }, + ], + } as unknown as McpManager; +} + +function createMockResolvedModel() { + return { + model: {} as never, + provider: "anthropic" as const, + modelId: "claude-sonnet-4-6" as const, + }; +} + +describe("LocalChatTransport", () => { + beforeEach(() => { + lastStreamTextTools = undefined; + streamTextMock.mockClear(); + }); + + test("sendMessages merges MCP tools with mcp__ prefix via buildMergedToolSet", async () => { + const transport = new LocalChatTransport({ + resolveModel: () => createMockResolvedModel(), + getMcpManager: createMockManager, + buildSystemPrompt: () => "test system prompt", + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "session-1", + messageId: undefined, + messages: [ + { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + metadata: { mode: Mode.BUILD, model: "claude-sonnet-4-6" }, + }, + ], + abortSignal: undefined, + }); + + expect(streamTextMock).toHaveBeenCalledTimes(1); + expect(lastStreamTextTools).toBeDefined(); + expect(Object.keys(lastStreamTextTools ?? {})).toContain( + "mcp__filesystem__read_file", + ); + expect(Object.keys(lastStreamTextTools ?? {})).toContain("readFile"); + }); + + test("does not use HTTP fetch for chat", async () => { + const source = await import("./local-chat-transport"); + const sourceText = await Bun.file( + new URL("./local-chat-transport.ts", import.meta.url), + ).text(); + expect(sourceText).not.toContain("apiClient"); + expect(source.LocalChatTransport).toBeDefined(); + }); + + test("stripIncompleteAssistantMessages removes failed stream placeholders", () => { + const messages = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "retry" }], + }, + ] as never; + + const stripped = stripIncompleteAssistantMessages(messages); + expect(stripped).toHaveLength(2); + expect(stripped.map((message) => message.id)).toEqual(["user-1", "user-2"]); + }); +}); diff --git a/packages/cli/src/lib/local-chat-transport.ts b/packages/cli/src/lib/local-chat-transport.ts new file mode 100644 index 0000000..21b9f08 --- /dev/null +++ b/packages/cli/src/lib/local-chat-transport.ts @@ -0,0 +1,177 @@ +/** + * BYOK in-process ChatTransport (D-06). + * + * Mirrors packages/server/src/routes/chat.ts `streamText` loop locally with + * merged builtin + MCP tools. No HTTP to MoCode server when `--local` is active. + * + * Pipeline per `sendMessages`: + * 1. Resolve mode/model from message metadata + * 2. Build tool set: builtin contracts + MCP dynamicTool (schemas only, no execute) + * 3. Detect MCP intent in last user turn → `mcpRequested` strengthens system prompt + * 4. validateUIMessages → convertToModelMessages → streamText → toUIMessageStream + * + * Tool execution still happens in `use-chat` `onToolCall` (Phase 11 model). + */ +import { + convertToModelMessages, + streamText, + validateUIMessages, + type ChatTransport, + type LanguageModelUsage, + type UIMessage, + type UIMessageChunk, +} from "ai"; +import { Mode, type ModeType } from "@mocode/shared"; +import { loadMergedMcpConfig } from "../mcp/config"; +import type { McpManager } from "../mcp/manager"; +import { + buildMergedToolSet, + buildMcpDynamicToolsFromManager, +} from "../mcp/tools"; +import { isMcpToolName } from "../mcp/heuristics"; +import type { ResolvedModel } from "./local-model"; + +/** Last user-visible text in the outgoing batch — used for MCP routing heuristics. */ +function lastUserText(messages: UIMessage[]): string { + const message = messages.findLast((entry) => entry.role === "user"); + if (!message?.parts) return ""; + return message.parts + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join(" "); +} + +/** + * True when the user explicitly asks for MCP this turn (word "mcp" or a full tool name). + * Drives `buildSystemPrompt({ mcpRequested })` so the model prioritizes `mcp__*` tools + * over grep/glob on the first tool-call step. + */ +function isMcpUserRequest(text: string): boolean { + return /\bmcp\b/i.test(text) || /\bmcp__[\w-]+__[\w-]+/i.test(text); +} + +/** Drops assistant placeholders left behind when a stream fails before any parts arrive. */ +export function stripIncompleteAssistantMessages( + messages: UI_MESSAGE[], +): UI_MESSAGE[] { + return messages.filter( + (message) => + message.role !== "assistant" || + (Array.isArray(message.parts) && message.parts.length > 0), + ); +} + +export type LocalChatTransportFinishEvent = { + messages: UI_MESSAGE[]; + usage?: LanguageModelUsage; +}; + +export type LocalChatTransportOptions = { + resolveModel: (modelId: string) => ResolvedModel; + getMcpManager: () => McpManager; + buildSystemPrompt: (params: { + mode: ModeType; + mcpToolNames?: string[]; + mcpRequested?: boolean; + }) => string; + onFinish?: (event: LocalChatTransportFinishEvent) => Promise; + cwd?: string; +}; + +/** + * In-process transport for BYOK `--local` sessions. + * Implements AI SDK `ChatTransport` so `useChat` can share the same hook for SaaS and local. + */ +export class LocalChatTransport + implements ChatTransport +{ + constructor(private readonly opts: LocalChatTransportOptions) {} + + /** + * Runs one model turn locally: merge tools, build system prompt, stream assistant UI chunks. + * MCP tools have no `execute` fn here — the client handles them in `onToolCall`. + */ + async sendMessages({ + messages, + abortSignal, + }: Parameters["sendMessages"]>[0]): Promise< + ReadableStream + > { + const mode = + messages.findLast((message) => message.metadata?.mode)?.metadata?.mode ?? + Mode.BUILD; + const modelId = messages.findLast((message) => message.metadata?.model)?.metadata + ?.model; + + if (!modelId || typeof modelId !== "string") { + throw new Error("Missing model in message metadata"); + } + + const cwd = this.opts.cwd ?? process.cwd(); + const config = loadMergedMcpConfig(cwd); + const mcpDynamicTools = buildMcpDynamicToolsFromManager( + this.opts.getMcpManager(), + config, + ); + const tools = buildMergedToolSet(mode, mcpDynamicTools, config); + const mcpToolNames = Object.keys(mcpDynamicTools).filter(isMcpToolName); + const mcpRequested = isMcpUserRequest(lastUserText(messages)); + const systemPrompt = this.opts.buildSystemPrompt({ mode, mcpToolNames, mcpRequested }); + const resolvedModel = this.opts.resolveModel(modelId); + const startTime = Date.now(); + let completedUsage: LanguageModelUsage | null = null; + + const messagesForValidation = stripIncompleteAssistantMessages(messages); + const nextMessages = await validateUIMessages({ + messages: messagesForValidation, + tools, + }); + const modelMessages = await convertToModelMessages(nextMessages, { tools }); + + const result = streamText({ + model: resolvedModel.model, + system: systemPrompt, + messages: modelMessages, + tools, + providerOptions: resolvedModel.providerOptions, + abortSignal, + onFinish(event) { + completedUsage = event.totalUsage; + }, + }); + + const onFinish = this.opts.onFinish; + + return result.toUIMessageStream({ + originalMessages: nextMessages, + messageMetadata({ part }) { + if (part.type === "start") { + return { mode, model: modelId }; + } + + if (part.type !== "finish") return undefined; + + return { + mode, + model: modelId, + durationMs: Date.now() - startTime, + ...(completedUsage ? { usage: completedUsage } : {}), + }; + }, + async onFinish(event) { + if (!onFinish) return; + await onFinish({ + messages: event.messages, + usage: completedUsage ?? undefined, + }); + }, + }); + } + + /** + * Stream resume is Phase 3 (HARNESS-07). BYOK returns null like the initial stub. + */ + async reconnectToStream(): Promise | null> { + return null; + } +} diff --git a/packages/cli/src/lib/local-mode.test.ts b/packages/cli/src/lib/local-mode.test.ts new file mode 100644 index 0000000..5895418 --- /dev/null +++ b/packages/cli/src/lib/local-mode.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { parseCliArgs } from "./local-mode"; + +describe("parseCliArgs", () => { + test("argv containing --local returns local:true per D-09", () => { + expect(parseCliArgs(["--local"])).toEqual({ local: true }); + }); + + test("argv without --local returns local:false", () => { + expect(parseCliArgs([])).toEqual({ local: false }); + expect(parseCliArgs(["--help"])).toEqual({ local: false }); + }); + + test("--local among other flags still sets local:true", () => { + expect(parseCliArgs(["mocode", "--local", "chat"])).toEqual({ local: true }); + }); +}); diff --git a/packages/cli/src/lib/local-mode.ts b/packages/cli/src/lib/local-mode.ts new file mode 100644 index 0000000..5b99412 --- /dev/null +++ b/packages/cli/src/lib/local-mode.ts @@ -0,0 +1,22 @@ +/** + * BYOK entry flag via `mocode --local` (D-09). + * + * When true: no OAuth/MoCode server chat HTTP — sessions persist under ~/.mocode/projects, + * inference uses LocalChatTransport + keys.json provider credentials. + */ +let localMode = false; + +/** Parses CLI argv for the explicit `--local` BYOK opt-in flag. */ +export function parseCliArgs(argv: string[]): { local: boolean } { + return { local: argv.includes("--local") }; +} + +/** Returns whether the CLI was started with `--local`. */ +export function isLocalMode(): boolean { + return localMode; +} + +/** Sets module-level local mode (called from index.tsx before router boot). */ +export function setLocalMode(value: boolean): void { + localMode = value; +} diff --git a/packages/cli/src/lib/local-model.test.ts b/packages/cli/src/lib/local-model.test.ts new file mode 100644 index 0000000..a08d5d0 --- /dev/null +++ b/packages/cli/src/lib/local-model.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { resolveChatModel } from "./local-model"; +import type { ProviderKeys } from "./keys"; + +describe("resolveChatModel", () => { + test("throws with /keys guidance when getKeys returns null", () => { + expect(() => + resolveChatModel("claude-sonnet-4-6", { getKeys: () => null }), + ).toThrow(/\/keys/i); + }); + + test("throws when provider apiKey is missing", () => { + const keys: ProviderKeys = { openai: { apiKey: "sk-test" } }; + expect(() => + resolveChatModel("claude-sonnet-4-6", { getKeys: () => keys }), + ).toThrow(/\/keys/i); + }); + + test("returns LanguageModel when anthropic apiKey is present", () => { + const keys: ProviderKeys = { anthropic: { apiKey: "sk-ant-test" } }; + const resolved = resolveChatModel("claude-sonnet-4-6", { getKeys: () => keys }); + expect(resolved.provider).toBe("anthropic"); + expect(resolved.modelId).toBe("claude-sonnet-4-6"); + expect(resolved.model).toBeDefined(); + }); + + test("returns LanguageModel when openai apiKey is present", () => { + const keys: ProviderKeys = { openai: { apiKey: "sk-test" } }; + const resolved = resolveChatModel("gpt-5.4", { getKeys: () => keys }); + expect(resolved.provider).toBe("openai"); + expect(resolved.modelId).toBe("gpt-5.4"); + expect(resolved.model).toBeDefined(); + }); +}); diff --git a/packages/cli/src/lib/local-model.ts b/packages/cli/src/lib/local-model.ts new file mode 100644 index 0000000..f8ce7bf --- /dev/null +++ b/packages/cli/src/lib/local-model.ts @@ -0,0 +1,239 @@ +/** + * BYOK model resolution from keys.json (D-12). + * + * Mirrors packages/server/src/lib/model.ts but injects user API keys from + * getKeys() instead of process.env. Never logs apiKey values. + * + * Provider-specific `providerOptions` enable reasoning/thinking streams where the + * upstream SDK supports them (Anthropic adaptive thinking, OpenRouter reasoning, etc.). + */ +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createCerebras } from "@ai-sdk/cerebras"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createGroq } from "@ai-sdk/groq"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; + +import { + findSupportedChatModel, + type SupportedChatModel, + type SupportedChatModelId, + type SupportedProvider, +} from "@mocode/shared"; + +import type { ProviderOptions } from "@ai-sdk/provider-utils"; +import type { LanguageModel } from "ai"; + +import { getKeys, type KeysOptions, type ProviderKeys } from "./keys"; + +/** Per-provider model id extracted from the shared catalog for compile-time safety. */ +type AnthropicModelId = Extract["id"]; +type OpenAIModelId = Extract["id"]; +type GoogleModelId = Extract["id"]; +type GroqModelId = Extract["id"]; +type CerebrasModelId = Extract["id"]; +type OpenRouterModelId = Extract["id"]; + +export type ResolvedModel = { + model: LanguageModel; + provider: SupportedProvider; + modelId: SupportedChatModelId; + /** Passed to `streamText({ providerOptions })` to enable provider-native reasoning/thinking streams. */ + providerOptions?: ProviderOptions; +}; + +export type ResolveChatModelOptions = KeysOptions & { + getKeys?: typeof getKeys; +}; + +const ANTHROPIC_PROVIDER_OPTIONS: Partial> = { + "claude-sonnet-4-6": { + anthropic: { + thinking: { type: "adaptive", display: "summarized" }, + }, + }, + "claude-haiku-4-5": { + anthropic: { + thinking: { type: "enabled", budgetTokens: 10000 }, + }, + }, + "claude-opus-4-6": { + anthropic: { + thinking: { type: "adaptive", display: "summarized" }, + }, + }, +}; + +const OPENAI_PROVIDER_OPTIONS: Partial> = { + "gpt-5.4": { + openai: { + reasoningEffort: "medium", + reasoningSummary: "auto", + }, + }, + "gpt-5.4-mini": { + openai: { + reasoningEffort: "medium", + reasoningSummary: "auto", + }, + }, + "gpt-5.4-nano": { + openai: { + reasoningEffort: "low", + reasoningSummary: "auto", + }, + }, +}; + +const GOOGLE_PROVIDER_OPTIONS: Partial> = { + "gemini-2.5-flash": { + google: { + thinkingConfig: { + includeThoughts: true, + }, + }, + }, +}; + +const CEREBRAS_PROVIDER_OPTIONS: Partial> = { + "gpt-oss-120b": { + cerebras: { + reasoningEffort: "medium", + }, + }, +}; + +const OPENROUTER_PROVIDER_OPTIONS: Partial> = { + "openai/gpt-oss-120b:free": { + openrouter: { + reasoning: { + enabled: true, + effort: "medium", + }, + }, + }, +}; + +function requireProviderApiKey( + provider: SupportedProvider, + keys: ProviderKeys | null, +): string { + if (!keys) { + throw new Error( + `Missing API keys for ${provider}. Run /keys in MoCode to configure your provider API key.`, + ); + } + + const entry = keys[provider]; + if (typeof entry?.apiKey !== "string" || entry.apiKey.length === 0) { + throw new Error( + `Missing ${provider} API key. Run /keys in MoCode to configure your provider API key.`, + ); + } + + return entry.apiKey; +} + +function resolveAnthropicModel(modelId: AnthropicModelId, apiKey: string): ResolvedModel { + const provider = createAnthropic({ apiKey }); + return { + model: provider(modelId), + provider: "anthropic", + modelId, + providerOptions: ANTHROPIC_PROVIDER_OPTIONS[modelId], + }; +} + +function resolveOpenAIModel(modelId: OpenAIModelId, apiKey: string): ResolvedModel { + const provider = createOpenAI({ apiKey }); + return { + model: provider(modelId), + provider: "openai", + modelId, + providerOptions: OPENAI_PROVIDER_OPTIONS[modelId], + }; +} + +function resolveGoogleModel(modelId: GoogleModelId, apiKey: string): ResolvedModel { + const provider = createGoogleGenerativeAI({ apiKey }); + return { + model: provider(modelId), + provider: "google", + modelId, + providerOptions: GOOGLE_PROVIDER_OPTIONS[modelId], + }; +} + +function resolveGroqModel(modelId: GroqModelId, apiKey: string): ResolvedModel { + const provider = createGroq({ apiKey }); + return { + model: provider(modelId), + provider: "groq", + modelId, + }; +} + +function resolveCerebrasModel(modelId: CerebrasModelId, apiKey: string): ResolvedModel { + const provider = createCerebras({ apiKey }); + return { + model: provider(modelId), + provider: "cerebras", + modelId, + providerOptions: CEREBRAS_PROVIDER_OPTIONS[modelId], + }; +} + +function resolveOpenRouterModel(modelId: OpenRouterModelId, apiKey: string): ResolvedModel { + const openrouter = createOpenRouter({ apiKey }); + return { + model: openrouter(modelId), + provider: "openrouter", + modelId, + providerOptions: OPENROUTER_PROVIDER_OPTIONS[modelId], + }; +} + +function resolveSupportedChatModel( + model: SupportedChatModel, + keys: ProviderKeys | null, +): ResolvedModel { + switch (model.provider) { + case "anthropic": + return resolveAnthropicModel( + model.id, + requireProviderApiKey("anthropic", keys), + ); + case "openai": + return resolveOpenAIModel(model.id, requireProviderApiKey("openai", keys)); + case "google": + return resolveGoogleModel(model.id, requireProviderApiKey("google", keys)); + case "groq": + return resolveGroqModel(model.id, requireProviderApiKey("groq", keys)); + case "cerebras": + return resolveCerebrasModel(model.id, requireProviderApiKey("cerebras", keys)); + case "openrouter": + return resolveOpenRouterModel( + model.id, + requireProviderApiKey("openrouter", keys), + ); + default: { + const _exhaustive: never = model; + throw new Error(`Unsupported provider: ${String(_exhaustive)}`); + } + } +} + +/** Looks up catalog entry and returns the bound SDK model using keys.json credentials. */ +export function resolveChatModel( + modelId: string, + options?: ResolveChatModelOptions, +): ResolvedModel { + const model = findSupportedChatModel(modelId); + if (!model) { + throw new Error(`Model ${modelId} not found`); + } + + const getKeysFn = options?.getKeys ?? getKeys; + const keys = getKeysFn(options); + return resolveSupportedChatModel(model, keys); +} diff --git a/packages/cli/src/lib/local-sessions.test.ts b/packages/cli/src/lib/local-sessions.test.ts new file mode 100644 index 0000000..f70fa2c --- /dev/null +++ b/packages/cli/src/lib/local-sessions.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createLocalSession, updateLocalSession } from "./local-sessions"; + +const TEST_CWD = "/tmp/mocode-test-project"; +const PROJECTS_DIR = join(homedir(), ".mocode-test-sessions", "projects"); + +function normalizedProjectDir(cwd: string): string { + return join(PROJECTS_DIR, cwd.replace(/\//g, "-")); +} + +describe("local-sessions", () => { + beforeEach(() => { + const dir = normalizedProjectDir(TEST_CWD); + if (existsSync(dir)) { + rmSync(dir, { recursive: true }); + } + }); + + afterEach(() => { + const dir = normalizedProjectDir(TEST_CWD); + if (existsSync(dir)) { + rmSync(dir, { recursive: true }); + } + }); + + test("createLocalSession writes sessions-index.json under normalized cwd path per D-10", () => { + const session = createLocalSession("Test Session", { cwd: TEST_CWD, projectsDir: PROJECTS_DIR }); + + const indexPath = join(normalizedProjectDir(TEST_CWD), "sessions-index.json"); + expect(existsSync(indexPath)).toBe(true); + + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + expect(index.sessions.some((s: { id: string }) => s.id === session.id)).toBe(true); + }); + + test("createLocalSession writes session-id.json file", () => { + const session = createLocalSession("Test Session", { cwd: TEST_CWD, projectsDir: PROJECTS_DIR }); + + const sessionPath = join(normalizedProjectDir(TEST_CWD), `${session.id}.json`); + expect(existsSync(sessionPath)).toBe(true); + + const data = JSON.parse(readFileSync(sessionPath, "utf-8")); + expect(data.id).toBe(session.id); + expect(data.title).toBe("Test Session"); + }); + + test("updateLocalSession persists messages to session file", () => { + const session = createLocalSession("Test", { cwd: TEST_CWD, projectsDir: PROJECTS_DIR }); + const messages = [{ id: "msg-1", role: "user" as const, parts: [{ type: "text" as const, text: "hello" }] }]; + + updateLocalSession(session.id, messages, { cwd: TEST_CWD, projectsDir: PROJECTS_DIR }); + + const sessionPath = join(normalizedProjectDir(TEST_CWD), `${session.id}.json`); + const data = JSON.parse(readFileSync(sessionPath, "utf-8")); + expect(data.messages).toEqual(messages); + }); +}); diff --git a/packages/cli/src/lib/local-sessions.ts b/packages/cli/src/lib/local-sessions.ts new file mode 100644 index 0000000..f6a556a --- /dev/null +++ b/packages/cli/src/lib/local-sessions.ts @@ -0,0 +1,150 @@ +/** + * BYOK local session persistence (D-10). + * + * Layout: `~/.mocode/projects//sessions-index.json` plus + * per-session `.json` files with continuous write on message updates. + */ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Message } from "../hooks/use-chat"; + +export type LocalSessionOptions = { + cwd?: string; + projectsDir?: string; +}; + +export type LocalSession = { + id: string; + title: string; + messages: Message[]; + createdAt: string; + updatedAt: string; +}; + +export type SessionIndexEntry = { + id: string; + title: string; + createdAt: string; + updatedAt: string; +}; + +type SessionsIndex = { + sessions: SessionIndexEntry[]; +}; + +const DEFAULT_PROJECTS_DIR = join(homedir(), ".mocode", "projects"); + +/** Normalizes a cwd path for use as a directory name under projects/. */ +export function normalizeProjectPath(cwd: string): string { + return cwd.replace(/\//g, "-"); +} + +function resolveProjectDir(options?: LocalSessionOptions): string { + const cwd = options?.cwd ?? process.cwd(); + const projectsDir = options?.projectsDir ?? DEFAULT_PROJECTS_DIR; + return join(projectsDir, normalizeProjectPath(cwd)); +} + +function ensureProjectDir(projectDir: string): void { + if (!existsSync(projectDir)) { + mkdirSync(projectDir, { mode: 0o700, recursive: true }); + } +} + +function readIndex(projectDir: string): SessionsIndex { + const indexPath = join(projectDir, "sessions-index.json"); + try { + const data = readFileSync(indexPath, "utf-8"); + const parsed = JSON.parse(data) as SessionsIndex; + return { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] }; + } catch { + return { sessions: [] }; + } +} + +function writeIndex(projectDir: string, index: SessionsIndex): void { + ensureProjectDir(projectDir); + writeFileSync(join(projectDir, "sessions-index.json"), JSON.stringify(index, null, 2), { + mode: 0o600, + }); +} + +/** Creates a new local session file and index entry. */ +export function createLocalSession(title: string, options?: LocalSessionOptions): LocalSession { + const projectDir = resolveProjectDir(options); + const now = new Date().toISOString(); + const session: LocalSession = { + id: randomUUID(), + title, + messages: [], + createdAt: now, + updatedAt: now, + }; + + ensureProjectDir(projectDir); + writeFileSync(join(projectDir, `${session.id}.json`), JSON.stringify(session, null, 2), { + mode: 0o600, + }); + + const index = readIndex(projectDir); + index.sessions.push({ + id: session.id, + title: session.title, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }); + writeIndex(projectDir, index); + + return session; +} + +/** Reads a local session by id, or null when missing. */ +export function getLocalSession(id: string, options?: LocalSessionOptions): LocalSession | null { + const sessionPath = join(resolveProjectDir(options), `${id}.json`); + try { + const data = readFileSync(sessionPath, "utf-8"); + return JSON.parse(data) as LocalSession; + } catch { + return null; + } +} + +/** Lists session index entries for the current or overridden project directory. */ +export function listLocalSessions(options?: LocalSessionOptions): SessionIndexEntry[] { + return readIndex(resolveProjectDir(options)).sessions; +} + +/** Persists message updates to the session file and refreshes index timestamps. */ +export function updateLocalSession( + id: string, + messages: Message[], + options?: LocalSessionOptions, +): void { + const projectDir = resolveProjectDir(options); + const session = getLocalSession(id, options); + if (!session) { + throw new Error(`Local session not found: ${id}`); + } + + const updated: LocalSession = { + ...session, + messages, + updatedAt: new Date().toISOString(), + }; + + writeFileSync(join(projectDir, `${id}.json`), JSON.stringify(updated, null, 2), { mode: 0o600 }); + + const index = readIndex(projectDir); + const entryIndex = index.sessions.findIndex((entry) => entry.id === id); + if (entryIndex !== -1) { + index.sessions[entryIndex] = { + id: updated.id, + title: updated.title, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }; + writeIndex(projectDir, index); + } +} diff --git a/packages/cli/src/lib/system-prompt.ts b/packages/cli/src/lib/system-prompt.ts new file mode 100644 index 0000000..e5479d6 --- /dev/null +++ b/packages/cli/src/lib/system-prompt.ts @@ -0,0 +1,206 @@ +/** + * System prompt builder for the BYOK local agent loop. + * + * Duplicated from packages/server/src/system-prompt.ts to avoid server coupling + * in CLI-only BYOK mode. Extended with MCP routing sections (Phase 02): + * - Code heuristics (`mcp/heuristics.ts`) gate execution and PLAN filtering + * - Prompt rules here steer the model toward `mcp__*` when the user asks for MCP + */ +import type { ModeType } from "@mocode/shared"; + +type SystemPromptParams = { + mode: ModeType; + mcpToolNames?: string[]; + /** True when the current user turn explicitly requests MCP — strengthens routing. */ + mcpRequested?: boolean; +}; + +const BUILD_BASH_PERMISSION_RULES = ` + 8. Invoke bash directly for shell operations — do not ask the user in chat whether to run a command before calling bash + 9. Blocklisted/destructive bash commands pause for user approval in the TUI approval dialog (Approve once / Reject / Allow for session) — the TUI is the sole confirmation mechanism; never treat chat messages as permission + 10. When command intent is not obvious from the command string alone, include the optional description field on bash tool calls + 11. If bash returns output-error from user rejection, do not retry the same command unless the user explicitly asks again; acknowledge the rejection and suggest alternatives — do not ask the user to confirm via chat (no typed confirmation phrases, no "reply X to continue"); the TUI approval dialog was the sole approval step and chat must never become a secondary permission gate; do not offer to retry the same rejected command contingent on chat confirmation (no "after you confirm", "if you confirm", or "once you confirm" phrasing); do not present chat replies or numbered option menus as the permission gate to retry — if the user wants the same command again, they must explicitly request it in a new message, which will invoke bash and the TUI approval dialog again`; + +/** + * MCP section inserted above generic tool rules when servers are registered. + * `mcpRequested` adds an ACTIVE TURN block — paired with `isMcpUserRequest` in local-chat-transport. + */ +function buildMcpToolsSection( + mode: ModeType, + mcpToolNames: string[], + mcpRequested: boolean, +): string { + if (mcpToolNames.length === 0) { + return ""; + } + + const toolList = mcpToolNames.map((name) => `- ${name}`).join("\n "); + const activeTurn = mcpRequested + ? ` + ## ACTIVE TURN — MCP REQUESTED + The user's current message asks for MCP. Your **first** tool call must be a matching \`mcp__*\` tool below. + **Forbidden this turn:** grep, glob, readFile, listDirectory on packages/ or source files to "find" or "understand" MCP — MCP is already connected; call it directly.` + : ""; + + return ` + # MCP Tools (connected) — priority over repo exploration + MCP servers are live. Tools are named \`mcp____\`.${activeTurn} + + Available MCP tools: + ${toolList} + + **MCP Rules (override glob/grep/readFile when user says MCP):** + 1. User says "MCP" or an \`mcp__\` tool name → invoke that MCP tool on the first tool-call turn + 2. Do not search the codebase for MCP implementation — you are the runtime client, not a code archaeologist + 3. Filesystem MCP paths must be under the server's allowed directories + 4. Read-only MCP tools auto-execute; write tools need TUI approval in BUILD mode + 5. PLAN mode exposes read-only MCP tools only (${mode === "PLAN" ? "filtered" : "get/list/read/fetch/search prefixes"})`; +} + +/** Thinking steps reorder when MCP is live — route external tools before repo grep/glob. */ +function buildThinkingProcess(hasMcp: boolean): string { + if (hasMcp) { + return ` + # Thinking Process + 1. **Understand** — Clarify the user's goal + 2. **Route** — If the user mentions MCP or an external path served by MCP → call \`mcp__*\` immediately; skip repo grep/glob + 3. **Explore** — Only for in-repo coding tasks: glob/grep, then readFile + 4. **Analyze** — Understand implementation, edge cases, trade-offs + 5. **Execute & Verify** — Implement or run tools; confirm results`; + } + + return ` + # Thinking Process (Always Follow) + Use this structured reasoning flow for every request: + + 1. **Understand** — Clarify the user's goal and constraints + 2. **Explore** — Use glob/grep to locate relevant files, then read them + 3. **Analyze** — Understand current implementation, edge cases, and trade-offs + 4. **Plan** — Formulate a concrete plan (PLAN mode) or execution steps (BUILD mode) + 5. **Execute & Verify** — (BUILD mode only) Make changes and validate results`; +} + +/** Assembles mode-specific instructions, tool rules, and response format. */ +export function buildSystemPrompt({ + mode, + mcpToolNames = [], + mcpRequested = false, +}: SystemPromptParams): string { + const parts: string[] = []; + + parts.push(`# Role + You are an expert software engineer and a highly capable coding assistant working inside a terminal-based development environment. + + The application has two distinct modes: + - **PLAN** — Read-only analysis and planning mode + - **BUILD** — Full implementation mode with read/write capabilities`); + + if (mode === "PLAN") { + parts.push(` + # Mode: PLAN + You are in **PLAN mode**. Your goal is to deeply understand the task, analyze the existing codebase, identify risks and trade-offs, and propose a clear, actionable plan. + + **Core Rules:** + - Do NOT make any file modifications + - Be thorough but efficient in exploration + - Always think step-by-step + - Clearly explain your reasoning and proposed approach + - Ask clarifying questions when requirements are ambiguous`); + } else { + parts.push(` + # Mode: BUILD + You are in **BUILD mode**. Your goal is to implement the requested changes correctly and cleanly. + + **Core Rules:** + - Always read and fully understand relevant code **before** making changes + - Make minimal, surgical changes when possible + - Maintain existing code style, architecture, and conventions + - Verify your work (build, test, lint) when appropriate + - Be decisive and proactive`); + } + + const hasMcp = mcpToolNames.length > 0; + const mcpSection = buildMcpToolsSection(mode, mcpToolNames, mcpRequested); + if (mcpSection) { + parts.push(mcpSection); + } + + parts.push(buildThinkingProcess(hasMcp)); + + if (mode === "PLAN") { + parts.push(` + # Available Tools (PLAN Mode) + - readFile — Read file contents + - listDirectory — List directory contents + - glob — Find files by pattern (e.g. "**/*.ts") + - grep — Search code with regex (ripgrep backend; respects .gitignore) + - gitStatus — Repository status (branch, clean/dirty, file counts) + - gitDiff — View unstaged changes (use staged or ref params to narrow) + + **Tool Rules:** + 1. Be decisive: Use glob + grep first to find relevant files + 2. Prefer gitStatus/gitDiff over bash for git inspection + 3. Never re-read files already read in this conversation + 4. Call multiple tools in parallel when possible + 5. Do not read the entire project — stay focused`); + } else { + parts.push(` + # Available Tools (BUILD Mode) + - readFile — Read file contents + - writeFile — Create new files or fully overwrite existing ones + - editFile — Make precise string replacements (preferred for modifications) + - listDirectory — List directory contents + - glob — Find files by pattern + - grep — Search code with regex + - gitStatus — Repository status (branch, clean/dirty, file counts) + - gitDiff — View unstaged changes (use staged or ref params to narrow) + - bash — Run shell commands (build, test, lint, git, etc.) + + **Tool Rules:** + 1. Always explore with glob/grep/readFile before editing + 2. Prefer editFile for small-to-medium changes (oldString must be unique and have enough context) + 3. Use writeFile only for new files or when rewriting most of a file + 4. Never re-read files already read in this conversation + 5. Batch tool calls when possible + 6. Prefer gitStatus/gitDiff over bash for git inspection + 7. Use bash sparingly — only when no dedicated tool suffices +${BUILD_BASH_PERMISSION_RULES}`); + } + + parts.push(` + # Code Style & Best Practices + - Strictly follow the existing code style, naming conventions, and architecture patterns in the codebase + - Do not introduce new dependencies unless explicitly required + - Prefer refactoring over duplication + - Keep changes minimal and focused + - Write clean, readable, and maintainable code + - Add comments only when they add real value`); + + if (mode === "PLAN") { + parts.push(` + # Response Format (PLAN Mode) + Structure your response as: + 1. **Summary** — One-sentence understanding of the task + 2. **Analysis** — Key findings from the codebase + 3. **Plan** — Detailed step-by-step plan + 4. **Risks & Trade-offs** — Important considerations + 5. **Questions** — Any clarifications needed (if any)`); + } else { + parts.push(` + # Response Format (BUILD Mode) + Structure your response as: + 1. **Summary** — What was done + 2. **Changes** — List of files modified/created + 3. **Verification** — Results of builds/tests/linting (if performed) + 4. **Next Steps** — Any recommended follow-up actions`); + } + + parts.push(` + # Final Reminders + - Stay in character as an expert engineer + - Be concise but clear — avoid unnecessary fluff + - If something is unclear, ask targeted questions rather than guessing + - Your ultimate goal is to make high-quality, production-ready changes`); + + return parts.join("\n"); +} From 3c649ad2946caed62035ea79050a607c3e82f508 Mon Sep 17 00:00:00 2001 From: moyun Date: Sat, 27 Jun 2026 19:36:03 +0800 Subject: [PATCH 4/7] feat(cli): wire MCP and BYOK into chat, sessions, and TUI dialogs Add /mcp and /keys slash commands, MCP/BYOK paths in use-chat, session mount MCP init, keys setup gate, and dialog integrations for MCP management and approvals. Co-authored-by: Cursor --- .../src/components/command-menu/commands.tsx | 26 +- packages/cli/src/components/dialogs/index.tsx | 5 +- .../components/dialogs/keys-wizard-dialog.tsx | 211 +++++++++++++ .../dialogs/mcp-approval-dialog.tsx | 114 +++++++ .../cli/src/components/dialogs/mcp-dialog.tsx | 281 ++++++++++++++++++ .../cli/src/components/keys-setup-gate.tsx | 31 ++ packages/cli/src/hooks/use-chat-bash.test.ts | 21 ++ packages/cli/src/hooks/use-chat-mcp.test.ts | 110 +++++++ packages/cli/src/hooks/use-chat.ts | 100 ++++++- packages/cli/src/index.tsx | 6 + packages/cli/src/layouts/root-layout.tsx | 2 + packages/cli/src/lib/session-navigation.ts | 1 + packages/cli/src/providers/dialog/index.tsx | 15 +- packages/cli/src/screens/new-session.tsx | 27 +- packages/cli/src/screens/session.tsx | 24 ++ packages/cli/src/utils/list-scroll-nav.ts | 15 +- 16 files changed, 968 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/components/dialogs/keys-wizard-dialog.tsx create mode 100644 packages/cli/src/components/dialogs/mcp-approval-dialog.tsx create mode 100644 packages/cli/src/components/dialogs/mcp-dialog.tsx create mode 100644 packages/cli/src/components/keys-setup-gate.tsx create mode 100644 packages/cli/src/hooks/use-chat-bash.test.ts create mode 100644 packages/cli/src/hooks/use-chat-mcp.test.ts diff --git a/packages/cli/src/components/command-menu/commands.tsx b/packages/cli/src/components/command-menu/commands.tsx index 1d5cdf2..9c4df7a 100644 --- a/packages/cli/src/components/command-menu/commands.tsx +++ b/packages/cli/src/components/command-menu/commands.tsx @@ -3,7 +3,9 @@ import { ThemeDialogContent, AgentsDialogContent, SessionDialogContent, - ModelsDialogContent + ModelsDialogContent, + KeysWizardDialogContent, + McpDialogContent, } from "../dialogs"; import { SUPPORTED_CHAT_MODELS } from "@mocode/shared"; @@ -64,6 +66,28 @@ export const COMMANDS: Command[] = [ }); }, }, + { + name: "mcp", + description: "Manage MCP servers", + value: "/mcp", + action: (ctx) => { + ctx.dialog.open({ + title: "MCP Servers", + children: , + }); + }, + }, + { + name: "keys", + description: "Configure API keys", + value: "/keys", + action: (ctx) => { + ctx.dialog.open({ + title: "API Keys", + children: , + }); + }, + }, { name: "theme", description: "Change color theme", diff --git a/packages/cli/src/components/dialogs/index.tsx b/packages/cli/src/components/dialogs/index.tsx index 790d97f..bc00705 100644 --- a/packages/cli/src/components/dialogs/index.tsx +++ b/packages/cli/src/components/dialogs/index.tsx @@ -3,4 +3,7 @@ export { ThemeDialogContent } from "./theme-dialog"; export { SessionDialogContent } from "./sessions-dialog"; export { AgentsDialogContent } from "./agents-dialog"; export { ModelsDialogContent } from "./models-dialog"; -export { BashApprovalDialog } from "./bash-approval-dialog"; \ No newline at end of file +export { KeysWizardDialogContent } from "./keys-wizard-dialog"; +export { McpDialogContent } from "./mcp-dialog"; +export { BashApprovalDialog } from "./bash-approval-dialog"; +export { McpApprovalDialog } from "./mcp-approval-dialog"; \ No newline at end of file diff --git a/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx b/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx new file mode 100644 index 0000000..8a3740c --- /dev/null +++ b/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx @@ -0,0 +1,211 @@ +/** + * `/keys` BYOK API key wizard (Phase 02, D-12). + * + * Two views: provider list (masked key preview) → edit form (paste key, save to keys.json). + * Keys never leave the machine; chmod 600 on `~/.mocode/keys.json`. + */ +import { TextAttributes, type InputRenderable } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useCallback, useRef, useState } from "react"; +import { moveDialogSelection } from "../../lib/dialog-action-nav"; +import { getKeys, saveKeys, type ProviderKeys } from "../../lib/keys"; +import { useKeyboardLayer } from "../../providers/keyboard-layer"; +import { useTheme } from "../../providers/theme"; +import { DialogSearchList } from "../dialog-search-list"; + +const PROVIDERS = ["anthropic", "openai", "google", "groq", "openrouter"] as const; + +type ProviderId = (typeof PROVIDERS)[number]; + +type WizardView = "list" | "edit"; + +const EDIT_ACTION_COUNT = 2; + +function maskApiKey(apiKey: string): string { + if (apiKey.length === 0) { + return "not set"; + } + if (apiKey.length <= 4) { + return "••••"; + } + return `${apiKey.slice(0, 4)}${"•".repeat(Math.min(8, apiKey.length - 4))}`; +} + +function getProviderLabel(provider: ProviderId): string { + return provider.charAt(0).toUpperCase() + provider.slice(1); +} + +type ActionButtonProps = { + label: string; + hint?: string; + selected?: boolean; + onSelect: () => void; + onMouseMove?: () => void; +}; + +function ActionButton({ label, hint, selected, onSelect, onMouseMove }: ActionButtonProps) { + const { colors } = useTheme(); + + return ( + + + {label} + + {hint ? ( + + {" "} + {hint} + + ) : null} + + ); +} + +/** Multi-provider API key wizard opened by `/keys` (D-12). */ +export function KeysWizardDialogContent() { + const { isTopLayer } = useKeyboardLayer(); + const [view, setView] = useState("list"); + const [keys, setKeys] = useState(() => getKeys() ?? {}); + const [editingProvider, setEditingProvider] = useState(null); + const [draftKey, setDraftKey] = useState(""); + const [actionIndex, setActionIndex] = useState(0); + const inputRef = useRef(null); + + const handleSelectProvider = useCallback( + (provider: ProviderId) => { + setEditingProvider(provider); + setDraftKey(keys[provider]?.apiKey ?? ""); + setActionIndex(0); + setView("edit"); + }, + [keys], + ); + + const handleBack = useCallback(() => { + setView("list"); + setEditingProvider(null); + setDraftKey(""); + }, []); + + const handleSaveKey = useCallback(() => { + if (!editingProvider) { + return; + } + + const nextKeys = { ...keys }; + const trimmed = draftKey.trim(); + + if (trimmed.length === 0) { + delete nextKeys[editingProvider]; + } else { + nextKeys[editingProvider] = { apiKey: trimmed }; + } + + saveKeys(nextKeys); + setKeys(nextKeys); + handleBack(); + }, [keys, editingProvider, draftKey, handleBack]); + + const handleContentChange = useCallback(() => { + setDraftKey(inputRef.current?.value ?? ""); + }, []); + + useKeyboard((key) => { + if (!isTopLayer("dialog") || view !== "edit") { + return; + } + + if (key.name === "escape") { + handleBack(); + } else if (key.name === "return" || key.name === "enter") { + key.preventDefault(); + if (actionIndex === 0) { + handleSaveKey(); + } else { + handleBack(); + } + } else if (key.name === "up") { + key.preventDefault(); + setActionIndex((index) => moveDialogSelection(index, "up", EDIT_ACTION_COUNT)); + } else if (key.name === "down") { + key.preventDefault(); + setActionIndex((index) => moveDialogSelection(index, "down", EDIT_ACTION_COUNT)); + } + }); + + if (view === "list") { + return ( + + + Select a provider to configure. Saved keys show a masked preview only. + + + getProviderLabel(provider).toLowerCase().includes(query.toLowerCase()) + } + renderItem={(provider, isSelected) => ( + + + {getProviderLabel(provider)} + + + {maskApiKey(keys[provider]?.apiKey ?? "")} + + + )} + getKey={(provider) => provider} + placeholder="Search providers..." + emptyText="No providers found" + /> + Esc to close + + ); + } + + const providerLabel = editingProvider ? getProviderLabel(editingProvider) : "Provider"; + const actions = [ + { label: "Save key", hint: "(Enter)", onSelect: handleSaveKey }, + { label: "Back", hint: "(Esc)", onSelect: handleBack }, + ] as const; + + return ( + + API key for {providerLabel} + + + Preview: {draftKey.length > 0 ? "•".repeat(Math.min(draftKey.length, 12)) : "(empty)"} + + + {actions.map((action, index) => ( + setActionIndex(index)} + /> + ))} + + + ); +} diff --git a/packages/cli/src/components/dialogs/mcp-approval-dialog.tsx b/packages/cli/src/components/dialogs/mcp-approval-dialog.tsx new file mode 100644 index 0000000..f63e9b2 --- /dev/null +++ b/packages/cli/src/components/dialogs/mcp-approval-dialog.tsx @@ -0,0 +1,114 @@ +/** + * TUI approval dialog for MCP write tool calls (Phase 02, D-15). + * Layout and keyboard model mirror BashApprovalDialog for consistent UX. + */ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useState } from "react"; +import { + BASH_APPROVAL_ACTION_COUNT, + BASH_APPROVAL_DEFAULT_INDEX, + moveDialogSelection, +} from "../../lib/dialog-action-nav"; +import { useKeyboardLayer } from "../../providers/keyboard-layer"; +import { useTheme } from "../../providers/theme"; + +type McpApprovalDialogProps = { + toolName: string; + input: unknown; + onApproveOnce: () => void; + onReject: () => void; + onAllowSession: () => void; +}; + +type ActionButtonProps = { + label: string; + hint?: string; + selected?: boolean; + onSelect: () => void; + onMouseMove?: () => void; +}; + +function ActionButton({ label, hint, selected, onSelect, onMouseMove }: ActionButtonProps) { + const { colors } = useTheme(); + + return ( + + + {label} + + {hint ? ( + + {" "} + {hint} + + ) : null} + + ); +} + +/** Three-action modal body for MCP write approval (D-15). Mirrors BashApprovalDialog keyboard UX. */ +export function McpApprovalDialog({ + toolName, + input, + onApproveOnce, + onReject, + onAllowSession, +}: McpApprovalDialogProps) { + const [selectedIndex, setSelectedIndex] = useState(BASH_APPROVAL_DEFAULT_INDEX); + const { isTopLayer } = useKeyboardLayer(); + const formattedInput = JSON.stringify(input, null, 2); + + const actions = [ + { label: "Approve once", onSelect: onApproveOnce }, + { label: "Reject", onSelect: onReject }, + { + label: "Allow for this session", + hint: "(skip future prompts for this tool)", + onSelect: onAllowSession, + }, + ] as const; + + useKeyboard((key) => { + if (!isTopLayer("dialog")) return; + + if (key.name === "return" || key.name === "enter") { + actions[selectedIndex]?.onSelect(); + } else if (key.name === "up") { + key.preventDefault(); + setSelectedIndex((i) => moveDialogSelection(i, "up", BASH_APPROVAL_ACTION_COUNT)); + } else if (key.name === "down") { + key.preventDefault(); + setSelectedIndex((i) => moveDialogSelection(i, "down", BASH_APPROVAL_ACTION_COUNT)); + } + }); + + return ( + + The model wants to run: + {toolName} + + {formattedInput} + + + {actions.map((action, i) => ( + setSelectedIndex(i)} + /> + ))} + + + ); +} diff --git a/packages/cli/src/components/dialogs/mcp-dialog.tsx b/packages/cli/src/components/dialogs/mcp-dialog.tsx new file mode 100644 index 0000000..5b03369 --- /dev/null +++ b/packages/cli/src/components/dialogs/mcp-dialog.tsx @@ -0,0 +1,281 @@ +/** + * `/mcp` runtime management dialog (Phase 02, D-03). + * + * Lists merged global + project servers with live status from McpManager. + * - Enter — manual reconnect (resets backoff, retries once) + * - t — toggle enabled (persists to mcp.json, calls applyServerEnabledChange) + * + * Per-server `busyServersRef` prevents duplicate reconnect/toggle while npx stdio + * cold-starts can take several seconds. Pending HTTP/SSE servers poll every 1s. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"; +import { useKeyboard, useTerminalDimensions } from "@opentui/react"; +import { getMcpConfigPaths, setServerEnabled } from "../../mcp/config"; +import { getMcpManager, type McpServerStatus } from "../../mcp/manager"; +import { moveDialogSelection } from "../../lib/dialog-action-nav"; +import { truncatePathForDisplay } from "../../lib/truncate-path"; +import { McpConnectionStatus } from "../../mcp/types"; +import { useKeyboardLayer } from "../../providers/keyboard-layer"; +import { useTheme } from "../../providers/theme"; +import { useToast } from "../../providers/toast"; +import { scrollIndexIntoView, visibleItemCount } from "../../utils/list-scroll-nav"; + +const MCP_ROW_HEIGHT = 2; +const MCP_LIST_MAX_ITEMS = 6; +const DIALOG_PADDING_X = 4; +const DIALOG_MAX_WIDTH = 72; + +function formatStatusRow( + server: McpServerStatus, + maxWidth: number, + options?: { busy?: boolean }, +): string { + let row: string; + if (options?.busy) { + row = `${server.transport} · reconnecting…`; + } else if (!server.enabled) { + row = `${server.transport} · disabled`; + } else if (server.status === McpConnectionStatus.CONNECTED) { + const toolSuffix = + server.toolCount === undefined + ? "" + : server.toolCount === 0 + ? " · no tools" + : ` · ${server.toolCount} tool${server.toolCount === 1 ? "" : "s"}`; + row = `${server.transport} · connected${toolSuffix}`; + } else if (server.status === McpConnectionStatus.PENDING) { + row = `${server.transport} · pending…`; + } else if (server.status === McpConnectionStatus.FAILED && server.error) { + row = `${server.transport} · failed · ${server.error}`; + } else { + row = `${server.transport} · ${server.status}`; + } + + if (row.length <= maxWidth) return row; + if (maxWidth <= 1) return "…"; + return `${row.slice(0, maxWidth - 1)}…`; +} + +function truncateLine(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 1) return "…"; + return `${text.slice(0, maxWidth - 1)}…`; +} + +type ConfigPathsProps = { + paths: { global: string; project: string }; + bodyWidth: number; +}; + +function ConfigPaths({ paths, bodyWidth }: ConfigPathsProps) { + return ( + <> + + Global: {truncatePathForDisplay(paths.global, bodyWidth - "Global: ".length)} + + + Project: {truncatePathForDisplay(paths.project, bodyWidth - "Project: ".length)} + + + ); +} + +/** Runtime MCP management dialog opened by `/mcp` (Phase 02, D-03). */ +export function McpDialogContent() { + const [refreshToken, setRefreshToken] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(0); + const [busyServers, setBusyServers] = useState>(() => new Set()); + /** Ref mirror of busy set — keyboard handlers read synchronously without stale closure. */ + const busyServersRef = useRef(new Set()); + const { isTopLayer } = useKeyboardLayer(); + const { colors } = useTheme(); + const { show } = useToast(); + const dimensions = useTerminalDimensions(); + + const bodyWidth = Math.min(DIALOG_MAX_WIDTH, dimensions.width - 4) - DIALOG_PADDING_X * 2; + const paths = useMemo(() => getMcpConfigPaths(process.cwd()), []); + const servers = useMemo(() => { + void refreshToken; + return getMcpManager().getStatus(); + }, [refreshToken]); + + const listViewportHeight = + Math.min(servers.length, MCP_LIST_MAX_ITEMS) * MCP_ROW_HEIGHT; + const pageSize = visibleItemCount(servers.length, MCP_LIST_MAX_ITEMS); + const scrollRef = useRef(null); + const suppressSelectionFromScrollRef = useRef(false); + + const selected = servers[selectedIndex]; + const bump = useCallback(() => setRefreshToken((value) => value + 1), []); + + const syncBusyServers = useCallback(() => { + setBusyServers(new Set(busyServersRef.current)); + }, []); + + const tryAcquireBusy = useCallback( + (serverName: string): boolean => { + if (busyServersRef.current.has(serverName)) return false; + busyServersRef.current.add(serverName); + syncBusyServers(); + return true; + }, + [syncBusyServers], + ); + + const releaseBusy = useCallback( + (serverName: string) => { + busyServersRef.current.delete(serverName); + syncBusyServers(); + }, + [syncBusyServers], + ); + + const isServerBusy = useCallback((serverName: string) => busyServersRef.current.has(serverName), []); + + useEffect(() => { + // Remote servers in backoff show PENDING — refresh list until connected or failed. + const hasAutoPending = servers.some( + (server) => + server.enabled && + server.status === McpConnectionStatus.PENDING && + !busyServersRef.current.has(server.name), + ); + if (!hasAutoPending) return; + + const interval = setInterval(() => bump(), 1000); + return () => clearInterval(interval); + }, [servers, busyServers, bump]); + + const handleReconnect = useCallback( + async (server: McpServerStatus) => { + if (!tryAcquireBusy(server.name)) return; + try { + await getMcpManager().reconnect(server.name); + bump(); + } catch (error) { + show({ + variant: "error", + message: error instanceof Error ? error.message : "Reconnect failed", + }); + } finally { + releaseBusy(server.name); + } + }, + [bump, releaseBusy, show, tryAcquireBusy], + ); + + const handleToggleEnabled = useCallback( + async (server: McpServerStatus) => { + if (!tryAcquireBusy(server.name)) return; + try { + setServerEnabled(server.name, !server.enabled, process.cwd()); + await getMcpManager().applyServerEnabledChange(server.name, !server.enabled, process.cwd()); + bump(); + } catch (error) { + show({ + variant: "error", + message: error instanceof Error ? error.message : "Failed to update MCP config", + }); + } finally { + releaseBusy(server.name); + } + }, + [bump, releaseBusy, show, tryAcquireBusy], + ); + + useKeyboard((key) => { + if (!isTopLayer("dialog")) return; + + if (key.name === "return" || key.name === "enter") { + if (selected && !isServerBusy(selected.name)) { + void handleReconnect(selected); + } + return; + } + + if (key.name === "t") { + key.preventDefault(); + if (selected && !isServerBusy(selected.name)) { + void handleToggleEnabled(selected); + } + return; + } + + if (key.name === "up" || key.name === "down") { + key.preventDefault(); + setSelectedIndex((index) => moveDialogSelection(index, key.name as "up" | "down", servers.length)); + } + }); + + const handleScrollPositionChange = useCallback( + (position: number) => { + if (suppressSelectionFromScrollRef.current) return; + setSelectedIndex( + Math.min(servers.length - 1, Math.max(0, Math.floor(position / MCP_ROW_HEIGHT))), + ); + }, + [servers.length], + ); + + useLayoutEffect(() => { + const scrollbox = scrollRef.current; + if (!scrollbox || servers.length === 0) return; + suppressSelectionFromScrollRef.current = true; + scrollIndexIntoView(scrollbox, selectedIndex, pageSize, MCP_ROW_HEIGHT); + suppressSelectionFromScrollRef.current = false; + }, [selectedIndex, servers.length, pageSize]); + + if (servers.length === 0) { + return ( + + No MCP servers configured. + + + ); + } + + return ( + + + {servers.map((server, index) => { + const isSelected = index === selectedIndex; + const isBusy = busyServers.has(server.name); + const isPending = + server.enabled && server.status === McpConnectionStatus.PENDING && !isBusy; + return ( + setSelectedIndex(index)} + > + + {truncateLine(server.name, bodyWidth)} + + + {formatStatusRow(server, bodyWidth, { busy: isBusy })} + + + ); + })} + + + {busyServers.size > 0 + ? `Reconnecting ${[...busyServers].join(", ")}…` + : "Enter reconnect · t toggle enabled · ↑↓ navigate"} + + + + ); +} diff --git a/packages/cli/src/components/keys-setup-gate.tsx b/packages/cli/src/components/keys-setup-gate.tsx new file mode 100644 index 0000000..0dacb52 --- /dev/null +++ b/packages/cli/src/components/keys-setup-gate.tsx @@ -0,0 +1,31 @@ +/** + * BYOK keys gate (Phase 02, D-12). + * + * Auto-opens the `/keys` wizard once on cold start when `shouldAutoOpenKeysWizard()` + * is true (local mode + missing provider key). `didAutoOpenRef` prevents re-open loops + * when the user dismisses the dialog — the effect must not re-fire on DialogProvider + * context identity changes after close. + */ +import { createElement, useEffect, useRef } from "react"; +import { KeysWizardDialogContent } from "./dialogs/keys-wizard-dialog"; +import { shouldAutoOpenKeysWizard } from "../lib/keys-wizard-trigger"; +import { useDialog } from "../providers/dialog"; + +/** Mount-time D-12 gate: auto-opens `/keys` wizard once when BYOK keys are missing. */ +export function KeysSetupGate() { + const { open } = useDialog(); + const didAutoOpenRef = useRef(false); + + useEffect(() => { + if (didAutoOpenRef.current || !shouldAutoOpenKeysWizard()) { + return; + } + didAutoOpenRef.current = true; + open({ + title: "API Keys", + children: createElement(KeysWizardDialogContent), + }); + }, [open]); + + return null; +} diff --git a/packages/cli/src/hooks/use-chat-bash.test.ts b/packages/cli/src/hooks/use-chat-bash.test.ts new file mode 100644 index 0000000..ee25540 --- /dev/null +++ b/packages/cli/src/hooks/use-chat-bash.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { BASH_REJECT_ERROR_TEXT } from "./use-chat"; + +/** Regression for Phase 01 plan 06 — post-TUI-reject errorText aligns with Rule 11 (D-22/D-23/D-25). */ +describe("BASH_REJECT_ERROR_TEXT", () => { + test("states positive re-ask path via new message and TUI", () => { + expect(BASH_REJECT_ERROR_TEXT).toMatch(/new message/i); + expect(BASH_REJECT_ERROR_TEXT).toMatch(/TUI/i); + expect(BASH_REJECT_ERROR_TEXT).toMatch(/approval dialog/i); + }); + + test("offers manual execution fallback", () => { + expect(BASH_REJECT_ERROR_TEXT).toMatch(/manually/i); + }); + + test("closes chat confirmation workaround", () => { + expect(BASH_REJECT_ERROR_TEXT).toMatch(/no chat confirmation path/i); + expect(BASH_REJECT_ERROR_TEXT).toMatch(/not a permission gate/i); + expect(BASH_REJECT_ERROR_TEXT).toMatch(/do not retry the same command/i); + }); +}); diff --git a/packages/cli/src/hooks/use-chat-mcp.test.ts b/packages/cli/src/hooks/use-chat-mcp.test.ts new file mode 100644 index 0000000..3067078 --- /dev/null +++ b/packages/cli/src/hooks/use-chat-mcp.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test, mock } from "bun:test"; +import { Mode } from "@mocode/shared"; +import { executeMcpToolCall } from "../lib/mcp-tool-call"; +import type { McpManager } from "../mcp/manager"; + +function createDeps(overrides: Partial<{ + requestMcpApproval: ReturnType; + sessionMcpAllowRef: Set; + mode: typeof Mode.BUILD; +}> = {}) { + const sessionMcpAllowRef = overrides.sessionMcpAllowRef ?? new Set(); + const callTool = mock(() => Promise.resolve({ content: [{ type: "text", text: "ok" }] })); + const requestMcpApproval = + overrides.requestMcpApproval ?? + mock(() => Promise.resolve("approve-once" as const)); + const addToolOutput = mock(() => {}); + + const getMcpManager = () => + ({ + callTool, + }) as unknown as McpManager; + + const deps = { + getMcpManager, + requestMcpApproval, + sessionMcpAllowRef, + mode: overrides.mode ?? Mode.BUILD, + dialog: {} as never, + addToolOutput, + }; + + return { deps, callTool, requestMcpApproval, addToolOutput, sessionMcpAllowRef }; +} + +describe("executeMcpToolCall", () => { + test("read-only MCP tool calls callTool without approval", async () => { + const { deps, callTool, requestMcpApproval } = createDeps(); + const handled = await executeMcpToolCall( + { toolName: "mcp__fs__get_file", toolCallId: "tc1", input: { path: "/tmp" } }, + deps, + ); + + expect(handled).toBe(true); + expect(requestMcpApproval).not.toHaveBeenCalled(); + expect(callTool).toHaveBeenCalledTimes(1); + expect(callTool).toHaveBeenCalledWith("fs", "get_file", { path: "/tmp" }); + }); + + test("write MCP tool awaits approval before callTool", async () => { + const { deps, callTool, requestMcpApproval } = createDeps(); + await executeMcpToolCall( + { toolName: "mcp__fs__write_file", toolCallId: "tc2", input: { path: "/tmp/a" } }, + deps, + ); + + expect(requestMcpApproval).toHaveBeenCalledTimes(1); + expect(callTool).toHaveBeenCalledTimes(1); + }); + + test("rejected write returns output-error without callTool", async () => { + const requestMcpApproval = mock(() => Promise.resolve("reject" as const)); + const { deps, callTool, addToolOutput } = createDeps({ requestMcpApproval }); + await executeMcpToolCall( + { toolName: "mcp__fs__write_file", toolCallId: "tc3", input: {} }, + deps, + ); + + expect(callTool).not.toHaveBeenCalled(); + expect(addToolOutput).toHaveBeenCalledWith( + expect.objectContaining({ state: "output-error", toolCallId: "tc3" }), + ); + }); + + test("session allowlist skips repeat approval", async () => { + const requestMcpApproval = mock(() => Promise.resolve("approve-once" as const)); + const sessionMcpAllowRef = new Set(["mcp__fs__write_file"]); + const { deps, callTool, requestMcpApproval: req } = createDeps({ + requestMcpApproval, + sessionMcpAllowRef, + }); + await executeMcpToolCall( + { toolName: "mcp__fs__write_file", toolCallId: "tc4", input: {} }, + deps, + ); + + expect(req).not.toHaveBeenCalled(); + expect(callTool).toHaveBeenCalledTimes(1); + }); + + test("allow-session adds full mcp__ name to session allowlist", async () => { + const requestMcpApproval = mock(() => Promise.resolve("allow-session" as const)); + const { deps, sessionMcpAllowRef } = createDeps({ requestMcpApproval }); + await executeMcpToolCall( + { toolName: "mcp__fs__write_file", toolCallId: "tc5", input: {} }, + deps, + ); + + expect(sessionMcpAllowRef.has("mcp__fs__write_file")).toBe(true); + }); + + test("non-mcp tool returns false without side effects", async () => { + const { deps } = createDeps(); + const handled = await executeMcpToolCall( + { toolName: "bash", toolCallId: "tc6", input: {} }, + deps, + ); + + expect(handled).toBe(false); + }); +}); diff --git a/packages/cli/src/hooks/use-chat.ts b/packages/cli/src/hooks/use-chat.ts index 5b80a65..19e1870 100644 --- a/packages/cli/src/hooks/use-chat.ts +++ b/packages/cli/src/hooks/use-chat.ts @@ -16,7 +16,7 @@ * sends `[previousUser, assistantWithToolCalls]` instead of the full history, * because the server merges against stored session messages. */ -import { useMemo, useCallback, useRef } from "react"; +import { useMemo, useCallback, useRef, useEffect } from "react"; import { useChat as useAiChat } from "@ai-sdk/react"; import { DefaultChatTransport, @@ -31,7 +31,16 @@ import { getAuth } from "../lib/auth"; import { executeLocalTool } from "../lib/local-tools"; import { requiresApproval, rememberSessionAllow } from "../lib/bash-approval"; import { requestBashApproval } from "../lib/bash-approval-ui"; +import { executeMcpToolCall } from "../lib/mcp-tool-call"; +import { requestMcpApproval } from "../lib/mcp-approval-ui"; +import { getMcpManager } from "../mcp/manager"; +import { isMcpToolName } from "../mcp/heuristics"; import { useDialog } from "../providers/dialog"; +import { isLocalMode } from "../lib/local-mode"; +import { updateLocalSession } from "../lib/local-sessions"; +import { resolveChatModel } from "../lib/local-model"; +import { LocalChatTransport, stripIncompleteAssistantMessages } from "../lib/local-chat-transport"; +import { buildSystemPrompt } from "../lib/system-prompt"; /** * Multi-sentence model guidance returned when the user rejects a blocklisted bash command. @@ -44,9 +53,9 @@ import { useDialog } from "../providers/dialog"; * 4. Model should acknowledge and suggest alternatives instead of re-asking. * 5. Positive retry path: new user message → bash tool call → TUI dialog again. * 6. Manual fallback: user runs the command outside the agent. - * 7. Hard ban: no chat confirmation path exists — do not wait for or solicit typed confirm. + * 7. Hard ban: chat cannot unlock retry — do not wait for or solicit typed confirm. */ -const BASH_REJECT_ERROR_TEXT = +export const BASH_REJECT_ERROR_TEXT = "User rejected this command in the TUI approval dialog — this is not a runtime failure. " + "Do not retry the same command unless the user explicitly requests it again in a new message. " + "Do not ask for typed chat confirmation to proceed; chat is not a permission gate and the TUI dialog was the only approval step. " + @@ -80,8 +89,20 @@ export function useChat(sessionId: string, initialMessages: Message[]) { // normalized command string. Owned here (not a global singleton) so each chat session // resets on mount and cannot leak across sessions (Phase 01, plan 02). const sessionAllowRef = useRef(new Set()); + // MCP write approval session allowlist — keyed by full `mcp____` (Phase 02, D-15). + // Separate from bash allowlist because MCP tools are named, not normalized shell strings. + const sessionMcpAllowRef = useRef(new Set()); const transport = useMemo(() => { + // BYOK: inference + tool schema merge run in-process; MCP execution stays in onToolCall below. + if (isLocalMode()) { + return new LocalChatTransport({ + resolveModel: resolveChatModel, + getMcpManager, + buildSystemPrompt, + }); + } + return new DefaultChatTransport({ api: apiClient.chat.$url().toString(), headers() { @@ -97,6 +118,7 @@ export function useChat(sessionId: string, initialMessages: Message[]) { const metadata = messages.findLast( (m) => m.metadata?.mode && m.metadata?.model, )?.metadata; + const requestMode = message.metadata?.mode ?? metadata?.mode ?? Mode.BUILD; const previousMessage = messages[messages.length - 2]; // Tool loop: server already has history; only ship the pair that changed. const requestMessages = @@ -108,8 +130,11 @@ export function useChat(sessionId: string, initialMessages: Message[]) { body: { id: sessionId, messages: requestMessages, - mode: message.metadata?.mode ?? metadata?.mode, + mode: requestMode, model: message.metadata?.model ?? metadata?.model, + // Phase 02 D-06: CLI discovers MCP tools locally; server merges schemas into streamText only. + // Execution remains on CLI — server never calls MCP SDK. + mcpTools: getMcpManager().getToolDefinitions(requestMode), }, } } @@ -121,13 +146,56 @@ export function useChat(sessionId: string, initialMessages: Message[]) { messages: initialMessages, transport, async onToolCall({ toolCall }) { - if (toolCall.dynamic) return; - // Tool-continuation assistant messages may omit metadata — scan backward like transport. const mode = chat.messages.findLast((message) => message.metadata?.mode)?.metadata?.mode ?? Mode.BUILD; + const isMcpCall = isMcpToolName(toolCall.toolName); + // AI SDK marks tools without static execute as `dynamic`. MCP tools are dynamic but must + // still run on the CLI — only skip unrelated dynamic tools we do not own. + if (toolCall.dynamic && !isMcpCall) return; + + if (isMcpCall) { + // dynamicTool MCP entries are not in ChatTools union — widen addToolOutput for MCP path. + const addMcpToolOutput = chat.addToolOutput as (params: { + toolCallId: string; + state?: "output-available" | "output-error"; + output?: unknown; + errorText?: string; + }) => void; + + await executeMcpToolCall( + { + toolName: toolCall.toolName, + toolCallId: toolCall.toolCallId, + input: toolCall.input, + }, + { + getMcpManager, + requestMcpApproval, + sessionMcpAllowRef: sessionMcpAllowRef.current, + mode, + dialog, + addToolOutput: (params) => { + if (params.state === "output-error") { + addMcpToolOutput({ + toolCallId: params.toolCallId, + state: "output-error", + errorText: params.errorText ?? "MCP tool call failed", + }); + return; + } + addMcpToolOutput({ + toolCallId: params.toolCallId, + output: params.output, + }); + }, + }, + ); + return; + } + try { // ── Phase 01 bash approval gate (HARNESS-03) ──────────────────────────── if (toolCall.toolName === "bash" && mode === Mode.BUILD) { @@ -167,6 +235,26 @@ export function useChat(sessionId: string, initialMessages: Message[]) { sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, }); + useEffect(() => { + if (!isLocalMode()) return; + if (chat.status !== "ready") return; + + try { + updateLocalSession(sessionId, chat.messages); + } catch { + // Session file may not exist yet during initial mount. + } + }, [sessionId, chat.messages, chat.status]); + + useEffect(() => { + if (!chat.error || !isLocalMode()) return; + + const pruned = stripIncompleteAssistantMessages(chat.messages); + if (pruned.length === chat.messages.length) return; + + chat.setMessages(pruned); + }, [chat.error, chat.status, chat.messages, chat.setMessages]); + const submit = useCallback( (params: { userText: string; mode: ModeType; model: SupportedChatModelId }) => { return chat.sendMessage({ diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index c59242b..71d922f 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -1,6 +1,7 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { runTerminalSetupFromArgv } from "./terminal-setup"; +import { parseCliArgs, setLocalMode } from "./lib/local-mode"; import { disableTerminalKeyboardProtocols, enableTerminalKeyboardProtocols, @@ -17,6 +18,11 @@ if (runTerminalSetupFromArgv(process.argv.slice(2))) { process.exit(0); } +const { local } = parseCliArgs(process.argv.slice(2)); +if (local) { + setLocalMode(true); +} + const router = createMemoryRouter([ { path: "/", diff --git a/packages/cli/src/layouts/root-layout.tsx b/packages/cli/src/layouts/root-layout.tsx index c77ab40..f3562a2 100644 --- a/packages/cli/src/layouts/root-layout.tsx +++ b/packages/cli/src/layouts/root-layout.tsx @@ -5,6 +5,7 @@ import { DialogProvider } from "../providers/dialog"; import { KeyboardLayerProvider } from "../providers/keyboard-layer"; import { ThemeProvider } from "../providers/theme"; import { PromptConfigProvider } from "../providers/prompt-config"; +import { KeysSetupGate } from "../components/keys-setup-gate"; /** * Provider stack for the CLI shell. @@ -17,6 +18,7 @@ export function RootLayout(){ + diff --git a/packages/cli/src/lib/session-navigation.ts b/packages/cli/src/lib/session-navigation.ts index 41cc504..41837e1 100644 --- a/packages/cli/src/lib/session-navigation.ts +++ b/packages/cli/src/lib/session-navigation.ts @@ -11,6 +11,7 @@ export const sessionLocationSchema = z.object({ model: supportedChatModelIdSchema, }) .optional(), + local: z.boolean().optional(), }); /** Coerce persisted session JSON into chat messages; empty when malformed. */ diff --git a/packages/cli/src/providers/dialog/index.tsx b/packages/cli/src/providers/dialog/index.tsx index d91abf7..20e7617 100644 --- a/packages/cli/src/providers/dialog/index.tsx +++ b/packages/cli/src/providers/dialog/index.tsx @@ -1,5 +1,5 @@ /** Modal overlay provider; pushes a "dialog" keyboard layer while open. */ -import { createContext, useContext, useState, useCallback, useRef } from "react"; +import { createContext, useContext, useState, useCallback, useRef, useMemo } from "react"; import type { ReactNode } from "react"; import { TextAttributes,RGBA } from "@opentui/core"; import { useKeyboard, useTerminalDimensions } from "@opentui/react"; @@ -56,10 +56,13 @@ export function DialogProvider({ children }: DialogProviderProps) { }); },[close,pop]); - const value: DialogContextValue = { - open, - close, - }; + const value: DialogContextValue = useMemo( + () => ({ + open, + close, + }), + [open, close], + ); return( {children} @@ -104,7 +107,7 @@ function Dialog({ currentDialog, close }: DialogProps) { onMouseDown={()=>close()} > { try{ + if (isLocalMode()) { + const provider = findSupportedChatModel(state.model)?.provider ?? "anthropic"; + if (!hasRequiredKeys(provider)) { + openKeysWizardIfNeeded(dialog, { provider }); + if (ignore) return; + navigate("/", { replace: true }); + return; + } + const session = createLocalSession(state.message.slice(0, 100)); + if (ignore) return; + navigate(`/sessions/${session.id}`, { + replace: true, + state: { session, initialPrompt: state, local: true }, + }); + return; + } + const response = await apiClient.sessions.$post({ json:{ title:state.message.slice(0,100), @@ -63,7 +86,7 @@ export function NewSession() { return ()=>{ ignore = true; }; - },[state,navigate,toast]); + },[state,navigate,toast,dialog]); if(!state) return null; diff --git a/packages/cli/src/screens/session.tsx b/packages/cli/src/screens/session.tsx index b45bb3d..3192c5f 100644 --- a/packages/cli/src/screens/session.tsx +++ b/packages/cli/src/screens/session.tsx @@ -15,8 +15,11 @@ import { usePromptConfig } from "../providers/prompt-config"; import type { Message } from "../hooks/use-chat"; import { apiClient } from "../lib/api-client"; import { getErrorMessage } from "../lib/http-errors"; +import { isLocalMode } from "../lib/local-mode"; +import { getLocalSession } from "../lib/local-sessions"; import { useKeyboardLayer } from "../providers/keyboard-layer"; import { parseInitialMessages, sessionLocationSchema } from "../lib/session-navigation"; +import { initMcpOnSessionMount } from "../mcp/session-mcp"; /** * Phase 11 session screen. @@ -70,6 +73,10 @@ function SessionChat({ ); const hasSubmittedInitialPromptRef = useRef(false); + useEffect(() => { + return initMcpOnSessionMount(process.cwd()); + }, []); + // Stop the pending reply when the user leaves this session. useEffect(() => { return () => { @@ -129,6 +136,7 @@ export function Session() { return { session: parsed.data.session as SessionData, initialPrompt: parsed.data.initialPrompt, + local: parsed.data.local, }; }, [location.state]); @@ -143,6 +151,22 @@ export function Session() { if (!id) return; let ignore = false; + + if (isLocalMode() || prefetched?.local) { + const localSession = getLocalSession(id); + if (ignore) return; + if (!localSession) { + toast.show({ + variant: "error", + message: "Local session not found", + }); + navigate("/", { replace: true }); + return; + } + setSession(localSession as SessionData); + return; + } + const fetchSession = async () => { try { const res = await apiClient.sessions[":id"].$get({ diff --git a/packages/cli/src/utils/list-scroll-nav.ts b/packages/cli/src/utils/list-scroll-nav.ts index 61cc19d..8b1392c 100644 --- a/packages/cli/src/utils/list-scroll-nav.ts +++ b/packages/cli/src/utils/list-scroll-nav.ts @@ -3,18 +3,23 @@ export function scrollIndexIntoView( scrollbox: { scrollTop: number; scrollTo: (position: number) => void }, index: number, pageSize: number, + itemRowHeight = 1, ): void { if (pageSize <= 0) return; const scrollTop = scrollbox.scrollTop; - if (index < scrollTop) { - scrollbox.scrollTo(index); + const viewportRows = pageSize * itemRowHeight; + const itemTop = index * itemRowHeight; + const itemBottom = itemTop + itemRowHeight - 1; + + if (itemTop < scrollTop) { + scrollbox.scrollTo(itemTop); return; } - const lastVisible = scrollTop + pageSize - 1; - if (index > lastVisible) { - scrollbox.scrollTo(index - pageSize + 1); + const lastVisibleRow = scrollTop + viewportRows - 1; + if (itemBottom > lastVisibleRow) { + scrollbox.scrollTo(Math.max(0, itemBottom - viewportRows + 1)); } } From 89036b276abc4c1fdc934d7022d2e7208e514a7e Mon Sep 17 00:00:00 2001 From: moyun Date: Sat, 27 Jun 2026 19:36:11 +0800 Subject: [PATCH 5/7] docs: add terminal UI standards and MCP permission notes Add AGENTS.md and DESIGN.md as CLI UI source of truth and extend agent-permissions with MCP write approval behavior aligned with bash TUI gates. Co-authored-by: Cursor --- AGENTS.md | 27 +++ DESIGN.md | 449 ++++++++++++++++++++++++++++++++++++++ docs/agent-permissions.md | 1 + 3 files changed, 477 insertions(+) create mode 100644 AGENTS.md create mode 100644 DESIGN.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..86cc486 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# AGENTS.md + +Guidance for any AI agent or contributor working on MoCode. + +## UI standard — read before touching the CLI + +**[`DESIGN.md`](./DESIGN.md) is the single source of truth for all terminal UI.** + +MoCode is a terminal-native app (OpenTUI + React), not a web app. Before adding +or changing anything under `packages/cli/src/components/`, `screens/`, or +`layouts/`, conform to DESIGN.md: + +- Layout is **bottom-anchored**: scrolling transcript above, pinned composer + + single status line below. +- Measurements are **character cells**, not pixels. No fonts, `px`, + border-radius, or shadows. +- Color is **theme-driven**: resolve semantic tokens via `useTheme()` + (`packages/cli/src/theme.ts`). **Never hardcode hex** in a component. +- Mode tint is global: `{color.primary}` (Build) / `{color.planMode}` (Plan) on + the accent bar (`┃`), `◉`, and spinner. +- Reference DESIGN.md tokens (`{color.*}`, `{glyph.*}`, `{space.*}`, `{size.*}`). + If you need a value that isn't a token, add it to DESIGN.md first, then use it. +- Follow the dialog shell + Width & Overflow rules (cap then truncate, no phantom + scrollbars, single `esc` affordance in the header). + +If a plan or `/gsd-ui-phase` UI-SPEC contradicts DESIGN.md, DESIGN.md wins — +reconcile to it. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..3563904 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,449 @@ +--- +version: 1.0 +name: MoCode-TUI-Design-System +description: >- + The authoritative UI standard for MoCode — a terminal-native AI coding agent + built on OpenTUI + React. This is NOT a web design system: there are no pixels, + no fonts, no shadows, no hero bands. The unit of layout is the character cell; + color is theme-driven (32 named palettes in packages/cli/src/theme.ts); depth + comes from box-drawing accent bars and background fills, never drop shadows. + The composition is bottom-anchored (Claude Code / Codex convention): a + scrolling transcript above, a pinned composer and a single status line below. + MoCode's signature is the left-accent bar (┃) that tints every message and the + composer by agent mode, instead of full bordered boxes or background-filled + prompts. Any LLM or contributor building MoCode UI MUST follow this document. + +# ───────────────────────────────────────────────────────────────────────────── +# AUTHORITY +# This file is the single source of truth for MoCode's terminal UI. When a GSD +# UI phase (/gsd-ui-phase), a plan, or a feature touches the CLI surface, the +# implementation MUST conform to the tokens, layout anatomy, and component specs +# below. Reference tokens by their {ref} form (e.g. {color.primary}, +# {glyph.accent-bar}, {space.gutter}) — never hardcode hex or magic cell counts +# in component code without a matching token here. +# ───────────────────────────────────────────────────────────────────────────── + +# Runtime / rendering substrate (informational — do not redefine in components) +runtime: + engine: "OpenTUI (@opentui/core + @opentui/react)" + layout: "Flexbox over character cells (box flexDirection/gap/padding/width)" + unit: "1 cell = 1 monospace column (x) / 1 row (y). All spacing is integer cells." + color: "24-bit hex resolved per active theme; never assume a fixed palette." + primitives: ["box", "text", "input", "textarea", "scrollbox", "ascii-font", "em"] + emphasis: ["TextAttributes.BOLD", "TextAttributes.DIM", "TextAttributes.ITALIC", ""] + +# ── Color tokens ────────────────────────────────────────────────────────────── +# These are SEMANTIC names resolved from the active theme via useTheme().colors. +# 32 palettes exist; DESIGN.md fixes the MEANING of each token, not its hex. +# Components MUST read colors from the theme context — never inline a hex value. +colors: + primary: "Build-mode accent. Composer/user-message left bar, ◉ glyph, primary spinner, key affordances." + planMode: "Plan-mode accent. Replaces primary on every mode-tinted element when mode = PLAN." + selection: "Active-row highlight fill in lists/menus/action buttons. Selected text flips to black on this fill." + thinking: "Reasoning emphasis (the 'Thinking:' label and inline reasoning accents)." + thinkingBorder: "The vertical │ accent bar on reasoning + tool blocks. Muted, recedes behind content." + success: "Confirmations, connected status, additions." + error: "Failures, rejections, deletions, disabled/failed status." + info: "Tool-name labels, neutral informational accents (the › separators lean on dimSeparator)." + background: "App canvas floor — the terminal base behind the transcript." + surface: "Raised fill for the composer interior and inline message bodies (user/assistant text wells)." + dialogSurface: "Modal overlay card fill. One step DARKER than surface so dialogs read as 'above' the canvas." + dimSeparator: "Low-contrast › / · separators and meta dividers in status/footer rows." + +# ── Spacing tokens (cells) ──────────────────────────────────────────────────── +# Terminal spacing is coarse. Keep it to these values; do not invent 0.5-cell +# fractions except the documented header kerning. +space: + none: 0 # tight stacks (action button lists use gap 0) + hair: 1 # default vertical gap between stacked blocks and inline gaps + gutter: 2 # horizontal padding inside message wells, composer, list rows + inset: 2 # screen edge padding (SessionShell paddingX) + dialog-x: 4 # dialog card horizontal padding + dialog-y: 1 # dialog card vertical padding + text-indent: 3 # assistant plain-text left padding (deeper than accent blocks) + +# ── Sizing tokens (cells) ───────────────────────────────────────────────────── +size: + dialog-width: "min(72, cols - 4)" # cap card width; never full-bleed. Long content TRUNCATES, never wraps the card wider. + list-max-rows: 8 # command/mention/search lists: viewport caps at 8 rows, then scroll + dialog-list-max-rows: 6 # in-dialog lists (e.g. /mcp) cap at 6 rows + command-col: "max(command-name-len) + 4" # fixed name column so descriptions align + status-row: 1 # status/footer is exactly one row + row: 1 # list/menu/action rows are single-row, overflow hidden + +# ── Glyph tokens (box-drawing + markers) ────────────────────────────────────── +# MoCode's visual identity lives in these glyphs. The accent bar is the signature. +glyph: + accent-bar: "┃" # heavy vertical — left edge of composer + user message, tinted by mode + accent-foot: "╹" # heavy bottom-left cap closing the accent bar + quiet-bar: "│" # light vertical — reasoning + tool blocks (recedes vs accent-bar) + mode-dot: "◉" # assistant message mode marker (primary=Build / planMode=Plan) + sep-chevron: "›" # inline meta separator (mode › model › duration), in dimSeparator + sep-dot: "·" # secondary inline separator in status/footer + rule: "─" # horizontal separator rule (use sparingly; prefer whitespace) + pending: "…" # trailing ellipsis on in-flight tool/streaming states + esc-affordance: "esc" # dim text in the dialog header's top-right — the ONLY close hint a dialog shows + +# ── Emphasis hierarchy (replaces web "typography") ──────────────────────────── +# Terminals have no type scale. Hierarchy is built from attribute + color + glyph. +emphasis: + display: "ascii-font (tiny) — reserved for the MoCode wordmark on the home header only" + title: "BOLD default-fg — dialog titles, action-button labels, list primary text" + body: "default-fg, no attribute — message text, transcript content" + label: " — inline tool/reasoning labels before content" + meta: "DIM (+ dimSeparator fg on separators) — model, duration, hints, paths, captions" + selected: "fg flips to black over {color.selection} fill — the universal 'active row' signal" + +# ── Component registry (specs live in prose below; keep refs stable) ────────── +components: + app-shell: { ref: SessionShell, role: "Root vertical layout: transcript / composer / status" } + home-header: { ref: Header, role: "Centered MoCode ascii wordmark on the home screen" } + user-message: { ref: UserMessage, role: "User turn — mode-tinted accent bar + surface well" } + assistant-message:{ ref: BotMessage, role: "Assistant turn — text + reasoning/tool blocks + mode footer" } + reasoning-block: { ref: "BotMessage.reasoning", role: "quiet-bar block, 'Thinking:' label, dim body" } + tool-block: { ref: "BotMessage.tool", role: "quiet-bar block, info-colored tool label, args, pending/error suffix" } + composer: { ref: InputBar, role: "Multiline textarea inside the mode-tinted accent bar; hosts overlays" } + status-bar: { ref: StatusBar, role: "Single dim row: mode › model · submit · newline hints" } + session-footer: { ref: "SessionShell.footer", role: "Spinner + interrupt hint (left) / tab-agent hint (right)" } + dialog-shell: { ref: "DialogProvider.Dialog", role: "Centered modal overlay card; dim backdrop; title + esc-affordance" } + search-list: { ref: DialogSearchList, role: "Filter input + scrolling single-select list with selection highlight" } + action-button: { ref: "ActionButton", role: "Single-row selectable label (+dim hint); used in approval dialogs" } + command-menu: { ref: CommandMenu, role: "Slash-command palette floating above the composer" } + mention-menu: { ref: FileMentionMenu, role: "@-file palette floating above the composer" } + spinner: { ref: Spinner, role: "Async activity indicator, tinted by mode" } +--- + +## Overview + +MoCode is a **terminal-native** product. Its closest design relatives are +**Claude Code** and the **OpenAI Codex CLI** — not a web app. Three principles +inherited from those tools, and one signature that is MoCode's own, define the +whole system: + +1. **Bottom-anchored composition.** The transcript scrolls in the upper region; + the composer and a single status line are pinned to the bottom. The user's + eyes and hands live at the bottom of the screen. (Both Claude Code and Codex + converge on this.) +2. **Cells, not pixels.** Every measurement is an integer character cell. Layout + is Flexbox over cells via OpenTUI `box`. There are no fonts, no `px`, no + border-radius, no drop shadows. +3. **Theme-driven color.** There is no single palette. 32 named themes live in + `packages/cli/src/theme.ts`; components resolve **semantic tokens** + (`{color.primary}`, `{color.surface}`, …) from `useTheme()`. Never hardcode a + hex value in a component. +4. **The accent bar is the signature** (MoCode's own). Instead of Codex's + background-filled prompt or Claude Code's separator-framed box, MoCode marks + the composer and the user's message with a heavy left bar `{glyph.accent-bar}` + tinted by agent mode — `{color.primary}` in Build, `{color.planMode}` in Plan. + Assistant reasoning/tool blocks use a quieter `{glyph.quiet-bar}`. This left-rail + rhythm is what makes a MoCode screen recognizable at a glance. + +> Why this rewrite exists: the previous DESIGN.md described the Claude.com +> marketing website (cream canvas, serif display, pricing cards, footer). MoCode +> ships none of that. This document replaces it with the real, terminal substrate. + +## Layout Anatomy + +Read **bottom-up** — the way the user experiences it. This mirrors Claude Code's +`output → separator → prompt → status → mode` stack and Codex's +`conversation view → bottom pane (composer + status)` split. + +``` +┌ terminal viewport ───────────────────────────────────────────┐ +│ │ +│ ◉ Build › model ← assistant message footer │ +│ │ scrolling +│ ┃ ← mode-tinted accent bar │ transcript +│ ◉ Build › model │ (scrollbox, +│ │ sticky to +│ …grows upward… │ bottom) +│ │ +│ ┃ │ +│ ┃ Ask anything... "Fix a bug in the database" ← composer │ pinned +│ ╹ Build › model · ⏎ 提交 · Ctrl+J 换行 ← status │ bottom +│ ⟳ esc to interrupt tab agent ← footer │ pane +└───────────────────────────────────────────────────────────────┘ +``` + +- **Transcript** (`{components.app-shell}` scrollbox): `flexGrow=1`, `stickyScroll` + to bottom so the newest turn stays visible as history grows. +- **Composer** (`{components.composer}`): the mode-tinted accent-bar well with a + multiline `textarea`. Slash-command and @-mention palettes float **above** it + (`position:absolute; bottom:100%`), never pushing the transcript. +- **Status line** (`{components.status-bar}`): exactly one dim row, Codex-style — + `mode {glyph.sep-chevron} model {glyph.sep-dot} submit hint {glyph.sep-dot} newline hint`. +- **Footer** (`{components.session-footer}`): spinner + `esc to interrupt` on the + left while streaming; `tab agent` hint pinned right. + +### Screen edges & width discipline +- The shell insets the whole app by `{space.inset}` horizontally, `{space.hair}` + vertically (`SessionShell paddingX=2 paddingY=1`). +- Transcript content stretches full width but each block manages its own + `{space.gutter}` interior padding. +- **Modals never go full-bleed.** Dialog cards cap at `{size.dialog-width}` + and are centered. Content that exceeds the card **truncates** (see Width Rules); + the card never grows to fit a long string. + +## Color System + +MoCode is **dark-first** and theme-driven. The active theme supplies every token; +the same component looks correct across all 32 palettes because it only ever +references semantic names. + +### Token meanings (stable across themes) +| Token | Meaning | +|---|---| +| `{color.primary}` | Build-mode accent — composer/user accent bar, `{glyph.mode-dot}`, primary spinner | +| `{color.planMode}` | Plan-mode accent — swaps in for `primary` on every mode-tinted element | +| `{color.selection}` | Active-row fill in any list/menu/button; selected fg flips to black | +| `{color.thinking}` | Reasoning emphasis (the `Thinking:` label / inline accents) | +| `{color.thinkingBorder}` | The `{glyph.quiet-bar}` rail on reasoning + tool blocks | +| `{color.info}` | Tool-name labels and neutral info accents | +| `{color.success}` / `{color.error}` | Status, approvals/rejections, diff add/remove | +| `{color.background}` | Canvas floor behind the transcript | +| `{color.surface}` | Raised well behind composer + inline message text | +| `{color.dialogSurface}` | Modal card fill — one step **darker** than `surface` | +| `{color.dimSeparator}` | `{glyph.sep-chevron}` / `{glyph.sep-dot}` separators and meta dividers | + +### Rules +- **Never inline hex.** Resolve from `useTheme().colors`. A literal `#…` in a + component is a design-system violation. +- **Mode tinting is binary and consistent.** Any element that signals agent mode + uses `{color.primary}` (Build) or `{color.planMode}` (Plan) — composer bar, user + accent bar, `{glyph.mode-dot}`, spinner. Never mix (e.g. a Build bar with a Plan dot). +- **Selection is the only "fill" interaction.** Active rows fill with + `{color.selection}` and flip text to black. Don't invent hover styling — the + terminal has no hover; pointer `onMouseMove` only mirrors keyboard selection. +- **Backdrop dims, never blacks out.** The dialog overlay is translucent black + (`RGBA 0,0,0,~150`) so the transcript stays faintly visible behind the modal. + +## Emphasis & "Typography" + +There is no type scale in a terminal. Hierarchy is composed from **attribute + +color + glyph**: + +| Level | Token | Treatment | Use | +|---|---|---|---| +| Display | `{emphasis.display}` | `ascii-font` (tiny) | MoCode wordmark on home only | +| Title | `{emphasis.title}` | `BOLD`, default fg | Dialog titles, action labels, list primary text | +| Body | `{emphasis.body}` | default fg, no attribute | Transcript + message content | +| Label | `{emphasis.label}` | `` | Inline tool/reasoning labels | +| Meta | `{emphasis.meta}` | `DIM` (+ `dimSeparator`) | Model, duration, hints, paths, captions | +| Selected | `{emphasis.selected}` | black fg over `{color.selection}` | The universal active-row signal | + +Principles: +- **Reach for DIM before color.** Most secondary text is `DIM`, not a new hue. + Color is reserved for mode, status, and selection. +- **BOLD is for the one primary thing** in a region (a dialog title, a button + label) — not for whole paragraphs. +- **Glyphs carry structure**, attributes carry emphasis. The `{glyph.mode-dot}`, + `{glyph.accent-bar}`, and `{glyph.sep-chevron}` do the work a font weight or + rule would do on the web. + +## Components + +### App shell — `{components.app-shell}` +Root column: `width/height 100%`, `flexDirection=column`, `gap {space.hair}`, +`paddingY {space.hair}`, `paddingX {space.inset}`. Three children, top→bottom: +scrollbox transcript (`flexGrow=1`, `stickyScroll` to bottom) · composer +(`flexShrink=0`) · status/footer row (`flexShrink=0`, `{size.status-row}`). + +### Home header — `{components.home-header}` +Centered `ascii-font` wordmark: `Mo` in gray + `Code` in default, kerning `gap 0.5`. +The only place `{emphasis.display}` is used. No tagline chrome. + +### User message — `{components.user-message}` +Mode-tinted **accent bar** + surface well: +- Left `border:["left"]` with `customBorderChars` `vertical={glyph.accent-bar}`, + `bottomLeft={glyph.accent-foot}`; `borderColor` = `{color.primary}` (Build) or + `{color.planMode}` (Plan). +- Inner box: `backgroundColor {color.surface}`, `paddingX {space.gutter}`, + `paddingY {space.hair}`, full width. Body text in `{emphasis.body}`. + +### Assistant message — `{components.assistant-message}` +A stack of part-blocks followed by a mode footer. +- **Plain text part:** `paddingX {space.text-indent}` (deeper than accent blocks, + so prose sits inset from the rail rhythm), `{emphasis.body}`. +- **Reasoning block** (`{components.reasoning-block}`): `border:["left"]`, + `vertical={glyph.quiet-bar}`, `borderColor {color.thinkingBorder}`, + `paddingX {space.gutter}`. Content `DIM` with `Thinking:` prefix. +- **Tool block** (`{components.tool-block}`): same quiet-bar frame. Label + `ToolName:` + args (`DIM`). Append `{glyph.pending}` + while running; append error text in place on `output-error`. +- **Mode footer:** `{glyph.mode-dot}` in `{color.primary}`/`{color.planMode}` then + `Build|Plan {glyph.sep-chevron} model [{glyph.sep-chevron} duration]`, separators + in `{color.dimSeparator}`, meta `DIM`. While streaming with no text/tools yet, + show `Generating response{glyph.pending}` (DIM). + +### Composer — `{components.composer}` +The interactive heart. Mode-tinted accent bar identical to the user message +(`{glyph.accent-bar}` + `{glyph.accent-foot}`, mode color). Interior: +`backgroundColor {color.surface}`, `paddingX {space.gutter}`, `paddingY {space.hair}`, +`gap {space.hair}`, holding a `textarea` (placeholder +`Ask anything... "Fix a bug in the database"`) above the `{components.status-bar}`. +Command/mention palettes mount as `position:absolute; bottom:100%; width:100%; +backgroundColor {color.surface}; zIndex≥10` so they overlay upward without +shifting the transcript. + +### Status bar — `{components.status-bar}` +Codex-style **single row**, all `DIM`: +`{mode color}Build|Plan{/} {glyph.sep-chevron} model {glyph.sep-dot} ⏎ 提交 {glyph.sep-dot} Ctrl+J 换行`. +Separators use `{color.dimSeparator}`. Keep it to one line; if the terminal is +narrow, drop the rightmost hints first (newline hint, then submit hint) — never +wrap to two rows. + +### Session footer — `{components.session-footer}` +Row, `justify space-between`, `{size.status-row}` tall. Left cluster: while +`loading`, a `{components.spinner}` (mode-tinted) + optional `esc to interrupt`. +Right cluster: `tab` + `DIM agent` hint, pinned with `marginLeft:auto`. + +### Dialog shell — `{components.dialog-shell}` +The modal contract every overlay shares. Fixing its consistency is the core of +this revision. +- **Overlay:** absolute, full viewport, translucent black backdrop; click on the + backdrop closes (`onMouseDown` → close). Card centered. +- **Card:** `width {size.dialog-width}`, `backgroundColor {color.dialogSurface}`, + `paddingX {space.dialog-x}`, `paddingY {space.dialog-y}`, `flexDirection=column`, + `gap {space.hair}`, `zIndex 100`. +- **Header row:** `justify space-between`. Title in `{emphasis.title}` (BOLD) at + left; `{glyph.esc-affordance}` in `DIM` at right. **This is the only close hint.** + Dialog bodies MUST NOT add their own "Esc to close" / "esc to close" line — it + duplicates the header and wastes a row. +- **Body:** the dialog's content component, full width. + +### Search list — `{components.search-list}` +`input` (filter, `focused`) above a `scrollbox` capped at `{size.list-max-rows}` +(or `{size.dialog-list-max-rows}` inside a dialog). Rows are `{size.row}` tall, +`overflow:hidden`. Selected row fills `{color.selection}` with black fg +(`{emphasis.selected}`). Empty state: a single `DIM` line (`emptyText`). +**Scroll chrome only when needed:** if items ≤ viewport, render no scrollbar — a +one-item list must not show a tall scrollbar (a current bug; see Width & Overflow). + +### Action button — `{components.action-button}` +Single-row selectable: `paddingX {space.hair}`, `{size.row}` tall. Label in BOLD; +optional `hint` appended in `DIM`/gray. Selected → `{color.selection}` fill, black +fg. Lists of action buttons stack with `gap {space.none}` (tight). Used by the +bash / MCP approval dialogs; default highlight is the **safe** choice (Reject). + +### Command & mention menus — `{components.command-menu}` / `{components.mention-menu}` +Float above the composer. Command palette: fixed `{size.command-col}` name column +(so descriptions align) + flexible description column (`overflow:hidden`). +Viewport caps at `{size.list-max-rows}`, then scrolls. Same selection model as the +search list. Empty state is a single `DIM` line. + +### Spinner — `{components.spinner}` +Async indicator tinted by mode (`{color.primary}`/`{color.planMode}`). Appears in +the session footer while the assistant streams, and inline where an operation is +pending. Pair with `{glyph.pending}` on the related text, not as a replacement. + +## Width & Overflow Rules + +Terminal width is the scarcest resource and the source of the reported layout +bugs. These rules are normative. + +1. **Cap, then truncate — never wrap the card.** A dialog card is `{size.dialog-width}`. + Long single-line values (file paths, model ids, commands) MUST be truncated to + fit. For paths, prefer **leading-ellipsis** truncation that keeps the tail: + `…/.mocode/mcp.json` rather than wrapping `/Users/long/…/mcp.json` across rows. +2. **Demote low-value chrome.** Absolute config paths, ids, and similar are + `{emphasis.meta}` (DIM) and may be hidden when space is tight. They are + reference detail, not primary content — they must not dominate a dialog. +3. **No phantom scrollbars.** A `scrollbox` height is `min(itemCount, maxRows)`. + When `itemCount ≤ maxRows`, do not reserve or render scroll chrome. A list + showing one item must look like one item, not a scroll region. +4. **One-row rows.** List/menu/status rows are `{size.row}` with `overflow:hidden`. + Never let a row reflow to two lines; truncate the secondary column first. +5. **Status never wraps.** The status line drops hints right-to-left under width + pressure instead of becoming a second row. +6. **Inset budget.** Account for `{space.inset}` (shell) and `{space.dialog-x}` + (card) when computing usable width: usable ≈ `min(72, cols-4) - 8`. + +## Motion & Feedback + +- **Streaming first.** Render assistant output incrementally; show + `Generating response{glyph.pending}` only until the first text/tool part lands. +- **Spinner = work in progress**, tinted by mode. Stop it the instant the turn + settles to `ready`/`error`. +- **No artificial delay.** Never use timers to "smooth" UI; drive everything off + real events/lifecycles (a hard rule — see the project's debug guidelines). +- **Selection feedback is instant**: arrow keys and `onMouseMove` both update the + selected row's `{color.selection}` fill with no transition. + +## Do's and Don'ts + +### Do +- Anchor the experience at the bottom: transcript above, composer + single status + line below. +- Tint the composer and user message with the **mode accent bar** + (`{glyph.accent-bar}` + `{glyph.accent-foot}`), `{color.primary}`/`{color.planMode}`. +- Use the **quiet bar** (`{glyph.quiet-bar}`, `{color.thinkingBorder}`) for + reasoning/tool blocks so they recede behind the conversation. +- Resolve every color from the theme; verify a new screen against several themes + (e.g. Nightfox, Gruvbox Dark, GitHub Dark) before shipping. +- Keep one consistent dialog shell: centered card, dim backdrop, title + + `{glyph.esc-affordance}` in the header, content below. +- Truncate long paths/ids with leading ellipsis; keep them `DIM`. +- Prefer whitespace (`gap {space.hair}`) over horizontal rules for separation. + +### Don't +- Don't import web design language — no fonts, `px`, border-radius, shadows, + hero/pricing/footer bands. (That was the prior DESIGN.md's mistake.) +- Don't hardcode hex colors in components. Always `useTheme()`. +- Don't add a body-level "Esc to close" line; the dialog header already shows + `{glyph.esc-affordance}`. +- Don't render scrollbars for lists that fit; don't let one item show a tall bar. +- Don't let any row wrap to two lines or let a dialog grow wider than + `{size.dialog-width}` to fit content — truncate instead. +- Don't invent hover states; the terminal has none. Pointer events only mirror + keyboard selection. +- Don't mix mode accents (e.g. Build bar with a Plan dot). Mode tint is global per + turn/composer. + +## Responsive Behavior (terminal sizes) + +The viewport is the terminal's `cols × rows`; "responsive" means degrading +gracefully as cells shrink. + +| Width (cols) | Behavior | +|---|---| +| Wide (≥ 100) | Full layout; dialogs at 72 cols; all status hints visible | +| Normal (80–99) | Dialogs at `cols-4`; status keeps mode/model + submit hint | +| Narrow (< 80) | Drop rightmost status hints; truncate model id; paths leading-ellipsis; menus stay ≤ `{size.list-max-rows}` | + +- Vertically, the transcript scrollbox absorbs the slack (`flexGrow=1`); composer, + status, and footer keep their fixed heights so the input never scrolls off. +- Long transcript content scrolls; it does not reflow the composer or status. + +## Iteration Guide (for contributors & LLMs) + +1. **Start from the layout anatomy.** Decide where the change lives: transcript + block, composer, status, or modal. Reuse the matching `{components.*}` spec. +2. **Reference tokens, not literals.** Use `{color.*}`, `{glyph.*}`, `{space.*}`, + `{size.*}`. If you need a value that isn't a token, add the token here first, + then use it — keep DESIGN.md and the code in lockstep. +3. **One component at a time.** Variants (selected/disabled/error) are states of + an existing component, not new ones. +4. **Mode tint and selection are global patterns** — apply them the same way + everywhere; don't reinvent per screen. +5. **Test across themes and widths.** A screen is done when it reads correctly in + 3+ themes and at 80 and 120 cols. +6. **When in doubt about emphasis:** DIM it before coloring it; structure with a + glyph before adding a rule. +7. **GSD UI phases** (`/gsd-ui-phase`, UI-SPEC.md) MUST treat this file as the + design contract. A UI-SPEC that contradicts these tokens is wrong and should + be reconciled to DESIGN.md. + +## Known Gaps / Out of Scope + +- **Per-theme hex** is intentionally not duplicated here — `theme.ts` is the + palette source; DESIGN.md fixes token meaning only. +- **Diff rendering** (syntax-highlighted add/remove blocks) is implied by + `{color.success}`/`{color.error}` but not yet a formalized component; spec it + here when built. +- **Markdown rendering inside assistant text** (lists, code fences, inline code) + uses default body emphasis today; a richer markdown component is a future token + set. +- **Mouse interaction** beyond selection mirroring and backdrop-close is + out of scope; MoCode is keyboard-first. +- **ascii-font** usage is limited to the home wordmark; large banners elsewhere + are discouraged (they eat vertical budget). diff --git a/docs/agent-permissions.md b/docs/agent-permissions.md index 2d4b246..63cbb7c 100644 --- a/docs/agent-permissions.md +++ b/docs/agent-permissions.md @@ -24,3 +24,4 @@ In Build mode the model should: ## Plan mode Plan mode has no bash tool. Git inspection uses read-only `gitStatus` and `gitDiff` tools instead. + \ No newline at end of file From 97dc98e42f0d04c1bcf07b1ed43bb680cbea4d42 Mon Sep 17 00:00:00 2001 From: moyun Date: Sun, 28 Jun 2026 00:10:56 +0800 Subject: [PATCH 6/7] fix(cli): harden MCP/BYOK from CodeRabbit review and surface stream errors Address PR review findings: MCP connect races, config I/O guards, mixed-case tool routing, keys chmod, and AI SDK error masking so BYOK users see real API failures. Co-authored-by: Cursor --- .../components/dialogs/keys-wizard-dialog.tsx | 26 +++++++++++--- packages/cli/src/hooks/use-chat.ts | 14 +++++--- .../cli/src/lib/keys-wizard-trigger.test.ts | 34 +++++++------------ packages/cli/src/lib/keys.ts | 5 ++- .../cli/src/lib/local-chat-transport.test.ts | 14 ++++---- packages/cli/src/lib/local-chat-transport.ts | 4 ++- packages/cli/src/lib/local-sessions.ts | 7 ++-- packages/cli/src/lib/mcp-tool-call.ts | 3 +- packages/cli/src/lib/stream-error.test.ts | 16 +++++++++ packages/cli/src/lib/stream-error.ts | 13 +++++++ packages/cli/src/mcp/config-schema.ts | 13 +++++-- packages/cli/src/mcp/config.ts | 12 +++++-- packages/cli/src/mcp/heuristics.test.ts | 18 +++++++++- packages/cli/src/mcp/heuristics.ts | 18 ++++++++-- packages/cli/src/mcp/integration.test.ts | 4 +-- packages/cli/src/mcp/manager.ts | 31 +++++++++++++---- packages/cli/src/mcp/watcher.test.ts | 8 ++--- packages/cli/src/mcp/watcher.ts | 10 ++++-- packages/cli/src/screens/new-session.tsx | 6 +++- packages/server/src/routes/chat.ts | 2 +- 20 files changed, 193 insertions(+), 65 deletions(-) create mode 100644 packages/cli/src/lib/stream-error.test.ts create mode 100644 packages/cli/src/lib/stream-error.ts diff --git a/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx b/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx index 8a3740c..c173aaa 100644 --- a/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx +++ b/packages/cli/src/components/dialogs/keys-wizard-dialog.tsx @@ -11,9 +11,17 @@ import { moveDialogSelection } from "../../lib/dialog-action-nav"; import { getKeys, saveKeys, type ProviderKeys } from "../../lib/keys"; import { useKeyboardLayer } from "../../providers/keyboard-layer"; import { useTheme } from "../../providers/theme"; +import { useToast } from "../../providers/toast"; import { DialogSearchList } from "../dialog-search-list"; -const PROVIDERS = ["anthropic", "openai", "google", "groq", "openrouter"] as const; +const PROVIDERS = [ + "anthropic", + "openai", + "google", + "groq", + "cerebras", + "openrouter", +] as const; type ProviderId = (typeof PROVIDERS)[number]; @@ -71,6 +79,7 @@ function ActionButton({ label, hint, selected, onSelect, onMouseMove }: ActionBu /** Multi-provider API key wizard opened by `/keys` (D-12). */ export function KeysWizardDialogContent() { const { isTopLayer } = useKeyboardLayer(); + const { show } = useToast(); const [view, setView] = useState("list"); const [keys, setKeys] = useState(() => getKeys() ?? {}); const [editingProvider, setEditingProvider] = useState(null); @@ -108,10 +117,17 @@ export function KeysWizardDialogContent() { nextKeys[editingProvider] = { apiKey: trimmed }; } - saveKeys(nextKeys); - setKeys(nextKeys); - handleBack(); - }, [keys, editingProvider, draftKey, handleBack]); + try { + saveKeys(nextKeys); + setKeys(nextKeys); + handleBack(); + } catch (error) { + show({ + variant: "error", + message: error instanceof Error ? error.message : "Failed to save API key", + }); + } + }, [keys, editingProvider, draftKey, handleBack, show]); const handleContentChange = useCallback(() => { setDraftKey(inputRef.current?.value ?? ""); diff --git a/packages/cli/src/hooks/use-chat.ts b/packages/cli/src/hooks/use-chat.ts index 19e1870..0cb92f6 100644 --- a/packages/cli/src/hooks/use-chat.ts +++ b/packages/cli/src/hooks/use-chat.ts @@ -34,7 +34,7 @@ import { requestBashApproval } from "../lib/bash-approval-ui"; import { executeMcpToolCall } from "../lib/mcp-tool-call"; import { requestMcpApproval } from "../lib/mcp-approval-ui"; import { getMcpManager } from "../mcp/manager"; -import { isMcpToolName } from "../mcp/heuristics"; +import { looksLikeMcpToolName } from "../mcp/heuristics"; import { useDialog } from "../providers/dialog"; import { isLocalMode } from "../lib/local-mode"; import { updateLocalSession } from "../lib/local-sessions"; @@ -151,7 +151,7 @@ export function useChat(sessionId: string, initialMessages: Message[]) { chat.messages.findLast((message) => message.metadata?.mode)?.metadata?.mode ?? Mode.BUILD; - const isMcpCall = isMcpToolName(toolCall.toolName); + const isMcpCall = looksLikeMcpToolName(toolCall.toolName); // AI SDK marks tools without static execute as `dynamic`. MCP tools are dynamic but must // still run on the CLI — only skip unrelated dynamic tools we do not own. if (toolCall.dynamic && !isMcpCall) return; @@ -241,8 +241,14 @@ export function useChat(sessionId: string, initialMessages: Message[]) { try { updateLocalSession(sessionId, chat.messages); - } catch { - // Session file may not exist yet during initial mount. + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Local session not found:") + ) { + return; + } + console.error("Failed to persist local session", { sessionId, error }); } }, [sessionId, chat.messages, chat.status]); diff --git a/packages/cli/src/lib/keys-wizard-trigger.test.ts b/packages/cli/src/lib/keys-wizard-trigger.test.ts index 89f2ee1..632b31c 100644 --- a/packages/cli/src/lib/keys-wizard-trigger.test.ts +++ b/packages/cli/src/lib/keys-wizard-trigger.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { homedir } from "node:os"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { DEFAULT_CHAT_MODEL_ID, findSupportedChatModel } from "@mocode/shared"; import { saveKeys } from "./keys"; import { setLocalMode } from "./local-mode"; import { openKeysWizardIfNeeded, shouldAutoOpenKeysWizard } from "./keys-wizard-trigger"; -const TEST_DIR = join(homedir(), ".mocode-test-keys-wizard"); +let testDir: string; function defaultProvider(): string { return findSupportedChatModel(DEFAULT_CHAT_MODEL_ID)!.provider; @@ -16,50 +16,42 @@ function defaultProvider(): string { describe("shouldAutoOpenKeysWizard", () => { beforeEach(() => { setLocalMode(false); - if (!existsSync(TEST_DIR)) { - mkdirSync(TEST_DIR, { recursive: true, mode: 0o700 }); - } + testDir = mkdtempSync(join(tmpdir(), "mocode-test-keys-wizard-")); }); afterEach(() => { setLocalMode(false); - if (existsSync(TEST_DIR)) { - rmSync(TEST_DIR, { recursive: true }); - } + rmSync(testDir, { recursive: true, force: true }); }); test("returns true when local mode and keys missing", () => { setLocalMode(true); const provider = defaultProvider(); - expect(shouldAutoOpenKeysWizard({ keysDir: TEST_DIR, provider })).toBe(true); + expect(shouldAutoOpenKeysWizard({ keysDir: testDir, provider })).toBe(true); }); test("returns false in SaaS mode", () => { setLocalMode(false); - expect(shouldAutoOpenKeysWizard({ keysDir: TEST_DIR, provider: defaultProvider() })).toBe(false); + expect(shouldAutoOpenKeysWizard({ keysDir: testDir, provider: defaultProvider() })).toBe(false); }); test("returns false when keys present", () => { setLocalMode(true); const provider = defaultProvider(); - saveKeys({ [provider]: { apiKey: "sk-test-key" } }, { keysDir: TEST_DIR }); - expect(shouldAutoOpenKeysWizard({ keysDir: TEST_DIR, provider })).toBe(false); + saveKeys({ [provider]: { apiKey: "sk-test-key" } }, { keysDir: testDir }); + expect(shouldAutoOpenKeysWizard({ keysDir: testDir, provider })).toBe(false); }); }); describe("openKeysWizardIfNeeded", () => { beforeEach(() => { setLocalMode(false); - if (!existsSync(TEST_DIR)) { - mkdirSync(TEST_DIR, { recursive: true, mode: 0o700 }); - } + testDir = mkdtempSync(join(tmpdir(), "mocode-test-keys-wizard-")); }); afterEach(() => { setLocalMode(false); - if (existsSync(TEST_DIR)) { - rmSync(TEST_DIR, { recursive: true }); - } + rmSync(testDir, { recursive: true, force: true }); }); test("opens dialog when local mode and keys missing", () => { @@ -74,7 +66,7 @@ describe("openKeysWizardIfNeeded", () => { close: () => {}, }; - expect(openKeysWizardIfNeeded(dialog, { keysDir: TEST_DIR, provider })).toBe(true); + expect(openKeysWizardIfNeeded(dialog, { keysDir: testDir, provider })).toBe(true); expect(opened).toBe(true); }); @@ -89,7 +81,7 @@ describe("openKeysWizardIfNeeded", () => { close: () => {}, }; - expect(openKeysWizardIfNeeded(dialog, { keysDir: TEST_DIR, provider: defaultProvider() })).toBe( + expect(openKeysWizardIfNeeded(dialog, { keysDir: testDir, provider: defaultProvider() })).toBe( false, ); expect(opened).toBe(false); diff --git a/packages/cli/src/lib/keys.ts b/packages/cli/src/lib/keys.ts index 93e6d6d..7aa5caf 100644 --- a/packages/cli/src/lib/keys.ts +++ b/packages/cli/src/lib/keys.ts @@ -4,7 +4,7 @@ * Stores provider keys in `~/.mocode/keys.json` with owner-only permissions. * Key values are never logged or included in thrown errors. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; @@ -62,9 +62,12 @@ export function saveKeys(keys: ProviderKeys, options?: KeysOptions): void { if (!existsSync(dir)) { mkdirSync(dir, { mode: 0o700 }); + } else { + chmodSync(dir, 0o700); } writeFileSync(keysFile, JSON.stringify(parsed.data, null, 2), { mode: 0o600 }); + chmodSync(keysFile, 0o600); } /** Returns whether the given provider has a non-empty API key on disk. */ diff --git a/packages/cli/src/lib/local-chat-transport.test.ts b/packages/cli/src/lib/local-chat-transport.test.ts index 38e11b8..167212b 100644 --- a/packages/cli/src/lib/local-chat-transport.test.ts +++ b/packages/cli/src/lib/local-chat-transport.test.ts @@ -26,13 +26,15 @@ mock.module("ai", () => ({ const { LocalChatTransport, stripIncompleteAssistantMessages } = await import("./local-chat-transport"); function createMockManager(): McpManager { + const registered = [ + { + serverName: "filesystem", + tools: [{ name: "read_file", description: "Read file", inputSchema: {} }], + }, + ]; return { - getDiscoveredTools: () => [ - { - serverName: "filesystem", - tools: [{ name: "read_file", description: "Read file", inputSchema: {} }], - }, - ], + getDiscoveredTools: () => registered, + getRegisteredTools: () => registered, } as unknown as McpManager; } diff --git a/packages/cli/src/lib/local-chat-transport.ts b/packages/cli/src/lib/local-chat-transport.ts index 21b9f08..7c191e8 100644 --- a/packages/cli/src/lib/local-chat-transport.ts +++ b/packages/cli/src/lib/local-chat-transport.ts @@ -30,6 +30,7 @@ import { } from "../mcp/tools"; import { isMcpToolName } from "../mcp/heuristics"; import type { ResolvedModel } from "./local-model"; +import { formatChatStreamError } from "./stream-error"; /** Last user-visible text in the outgoing batch — used for MCP routing heuristics. */ function lastUserText(messages: UIMessage[]): string { @@ -114,7 +115,7 @@ export class LocalChatTransport config, ); const tools = buildMergedToolSet(mode, mcpDynamicTools, config); - const mcpToolNames = Object.keys(mcpDynamicTools).filter(isMcpToolName); + const mcpToolNames = Object.keys(tools).filter(isMcpToolName); const mcpRequested = isMcpUserRequest(lastUserText(messages)); const systemPrompt = this.opts.buildSystemPrompt({ mode, mcpToolNames, mcpRequested }); const resolvedModel = this.opts.resolveModel(modelId); @@ -144,6 +145,7 @@ export class LocalChatTransport return result.toUIMessageStream({ originalMessages: nextMessages, + onError: formatChatStreamError, messageMetadata({ part }) { if (part.type === "start") { return { mode, model: modelId }; diff --git a/packages/cli/src/lib/local-sessions.ts b/packages/cli/src/lib/local-sessions.ts index f6a556a..e2e2d14 100644 --- a/packages/cli/src/lib/local-sessions.ts +++ b/packages/cli/src/lib/local-sessions.ts @@ -59,8 +59,11 @@ function readIndex(projectDir: string): SessionsIndex { const data = readFileSync(indexPath, "utf-8"); const parsed = JSON.parse(data) as SessionsIndex; return { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] }; - } catch { - return { sessions: [] }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { sessions: [] }; + } + throw error; } } diff --git a/packages/cli/src/lib/mcp-tool-call.ts b/packages/cli/src/lib/mcp-tool-call.ts index f0a1244..4d8aaf6 100644 --- a/packages/cli/src/lib/mcp-tool-call.ts +++ b/packages/cli/src/lib/mcp-tool-call.ts @@ -16,6 +16,7 @@ import { loadMergedMcpConfig } from "../mcp/config"; import { isMcpReadOnlyTool, isMcpToolName, + looksLikeMcpToolName, parseMcpToolName, requiresMcpWriteApproval, } from "../mcp/heuristics"; @@ -64,7 +65,7 @@ export async function executeMcpToolCall( // Some free models emit `Mcp__filesystem__read_file` — normalize to lowercase prefix. const toolName = isMcpToolName(rawToolName) ? rawToolName - : rawToolName.toLowerCase().startsWith("mcp__") + : looksLikeMcpToolName(rawToolName) ? rawToolName.toLowerCase() : rawToolName; diff --git a/packages/cli/src/lib/stream-error.test.ts b/packages/cli/src/lib/stream-error.test.ts new file mode 100644 index 0000000..16fe201 --- /dev/null +++ b/packages/cli/src/lib/stream-error.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { formatChatStreamError } from "./stream-error"; + +describe("formatChatStreamError", () => { + test("returns Error.message", () => { + expect(formatChatStreamError(new Error("Rate limit reached"))).toBe("Rate limit reached"); + }); + + test("returns string as-is", () => { + expect(formatChatStreamError("provider unavailable")).toBe("provider unavailable"); + }); + + test("handles null", () => { + expect(formatChatStreamError(null)).toBe("Unknown error"); + }); +}); diff --git a/packages/cli/src/lib/stream-error.ts b/packages/cli/src/lib/stream-error.ts new file mode 100644 index 0000000..6d23130 --- /dev/null +++ b/packages/cli/src/lib/stream-error.ts @@ -0,0 +1,13 @@ +/** Surfaces real stream/API errors in the TUI instead of AI SDK's generic mask. */ +export function formatChatStreamError(error: unknown): string { + if (error == null) { + return "Unknown error"; + } + if (typeof error === "string") { + return error; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/packages/cli/src/mcp/config-schema.ts b/packages/cli/src/mcp/config-schema.ts index 389adf8..74ee7df 100644 --- a/packages/cli/src/mcp/config-schema.ts +++ b/packages/cli/src/mcp/config-schema.ts @@ -17,10 +17,19 @@ const mcpServerStdioSchema = z.object({ tools: z.record(z.string(), mcpToolOverrideSchema).optional(), }); +const mcpUrlSchema = z.string().refine((value) => { + try { + new URL(value); + return true; + } catch { + return false; + } +}, "Invalid MCP server URL"); + const mcpServerHttpSchema = z.object({ enabled: z.boolean().default(true), transport: z.literal("http"), - url: z.string().min(1), + url: mcpUrlSchema, headers: z.record(z.string(), z.string()).optional(), timeoutMs: z.number().positive().default(60000), tools: z.record(z.string(), mcpToolOverrideSchema).optional(), @@ -29,7 +38,7 @@ const mcpServerHttpSchema = z.object({ const mcpServerSseSchema = z.object({ enabled: z.boolean().default(true), transport: z.literal("sse"), - url: z.string().min(1), + url: mcpUrlSchema, headers: z.record(z.string(), z.string()).optional(), timeoutMs: z.number().positive().default(60000), tools: z.record(z.string(), mcpToolOverrideSchema).optional(), diff --git a/packages/cli/src/mcp/config.ts b/packages/cli/src/mcp/config.ts index 751496a..692699b 100644 --- a/packages/cli/src/mcp/config.ts +++ b/packages/cli/src/mcp/config.ts @@ -38,8 +38,16 @@ function readMcpJsonFile(path: string): Record { const raw = readFileSync(path, "utf-8"); const parsed = JSON.parse(raw) as Record; return parsed; - } catch { - return {}; + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return {}; + } + throw error; } } diff --git a/packages/cli/src/mcp/heuristics.test.ts b/packages/cli/src/mcp/heuristics.test.ts index b22a278..c0a5408 100644 --- a/packages/cli/src/mcp/heuristics.test.ts +++ b/packages/cli/src/mcp/heuristics.test.ts @@ -1,5 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { isMcpReadOnlyTool, requiresMcpWriteApproval } from "./heuristics"; +import { isMcpReadOnlyTool, isMcpToolName, looksLikeMcpToolName, requiresMcpWriteApproval } from "./heuristics"; + +describe("isMcpToolName", () => { + test("accepts canonical mcp__server__tool names", () => { + expect(isMcpToolName("mcp__filesystem__read_file")).toBe(true); + }); + + test("rejects malformed names", () => { + expect(isMcpToolName("mcp__bad")).toBe(false); + expect(isMcpToolName("bash")).toBe(false); + }); + + test("looksLikeMcpToolName accepts mixed-case prefix", () => { + expect(looksLikeMcpToolName("Mcp__filesystem__read_file")).toBe(true); + expect(looksLikeMcpToolName("mcp__filesystem__read_file")).toBe(true); + }); +}); describe("isMcpReadOnlyTool", () => { const readOnlyTools = [ diff --git a/packages/cli/src/mcp/heuristics.ts b/packages/cli/src/mcp/heuristics.ts index e06b4c1..6c05263 100644 --- a/packages/cli/src/mcp/heuristics.ts +++ b/packages/cli/src/mcp/heuristics.ts @@ -13,9 +13,23 @@ export type McpToolConfigOverride = { readOnly?: boolean; }; -/** True when the tool name uses Claude Code MCP naming (`mcp__server__tool`). */ +function hasMcpToolShape(name: string, prefix: string): boolean { + if (!name.startsWith(prefix)) { + return false; + } + const rest = name.slice(prefix.length); + const separator = rest.indexOf("__"); + return separator > 0 && separator < rest.length - 2; +} + +/** True when the tool name uses canonical MCP naming (`mcp__server__tool`). */ export function isMcpToolName(name: string): boolean { - return name.startsWith(MCP_PREFIX); + return hasMcpToolShape(name, MCP_PREFIX); +} + +/** True for canonical or mixed-case MCP tool names (e.g. `Mcp__server__tool`). */ +export function looksLikeMcpToolName(name: string): boolean { + return isMcpToolName(name) || hasMcpToolShape(name.toLowerCase(), MCP_PREFIX); } /** Splits `mcp____` into server and raw MCP tool segments. */ diff --git a/packages/cli/src/mcp/integration.test.ts b/packages/cli/src/mcp/integration.test.ts index 8eee099..c92307d 100644 --- a/packages/cli/src/mcp/integration.test.ts +++ b/packages/cli/src/mcp/integration.test.ts @@ -16,8 +16,8 @@ async function isNpxAvailable(): Promise { describe("MCP stdio integration", () => { test("connectAll discovers tools from filesystem MCP server", async () => { - if (!(await isNpxAvailable())) { - console.warn("SKIP: npx unavailable — MCP stdio integration test skipped"); + if (!process.env.RUN_MCP_INTEGRATION_TESTS || !(await isNpxAvailable())) { + console.warn("SKIP: MCP integration test disabled or npx unavailable"); return; } diff --git a/packages/cli/src/mcp/manager.ts b/packages/cli/src/mcp/manager.ts index 6eca450..ef77a07 100644 --- a/packages/cli/src/mcp/manager.ts +++ b/packages/cli/src/mcp/manager.ts @@ -98,7 +98,7 @@ function registerExitHandlers(manager: McpManager): void { export class McpManager { private readonly servers = new Map(); /** Prevents duplicate in-flight `connectServer` for the same name (e.g. rapid `/mcp` toggles). */ - private readonly connecting = new Set(); + private readonly connecting = new Map>(); private cwd = process.cwd(); private readonly loadConfig: typeof loadMergedMcpConfig; private readonly createClient: () => Client; @@ -313,10 +313,24 @@ export class McpManager { * can still expose schemas while status is pending/failed. */ private async connectServer(name: string, config: McpServerEntry): Promise { - if (this.connecting.has(name)) { + const inFlight = this.connecting.get(name); + if (inFlight) { + await inFlight; return; } - this.connecting.add(name); + + const promise = this.connectServerOnce(name, config); + this.connecting.set(name, promise); + try { + await promise; + } finally { + if (this.connecting.get(name) === promise) { + this.connecting.delete(name); + } + } + } + + private async connectServerOnce(name: string, config: McpServerEntry): Promise { const previous = this.servers.get(name); if (previous?.reconnectTimer) { this.clearTimer(previous.reconnectTimer); @@ -330,7 +344,7 @@ export class McpManager { config, status: McpConnectionStatus.PENDING, reconnectAttempts: previous?.reconnectAttempts ?? 0, - tools: [], + tools: previous?.tools ?? [], }; this.servers.set(name, entry); @@ -346,6 +360,12 @@ export class McpManager { await client.close().catch(() => {}); entry.status = McpConnectionStatus.FAILED; entry.error = sanitizeErrorMessage(discoveryError); + + const isRemoteTransport = + config.transport === McpTransport.HTTP || config.transport === McpTransport.SSE; + if (isRemoteTransport) { + this.scheduleReconnect(name); + } return; } @@ -357,7 +377,6 @@ export class McpManager { } catch (error) { entry.status = McpConnectionStatus.FAILED; entry.error = sanitizeErrorMessage(error); - entry.tools = []; const isRemoteTransport = config.transport === McpTransport.HTTP || config.transport === McpTransport.SSE; @@ -365,8 +384,6 @@ export class McpManager { if (isRemoteTransport) { this.scheduleReconnect(name); } - } finally { - this.connecting.delete(name); } } diff --git a/packages/cli/src/mcp/watcher.test.ts b/packages/cli/src/mcp/watcher.test.ts index 48eeea1..821632b 100644 --- a/packages/cli/src/mcp/watcher.test.ts +++ b/packages/cli/src/mcp/watcher.test.ts @@ -3,7 +3,7 @@ import type { FSWatcher } from "chokidar"; import type { McpManager } from "./manager"; import { stopMcpWatcher, watchMcpConfig } from "./watcher"; -type ChangeHandler = () => void; +type ChangeHandler = (path: string) => void; describe("watchMcpConfig", () => { const changeHandlers: ChangeHandler[] = []; @@ -14,7 +14,7 @@ describe("watchMcpConfig", () => { const mockWatch = mock((_path: string) => ({ on: mock((event: string, handler: ChangeHandler) => { - if (event === "change") { + if (event === "change" || event === "add" || event === "unlink") { changeHandlers.push(handler); } }), @@ -73,8 +73,8 @@ describe("watchMcpConfig", () => { test("debounces rapid changes into one reload after 300ms", async () => { watchMcpConfig("/tmp/project", undefined, testDeps); - changeHandlers[0]?.(); - changeHandlers[0]?.(); + changeHandlers[0]?.("/home/user/.mocode/mcp.json"); + changeHandlers[0]?.("/home/user/.mocode/mcp.json"); expect(mockDisconnectAll).toHaveBeenCalledTimes(0); expect(mockConnectAll).toHaveBeenCalledTimes(0); diff --git a/packages/cli/src/mcp/watcher.ts b/packages/cli/src/mcp/watcher.ts index 44c75d9..c947f16 100644 --- a/packages/cli/src/mcp/watcher.ts +++ b/packages/cli/src/mcp/watcher.ts @@ -72,7 +72,9 @@ function scheduleReload(cwd: string, onReload: (() => void) | undefined, deps: R const callback = debounceOnReload; debounceCwd = undefined; debounceOnReload = undefined; - void reloadMcp(targetCwd, callback, deps.getManager); + void reloadMcp(targetCwd, callback, deps.getManager).catch((error) => { + console.error("MCP config reload failed:", error); + }); }, DEBOUNCE_MS); } @@ -88,12 +90,16 @@ export function watchMcpConfig(cwd: string, onReload?: () => void, deps?: McpWat const target = resolveWatchTarget(configPath, resolved.exists); const handle = resolved.watch(target, { ignoreInitial: true }); - const onConfigEvent = () => { + const onConfigEvent = (changedPath: string) => { + if (changedPath !== configPath) { + return; + } scheduleReload(cwd, onReload, resolved); }; handle.on("change", onConfigEvent); handle.on("add", onConfigEvent); + handle.on("unlink", onConfigEvent); activeWatchers.push({ handle }); } } diff --git a/packages/cli/src/screens/new-session.tsx b/packages/cli/src/screens/new-session.tsx index ebb2b82..6bfa7d6 100644 --- a/packages/cli/src/screens/new-session.tsx +++ b/packages/cli/src/screens/new-session.tsx @@ -47,7 +47,11 @@ export function NewSession() { const createSession = async () => { try{ if (isLocalMode()) { - const provider = findSupportedChatModel(state.model)?.provider ?? "anthropic"; + const model = findSupportedChatModel(state.model); + if (!model) { + throw new Error(`Unsupported chat model: ${state.model}`); + } + const provider = model.provider; if (!hasRequiredKeys(provider)) { openKeysWizardIfNeeded(dialog, { provider }); if (ignore) return; diff --git a/packages/server/src/routes/chat.ts b/packages/server/src/routes/chat.ts index 9932ef4..6c4f9cc 100644 --- a/packages/server/src/routes/chat.ts +++ b/packages/server/src/routes/chat.ts @@ -66,7 +66,7 @@ type MocodeUIMessage = UIMessage Date: Sun, 28 Jun 2026 00:24:46 +0800 Subject: [PATCH 7/7] fix(cli): guard MCP config errors and preserve server name casing Handle invalid mcp.json during tool execution and SaaS submit without hanging the chat loop, and normalize only the mcp__ prefix for mixed-case models. Co-authored-by: Cursor --- packages/cli/src/hooks/use-chat-mcp.test.ts | 10 +++++++++ packages/cli/src/hooks/use-chat.ts | 10 ++++++++- packages/cli/src/lib/mcp-tool-call.ts | 25 +++++++++++++-------- packages/cli/src/mcp/heuristics.test.ts | 16 ++++++++++++- packages/cli/src/mcp/heuristics.ts | 11 +++++++++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/hooks/use-chat-mcp.test.ts b/packages/cli/src/hooks/use-chat-mcp.test.ts index 3067078..4c684a9 100644 --- a/packages/cli/src/hooks/use-chat-mcp.test.ts +++ b/packages/cli/src/hooks/use-chat-mcp.test.ts @@ -107,4 +107,14 @@ describe("executeMcpToolCall", () => { expect(handled).toBe(false); }); + + test("mixed-case prefix preserves server segment for callTool", async () => { + const { deps, callTool } = createDeps(); + await executeMcpToolCall( + { toolName: "Mcp__MyServer__get_file", toolCallId: "tc7", input: { path: "/tmp" } }, + deps, + ); + + expect(callTool).toHaveBeenCalledWith("MyServer", "get_file", { path: "/tmp" }); + }); }); diff --git a/packages/cli/src/hooks/use-chat.ts b/packages/cli/src/hooks/use-chat.ts index 0cb92f6..69d2967 100644 --- a/packages/cli/src/hooks/use-chat.ts +++ b/packages/cli/src/hooks/use-chat.ts @@ -34,6 +34,7 @@ import { requestBashApproval } from "../lib/bash-approval-ui"; import { executeMcpToolCall } from "../lib/mcp-tool-call"; import { requestMcpApproval } from "../lib/mcp-approval-ui"; import { getMcpManager } from "../mcp/manager"; +import type { McpManager } from "../mcp/manager"; import { looksLikeMcpToolName } from "../mcp/heuristics"; import { useDialog } from "../providers/dialog"; import { isLocalMode } from "../lib/local-mode"; @@ -126,6 +127,13 @@ export function useChat(sessionId: string, initialMessages: Message[]) { ? [previousMessage, message] : [message]; + let mcpTools: ReturnType = []; + try { + mcpTools = getMcpManager().getToolDefinitions(requestMode); + } catch (error) { + console.error("Failed to load MCP tool definitions", error); + } + return { body: { id: sessionId, @@ -134,7 +142,7 @@ export function useChat(sessionId: string, initialMessages: Message[]) { model: message.metadata?.model ?? metadata?.model, // Phase 02 D-06: CLI discovers MCP tools locally; server merges schemas into streamText only. // Execution remains on CLI — server never calls MCP SDK. - mcpTools: getMcpManager().getToolDefinitions(requestMode), + mcpTools, }, } } diff --git a/packages/cli/src/lib/mcp-tool-call.ts b/packages/cli/src/lib/mcp-tool-call.ts index 4d8aaf6..d60cee4 100644 --- a/packages/cli/src/lib/mcp-tool-call.ts +++ b/packages/cli/src/lib/mcp-tool-call.ts @@ -16,7 +16,7 @@ import { loadMergedMcpConfig } from "../mcp/config"; import { isMcpReadOnlyTool, isMcpToolName, - looksLikeMcpToolName, + normalizeMcpToolName, parseMcpToolName, requiresMcpWriteApproval, } from "../mcp/heuristics"; @@ -62,20 +62,14 @@ export async function executeMcpToolCall( deps: ExecuteMcpToolCallDeps, ): Promise { const { toolName: rawToolName, toolCallId, input } = toolCall; - // Some free models emit `Mcp__filesystem__read_file` — normalize to lowercase prefix. - const toolName = isMcpToolName(rawToolName) - ? rawToolName - : looksLikeMcpToolName(rawToolName) - ? rawToolName.toLowerCase() - : rawToolName; + // Some free models emit `Mcp__filesystem__read_file` — normalize prefix only. + const toolName = normalizeMcpToolName(rawToolName); if (!isMcpToolName(toolName)) { return false; } const { server, tool } = parseMcpToolName(toolName); - const config = loadMergedMcpConfig(process.cwd()); - const toolConfig = config.mcpServers[server]?.tools?.[tool]; const { getMcpManager, @@ -86,6 +80,19 @@ export async function executeMcpToolCall( addToolOutput, } = deps; + let toolConfig; + try { + const config = loadMergedMcpConfig(process.cwd()); + toolConfig = config.mcpServers[server]?.tools?.[tool]; + } catch (error) { + addToolOutput({ + toolCallId, + state: "output-error", + errorText: error instanceof Error ? error.message : String(error), + }); + return true; + } + // D-08: PLAN strips write MCP tools from contracts; this is a second guard at execution time. if (mode === Mode.PLAN && !isMcpReadOnlyTool(tool, toolConfig)) { addToolOutput({ diff --git a/packages/cli/src/mcp/heuristics.test.ts b/packages/cli/src/mcp/heuristics.test.ts index c0a5408..afaf3a3 100644 --- a/packages/cli/src/mcp/heuristics.test.ts +++ b/packages/cli/src/mcp/heuristics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { isMcpReadOnlyTool, isMcpToolName, looksLikeMcpToolName, requiresMcpWriteApproval } from "./heuristics"; +import { isMcpReadOnlyTool, isMcpToolName, looksLikeMcpToolName, normalizeMcpToolName, requiresMcpWriteApproval } from "./heuristics"; describe("isMcpToolName", () => { test("accepts canonical mcp__server__tool names", () => { @@ -17,6 +17,20 @@ describe("isMcpToolName", () => { }); }); +describe("normalizeMcpToolName", () => { + test("lowercases prefix only and preserves server casing", () => { + expect(normalizeMcpToolName("Mcp__FileSystem__read_file")).toBe( + "mcp__FileSystem__read_file", + ); + }); + + test("leaves canonical names unchanged", () => { + expect(normalizeMcpToolName("mcp__filesystem__read_file")).toBe( + "mcp__filesystem__read_file", + ); + }); +}); + describe("isMcpReadOnlyTool", () => { const readOnlyTools = [ { name: "get_foo", tool: "get_foo" }, diff --git a/packages/cli/src/mcp/heuristics.ts b/packages/cli/src/mcp/heuristics.ts index 6c05263..0150c4f 100644 --- a/packages/cli/src/mcp/heuristics.ts +++ b/packages/cli/src/mcp/heuristics.ts @@ -32,6 +32,17 @@ export function looksLikeMcpToolName(name: string): boolean { return isMcpToolName(name) || hasMcpToolShape(name.toLowerCase(), MCP_PREFIX); } +/** Normalizes mixed-case MCP prefix to canonical `mcp__` without changing server/tool segments. */ +export function normalizeMcpToolName(name: string): string { + if (isMcpToolName(name)) { + return name; + } + if (!looksLikeMcpToolName(name)) { + return name; + } + return MCP_PREFIX + name.slice(MCP_PREFIX.length); +} + /** Splits `mcp____` into server and raw MCP tool segments. */ export function parseMcpToolName(fullName: string): { server: string; tool: string } { if (!isMcpToolName(fullName)) {