Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/cli/commands/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -231,10 +231,15 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
: 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(
Expand Down Expand Up @@ -278,7 +283,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
spec: raw,
toolCount: bridge.registeredNames.length,
report,
client: mcp,
host,
});
} catch (err) {
// Per-server failure is non-fatal: one broken server shouldn't
Expand Down
18 changes: 2 additions & 16 deletions src/cli/ui/mcp-browse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));
Expand Down
5 changes: 4 additions & 1 deletion src/cli/ui/mcp-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpLifecycleEvent["state"], { glyph: string; label: string }> = {
handshake: { glyph: "↻", label: "handshake…" },
connected: { glyph: "✓", label: "connected" },
failed: { glyph: "✖", label: "failed" },
disabled: { glyph: "○", label: "disabled" },
reconnect: { glyph: "↻", label: "reconnect…" },
};

const NAME_COL = 22;
Expand All @@ -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`);
Expand Down
71 changes: 71 additions & 0 deletions src/cli/ui/slash/handlers/mcp.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [];
Expand All @@ -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";
Expand Down Expand Up @@ -123,4 +129,69 @@ function parseLabelFromSpec(spec: string): string | null {
return match ? (match[1] ?? null) : null;
}

function triggerReconnect(
rawName: string | undefined,
servers: ReadonlyArray<McpServerSummary>,
postInfo: ((text: string) => void) | undefined,
): { info: string } {
const name = rawName?.trim();
if (!name) {
return {
info: "usage: /mcp reconnect <name> · 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<string, SlashHandler> = { mcp };
6 changes: 3 additions & 3 deletions src/cli/ui/slash/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
120 changes: 120 additions & 0 deletions src/mcp/reconnect.ts
Original file line number Diff line number Diff line change
@@ -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<ReconnectResult> {
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<ReturnType<typeof classifyToolListDrift>["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<typeof classifyToolListDrift>): 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,
},
}));
}
21 changes: 11 additions & 10 deletions src/mcp/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,19 +75,13 @@ export async function bridgeMcpTools(
parameters: mcpTool.inputSchema as JSONSchema,
fn: async (args: Record<string, unknown>, 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);
Expand Down
10 changes: 8 additions & 2 deletions tests/mcp-browse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ function makeFakeLog() {
return { rows, log };
}

function server(partial: Partial<McpServerSummary> & { label: string }): McpServerSummary {
function server(
partial: Partial<McpServerSummary> & { 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" },
Expand All @@ -62,7 +68,7 @@ function server(partial: Partial<McpServerSummary> & { label: string }): McpServ
resources: { supported: true, items: [] },
prompts: { supported: true, items: [] },
},
...partial,
...rest,
};
}

Expand Down
Loading
Loading