From e94c7e189c5728c56253b2d253f577921e069665 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 2 Oct 2026 09:27:21 -0700 Subject: [PATCH 1/3] Start Nod MCP mounting Signed-off-by: scgopi From fd339d6a81f8384f66e2495b7717a5d6c5630567 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 2 Oct 2026 09:35:20 -0700 Subject: [PATCH 2/3] Mount the graphcode and project MCP servers in both Nod engines Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/bun.lock | 1 + NodRuntime/package.json | 3 +- NodRuntime/src/claudeEngine.ts | 8 +- NodRuntime/src/copilotEngine.ts | 17 +- NodRuntime/src/engine.ts | 3 + NodRuntime/src/main.ts | 4 + NodRuntime/src/mcp/daemon.ts | 2 +- NodRuntime/src/mcpServers.ts | 144 ++++++++++++ NodRuntime/src/permissions.ts | 7 +- NodRuntime/src/runtime.ts | 66 +++++- NodRuntime/src/settings.ts | 4 + NodRuntime/src/tools.ts | 5 +- NodRuntime/test/mcpMount.test.ts | 369 +++++++++++++++++++++++++++++++ 13 files changed, 621 insertions(+), 12 deletions(-) create mode 100644 NodRuntime/src/mcpServers.ts create mode 100644 NodRuntime/test/mcpMount.test.ts diff --git a/NodRuntime/bun.lock b/NodRuntime/bun.lock index 189d0bc8..d37573d3 100644 --- a/NodRuntime/bun.lock +++ b/NodRuntime/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.287", "@github/copilot-sdk": "^1.0.16", + "zod": "^4.3.6", }, "devDependencies": { "@types/bun": "^1.4.2", diff --git a/NodRuntime/package.json b/NodRuntime/package.json index 1d63b1bb..e81b6b50 100644 --- a/NodRuntime/package.json +++ b/NodRuntime/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.287", - "@github/copilot-sdk": "^1.0.16" + "@github/copilot-sdk": "^1.0.16", + "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "^1.4.2", diff --git a/NodRuntime/src/claudeEngine.ts b/NodRuntime/src/claudeEngine.ts index aa7efa68..b03571a0 100644 --- a/NodRuntime/src/claudeEngine.ts +++ b/NodRuntime/src/claudeEngine.ts @@ -12,6 +12,7 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { claudeEnvironment } from "./credentials"; import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine"; +import { claudeMcpServers } from "./mcpServers"; import type { NodAttachment } from "./protocol"; import { summarizeResult } from "./tools"; @@ -68,6 +69,8 @@ export interface ClaudeEngineOptions { configDir: string; /** Claude Code to run; the SDK's bundled build when absent. */ executable?: string; + /** The SDK's `query`; tests pass a fake to see the options a session opens with. */ + query?: typeof query; } const NO_KEY = "Nod needs an Anthropic API key. Add one in Settings › Agents › Nod."; @@ -124,6 +127,9 @@ export class ClaudeEngine implements Engine { // "user" would read Nod's own config directory, not the human's ~/.claude. settingSources: ["project", "local"], systemPrompt: { type: "preset", preset: "claude_code", append: start.systemAppend }, + // Only what Nod mounts: the project's .mcp.json minus disabledMCPServers, read by the runtime. + mcpServers: start.mcp ? claudeMcpServers(start.mcp) : {}, + strictMcpConfig: true, canUseTool: (tool, input, { signal }) => this.canUseTool(tool, input, signal), hooks: { PreToolUse: [{ hooks: [this.preToolUse] }], @@ -133,7 +139,7 @@ export class ClaudeEngine implements Engine { ...(this.options.executable ? { pathToClaudeCodeExecutable: this.options.executable } : {}), stderr: () => {}, }; - this.q = query({ prompt: this.inbox, options }); + this.q = (this.options.query ?? query)({ prompt: this.inbox, options }); this.consumer = this.consume(this.q); } diff --git a/NodRuntime/src/copilotEngine.ts b/NodRuntime/src/copilotEngine.ts index 669e7cb9..be51774f 100644 --- a/NodRuntime/src/copilotEngine.ts +++ b/NodRuntime/src/copilotEngine.ts @@ -4,6 +4,7 @@ import { isAbsolute, resolve } from "node:path"; import { CopilotClient, RuntimeConnection, + type CopilotClientOptions, type CopilotSession, type PermissionRequest, type PermissionRequestResult, @@ -11,6 +12,8 @@ import { type SessionEvent, } from "@github/copilot-sdk"; import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine"; +import { serverName } from "./mcp"; +import { copilotGraphcodeTools, copilotMcpServers, copilotToolPrefix } from "./mcpServers"; import type { NodAttachment } from "./protocol"; import { summarizeResult } from "./tools"; @@ -29,6 +32,8 @@ export interface CopilotEngineOptions { githubToken?: string; /** The Copilot runtime to spawn; the SDK's bundled one when absent. */ cliPath?: string; + /** Tests pass a fake client to see the config a session opens with. */ + client?: (options: CopilotClientOptions) => CopilotClient; } /** @@ -64,6 +69,8 @@ export class CopilotEngine implements Engine { streaming: true, systemMessage: start.systemAppend ? { mode: "append", content: start.systemAppend } : undefined, onPermissionRequest: (request) => this.onPermission(request), + tools: copilotGraphcodeTools(start.mcp?.graphcode ?? []), + mcpServers: copilotMcpServers(start.mcp ?? { graphcode: [], servers: {} }), hooks: { onPostToolUse: () => { const steer = this.turn?.callbacks.takeSteer(); @@ -84,12 +91,13 @@ export class CopilotEngine implements Engine { private makeClient(cwd: string): CopilotClient { const path = this.options.cliPath; - return new CopilotClient({ + const options: CopilotClientOptions = { workingDirectory: cwd, logLevel: "error", ...(this.options.githubToken ? { gitHubToken: this.options.githubToken, useLoggedInUser: false } : { useLoggedInUser: true }), ...(path ? { connection: RuntimeConnection.forStdio({ path }) } : {}), - }); + }; + return this.options.client ? this.options.client(options) : new CopilotClient(options); } private async currentModel(): Promise { @@ -276,6 +284,11 @@ export function toolRequest(request: PermissionRequest, cwd: string): ToolReques return { intent: { kind: "fetch", url: str("url") } }; case "mcp": return { intent: { kind: "mcp", server: str("serverName"), tool: str("toolName") } }; + case "custom-tool": { + const name = str("toolName"); + if (name.startsWith(copilotToolPrefix)) return { intent: { kind: "mcp", server: serverName, tool: name.slice(copilotToolPrefix.length) } }; + return { intent: { kind: "read" } }; + } case "read": return { intent: { kind: "read" } }; } diff --git a/NodRuntime/src/engine.ts b/NodRuntime/src/engine.ts index 2412b7b8..0820871a 100644 --- a/NodRuntime/src/engine.ts +++ b/NodRuntime/src/engine.ts @@ -1,4 +1,5 @@ import type { NodAttachment, NodEngineKind, NodFailureKind, NodToolStatus } from "./protocol"; +import type { McpMount } from "./mcpServers"; import type { ToolIntent } from "./permissions"; export interface EngineStart { @@ -10,6 +11,8 @@ export interface EngineStart { forkFrom?: string; /** Appended to the engine's own system prompt: the briefing and Nod's identity. */ systemAppend?: string; + /** The graphcode server and the project's `.mcp.json` servers; nothing else is mounted. */ + mcp?: McpMount; } export interface EngineSession { diff --git a/NodRuntime/src/main.ts b/NodRuntime/src/main.ts index 56af57d2..322d9b58 100644 --- a/NodRuntime/src/main.ts +++ b/NodRuntime/src/main.ts @@ -11,6 +11,7 @@ import { anthropicAPIKey, githubToken } from "./credentials"; import type { Engine } from "./engine"; import { readBrief } from "./brief"; import { EventLog } from "./eventLog"; +import { loadProjectMcpServers } from "./mcpServers"; import { PresenceReporter } from "./presence"; import type { NodEngineKind } from "./protocol"; import { NodRuntime, type LoopType } from "./runtime"; @@ -111,6 +112,9 @@ async function main(argv: string[]): Promise { resume: values.resume, inherit: values.inherit && !values.resume ? readBrief(values.inherit) : undefined, unattended: values.unattended || undefined, + projectPath: process.env.NOD_PROJECT_PATH || undefined, + messagesOtherLoops: () => loadSettings().messagesOtherLoops, + mcpServers: loadProjectMcpServers(cwd, settings.disabledMCPServers), }); const control = new ControlSocket(join(stateDir, "control.sock"), (command) => runtime.handle(command)); await control.listen(); diff --git a/NodRuntime/src/mcp/daemon.ts b/NodRuntime/src/mcp/daemon.ts index e001f072..1d663bf9 100644 --- a/NodRuntime/src/mcp/daemon.ts +++ b/NodRuntime/src/mcp/daemon.ts @@ -100,7 +100,7 @@ export class DaemonConnection { while (this.queue.length > 0) { const event = this.queue.shift()!; const key = keys.find((k) => k in event); - if (key) return [key, event[key]]; + if (key) return [key, event[key]!]; } if (this.closed) throw this.closed; const remaining = deadline - Date.now(); diff --git a/NodRuntime/src/mcpServers.ts b/NodRuntime/src/mcpServers.ts new file mode 100644 index 00000000..bfab71d2 --- /dev/null +++ b/NodRuntime/src/mcpServers.ts @@ -0,0 +1,144 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { createSdkMcpServer, tool, type McpServerConfig, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; +import type { MCPServerConfig, Tool, ToolResultObject } from "@github/copilot-sdk"; +import { z } from "zod"; +import { serverName, type GraphcodeTool, type ToolResult } from "./mcp"; + +/** A server entry from `.mcp.json`, in the shape Claude Code and Copilot CLI both read. */ +export type ProjectMcpServer = + | { type?: "stdio"; command: string; args?: string[]; env?: Record } + | { type: "http" | "sse"; url: string; headers?: Record }; + +/** What each engine mounts: the built-in graphcode server and the project's own servers. */ +export interface McpMount { + graphcode: GraphcodeTool[]; + servers: Record; +} + +/** Copilot custom tools share one namespace with its built-ins, so they carry the server's name. */ +export const copilotToolPrefix = `${serverName}_`; + +/** + * The project's `.mcp.json` servers, nearest file first as the CLIs find it walking up from + * the working directory, with `${VAR}` and `${VAR:-default}` expanded. Names in `disabled` + * are left out, and so is any entry called `graphcode`: the built-in server always wins. + */ +export function loadProjectMcpServers( + cwd: string, + disabled: string[], + env: Record = process.env, +): Record { + const servers: Record = {}; + const skip = new Set([...disabled, serverName]); + for (let dir = resolve(cwd); ; dir = dirname(dir)) { + const file = join(dir, ".mcp.json"); + if (existsSync(file)) { + for (const [name, entry] of Object.entries(readServers(file))) { + if (skip.has(name) || name in servers) continue; + const server = projectServer(entry, env); + if (server) servers[name] = server; + } + } + if (dirname(dir) === dir) return servers; + } +} + +function readServers(file: string): Record { + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as { mcpServers?: unknown }; + return parsed.mcpServers && typeof parsed.mcpServers === "object" ? (parsed.mcpServers as Record) : {}; + } catch { + return {}; + } +} + +function projectServer(entry: unknown, env: Record): ProjectMcpServer | undefined { + if (!entry || typeof entry !== "object") return undefined; + const e = entry as Record; + const expand = (text: string) => + text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name: string, fallback?: string) => env[name] ?? fallback ?? ""); + const strings = (value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, expand(v as string)])) + : undefined; + if ((e.type === "http" || e.type === "sse") && typeof e.url === "string") { + const headers = strings(e.headers); + return { type: e.type, url: expand(e.url), ...(headers ? { headers } : {}) }; + } + if ((e.type === undefined || e.type === "stdio") && typeof e.command === "string") { + const args = Array.isArray(e.args) ? e.args.filter((a): a is string => typeof a === "string").map(expand) : undefined; + const vars = strings(e.env); + return { type: "stdio", command: expand(e.command), ...(args ? { args } : {}), ...(vars ? { env: vars } : {}) }; + } + return undefined; +} + +/** A thrown handler — graphcoded down, a bad reply — is the model's to read, not a crash. */ +async function run(t: GraphcodeTool, args: Record): Promise { + try { + return await t.handler(args ?? {}); + } catch (error) { + return { text: error instanceof Error ? error.message : String(error), isError: true }; + } +} + +/** The Agent SDK's in-process server takes zod shapes; the graphcode tools use flat JSON Schema. */ +function zodShape(schema: GraphcodeTool["inputSchema"]): Record { + const required = new Set(schema.required ?? []); + return Object.fromEntries( + Object.entries(schema.properties).map(([key, value]) => { + const property = value as { type?: string; description?: string }; + let field: z.ZodType = property.type === "number" ? z.number() : property.type === "boolean" ? z.boolean() : z.string(); + if (property.description) field = field.describe(property.description); + return [key, required.has(key) ? field : field.optional()]; + }), + ); +} + +export function claudeGraphcodeTools(tools: GraphcodeTool[]): SdkMcpToolDefinition[] { + return tools.map((t) => + tool( + t.name, + t.description, + zodShape(t.inputSchema), + async (args) => { + const result = await run(t, args as Record); + return { content: [{ type: "text", text: result.text }], ...(result.isError ? { isError: true } : {}) }; + }, + { annotations: { readOnlyHint: t.readOnly }, alwaysLoad: true }, + ), + ); +} + +export function claudeMcpServers(mount: McpMount): Record { + return { + ...mount.servers, + [serverName]: createSdkMcpServer({ name: serverName, version: "1", tools: claudeGraphcodeTools(mount.graphcode), alwaysLoad: true }), + }; +} + +export function copilotGraphcodeTools(tools: GraphcodeTool[]): Tool[] { + return tools.map((t) => ({ + name: copilotToolPrefix + t.name, + description: t.description, + parameters: t.inputSchema, + defer: "never", + async handler(args: unknown): Promise { + const result = await run(t, args as Record); + return result.isError + ? { textResultForLlm: result.text, resultType: "failure", error: result.text } + : { textResultForLlm: result.text, resultType: "success" }; + }, + })); +} + +export function copilotMcpServers(mount: McpMount): Record { + return Object.fromEntries( + Object.entries(mount.servers).map(([name, server]): [string, MCPServerConfig] => + "url" in server + ? [name, { type: server.type, url: server.url, ...(server.headers ? { headers: server.headers } : {}), tools: ["*"] }] + : [name, { type: "stdio", command: server.command, args: server.args ?? [], ...(server.env ? { env: server.env } : {}), tools: ["*"] }], + ), + ); +} diff --git a/NodRuntime/src/permissions.ts b/NodRuntime/src/permissions.ts index 08078d1f..1e3221ef 100644 --- a/NodRuntime/src/permissions.ts +++ b/NodRuntime/src/permissions.ts @@ -1,6 +1,7 @@ import { existsSync, realpathSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { EventLog } from "./eventLog"; +import { serverName } from "./mcp"; import type { NodPermissionDecision, NodPermissionKind } from "./protocol"; import type { Ask, NodSettings } from "./settings"; @@ -38,7 +39,7 @@ interface OpenAsk { } /** - * Gates shell, network and out-of-worktree work by `NodSettings` and the shell allowlist. + * Gates shell, network, out-of-worktree and project-MCP work by `NodSettings` and the shell allowlist. * Reads, searches and in-worktree edits never ask here — edits are reviewed as hunks. */ export class PermissionGate { @@ -74,7 +75,9 @@ export class PermissionGate { case "fetch": return this.byPolicy(settings.network, "network", intent.url, "Fetches a URL."); case "mcp": - return { verdict: "allow" }; + // graphcode's own tools apply messagesOtherLoops themselves, as drafts or refusals. + if (intent.server === serverName) return { verdict: "allow" }; + return this.ask("mcpTool", `${intent.server}/${intent.tool}`, "Calls a tool on one of this project's MCP servers."); case "messageLoop": if (settings.messagesOtherLoops === "send") return { verdict: "allow" }; if (settings.messagesOtherLoops === "never") return deny("messageLoop", intent.subject); diff --git a/NodRuntime/src/runtime.ts b/NodRuntime/src/runtime.ts index ad152adf..8f03ab87 100644 --- a/NodRuntime/src/runtime.ts +++ b/NodRuntime/src/runtime.ts @@ -1,10 +1,20 @@ import { randomUUID } from "node:crypto"; -import { writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Authorization, Engine, EngineFailure, ToolRequest, TurnCallbacks, TurnResult, UsageReport } from "./engine"; import type { EventLog } from "./eventLog"; import { GoalEvaluator } from "./goal"; import { HunkStager, type StagedHunk } from "./hunks"; +import { + createGraphcodeTools, + daemonClient, + DaemonError, + sendDraft, + type GraphDaemon, + type GraphcodeToolContext, + type MessagePolicy, +} from "./mcp"; +import type { ProjectMcpServer } from "./mcpServers"; import { PermissionGate } from "./permissions"; import type { PresenceReporter } from "./presence"; import type { NodAttachment, NodCommand, NodDelivery, NodTurnOrigin } from "./protocol"; @@ -33,6 +43,14 @@ export interface RuntimeOptions { unattended?: boolean; /** Consecutive "not yet" goal checks before Nod stops and waits for a human. */ maxGoalContinuations?: number; + /** `$NOD_PROJECT_PATH`: the graph the graphcode MCP server reads. */ + projectPath?: string; + /** graphcoded's socket; a client on the default socket when absent. */ + daemon?: GraphDaemon; + /** Read per call, so Settings › Agents › Nod applies without a restart. */ + messagesOtherLoops?: () => MessagePolicy; + /** The project's `.mcp.json` servers, already without `disabledMCPServers`. */ + mcpServers?: Record; } interface Pending { @@ -70,6 +88,9 @@ export class NodRuntime { private closed = false; private idleWaiters: (() => void)[] = []; private readonly unattended: boolean; + private readonly graph: GraphcodeToolContext; + /** `mailDraft` id → the loop it is addressed to, for `sendDraft`. */ + private drafts = new Map(); constructor(private readonly options: RuntimeOptions) { const { log, settings, cwd } = options; @@ -81,6 +102,17 @@ export class NodRuntime { log, onAwaiting: (awaiting) => void this.options.presence.presence(awaiting ? "awaitingInput" : "busy"), }); + const { projectPath } = options; + this.graph = { + nodeID: options.nodeID, + projectPath: projectPath ?? "", + daemon: projectPath ? (options.daemon ?? daemonClient()) : noProject, + messagesOtherLoops: options.messagesOtherLoops ?? (() => settings.messagesOtherLoops), + emit: (draft) => { + this.drafts.set(draft.draftID, draft.toNodeID); + log.append(draft); + }, + }; this.stager = new HunkStager(log, cwd); this.stager.onLateDecision = (hunk) => this.steer(lateDecisionNote(hunk)); if (options.goal?.trim()) { @@ -113,6 +145,7 @@ export class NodRuntime { resume: this.options.resume, forkFrom: inherit?.fork?.conversationID, systemAppend, + mcp: { graphcode: createGraphcodeTools(this.graph), servers: this.options.mcpServers ?? {} }, }); this.conversationID = session.conversationID; this.model = session.model; @@ -161,8 +194,13 @@ export class NodRuntime { return; case "fork": throw new Error("fork is not supported by this runtime yet"); - case "sendDraft": - throw new Error("mail drafts are sent by the graph layer, not the runtime"); + case "sendDraft": { + const toNodeID = this.drafts.get(command.draftID) ?? this.loggedDraftTarget(command.draftID); + if (!toNodeID) throw new Error(`no mail draft ${command.draftID}`); + await sendDraft(this.graph, { toNodeID, text: command.text }); + this.drafts.delete(command.draftID); + return; + } case "compact": if (this.busy) this.compactRequested = true; else await this.compactNow(); @@ -388,12 +426,34 @@ export class NodRuntime { this.options.log.append({ type: "failure", kind: failure.kind, message: failure.message }); } + /** A draft from before a resume is only in the log. */ + private loggedDraftTarget(draftID: string): string | undefined { + const path = this.options.log.path; + if (!existsSync(path)) return undefined; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line.includes(draftID)) continue; + const record = JSON.parse(line) as { type?: string; draftID?: string; toNodeID?: string }; + if (record.type === "mailDraft" && record.draftID === draftID) return record.toNodeID; + } + return undefined; + } + private writeConversation(): void { const conversation = { engine: this.options.engine.kind, model: this.model, conversationID: this.conversationID }; writeFileSync(join(this.options.stateDir, "conversation.json"), JSON.stringify(conversation, null, 2) + "\n"); } } +const noProject: GraphDaemon = { + snapshot: unreachable, + mailbox: unreachable, + graphCommand: unreachable, +}; + +async function unreachable(): Promise { + throw new DaemonError("This loop was launched without its project path (NOD_PROJECT_PATH), so the graph is out of reach."); +} + function lateDecisionNote(hunk: StagedHunk): string { const verb = hunk.state === "rejected" ? "rejected" : "sent back"; return `The reviewer ${verb} an edit you made to ${hunk.file} and it has been reverted on disk${hunk.note ? `: ${hunk.note}` : "."}`; diff --git a/NodRuntime/src/settings.ts b/NodRuntime/src/settings.ts index 0891f1f6..45d82a73 100644 --- a/NodRuntime/src/settings.ts +++ b/NodRuntime/src/settings.ts @@ -18,6 +18,8 @@ export interface NodSettings { messagesOtherLoops: "draftForMe" | "send" | "never"; shellAllowlist: string[]; spendCapUSD: number; + /** `.mcp.json` servers switched off for Nod; the built-in graphcode server never is. */ + disabledMCPServers: string[]; } export const defaultSettings: NodSettings = { @@ -30,6 +32,7 @@ export const defaultSettings: NodSettings = { messagesOtherLoops: "draftForMe", shellAllowlist: [], spendCapUSD: 2, + disabledMCPServers: [], }; /** `~/.graphcode`, or `GRAPHCODE_SUPPORT_DIR` resolved against home, as `SupportDirectory` does. */ @@ -65,5 +68,6 @@ export function loadSettings(path = join(supportDirectory(), "settings.json")): messagesOtherLoops: pick("messagesOtherLoops", oneOf("draftForMe", "send", "never")), shellAllowlist: pick("shellAllowlist", (v) => Array.isArray(v) && v.every(isString)), spendCapUSD: pick("spendCapUSD", (v) => typeof v === "number" && Number.isFinite(v) && v >= 0), + disabledMCPServers: pick("disabledMCPServers", (v) => Array.isArray(v) && v.every(isString)), }; } diff --git a/NodRuntime/src/tools.ts b/NodRuntime/src/tools.ts index 8d0b62b8..f7e65eb1 100644 --- a/NodRuntime/src/tools.ts +++ b/NodRuntime/src/tools.ts @@ -1,4 +1,5 @@ import { basename } from "node:path"; +import { copilotToolPrefix } from "./mcpServers"; export interface ToolDescription { /** The work card's one line, e.g. `Search "UsageGate"`. */ @@ -71,8 +72,8 @@ export function describeTool(tool: string, input: unknown): ToolDescription { case "update_todo": return { title: "Plan", activity: "planning" }; } - if (tool.startsWith("mcp__")) { - const short = tool.split("__").pop() ?? tool; + if (tool.startsWith("mcp__") || tool.startsWith(copilotToolPrefix)) { + const short = tool.startsWith("mcp__") ? (tool.split("__").pop() ?? tool) : tool.slice(copilotToolPrefix.length); return { title: `Use ${short}`, activity: `using ${short}` }; } return { title: tool, activity: `using ${tool}` }; diff --git a/NodRuntime/test/mcpMount.test.ts b/NodRuntime/test/mcpMount.test.ts new file mode 100644 index 00000000..3201ae69 --- /dev/null +++ b/NodRuntime/test/mcpMount.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { McpSdkServerConfigWithInstance, Options, Query } from "@anthropic-ai/claude-agent-sdk"; +import type { CopilotClient, SessionConfig, Tool } from "@github/copilot-sdk"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ClaudeEngine, toolRequest as claudeToolRequest } from "../src/claudeEngine"; +import { CopilotEngine, toolRequest as copilotToolRequest } from "../src/copilotEngine"; +import type { ToolRequest, TurnCallbacks } from "../src/engine"; +import { EventLog } from "../src/eventLog"; +import { createGraphcodeTools, type GraphDaemon, type Json, type MailDraftEvent, type MessagePolicy } from "../src/mcp"; +import { loadProjectMcpServers, type McpMount } from "../src/mcpServers"; +import { PermissionGate } from "../src/permissions"; +import { PresenceReporter } from "../src/presence"; +import type { NodEventRecord } from "../src/protocol"; +import { NodRuntime } from "../src/runtime"; +import { defaultSettings, loadSettings } from "../src/settings"; +import { FakeEngine, until } from "./fakeEngine"; + +const ME = "11111111-1111-1111-1111-111111111111"; +const PEER = "22222222-2222-2222-2222-222222222222"; +const graph = { + nodes: [ + { id: ME, title: "Monetization", loopType: "goalBased", state: { running: {} } }, + { id: PEER, title: "Pricing", loopType: "goalBased", state: { idle: {} } }, + ], + edges: [], +}; + +class FakeDaemon implements GraphDaemon { + commands: Json[] = []; + async snapshot() { + return graph as unknown as Json; + } + async mailbox() { + return { posts: [] } as Json; + } + async graphCommand(_: string, command: Json) { + this.commands.push(command); + } +} + +function project(mcp: object): string { + const dir = mkdtempSync(join(tmpdir(), "nod-mcp-")); + writeFileSync(join(dir, ".mcp.json"), JSON.stringify({ mcpServers: mcp })); + return dir; +} + +const projectServers = { + github: { command: "github-mcp", args: ["--token", "${GH_TOKEN}"], env: { MODE: "${MODE:-read}" } }, + linear: { type: "http", url: "https://mcp.linear.app/${PATH_PART}", headers: { Authorization: "Bearer ${LINEAR}" } }, + graphcode: { command: "impostor" }, +}; + +function mount(policy: MessagePolicy, disabled: string[] = []) { + const daemon = new FakeDaemon(); + const drafts: MailDraftEvent[] = []; + const servers = loadProjectMcpServers(project(projectServers), disabled, { GH_TOKEN: "t", PATH_PART: "sse", LINEAR: "k" }); + const graphcode = createGraphcodeTools({ + nodeID: ME, + projectPath: "/work/repo", + daemon, + messagesOtherLoops: () => policy, + emit: (draft) => drafts.push(draft), + newID: () => "d1", + }); + return { mcp: { graphcode, servers } satisfies McpMount, daemon, drafts }; +} + +/** A turn's callbacks that record what the engine asks the gate. */ +function recordingCallbacks() { + const asked: ToolRequest[] = []; + const callbacks: TurnCallbacks = { + text() {}, + toolCall() {}, + toolResult() {}, + usage() {}, + compacted() {}, + authorize: async (request) => (asked.push(request), { allow: true }), + takeSteer: () => undefined, + }; + return { asked, callbacks }; +} + +function startClaude(mcp: McpMount) { + let options: Options | undefined; + let release = () => {}; + const fakeQuery = ((args: { options: Options }) => { + options = args.options; + const ended = new Promise((resolve) => (release = resolve)); + return { + async *[Symbol.asyncIterator]() { + await ended; + }, + interrupt: async () => {}, + setModel: async () => {}, + close: () => release(), + } as unknown as Query; + }) as unknown as typeof import("@anthropic-ai/claude-agent-sdk").query; + const engine = new ClaudeEngine({ apiKey: "sk-test", configDir: mkdtempSync(join(tmpdir(), "nod-cfg-")), query: fakeQuery }); + return { engine, options: () => options! }; +} + +async function connectSdkServer(server: McpSdkServerConfigWithInstance): Promise { + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.instance.connect(serverSide); + const client = new Client({ name: "test", version: "1" }); + await client.connect(clientSide); + return client; +} + +function startCopilot() { + let config: SessionConfig | undefined; + const session = { + sessionId: "s1", + on() {}, + getEvents: async () => [], + send: () => new Promise(() => {}), + abort: async () => {}, + disconnect: async () => {}, + }; + const client = { + start: async () => {}, + stop: async () => {}, + createSession: async (c: SessionConfig) => ((config = c), session), + resumeSession: async (_: string, c: SessionConfig) => ((config = c), session), + } as unknown as CopilotClient; + const engine = new CopilotEngine({ client: () => client }); + return { engine, config: () => config! }; +} + +const textOf = (result: unknown) => ((result as { content: { text: string }[] }).content[0]?.text ?? ""); + +describe(".mcp.json", () => { + test("loads stdio and http servers with ${VAR} and ${VAR:-default} expanded", () => { + const servers = loadProjectMcpServers(project(projectServers), [], { GH_TOKEN: "t", PATH_PART: "sse", LINEAR: "k" }); + expect(servers).toEqual({ + github: { type: "stdio", command: "github-mcp", args: ["--token", "t"], env: { MODE: "read" } }, + linear: { type: "http", url: "https://mcp.linear.app/sse", headers: { Authorization: "Bearer k" } }, + }); + }); + + test("skips disabledMCPServers, and a project entry can never replace or disable graphcode", () => { + const servers = loadProjectMcpServers(project(projectServers), ["github", "graphcode"], {}); + expect(Object.keys(servers)).toEqual(["linear"]); + }); + + test("walks up from the worktree, the nearer file winning a name", () => { + const root = project({ github: { command: "outer" }, sentry: { command: "sentry-mcp" } }); + const inner = join(root, "worktrees", "a"); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, ".mcp.json"), JSON.stringify({ mcpServers: { github: { command: "inner" } } })); + const servers = loadProjectMcpServers(inner, [], {}); + expect(servers.github).toMatchObject({ command: "inner" }); + expect(servers.sentry).toMatchObject({ command: "sentry-mcp" }); + }); + + test("disabledMCPServers is read from settings.json's nod key", () => { + const dir = mkdtempSync(join(tmpdir(), "nod-settings-")); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ nod: { disabledMCPServers: ["github"] } })); + expect(loadSettings(join(dir, "settings.json")).disabledMCPServers).toEqual(["github"]); + expect(defaultSettings.disabledMCPServers).toEqual([]); + }); +}); + +describe("Claude engine mounts", () => { + test("graphcode in-process and the project's servers, and nothing Claude Code would find itself", async () => { + const { mcp } = mount("draftForMe", ["linear"]); + const { engine, options } = startClaude(mcp); + await engine.start({ cwd: "/work/repo", mcp }); + expect(options().strictMcpConfig).toBe(true); + expect(Object.keys(options().mcpServers!).sort()).toEqual(["github", "graphcode"]); + expect(options().mcpServers!.github).toEqual(mcp.servers.github!); + expect(options().mcpServers!.graphcode).toMatchObject({ type: "sdk", name: "graphcode" }); + await engine.close(); + }); + + test("the graphcode server lists its tools and Draft for me emits mailDraft without sending", async () => { + const { mcp, daemon, drafts } = mount("draftForMe"); + const { engine, options } = startClaude(mcp); + await engine.start({ cwd: "/work/repo", mcp }); + const client = await connectSdkServer(options().mcpServers!.graphcode as McpSdkServerConfigWithInstance); + const names = (await client.listTools()).tools.map((t) => t.name).sort(); + expect(names).toEqual(["ask", "edges", "handoff", "handoff_briefs", "mailroom", "mailroom_read", "siblings"]); + const siblings = await client.callTool({ name: "siblings", arguments: {} }); + expect(textOf(siblings)).toContain(`Pricing (${PEER})`); + const asked = await client.callTool({ name: "ask", arguments: { to: "Pricing", text: "free tier?" } }); + expect(textOf(asked)).toContain("Drafted for the human"); + expect(drafts).toEqual([{ type: "mailDraft", draftID: "d1", toNodeID: PEER, inReplyTo: undefined, text: "free tier?" }]); + expect(daemon.commands).toEqual([]); + await client.close(); + await engine.close(); + }); + + test("Never refuses ask and handoff; Send sends as this loop", async () => { + for (const policy of ["never", "send"] as const) { + const { mcp, daemon, drafts } = mount(policy); + const { engine, options } = startClaude(mcp); + await engine.start({ cwd: "/work/repo", mcp }); + const client = await connectSdkServer(options().mcpServers!.graphcode as McpSdkServerConfigWithInstance); + const result = await client.callTool({ name: "handoff", arguments: { brief: "done", to: "Pricing" } }); + expect(drafts).toEqual([]); + if (policy === "never") { + expect(result.isError).toBe(true); + expect(daemon.commands).toEqual([]); + } else { + expect(daemon.commands).toEqual([{ messageNode: { _0: PEER, text: "Handoff: done", from: ME, followUp: true } }]); + } + await client.close(); + await engine.close(); + } + }); + + test("every mcp__ call is forced to an ask and reaches the gate through canUseTool", async () => { + const { mcp } = mount("draftForMe"); + const { engine, options } = startClaude(mcp); + await engine.start({ cwd: "/work/repo", mcp }); + const preToolUse = options().hooks!.PreToolUse![0]!.hooks[0]!; + const signal = new AbortController().signal; + for (const tool of ["mcp__graphcode__ask", "mcp__github__create_issue"]) { + const out = await preToolUse({ hook_event_name: "PreToolUse", tool_name: tool, tool_input: {} } as never, undefined, { signal }); + expect(out).toMatchObject({ hookSpecificOutput: { permissionDecision: "ask" } }); + } + const { asked, callbacks } = recordingCallbacks(); + void engine.runTurn("go", [], callbacks); + await options().canUseTool!("mcp__github__create_issue", {}, { signal } as never); + expect(asked).toEqual([{ intent: { kind: "mcp", server: "github", tool: "create_issue" } }]); + await engine.close(); + }); +}); + +describe("Copilot engine mounts", () => { + test("graphcode as custom tools and the project's servers as mcpServers", async () => { + const { mcp } = mount("draftForMe", ["github"]); + const { engine, config } = startCopilot(); + await engine.start({ cwd: "/work/repo", mcp }); + expect(config().tools!.map((t) => t.name).sort()).toEqual([ + "graphcode_ask", "graphcode_edges", "graphcode_handoff", "graphcode_handoff_briefs", + "graphcode_mailroom", "graphcode_mailroom_read", "graphcode_siblings", + ]); + expect(config().mcpServers).toEqual({ + linear: { type: "http", url: "https://mcp.linear.app/sse", headers: { Authorization: "Bearer k" }, tools: ["*"] }, + }); + }); + + test("Draft for me drafts, Never refuses, Send sends", async () => { + const call = (tools: Tool[], name: string, args: object) => + tools.find((t) => t.name === name)!.handler!(args, {} as never) as Promise<{ resultType: string; textResultForLlm: string }>; + const draft = mount("draftForMe"); + let session = startCopilot(); + await session.engine.start({ cwd: "/work/repo", mcp: draft.mcp }); + expect((await call(session.config().tools!, "graphcode_ask", { to: PEER, text: "hi" })).resultType).toBe("success"); + expect(draft.drafts).toHaveLength(1); + expect(draft.daemon.commands).toEqual([]); + + const never = mount("never"); + session = startCopilot(); + await session.engine.start({ cwd: "/work/repo", mcp: never.mcp }); + expect((await call(session.config().tools!, "graphcode_ask", { to: PEER, text: "hi" })).resultType).toBe("failure"); + expect(never.daemon.commands).toEqual([]); + + const send = mount("send"); + session = startCopilot(); + await session.engine.start({ cwd: "/work/repo", mcp: send.mcp }); + await call(session.config().tools!, "graphcode_ask", { to: "Pricing", text: "hi" }); + expect(send.daemon.commands).toHaveLength(1); + expect(send.drafts).toEqual([]); + }); + + test("custom-tool and mcp permission requests reach the gate", async () => { + const { mcp } = mount("draftForMe"); + const { engine, config } = startCopilot(); + await engine.start({ cwd: "/work/repo", mcp }); + const { asked, callbacks } = recordingCallbacks(); + void engine.runTurn("go", [], callbacks); + await config().onPermissionRequest!({ kind: "custom-tool", toolName: "graphcode_ask", toolDescription: "" } as never, { sessionId: "s1" } as never); + await config().onPermissionRequest!({ kind: "mcp", serverName: "linear", toolName: "list_issues", readOnly: true } as never, { sessionId: "s1" } as never); + expect(asked).toEqual([ + { intent: { kind: "mcp", server: "graphcode", tool: "ask" } }, + { intent: { kind: "mcp", server: "linear", tool: "list_issues" } }, + ]); + }); +}); + +describe("the gate over MCP tools", () => { + function gate(unattended = false) { + const log = new EventLog(join(mkdtempSync(join(tmpdir(), "nod-gate-")), "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + return { gate: new PermissionGate({ settings: defaultSettings, worktree: "/work/repo", unattended, log }), records }; + } + + test("graphcode's tools pass, because they apply messagesOtherLoops themselves", async () => { + const { gate: g } = gate(true); + for (const request of [ + claudeToolRequest("mcp__graphcode__handoff", {}, "/work/repo"), + copilotToolRequest({ kind: "custom-tool", toolName: "graphcode_handoff" } as never, "/work/repo"), + ]) { + expect(await g.check(request.intent)).toEqual({ verdict: "allow" }); + } + }); + + test("a project server's tool is an mcpTool ask on both engines, and fails an unattended loop", async () => { + for (const request of [ + claudeToolRequest("mcp__github__create_issue", {}, "/work/repo"), + copilotToolRequest({ kind: "mcp", serverName: "github", toolName: "create_issue" } as never, "/work/repo"), + ]) { + const { gate: g, records } = gate(); + const verdict = g.check(request.intent); + await until(() => records.length === 1); + expect(records[0]).toMatchObject({ type: "permissionAsked", kind: "mcpTool", subject: "github/create_issue" }); + g.resolve((records[0] as { askID: string }).askID, "deny"); + expect(await verdict).toMatchObject({ verdict: "deny" }); + expect(await gate(true).gate.check(request.intent)).toMatchObject({ verdict: "fail" }); + } + }); +}); + +describe("runtime", () => { + function runtime(engine: FakeEngine, projectPath: string | undefined, daemon: GraphDaemon) { + const cwd = mkdtempSync(join(tmpdir(), "nod-rtm-")); + const log = new EventLog(join(cwd, "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + const rt = new NodRuntime({ + nodeID: ME, + cwd, + stateDir: cwd, + loopType: "main", + settings: defaultSettings, + engine, + log, + presence: new PresenceReporter(undefined, async () => {}, join(cwd, "support")), + projectPath, + daemon, + mcpServers: { github: { type: "stdio", command: "github-mcp" } }, + }); + return { rt, records }; + } + + test("hands the engine graphcode and the project's servers, and sends an approved draft as edited", async () => { + const engine = new FakeEngine(); + const daemon = new FakeDaemon(); + const { rt, records } = runtime(engine, "/work/repo", daemon); + await rt.start(); + expect(engine.started!.mcp!.servers).toEqual({ github: { type: "stdio", command: "github-mcp" } }); + const ask = engine.started!.mcp!.graphcode.find((t) => t.name === "ask")!; + await ask.handler({ to: "Pricing", text: "free tier?" }); + const draft = records.find((r) => r.type === "mailDraft") as NodEventRecord & { draftID: string; toNodeID: string }; + expect(draft.toNodeID).toBe(PEER); + expect(daemon.commands).toEqual([]); + await rt.handle({ type: "sendDraft", draftID: draft.draftID, text: "free tier: 50?" }); + expect(daemon.commands).toEqual([{ messageNode: { _0: PEER, text: "free tier: 50?", from: ME, followUp: true } }]); + await expect(rt.handle({ type: "sendDraft", draftID: "nope", text: "x" })).rejects.toThrow("no mail draft"); + await rt.close(); + }); + + test("without NOD_PROJECT_PATH graphcode stays mounted and says why it can't reach the graph", async () => { + const engine = new FakeEngine(); + const daemon = new FakeDaemon(); + const { rt } = runtime(engine, undefined, daemon); + await rt.start(); + const siblings = engine.started!.mcp!.graphcode.find((t) => t.name === "siblings")!; + await expect(siblings.handler({})).rejects.toThrow("NOD_PROJECT_PATH"); + await rt.close(); + }); +}); From c3c4ed4f496b45fe5fb29f83d1f773d71ed75a44 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 2 Oct 2026 09:36:57 -0700 Subject: [PATCH 3/3] Document Nod's MCP mounting and smoke a graphcode tool call Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/PROTOCOL.md | 18 +++++++++++++++++- NodRuntime/README.md | 8 ++++++++ NodRuntime/scripts/smoke.ts | 7 ++++++- NodRuntime/test/mcpMount.test.ts | 3 ++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/NodRuntime/PROTOCOL.md b/NodRuntime/PROTOCOL.md index 0b5b2155..69d547aa 100644 --- a/NodRuntime/PROTOCOL.md +++ b/NodRuntime/PROTOCOL.md @@ -116,7 +116,23 @@ graph files. `ctx.projectPath` comes from `$NOD_PROJECT_PATH`. `ask` and `handoff` follow `NodSettings.messagesOtherLoops`, read on every call. `draftForMe` emits `mailDraft` and sends nothing. `send` sends `messageNode` as this loop. -`never` refuses. The runtime answers `sendDraft` with `sendDraft(ctx, {toNodeID, text})`. +`never` refuses. +The runtime keeps each draft's addressee and answers `sendDraft` with `sendDraft(ctx, {toNodeID, text})`, the text as the human edited it. +Without `$NOD_PROJECT_PATH` the server stays mounted and its tools say the graph is out of reach. + +### Mounting + +| | Claude engine | Copilot engine | +|---|---|---| +| graphcode | in-process SDK MCP server `graphcode` (`mcp__graphcode__`) | custom tools `graphcode_` | +| `.mcp.json` | `mcpServers`, with `strictMcpConfig: true` | `mcpServers` (config discovery stays off) | +| gate | PreToolUse forces every `mcp__` call to `canUseTool` | `mcp` and `custom-tool` permission requests | + +`.mcp.json` is read walking up from the loop's working directory, the nearer file winning a +name, with `${VAR}` and `${VAR:-default}` expanded. Names in `NodSettings.disabledMCPServers` +are skipped, and an entry called `graphcode` is ignored. A project server's tool asks as +`mcpTool` with subject `/`. + `classifyInbound(line, graph, nodeID)` maps a typed `[graphcode] : …` line to the sending loop, so a drafted reply goes to the right place. diff --git a/NodRuntime/README.md b/NodRuntime/README.md index eaa76aa5..1eea24f9 100644 --- a/NodRuntime/README.md +++ b/NodRuntime/README.md @@ -70,6 +70,7 @@ graphcode-nod -p [--engine claude|copilot] [--model ] # one-shot | `presence.ts` | the `presence`/`activity`/`usage` zmx labels and `sessions/.id` that `PresenceHooks` writes | | `credentials.ts` · `agentRuntimes.ts` | Keychain sign-ins, and where each engine's agent runtime is | | `brief.ts` | `--inherit ` (PROTOCOL.md, Inherited briefs) | +| `mcp/` · `mcpServers.ts` | the built-in graphcode server, the project's `.mcp.json` servers, and how each engine mounts them | ```sh bun install && bun test # unit tests run against fake engines; the contract test compiles NodProtocol.swift @@ -107,6 +108,13 @@ bun src/main.ts -p "hello" # from source turn a human or timer started). Claude's cost is exact at each turn end and estimated mid-turn from tokens, scaled to the last exact figure, so the cap can stop mid-turn. Copilot reports premium requests, which its plan caps. +- **MCP**: each engine mounts exactly two kinds of server. The built-in `graphcode` server + is always on: in-process on Claude (an SDK MCP server), custom tools named + `graphcode_` on Copilot. The project's `.mcp.json` servers are found the way the + CLIs find them, walking up from the working directory with `${VAR}` expanded, minus + `NodSettings.disabledMCPServers`. Claude runs with `strictMcpConfig`, so Claude Code adds + nothing of its own; Copilot never discovers config. graphcode's tools pass the gate and + apply `messagesOtherLoops` themselves; any other server's tool is an `mcpTool` ask. - **Context** is reported as `usage.contextUsed`; at 95% Nod compacts after the turn. - **Resume**: `--resume ` continues the engine conversation and appends to the same `events.jsonl`, continuing its `seq`. `conversation.json` holds engine, model and diff --git a/NodRuntime/scripts/smoke.ts b/NodRuntime/scripts/smoke.ts index 25bb6829..2716c2f9 100644 --- a/NodRuntime/scripts/smoke.ts +++ b/NodRuntime/scripts/smoke.ts @@ -5,6 +5,8 @@ // // The runtime command defaults to this checkout's source; pass "/path/to/graphcode-nod" to // smoke a packaged build. Claude needs an anthropic-api-key item in app.graphcode.nod. +// With NOD_PROJECT_PATH (and GRAPHCODE_SOCKET) set, Nod also calls the graphcode MCP +// server's siblings tool against that graph first. import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { connect } from "node:net"; import { join } from "node:path"; @@ -23,10 +25,13 @@ writeFileSync(join(support, "settings.json"), JSON.stringify({ nod: { engine, sh const goalFile = join(root, "goal.txt"); writeFileSync(goalFile, "Done when hello.txt contains the line `hello nod` and `cat hello.txt` has printed it"); +const graphStep = process.env.NOD_PROJECT_PATH + ? "First call the graphcode server's siblings tool and say how many other loops it lists. " + : ""; const state = join(support, "nod", node); const child = Bun.spawn( [...runtime.split(" "), "--node", node, "--cwd", work, "--engine", engine, "--loop-type", "goal", "--goal-file", goalFile, - "--prompt", "Use your Write tool (not the shell) to create hello.txt containing the line `hello nod`. Then run `ls -la` and then `cat hello.txt`.", + "--prompt", graphStep + "Use your Write tool (not the shell) to create hello.txt containing the line `hello nod`. Then run `ls -la` and then `cat hello.txt`.", "--exit-when-idle"], { env: { ...process.env, GRAPHCODE_SUPPORT_DIR: support, NOD_STATE: state }, stdout: "inherit", stderr: "inherit" }, ); diff --git a/NodRuntime/test/mcpMount.test.ts b/NodRuntime/test/mcpMount.test.ts index 3201ae69..34a3b1f1 100644 --- a/NodRuntime/test/mcpMount.test.ts +++ b/NodRuntime/test/mcpMount.test.ts @@ -10,7 +10,8 @@ import { ClaudeEngine, toolRequest as claudeToolRequest } from "../src/claudeEng import { CopilotEngine, toolRequest as copilotToolRequest } from "../src/copilotEngine"; import type { ToolRequest, TurnCallbacks } from "../src/engine"; import { EventLog } from "../src/eventLog"; -import { createGraphcodeTools, type GraphDaemon, type Json, type MailDraftEvent, type MessagePolicy } from "../src/mcp"; +import { createGraphcodeTools, type GraphDaemon, type MailDraftEvent, type MessagePolicy } from "../src/mcp"; +import type { Json } from "../src/mcp/daemon"; import { loadProjectMcpServers, type McpMount } from "../src/mcpServers"; import { PermissionGate } from "../src/permissions"; import { PresenceReporter } from "../src/presence";