{"version":3,"file":"mcp-loader.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/mcp-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAiBH,OAAO,KAAK,EAAE,YAAY,EAAuD,MAAM,gCAAgC,CAAC;AA8dxH,wBAAgB,cAAc,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CA2RtD","sourcesContent":["/**\n * MCP server loader — discovers server configs (standard mcp.json locations,\n * hoocode's per-server JSON files, plugin registrations), connects via JSON-RPC\n * 2.0 over stdio (`command`), Streamable HTTP (`{ \"type\": \"http\", \"url\": ... }`),\n * or legacy SSE (`\"type\": \"sse\"`), and registers each server tool as\n * `mcp_<server>_<tool>`.\n *\n * Config sources (first-wins by server name):\n *   1. ~/.agents/mcp.json (user), ./.agents/mcp.json (project),\n *      ~/.config/claude/mcp.json (Claude Desktop)\n *   2. ~/.hoocode/mcp-servers/*.json and ./.hoocode/mcp-servers/*.json\n *   3. MCP servers registered by plugins/extensions during load\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport type { AgentToolResult, AgentToolUpdateCallback, McpRemoteOptions } from \"@kolisachint/hoocode-agent-core\";\nimport { connectHttpMcpServer, summarizeArgs } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { ensureDenseIndex } from \"../../core/capabilities/dense.js\";\nimport { getCapabilities, registerCapabilities } from \"../../core/capabilities/registry.js\";\nimport { resetCapabilitySearch, searchCapabilities } from \"../../core/capabilities/search.js\";\nimport { getExtensionMcpServers } from \"../../core/extension-mcp-servers.js\";\nimport type { ExtensionAPI, ExtensionContext, SessionStartEvent, ToolDefinition } from \"../../core/extensions/types.js\";\nimport { formatDurationSecs } from \"../../core/format-duration.js\";\nimport { clearMcpServerStatuses, setMcpServerStatus } from \"../../core/mcp-status.js\";\nimport { deferMcpSchemas, subagentSkipMcp } from \"../../core/subagent-depth.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport { shouldUseWindowsShell } from \"../../utils/child-process.js\";\nimport {\n\ttype DeferredMcpToolEntry,\n\tformatDeferredCatalog,\n\tselectResolvable,\n\ttoCapabilityDocs,\n} from \"./mcp-deferred.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\n\ninterface McpToolDetails {\n\telapsed?: number;\n}\n\ninterface McpToolDef {\n\tname: string;\n\tdescription: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\ninterface McpServerConfig {\n\t/** Unique server identifier used as prefix for registered tool names */\n\tname: string;\n\t/** Executable to spawn (stdio transport). One of command/url is required. */\n\tcommand?: string;\n\t/** Optional arguments passed to the command */\n\targs?: string[];\n\t/** Optional extra environment variables for the server process */\n\tenv?: Record<string, string>;\n\t/** Transport: \"stdio\" (default with command), \"http\" (Streamable HTTP), or \"sse\" (legacy) */\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\t/** Remote server URL (http/sse transports) */\n\turl?: string;\n\t/** Extra HTTP headers (e.g. Authorization) for remote transports */\n\theaders?: Record<string, string>;\n\t/** Run MCP tools in background by default (default: true for MCP servers) */\n\tbackground?: boolean;\n}\n\n/** Standard MCP config format used by Claude Desktop, Claude Code, VS Code / Copilot */\ninterface StandardMcpServerConfig {\n\tcommand?: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\turl?: string;\n\theaders?: Record<string, string>;\n\tbackground?: boolean;\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, StandardMcpServerConfig>;\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nconst mcpConnections = new Map<string, McpConnection>();\n/**\n * Server configs retained by name so a tool call can transparently reconnect a\n * dropped server (process churn between turns, server exit, a racing teardown)\n * instead of permanently failing with \"not connected\".\n */\nconst mcpServerConfigs = new Map<string, McpServerConfig>();\n/** Remote-transport options (OAuth storage, auth callbacks) per server name. */\nconst mcpRemoteOptions = new Map<string, McpRemoteOptions>();\n/**\n * Servers with a browser authorization flow in flight. While a name is here,\n * a failed handshake must NOT tear the connection down (that would kill the\n * OAuth loopback listener); the auth-completion handler reconnects instead.\n */\nconst mcpAuthPending = new Set<string>();\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n *  themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nfunction spawnMcpServer(config: McpServerConfig & { command: string }): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t// `npx`/`npm` are .cmd shims on Windows, which spawn() cannot execute\n\t\t// directly — without this the launch fails with ENOENT.\n\t\tshell: shouldUseWindowsShell(config.command),\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\t/** Set once the child fails to launch, so later calls fail fast with the cause. */\n\tlet launchError: Error | undefined;\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as {\n\t\t\t\tid?: number;\n\t\t\t\tresult?: unknown;\n\t\t\t\terror?: { message: string };\n\t\t\t};\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\n\t// A ChildProcess that emits \"error\" with no listener takes the whole process\n\t// down with it. A missing MCP command is a bad config line, not a reason for\n\t// hoocode to refuse to start, so absorb it and let the handshake report it.\n\tproc.on(\"error\", (err: NodeJS.ErrnoException) => {\n\t\tlaunchError =\n\t\t\terr.code === \"ENOENT\"\n\t\t\t\t? new Error(`MCP server \"${config.name}\": command not found: ${config.command}`)\n\t\t\t\t: new Error(`MCP server \"${config.name}\" failed to start: ${err.message}`);\n\t\tfor (const cb of pending.values()) cb.reject(launchError);\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\t// stdin dies with the child; its EPIPE surfaces through the same path.\n\tproc.stdin?.on(\"error\", () => {});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tif (launchError) return Promise.reject(launchError);\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\ttry {\n\t\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t\t} catch (err) {\n\t\t\t\tpending.delete(id);\n\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\treject(launchError ?? (err instanceof Error ? err : new Error(String(err))));\n\t\t\t}\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\ttry {\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t\t} catch {\n\t\t\t// The server is gone; the pending rpc rejections already carry the reason.\n\t\t}\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\n/** Remote servers use `{ type: \"http\" | \"sse\", url }`; stdio servers use `command`. */\nfunction isRemoteServer(config: McpServerConfig): boolean {\n\treturn config.type === \"http\" || config.type === \"sse\" || (!config.command && typeof config.url === \"string\");\n}\n\nfunction openMcpConnection(config: McpServerConfig): McpConnection {\n\tif (isRemoteServer(config)) {\n\t\tif (typeof config.url !== \"string\") {\n\t\t\tthrow new Error(`MCP server \"${config.name}\" has type \"${config.type}\" but no \"url\"`);\n\t\t}\n\t\treturn connectHttpMcpServer(\n\t\t\t{\n\t\t\t\tname: config.name,\n\t\t\t\turl: config.url,\n\t\t\t\theaders: config.headers,\n\t\t\t\ttype: config.type === \"sse\" ? \"sse\" : \"http\",\n\t\t\t},\n\t\t\tmcpRemoteOptions.get(config.name),\n\t\t);\n\t}\n\tif (typeof config.command !== \"string\") {\n\t\tthrow new Error(`MCP server \"${config.name}\" needs a \"command\" (stdio) or a \"url\" (http/sse)`);\n\t}\n\treturn spawnMcpServer(config as McpServerConfig & { command: string });\n}\n\nasync function connectMcpServer(config: McpServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tmcpConnections.get(config.name)?.terminate();\n\n\tconst conn = openMcpConnection(config);\n\tmcpConnections.set(config.name, conn);\n\n\ttry {\n\t\tawait conn.rpc(\n\t\t\t\"initialize\",\n\t\t\t{\n\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\tclientInfo: { name: \"hoocode\", version: \"1.0.0\" },\n\t\t\t},\n\t\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t\t);\n\n\t\t// Per the MCP spec the client must acknowledge a successful initialize with the\n\t\t// initialized notification before issuing further requests; strict servers gate\n\t\t// tools/call on it.\n\t\tconn.notify(\"notifications/initialized\");\n\n\t\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as {\n\t\t\ttools?: McpToolDef[];\n\t\t};\n\t\treturn { conn, tools: toolsResult.tools ?? [] };\n\t} catch (error) {\n\t\t// Don't leave a half-connected entry behind: remote transports have no\n\t\t// process-exit event to evict them, so getOrConnectMcp would keep handing\n\t\t// out the dead connection instead of reconnecting. Exception: a pending\n\t\t// browser authorization — terminating would kill the OAuth loopback\n\t\t// listener; the auth-completion handler reconnects that server itself.\n\t\tif (!mcpAuthPending.has(config.name)) {\n\t\t\tconn.terminate();\n\t\t\tif (mcpConnections.get(config.name) === conn) mcpConnections.delete(config.name);\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Return a live connection for a server, lazily reconnecting from the retained\n * config when the previous connection was torn down. Returns undefined only when\n * no config is known or a fresh connect attempt fails.\n */\nasync function getOrConnectMcp(name: string): Promise<McpConnection | undefined> {\n\tconst existing = mcpConnections.get(name);\n\tif (existing) return existing;\n\tconst config = mcpServerConfigs.get(name);\n\tif (!config) return undefined;\n\ttry {\n\t\tconst { conn } = await connectMcpServer(config);\n\t\treturn conn;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nlet mcpExitCleanupInstalled = false;\n/** Kill spawned MCP servers when the host process exits so they don't linger as\n *  orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installMcpExitCleanup(): void {\n\tif (mcpExitCleanupInstalled) return;\n\tmcpExitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tfor (const conn of mcpConnections.values()) {\n\t\t\ttry {\n\t\t\t\tconn.terminate();\n\t\t\t} catch {\n\t\t\t\t// best-effort cleanup\n\t\t\t}\n\t\t}\n\t\tmcpConnections.clear();\n\t});\n}\n\nfunction buildMcpSchema(tool: McpToolDef): ReturnType<typeof Type.Object> {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\n/**\n * Parse standard MCP config format (used by Claude Desktop, VS Code, etc.)\n * into hoocode's McpServerConfig format.\n */\nfunction parseStandardMcpConfig(config: StandardMcpConfig, _source: string): McpServerConfig[] {\n\tif (!config.mcpServers) return [];\n\n\tconst servers: McpServerConfig[] = [];\n\tfor (const [name, serverConfig] of Object.entries(config.mcpServers)) {\n\t\tservers.push({\n\t\t\tname,\n\t\t\tcommand: serverConfig.command,\n\t\t\targs: serverConfig.args,\n\t\t\tenv: serverConfig.env,\n\t\t\ttype: serverConfig.type,\n\t\t\turl: serverConfig.url,\n\t\t\theaders: serverConfig.headers,\n\t\t\tbackground: serverConfig.background,\n\t\t});\n\t}\n\treturn servers;\n}\n\n/**\n * Load MCP servers from a standard mcp.json file.\n * Returns an array of McpServerConfig, or empty array if file doesn't exist or is invalid.\n */\nfunction loadStandardMcpFile(filePath: string): McpServerConfig[] {\n\tif (!existsSync(filePath)) return [];\n\n\ttry {\n\t\tconst content = readFileSync(filePath, \"utf8\");\n\t\tconst config = JSON.parse(content) as StandardMcpConfig;\n\t\treturn parseStandardMcpConfig(config, filePath);\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n/**\n * Build the full {@link ToolDefinition} for one MCP tool — the complete JSON\n * schema plus the connect/execute machinery. Shared by the eager path (register\n * every tool up front) and the deferred path (materialize on resolve), so both\n * produce identical, callable tools.\n */\nfunction buildMcpToolDefinition(serverConfig: McpServerConfig, tool: McpToolDef): ToolDefinition {\n\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\tconst schema = buildMcpSchema(tool);\n\tconst capturedServer = serverConfig.name;\n\tconst capturedTool = tool.name;\n\t// MCP tools default to background mode since they are external processes with potential high latency\n\tconst isBackground = serverConfig.background !== false;\n\n\treturn {\n\t\tname: toolName,\n\t\tlabel: `[MCP] ${serverConfig.name} › ${tool.name}`,\n\t\tdescription: tool.description,\n\t\tparameters: schema,\n\t\tbackground: isBackground,\n\t\t// Render a clean, prefixed title in chat — `MCP [server › tool] <args>` —\n\t\t// parallel to the subagent `Task [type] <desc>` line. Without this the\n\t\t// ToolExecutionComponent falls back to the raw `mcp_<server>_<tool>` name.\n\t\t// The args summary reuses the same helper as the background start/finish\n\t\t// messages so the chat title stays in sync with them.\n\t\trenderCall(args, theme) {\n\t\t\tconst summary = summarizeArgs((args ?? {}) as Record<string, unknown>);\n\t\t\tconst text =\n\t\t\t\ttheme.fg(\"toolTitle\", theme.bold(\"MCP \")) +\n\t\t\t\ttheme.fg(\"mcp\", `[${capturedServer} › ${capturedTool}]`) +\n\t\t\t\t(summary ? theme.fg(\"dim\", ` ${summary}`) : \"\");\n\t\t\treturn new Text(text, 0, 0);\n\t\t},\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof schema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<McpToolDetails>> {\n\t\t\tconst startTime = Date.now();\n\t\t\t// Background MCP tools get a task store entry so they appear in the task pane.\n\t\t\t// Foreground tools skip this (their result is awaited inline). The server\n\t\t\t// name rides in subagentMode and becomes the row's `[server]` origin tag;\n\t\t\t// the title carries just the tool.\n\t\t\tconst task = isBackground\n\t\t\t\t? taskStore.create(capturedTool, { source: \"mcp\", subagentMode: capturedServer })\n\t\t\t\t: undefined;\n\t\t\tif (task) taskStore.update(task.id, { status: \"in_progress\" });\n\n\t\t\t// Lazily (re)connect: a dropped connection (server exit, process churn\n\t\t\t// between turns, a racing teardown) should transparently reconnect from\n\t\t\t// the retained config rather than permanently fail with \"not connected\".\n\t\t\tconst activeConn = await getOrConnectMcp(capturedServer);\n\t\t\tif (!activeConn) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: `MCP server \"${capturedServer}\" is not connected (reconnect attempt failed)` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { elapsed: Date.now() - startTime } as McpToolDetails,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")));\n\t\t\t\t});\n\n\t\t\t\tconst result = await Promise.race([\n\t\t\t\t\tactiveConn.rpc(\"tools/call\", { name: capturedTool, arguments: params }),\n\t\t\t\t\tabortPromise,\n\t\t\t\t]);\n\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"done\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: JSON.stringify(result, null, 2) }],\n\t\t\t\t\tdetails: { elapsed: Date.now() - startTime } as McpToolDetails,\n\t\t\t\t};\n\t\t\t} catch (error) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst text = result.content\n\t\t\t\t.map((c) => (c.type === \"text\" ? c.text : \"\"))\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.join(\"\\n\");\n\t\t\tif (!text) return new Text(\"\", 0, 0);\n\n\t\t\tconst details = result.details as McpToolDetails | undefined;\n\t\t\tconst elapsed = details?.elapsed;\n\t\t\tconst elapsedText = elapsed ? ` ${formatDurationSecs(elapsed / 1000)}` : \"\";\n\t\t\tconst identity = `${capturedServer} › ${capturedTool}`;\n\n\t\t\t// Use context.isError to determine status (framework sets this when tools throw)\n\t\t\tconst isError = context.isError;\n\t\t\tconst statusGlyph = isError ? \"✗\" : \"✓\";\n\t\t\tconst statusLabel = isError ? \"failed\" : \"done\";\n\t\t\tconst statusColor = isError ? \"error\" : \"success\";\n\n\t\t\tconst spine = (s: string) => theme.fg(\"borderMuted\", s);\n\t\t\tconst header =\n\t\t\t\t`${spine(\"╭\")} ${theme.fg(statusColor, statusGlyph)} ` +\n\t\t\t\t`${theme.bold(theme.fg(statusColor, statusLabel))} ` +\n\t\t\t\t`${theme.fg(\"mcp\", identity)}${elapsedText}`;\n\t\t\tconst body = text\n\t\t\t\t.split(\"\\n\")\n\t\t\t\t.map((line) => `${spine(\"│\")} ${theme.fg(\"toolOutput\", line)}`)\n\t\t\t\t.join(\"\\n\");\n\t\t\treturn new Text(`${header}\\n${body}\\n${spine(\"╰\")}`, 0, 0);\n\t\t},\n\t} as ToolDefinition;\n}\n\nconst RESOLVE_MCP_TOOLS_NAME = \"ResolveMcpTools\";\n\nexport function setupMcpLoader(hoo: ExtensionAPI): void {\n\thoo.on(\"session_start\", async (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\t// A spawned subagent whose tool allowlist has no MCP tools is told by its\n\t\t// parent to skip server connection entirely (see SUBAGENT_SKIP_MCP_ENV).\n\t\t// Each connect is a ~15s-timeout handshake; doing it for a subagent that can\n\t\t// never call the tools is pure startup latency.\n\t\tif (subagentSkipMcp()) return;\n\n\t\tinstallMcpExitCleanup();\n\t\t// Rebuild the status registry from scratch: a reload or session switch runs\n\t\t// this pass again and the startup summary must not double-count.\n\t\tclearMcpServerStatuses();\n\t\tconst allServerConfigs: McpServerConfig[] = [];\n\t\tconst seenNames = new Set<string>();\n\n\t\t// 1. Load from standard mcp.json locations\n\t\t// User-level: ~/.agents/mcp.json\n\t\tconst userAgentsConfig = loadStandardMcpFile(join(homedir(), \".agents\", \"mcp.json\"));\n\t\tfor (const config of userAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Project-level: ./.agents/mcp.json\n\t\tconst projectAgentsConfig = loadStandardMcpFile(join(ctx.cwd, \".agents\", \"mcp.json\"));\n\t\tfor (const config of projectAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Claude Desktop: ~/.config/claude/mcp.json\n\t\tconst claudeDesktopConfig = loadStandardMcpFile(join(homedir(), \".config\", \"claude\", \"mcp.json\"));\n\t\tfor (const config of claudeDesktopConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// 2. Load from hoocode's per-server format (existing behavior)\n\t\tconst searchDirs = [join(HOOCODE_DIR, \"mcp-servers\"), join(ctx.cwd, \".hoocode\", \"mcp-servers\")];\n\n\t\tfor (const dir of searchDirs) {\n\t\t\tif (!existsSync(dir)) continue;\n\n\t\t\tlet files: string[];\n\t\t\ttry {\n\t\t\t\tfiles = (await readdir(dir)).filter((f) => f.endsWith(\".json\"));\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const file of files) {\n\t\t\t\tconst cfgPath = join(dir, file);\n\t\t\t\tlet serverConfig: McpServerConfig;\n\n\t\t\t\ttry {\n\t\t\t\t\tserverConfig = JSON.parse(readFileSync(cfgPath, \"utf8\")) as McpServerConfig;\n\t\t\t\t\tif (!serverConfig.name || (!serverConfig.command && !serverConfig.url)) {\n\t\t\t\t\t\tctx.ui.notify(`MCP: config \"${file}\" is missing required \"name\" or \"command\"/\"url\"`, \"warning\");\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to parse \"${file}\": ${String(err)}`, \"error\");\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Skip if already loaded from standard config\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// 2b. Load from plugins/extensions that registered MCP servers during load.\n\t\tfor (const entry of getExtensionMcpServers()) {\n\t\t\tfor (const serverConfig of parseStandardMcpConfig(\n\t\t\t\t{ mcpServers: entry.mcpServers },\n\t\t\t\t`plugin:${entry.source}`,\n\t\t\t)) {\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// Deferral (spec §2): inject MCP tool names only and materialize each schema\n\t\t// on demand via ResolveMcpTools. Default-on and top-level only — a subagent that\n\t\t// needs MCP has this env cleared, so it eager-registers its allowlisted tools\n\t\t// at dispatch (the dispatch ↔ schema interaction) and they are immediately callable.\n\t\tconst defer = deferMcpSchemas();\n\t\tconst deferredCatalog: DeferredMcpToolEntry[] = [];\n\t\t// Retain each deferred tool's raw definition + config so ResolveMcpTools can\n\t\t// build the full ToolDefinition on request.\n\t\tconst deferredByName = new Map<string, { serverConfig: McpServerConfig; tool: McpToolDef }>();\n\t\tconst resolvedNames = new Set<string>();\n\n\t\t// Remote servers get OAuth support: tokens persist under\n\t\t// ~/.hoocode/mcp-auth, and when a server demands interactive (browser)\n\t\t// authorization the handshake fails fast while the flow keeps running in\n\t\t// the background — on completion the server is reconnected and its tools\n\t\t// registered (eagerly, since the deferred catalog is already sealed).\n\t\tconst buildRemoteOptions = (serverConfig: McpServerConfig): McpRemoteOptions => ({\n\t\t\tauthStorageDir: join(HOOCODE_DIR, \"mcp-auth\"),\n\t\t\tonAuthRequired: (authorizationUrl, completed) => {\n\t\t\t\tmcpAuthPending.add(serverConfig.name);\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`MCP: \"${serverConfig.name}\" requires authorization — complete the sign-in opened in your browser` +\n\t\t\t\t\t\t(authorizationUrl ? `: ${authorizationUrl}` : \"\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\tcompleted\n\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\t\t\t\t\t\t\tfor (const tool of tools) hoo.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\t\t\tdeferred: false,\n\t\t\t\t\t\t\t\tstate: \"connected\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: connected \"${serverConfig.name}\" after authorization (${tools.length} tool${tools.length === 1 ? \"\" : \"s\"})`,\n\t\t\t\t\t\t\t\t\"info\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: failed to connect \"${serverConfig.name}\" after authorization: ${String(err)}`,\n\t\t\t\t\t\t\t\t\"error\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\tctx.ui.notify(`MCP: authorization for \"${serverConfig.name}\" failed: ${String(err)}`, \"error\");\n\t\t\t\t\t});\n\t\t\t},\n\t\t});\n\n\t\t// 3. Connect to all servers and register (or defer) tools\n\t\tfor (const serverConfig of allServerConfigs) {\n\t\t\t// Retain the config so a tool call can lazily reconnect a dropped server.\n\t\t\tmcpServerConfigs.set(serverConfig.name, serverConfig);\n\t\t\tmcpRemoteOptions.set(serverConfig.name, buildRemoteOptions(serverConfig));\n\t\t\ttry {\n\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\n\t\t\t\tfor (const tool of tools) {\n\t\t\t\t\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\t\t\t\t\tif (defer) {\n\t\t\t\t\t\tdeferredCatalog.push({ toolName, server: serverConfig.name, description: tool.description });\n\t\t\t\t\t\tdeferredByName.set(toolName, { serverConfig, tool });\n\t\t\t\t\t} else {\n\t\t\t\t\t\thoo.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// A successful connect is reported by the startup resource summary (one\n\t\t\t\t// `mcp` cell plus a details row), not as a transient line above it.\n\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\tdeferred: defer,\n\t\t\t\t\tstate: \"connected\",\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tif (mcpAuthPending.has(serverConfig.name)) {\n\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\ttoolCount: 0,\n\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\tdeferred: defer,\n\t\t\t\t\t\tstate: \"authorizing\",\n\t\t\t\t\t});\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`MCP: \"${serverConfig.name}\" is waiting for browser authorization; tools will register once it completes`,\n\t\t\t\t\t\t\"info\",\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to connect \"${serverConfig.name}\": ${String(err)}`, \"error\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 4. In deferred mode, register the single resolver that materializes schemas on demand.\n\t\tif (defer && deferredCatalog.length > 0) {\n\t\t\t// Feed the capability index. This is what lets the catalog above be\n\t\t\t// summarized instead of dumped: a tool the model cannot see in the\n\t\t\t// description is still reachable by describing what it needs.\n\t\t\tregisterCapabilities(\"mcp-tool\", toCapabilityDocs(deferredCatalog, true));\n\t\t\tresetCapabilitySearch();\n\t\t\t// Fire-and-forget: the lexical leg answers immediately, and the dense one\n\t\t\t// joins when (and if) it is ready. Awaiting here would put an embedding\n\t\t\t// model's startup in front of the session's.\n\t\t\t//\n\t\t\t// Indexed over everything registered, not just these tools: the dense\n\t\t\t// store is shared and keyed on the hash of whatever it was built from, so\n\t\t\t// passing a subset here would evict the other producers' vectors (and\n\t\t\t// theirs would evict these). Every caller passing the full set makes the\n\t\t\t// last write the complete one.\n\t\t\tvoid ensureDenseIndex(getCapabilities()).catch(() => {});\n\n\t\t\tconst resolveParams = Type.Object(\n\t\t\t\t{\n\t\t\t\t\tnames: Type.Optional(\n\t\t\t\t\t\tType.Array(Type.String(), {\n\t\t\t\t\t\t\tdescription: \"Exact MCP tool names to make callable (e.g. 'mcp_github_create_pr' or 'create_pr').\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tquery: Type.Optional(\n\t\t\t\t\t\tType.String({\n\t\t\t\t\t\t\tdescription:\n\t\t\t\t\t\t\t\t\"Describe the capability you need ('open a pull request', 'send a message') to find tools by \" +\n\t\t\t\t\t\t\t\t\"meaning when you do not know their names. Matching tools are resolved and become callable.\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tlimit: Type.Optional(Type.Number({ description: \"Maximum tools to resolve from a query. Default 5.\" })),\n\t\t\t\t},\n\t\t\t\t{ additionalProperties: false },\n\t\t\t);\n\t\t\thoo.registerTool({\n\t\t\t\tname: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tlabel: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tdescription:\n\t\t\t\t\t\"MCP tools are connected but their schemas are loaded on demand to keep context small. Name the tool(s) \" +\n\t\t\t\t\t\"you need in `names`, or describe the capability in `query` to find them, then call the tool(s). \" +\n\t\t\t\t\t\"Available MCP tools:\\n\" +\n\t\t\t\t\tformatDeferredCatalog(deferredCatalog),\n\t\t\t\tpromptSnippet: \"Resolve deferred MCP tool schemas by name, or find them by describing the capability.\",\n\t\t\t\tparameters: resolveParams,\n\t\t\t\tasync execute(_toolCallId: string, params: Static<typeof resolveParams>) {\n\t\t\t\t\tconst names = params.names ?? [];\n\t\t\t\t\tconst query = params.query?.trim();\n\t\t\t\t\tconst matched = selectResolvable(deferredCatalog, names);\n\t\t\t\t\tconst seen = new Set(matched.map((m) => m.toolName));\n\n\t\t\t\t\t// Both parameters may be given: name what you know, describe the rest.\n\t\t\t\t\tlet searchNote = \"\";\n\t\t\t\t\tif (query) {\n\t\t\t\t\t\tconst { hits, legs } = await searchCapabilities(query, {\n\t\t\t\t\t\t\tkinds: [\"mcp-tool\"],\n\t\t\t\t\t\t\tlimit: params.limit ?? 5,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tfor (const hit of hits) {\n\t\t\t\t\t\t\tconst entry = deferredCatalog.find((e) => e.toolName === hit.doc.id);\n\t\t\t\t\t\t\tif (entry && !seen.has(entry.toolName)) {\n\t\t\t\t\t\t\t\tmatched.push(entry);\n\t\t\t\t\t\t\t\tseen.add(entry.toolName);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Say which legs answered: a lexical-only result set on a\n\t\t\t\t\t\t// conceptual query is a weaker answer, and the caller should be\n\t\t\t\t\t\t// able to tell that from the result rather than guess.\n\t\t\t\t\t\tsearchNote = hits.length\n\t\t\t\t\t\t\t? `\\nMatched by ${legs.join(\"+\") || \"none\"} search for \"${query}\".`\n\t\t\t\t\t\t\t: `\\nNothing matched \"${query}\".`;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst newlyResolved: string[] = [];\n\t\t\t\t\tfor (const entry of matched) {\n\t\t\t\t\t\tif (resolvedNames.has(entry.toolName)) continue;\n\t\t\t\t\t\tconst raw = deferredByName.get(entry.toolName);\n\t\t\t\t\t\tif (!raw) continue;\n\t\t\t\t\t\thoo.registerTool(buildMcpToolDefinition(raw.serverConfig, raw.tool));\n\t\t\t\t\t\tresolvedNames.add(entry.toolName);\n\t\t\t\t\t\tnewlyResolved.push(entry.toolName);\n\t\t\t\t\t}\n\t\t\t\t\tconst text = matched.length\n\t\t\t\t\t\t? `Resolved ${newlyResolved.length} MCP tool(s): ${matched.map((m) => m.toolName).join(\", \")}. They are now callable.${searchNote}`\n\t\t\t\t\t\t: `No MCP tools matched: ${[...names, query].filter(Boolean).join(\", \") || \"(none)\"}.${searchNote}`;\n\t\t\t\t\treturn { content: [{ type: \"text\" as const, text }], details: undefined };\n\t\t\t\t},\n\t\t\t} as ToolDefinition);\n\t\t}\n\t});\n}\n"]}