From af418d08627a600f2bb1fbe9ddf56346d864614d Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Sat, 2 May 2026 02:20:12 -0700 Subject: [PATCH] feat(mcp): /mcp reconnect for identity drift only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2b implementation per RFC #110. Identity-drift only — append / edit / reorder / remove drift cases surface a clear "restart Reasonix to apply" message instead of mutating the registry or prefix mid-session. The graduated permissive policy from the empirical spike (#113) needs API work on `ImmutablePrefix` (replaceTool / removeTool) before the other drift kinds can take effect mid-session; that's a follow-up PR. Touch: - `src/mcp/registry.ts`: new `McpClientHost = { client: McpClient }` indirection on BridgeOptions. Tool closures resolve the live client via `host.client` at call time, so reconnect can swap the underlying socket without re-bridging tools. - `src/mcp/reconnect.ts` (new): `reconnectMcpServer({ host, spec, beforeTools })` re-handshakes a fresh transport, classifies drift, swaps `host.client` only on identity, closes the new client cleanly on refusal so the old one stays untouched. - `src/cli/ui/slash/handlers/mcp.ts`: third subcommand `reconnect `, fires async, reports via `ctx.postInfo` with the lifecycle `↻ reconnect…` / `✓ connected` / `✖ failed` formatter. - `src/cli/ui/mcp-lifecycle.ts`: `reconnect` state added to the union. - `src/cli/ui/slash/types.ts`: `McpServerSummary.client?` replaced by `host: McpClientHost`. `McpClient` import dropped (now via host). - `src/cli/ui/mcp-browse.ts`: `/resource` and `/prompt` read through `server.host.client`. Disconnected-server warnings dropped — host always carries a client now. - `src/cli/commands/chat.tsx`: builds the host at bridge time, stores it on the summary. Tests: - `tests/mcp-reconnect.test.ts`: 2 cases for spec_parse early returns. - `tests/mcp-integration.test.ts`: live test that bridge + host indirection routes a swapped client correctly through registry.dispatch. - `tests/slash.test.ts`: 3 cases for the slash dispatch (lifecycle line emission, unknown-name rejection with hint, no-arg usage). - `tests/mcp-browse.test.ts`: server() helper updated to accept `client` and wrap in host shape. Closes part of #110 (identity case only). Append/edit/reorder/remove mid-session handling deferred — needs ImmutablePrefix surgery. --- src/cli/commands/chat.tsx | 9 ++- src/cli/ui/mcp-browse.ts | 18 +---- src/cli/ui/mcp-lifecycle.ts | 5 +- src/cli/ui/slash/handlers/mcp.ts | 71 ++++++++++++++++++ src/cli/ui/slash/types.ts | 6 +- src/mcp/reconnect.ts | 120 +++++++++++++++++++++++++++++++ src/mcp/registry.ts | 21 +++--- tests/mcp-browse.test.ts | 10 ++- tests/mcp-integration.test.ts | 34 ++++++++- tests/mcp-reconnect.test.ts | 37 ++++++++++ tests/slash.test.ts | 51 +++++++++++++ 11 files changed, 347 insertions(+), 35 deletions(-) create mode 100644 src/mcp/reconnect.ts create mode 100644 tests/mcp-reconnect.test.ts diff --git a/src/cli/commands/chat.tsx b/src/cli/commands/chat.tsx index 29d7587..3d122ed 100644 --- a/src/cli/commands/chat.tsx +++ b/src/cli/commands/chat.tsx @@ -4,7 +4,7 @@ import { loadApiKey, readConfig, searchEnabled } from "../../config.js"; import { loadDotenv } from "../../env.js"; import { McpClient } from "../../mcp/client.js"; import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js"; -import { bridgeMcpTools } from "../../mcp/registry.js"; +import { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js"; import { parseMcpSpec } from "../../mcp/spec.js"; import { SseTransport } from "../../mcp/sse.js"; import { type McpTransport, StdioTransport } from "../../mcp/stdio.js"; @@ -231,10 +231,15 @@ export async function chatCommand(opts: ChatOptions): Promise { : new StdioTransport({ command: spec.command, args: spec.args }); const mcp = new McpClient({ transport }); await mcp.initialize(); + // Host indirection lets `/mcp reconnect` swap the underlying client + // without re-bridging the registered tools (closures resolve via + // host.client at call time). + const host: McpClientHost = { client: mcp }; const bridge = await bridgeMcpTools(mcp, { registry: tools, namePrefix: prefix, serverName: label, + host, onProgress: (info) => progressSink.current?.(info), onSlow: (info) => process.stderr.write( @@ -278,7 +283,7 @@ export async function chatCommand(opts: ChatOptions): Promise { spec: raw, toolCount: bridge.registeredNames.length, report, - client: mcp, + host, }); } catch (err) { // Per-server failure is non-fatal: one broken server shouldn't diff --git a/src/cli/ui/mcp-browse.ts b/src/cli/ui/mcp-browse.ts index 6293272..7cd139f 100644 --- a/src/cli/ui/mcp-browse.ts +++ b/src/cli/ui/mcp-browse.ts @@ -161,14 +161,7 @@ export async function handleMcpBrowseSlash( ); return; } - const client: McpClient | undefined = server.client; - if (!client) { - log.pushWarning( - `server [${server.label}] is not connected (display-only)`, - "Resource read requires a live MCP client.", - ); - return; - } + const client: McpClient = server.host.client; try { const result = await client.readResource(arg); log.pushInfo(formatResourceContents(arg, result)); @@ -187,14 +180,7 @@ export async function handleMcpBrowseSlash( ); return; } - const client: McpClient | undefined = server.client; - if (!client) { - log.pushWarning( - `server [${server.label}] is not connected (display-only)`, - "Prompt fetch requires a live MCP client.", - ); - return; - } + const client: McpClient = server.host.client; try { const result = await client.getPrompt(arg); log.pushInfo(formatPromptMessages(arg, result)); diff --git a/src/cli/ui/mcp-lifecycle.ts b/src/cli/ui/mcp-lifecycle.ts index a1b9953..5d1d5f2 100644 --- a/src/cli/ui/mcp-lifecycle.ts +++ b/src/cli/ui/mcp-lifecycle.ts @@ -11,13 +11,15 @@ export type McpLifecycleEvent = ms: number; } | { state: "failed"; name: string; reason: string } - | { state: "disabled"; name: string }; + | { state: "disabled"; name: string } + | { state: "reconnect"; name: string }; const STATE: Record = { handshake: { glyph: "↻", label: "handshake…" }, connected: { glyph: "✓", label: "connected" }, failed: { glyph: "✖", label: "failed" }, disabled: { glyph: "○", label: "disabled" }, + reconnect: { glyph: "↻", label: "reconnect…" }, }; const NAME_COL = 22; @@ -35,6 +37,7 @@ function describeDetail(ev: McpLifecycleEvent): string { if (ev.state === "handshake") return "initialise → tools/list → resources/list"; if (ev.state === "failed") return ev.reason; if (ev.state === "disabled") return `via /mcp disable ${ev.name}`; + if (ev.state === "reconnect") return "tearing down · re-handshake · listing tools"; const parts: string[] = [`${ev.tools} tools`]; if (ev.resources && ev.resources > 0) parts.push(`${ev.resources} resources`); if (ev.prompts && ev.prompts > 0) parts.push(`${ev.prompts} prompts`); diff --git a/src/cli/ui/slash/handlers/mcp.ts b/src/cli/ui/slash/handlers/mcp.ts index 4aafe07..5e27d78 100644 --- a/src/cli/ui/slash/handlers/mcp.ts +++ b/src/cli/ui/slash/handlers/mcp.ts @@ -1,6 +1,9 @@ import { readConfig, writeConfig } from "../../../../config.js"; +import { reconnectMcpServer } from "../../../../mcp/reconnect.js"; +import { formatMcpLifecycleEvent } from "../../mcp-lifecycle.js"; import type { SlashHandler } from "../dispatch.js"; import { appendSection } from "../helpers.js"; +import type { McpServerSummary } from "../types.js"; const mcp: SlashHandler = (args, loop, ctx) => { const servers = ctx.mcpServers ?? []; @@ -10,6 +13,9 @@ const mcp: SlashHandler = (args, loop, ctx) => { if (sub === "disable" || sub === "enable") { return toggleDisabled(sub, args[1], { servers, specs }); } + if (sub === "reconnect") { + return triggerReconnect(args[1], servers, ctx.postInfo); + } // `/mcp text` (or non-TTY) falls through to the printed-card path. The // default `/mcp` opens the interactive browser modal. const wantsTextDump = sub === "text"; @@ -123,4 +129,69 @@ function parseLabelFromSpec(spec: string): string | null { return match ? (match[1] ?? null) : null; } +function triggerReconnect( + rawName: string | undefined, + servers: ReadonlyArray, + postInfo: ((text: string) => void) | undefined, +): { info: string } { + const name = rawName?.trim(); + if (!name) { + return { + info: "usage: /mcp reconnect · pick a name shown in /mcp.", + }; + } + const target = servers.find((s) => s.label === name); + if (!target) { + const list = servers + .map((s) => s.label) + .sort() + .join(", "); + return { info: `unknown MCP server "${name}". Known: ${list || "(none)"}.` }; + } + if (!postInfo) { + return { info: "/mcp reconnect requires the interactive TUI (postInfo not wired)." }; + } + // Sync return: kick off the async work and let it report via postInfo. + // Identity drift is the only currently-supported success case; everything + // else surfaces as a "restart Reasonix to apply" line so the user knows + // why the reconnect didn't take. + const beforeTools = target.report.tools.supported ? target.report.tools.items : []; + void (async () => { + try { + const result = await reconnectMcpServer({ + host: target.host, + spec: target.spec, + beforeTools, + }); + if (result.ok) { + postInfo( + formatMcpLifecycleEvent({ + state: "connected", + name: target.label, + tools: beforeTools.length, + ms: result.ms, + }), + ); + } else { + postInfo( + formatMcpLifecycleEvent({ + state: "failed", + name: target.label, + reason: `${result.reason} · ${result.message}`, + }), + ); + } + } catch (err) { + postInfo( + formatMcpLifecycleEvent({ + state: "failed", + name: target.label, + reason: (err as Error).message, + }), + ); + } + })(); + return { info: formatMcpLifecycleEvent({ state: "reconnect", name: target.label }) }; +} + export const handlers: Record = { mcp }; diff --git a/src/cli/ui/slash/types.ts b/src/cli/ui/slash/types.ts index a21a8e9..2528e4e 100644 --- a/src/cli/ui/slash/types.ts +++ b/src/cli/ui/slash/types.ts @@ -1,6 +1,6 @@ import type { EditMode } from "../../../config.js"; -import type { McpClient } from "../../../mcp/client.js"; import type { InspectionReport } from "../../../mcp/inspect.js"; +import type { McpClientHost } from "../../../mcp/registry.js"; import type { JobRegistry } from "../../../tools/jobs.js"; import type { PlanStep } from "../../../tools/plan.js"; @@ -120,8 +120,8 @@ export interface McpServerSummary { toolCount: number; /** Full inspection snapshot — used for the resources + prompts sections. */ report: InspectionReport; - /** Live client for `/resource` + `/prompt`; absent → those slashes fall back to "not available". */ - client?: McpClient; + /** Mutable client handle so `/mcp reconnect` can swap the underlying socket without re-bridging tools. */ + host: McpClientHost; } export interface SlashCommandSpec { diff --git a/src/mcp/reconnect.ts b/src/mcp/reconnect.ts new file mode 100644 index 0000000..5d4f47a --- /dev/null +++ b/src/mcp/reconnect.ts @@ -0,0 +1,120 @@ +/** `/mcp reconnect` — open a fresh client, accept identity drift only, refuse the rest cleanly. */ + +import { McpClient } from "./client.js"; +import { classifyToolListDrift } from "./drift.js"; +import type { McpClientHost } from "./registry.js"; +import { type McpSpec, parseMcpSpec } from "./spec.js"; +import { SseTransport } from "./sse.js"; +import { type McpTransport, StdioTransport } from "./stdio.js"; +import { StreamableHttpTransport } from "./streamable-http.js"; +import type { McpTool } from "./types.js"; + +export interface ReconnectArgs { + /** Live host whose `client` will be swapped on success. */ + host: McpClientHost; + /** Original `--mcp` spec string the server was launched with. Re-parsed to rebuild transport. */ + spec: string; + /** The current tool list, used as the drift baseline. */ + beforeTools: readonly McpTool[]; +} + +export type ReconnectResult = + | { ok: true; afterTools: McpTool[]; ms: number } + | { + ok: false; + reason: + | "spec_parse" + | "handshake" + | "drift_added" + | "drift_edited" + | "drift_reordered" + | "drift_removed"; + message: string; + ms: number; + }; + +export async function reconnectMcpServer(args: ReconnectArgs): Promise { + const t0 = Date.now(); + let parsed: McpSpec; + try { + parsed = parseMcpSpec(args.spec); + } catch (err) { + return { + ok: false, + reason: "spec_parse", + message: (err as Error).message, + ms: Date.now() - t0, + }; + } + const transport: McpTransport = + parsed.transport === "sse" + ? new SseTransport({ url: parsed.url }) + : parsed.transport === "streamable-http" + ? new StreamableHttpTransport({ url: parsed.url }) + : new StdioTransport({ command: parsed.command, args: parsed.args }); + const next = new McpClient({ transport }); + try { + await next.initialize(); + const listed = await next.listTools(); + const drift = classifyToolListDrift(toolsToSpecs(args.beforeTools), toolsToSpecs(listed.tools)); + if (drift.kind !== "identity") { + // The new client is fine but its tool surface differs — accepting it + // would either mutate the registry/prefix (we don't do that yet) or + // silently break the cache invariant. Close the new handle and leave + // the old one in place untouched. + await next.close().catch(() => {}); + return { + ok: false, + reason: driftReason(drift.kind), + message: driftMessage(drift), + ms: Date.now() - t0, + }; + } + // Identity drift — safe to swap. + const old = args.host.client; + args.host.client = next; + await old.close().catch(() => {}); + return { ok: true, afterTools: listed.tools, ms: Date.now() - t0 }; + } catch (err) { + await next.close().catch(() => {}); + return { + ok: false, + reason: "handshake", + message: (err as Error).message, + ms: Date.now() - t0, + }; + } +} + +function driftReason( + kind: Exclude["kind"], "identity">, +): "drift_added" | "drift_edited" | "drift_reordered" | "drift_removed" { + if (kind === "append") return "drift_added"; + if (kind === "edit") return "drift_edited"; + if (kind === "reorder") return "drift_reordered"; + return "drift_removed"; +} + +function driftMessage(drift: ReturnType): string { + if (drift.kind === "append") { + return `tool list grew (${drift.added.length} added: ${drift.added.join(", ")}). Restart Reasonix to bridge the new tool(s).`; + } + if (drift.kind === "edit") { + return `tool description/schema changed for ${drift.edited.join(", ")}. Restart Reasonix to apply.`; + } + if (drift.kind === "remove") { + return `tool(s) removed: ${drift.removed.join(", ")}. Restart Reasonix to drop them from the registry.`; + } + return "tool list reordered or restructured — cache prefix would be invalidated. Restart Reasonix."; +} + +function toolsToSpecs(tools: readonly McpTool[]): import("../types.js").ToolSpec[] { + return tools.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description ?? "", + parameters: t.inputSchema as unknown as import("../types.js").JSONSchema, + }, + })); +} diff --git a/src/mcp/registry.ts b/src/mcp/registry.ts index 58bff04..8455362 100644 --- a/src/mcp/registry.ts +++ b/src/mcp/registry.ts @@ -27,6 +27,13 @@ export interface BridgeOptions { slowThresholdMs?: number; /** Fired exactly when the per-server p95 transitions over `slowThresholdMs`. */ onSlow?: (ev: SlowEvent) => void; + /** Indirection so reconnect can swap the underlying client without re-registering tools. */ + host?: McpClientHost; +} + +/** Mutable holder so `/mcp reconnect` can swap the underlying client without re-bridging tools. */ +export interface McpClientHost { + client: McpClient; } export const DEFAULT_MAX_RESULT_CHARS = 32_000; @@ -68,19 +75,13 @@ export async function bridgeMcpTools( parameters: mcpTool.inputSchema as JSONSchema, fn: async (args: Record, ctx) => { const t0 = tracker ? Date.now() : 0; - const toolResult = await client.callTool(mcpTool.name, args, { - // Forward server-side progress frames to the bridge caller, - // tagged with the registered name so multi-server UIs can - // disambiguate. No-op when `onProgress` isn't configured — - // the client then also omits the _meta.progressToken and - // the server won't emit progress. + // Resolve client at call time via the host indirection (when given) so + // `/mcp reconnect` can swap a fresh client in without re-bridging tools. + const live = opts.host?.client ?? client; + const toolResult = await live.callTool(mcpTool.name, args, { onProgress: opts.onProgress ? (info) => opts.onProgress!({ toolName: registeredName, ...info }) : undefined, - // Thread the tool-dispatch AbortSignal all the way down to - // the MCP request so Esc truly cancels in flight — the - // client will emit notifications/cancelled AND reject the - // pending promise immediately, no "wait for subprocess". signal: ctx?.signal, }); if (tracker) tracker.record(Date.now() - t0); diff --git a/tests/mcp-browse.test.ts b/tests/mcp-browse.test.ts index 56911f7..3943f7d 100644 --- a/tests/mcp-browse.test.ts +++ b/tests/mcp-browse.test.ts @@ -50,10 +50,16 @@ function makeFakeLog() { return { rows, log }; } -function server(partial: Partial & { label: string }): McpServerSummary { +function server( + partial: Partial & { label: string; client?: unknown }, +): McpServerSummary { + // Tests pass a stubbed `client` for convenience; wrap it in the host shape + // the bridge expects. + const { client, ...rest } = partial; return { spec: partial.spec ?? `fake://${partial.label}`, toolCount: partial.toolCount ?? 0, + host: rest.host ?? { client: client as never }, report: partial.report ?? { protocolVersion: "2024-11-05", serverInfo: { name: partial.label, version: "1.0" }, @@ -62,7 +68,7 @@ function server(partial: Partial & { label: string }): McpServ resources: { supported: true, items: [] }, prompts: { supported: true, items: [] }, }, - ...partial, + ...rest, }; } diff --git a/tests/mcp-integration.test.ts b/tests/mcp-integration.test.ts index fdd0db5..ca91d49 100644 --- a/tests/mcp-integration.test.ts +++ b/tests/mcp-integration.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { McpClient } from "../src/mcp/client.js"; -import { bridgeMcpTools } from "../src/mcp/registry.js"; +import { reconnectMcpServer } from "../src/mcp/reconnect.js"; +import { type McpClientHost, bridgeMcpTools } from "../src/mcp/registry.js"; import { StdioTransport } from "../src/mcp/stdio.js"; import { ToolRegistry } from "../src/tools.js"; @@ -71,6 +72,37 @@ describe("MCP integration — real subprocess against bundled demo server", () = expect(out).toContain("101"); }, 30_000); + it("host indirection: bridged tool calls follow host.client when it's swapped out", async () => { + // Without invoking reconnect (which adds parseMcpSpec / shell quoting + // concerns on Windows paths with spaces), prove the indirection layer + // alone: bridge with a host, manually swap host.client to a fresh + // McpClient pointing at a second demo subprocess, confirm the existing + // registered tool routes through the new client. + const tA = new StdioTransport({ command: NODE_CMD, args: DEMO_SERVER_ARGS, shell: false }); + const a = new McpClient({ transport: tA, requestTimeoutMs: 15_000 }); + await a.initialize(); + const host: McpClientHost = { client: a }; + const { registry } = await bridgeMcpTools(a, { + registry: new ToolRegistry(), + namePrefix: "demo_", + host, + }); + const okBefore = await registry.dispatch("demo_add", JSON.stringify({ a: 1, b: 1 })); + expect(okBefore).toContain("2"); + + // Spin up a fresh subprocess and swap host.client. + const tB = new StdioTransport({ command: NODE_CMD, args: DEMO_SERVER_ARGS, shell: false }); + const b = new McpClient({ transport: tB, requestTimeoutMs: 15_000 }); + await b.initialize(); + host.client = b; + await a.close(); + + // Same registered tool, now serviced by the new client. + const okAfter = await registry.dispatch("demo_add", JSON.stringify({ a: 7, b: 8 })); + expect(okAfter).toContain("15"); + await b.close(); + }, 60_000); + it("bridges two MCP servers into a shared registry with different prefixes", async () => { // Two instances of the same demo server, namespaced `a_` and `b_`. // Proves the multi-server CLI wiring: both dispatches go through diff --git a/tests/mcp-reconnect.test.ts b/tests/mcp-reconnect.test.ts new file mode 100644 index 0000000..8e5b7ed --- /dev/null +++ b/tests/mcp-reconnect.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { McpClient } from "../src/mcp/client.js"; +import { reconnectMcpServer } from "../src/mcp/reconnect.js"; +import type { McpClientHost } from "../src/mcp/registry.js"; +import { StdioTransport } from "../src/mcp/stdio.js"; + +/** A throwaway client we can hand to the host without bothering to initialize — reconnect won't touch it on the parse-failure path. */ +function dummyHost(): McpClientHost { + const transport = new StdioTransport({ command: "true", args: [], shell: false }); + return { client: new McpClient({ transport, requestTimeoutMs: 1_000 }) }; +} + +describe("reconnectMcpServer — early-return paths", () => { + it("returns spec_parse when the spec string is empty", async () => { + const host = dummyHost(); + const r = await reconnectMcpServer({ host, spec: "", beforeTools: [] }); + expect(r.ok).toBe(false); + if (r.ok) throw new Error("unreachable"); + expect(r.reason).toBe("spec_parse"); + expect(r.message).toMatch(/empty MCP spec/); + await host.client.close(); + }); + + it("returns spec_parse when the spec has a name but no command", async () => { + const host = dummyHost(); + const r = await reconnectMcpServer({ host, spec: "fs=", beforeTools: [] }); + expect(r.ok).toBe(false); + if (r.ok) throw new Error("unreachable"); + expect(r.reason).toBe("spec_parse"); + expect(r.message).toMatch(/has name but no command/); + await host.client.close(); + }); + + // Handshake-failure path is platform-sensitive (Windows shell:true doesn't + // surface ENOENT synchronously). Exercised in mcp-integration.test.ts via + // the live demo server instead. +}); diff --git a/tests/slash.test.ts b/tests/slash.test.ts index fdb1732..9802bcf 100644 --- a/tests/slash.test.ts +++ b/tests/slash.test.ts @@ -786,6 +786,57 @@ describe("handleSlash", () => { expect(r.info).toMatch(/prompts\s+\(not supported\)/); }); + describe("/mcp reconnect", () => { + function summary(label: string, spec: string) { + // Stub host — slash dispatch only reads it; the async reconnect runs + // in the background and we only inspect the synchronous return. + const host = { client: {} as never }; + return { + label, + spec, + toolCount: 0, + host, + report: { + protocolVersion: "2024-11-05", + serverInfo: { name: label, version: "1.0.0" }, + capabilities: { tools: {} }, + tools: { supported: true as const, items: [] }, + resources: { supported: false as const, reason: "method not found" }, + prompts: { supported: false as const, reason: "method not found" }, + elapsedMs: 0, + }, + }; + } + + it("/mcp reconnect emits the lifecycle line on dispatch", () => { + const r = handleSlash("mcp", ["reconnect", "notion"], makeLoop(), { + mcpServers: [summary("notion", "notion=node nope")], + postInfo: () => { + /* swallowed for this test */ + }, + }); + expect(r.info).toMatch(/MCP · notion/); + expect(r.info).toMatch(/↻ reconnect…/); + }); + + it("/mcp reconnect rejects unknown name with the list of known", () => { + const r = handleSlash("mcp", ["reconnect", "ghost"], makeLoop(), { + mcpServers: [summary("notion", "notion=cmd"), summary("linear", "linear=cmd")], + postInfo: () => {}, + }); + expect(r.info).toMatch(/unknown MCP server "ghost"/); + expect(r.info).toMatch(/Known: linear, notion/); + }); + + it("/mcp reconnect with no name shows usage", () => { + const r = handleSlash("mcp", ["reconnect"], makeLoop(), { + mcpServers: [summary("notion", "notion=cmd")], + postInfo: () => {}, + }); + expect(r.info).toMatch(/usage: \/mcp reconnect /); + }); + }); + it("/mcp falls back to the spec-only list when mcpServers is absent", () => { const r = handleSlash("mcp", [], makeLoop(), { mcpSpecs: ["filesystem=npx -y @scope/fs /tmp"],