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
1 change: 1 addition & 0 deletions src/cli/commands/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
toolCount: bridge.registeredNames.length,
report,
host,
bridgeEnv: bridge.env,
});
} catch (err) {
// Per-server failure is non-fatal: one broken server shouldn't
Expand Down
2 changes: 2 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import {
import { StatusRow } from "./layout/StatusRow.js";
import { ToastRail } from "./layout/ToastRail.js";
import { formatLoopStatus } from "./loop.js";
import { applyMcpAppend } from "./mcp-append.js";
import { handleMcpBrowseSlash } from "./mcp-browse.js";
import { formatLongPaste } from "./paste-collapse.js";
import { resolvePreset } from "./presets.js";
Expand Down Expand Up @@ -3387,6 +3388,7 @@ function AppInner({
configPath={defaultConfigPath()}
onClose={() => setPendingMcpBrowser(false)}
postInfo={(text) => log.pushInfo(text)}
applyAppend={(target, addedTools) => applyMcpAppend(loop, target, addedTools)}
/>
) : pendingPlan ? (
<PlanConfirm
Expand Down
14 changes: 11 additions & 3 deletions src/cli/ui/McpBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { kickOffMcpReconnect } from "./mcp-reconnect-kickoff.js";
import { type ApplyAppend, kickOffMcpReconnect } from "./mcp-reconnect-kickoff.js";
import type { McpServerSummary } from "./slash/types.js";
import { COLOR } from "./theme.js";

Expand All @@ -14,9 +14,17 @@ export interface McpBrowserProps {
onClose: () => void;
/** Pushed by the modal when a key triggers async work (`r` reconnect). */
postInfo: (text: string) => void;
/** Optional — opt-in to append-drift acceptance on `r`. Without it, append-drift refuses. */
applyAppend?: ApplyAppend;
}

export function McpBrowser({ servers, configPath, onClose, postInfo }: McpBrowserProps) {
export function McpBrowser({
servers,
configPath,
onClose,
postInfo,
applyAppend,
}: McpBrowserProps) {
const [index, setIndex] = useState(0);
const max = Math.max(0, servers.length - 1);

Expand All @@ -31,7 +39,7 @@ export function McpBrowser({ servers, configPath, onClose, postInfo }: McpBrowse
// Hand the "starting" lifecycle line to scrollback and let the
// kickoff schedule the result line via postInfo. Close the modal
// so the line is visible immediately.
postInfo(kickOffMcpReconnect(target, postInfo));
postInfo(kickOffMcpReconnect(target, postInfo, applyAppend));
onClose();
}
});
Expand Down
40 changes: 40 additions & 0 deletions src/cli/ui/mcp-append.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** Applies an MCP append-drift mid-session: registers each new tool in the loop's registry + prefix, and updates the summary's report. */

import type { CacheFirstLoop } from "../../loop.js";
import { registerSingleMcpTool } from "../../mcp/registry.js";
import type { McpTool } from "../../mcp/types.js";
import type { JSONSchema, ToolSpec } from "../../types.js";
import type { McpServerSummary } from "./slash/types.js";

export function applyMcpAppend(
loop: CacheFirstLoop,
target: McpServerSummary,
addedTools: McpTool[],
): void {
const accepted: McpTool[] = [];
for (const mcpTool of addedTools) {
if (!mcpTool.name) continue;
const registeredName = registerSingleMcpTool(mcpTool, target.bridgeEnv);
if (!registeredName) continue;
const spec: ToolSpec = {
type: "function",
function: {
name: registeredName,
description: mcpTool.description ?? "",
parameters: mcpTool.inputSchema as unknown as JSONSchema,
},
};
loop.prefix.addTool(spec);
accepted.push(mcpTool);
}
if (accepted.length === 0) return;
// Refresh the summary's snapshot so `/mcp` and the browser modal show the
// new shape on their next render.
if (target.report.tools.supported) {
const merged = [...target.report.tools.items, ...accepted];
// biome-ignore lint/suspicious/noExplicitAny: report is a typed snapshot we mutate in place; deeper refactor isn't worth it here
(target.report.tools as any).items = merged;
// biome-ignore lint/suspicious/noExplicitAny: same — toolCount mirrors items.length post-append
(target as any).toolCount = merged.length;
}
}
18 changes: 17 additions & 1 deletion src/cli/ui/mcp-reconnect-kickoff.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
/** Shared async-fire-and-forget reconnect trigger — called by both `/mcp reconnect` and the McpBrowser `r` keybind. */

import { reconnectMcpServer } from "../../mcp/reconnect.js";
import type { McpTool } from "../../mcp/types.js";
import { formatMcpLifecycleEvent } from "./mcp-lifecycle.js";
import type { McpServerSummary } from "./slash/types.js";

/** Applies append-drift mid-session: registers each new MCP tool in the registry + prefix. */
export type ApplyAppend = (target: McpServerSummary, addedTools: McpTool[]) => void;

/** Kicks off async reconnect; returns the start-line, schedules result via postInfo. */
export function kickOffMcpReconnect(
target: McpServerSummary,
postInfo: (text: string) => void,
applyAppend?: ApplyAppend,
): string {
const beforeTools = target.report.tools.supported ? target.report.tools.items : [];
// Only opt into "append" when the caller wired an applyAppend handler;
// otherwise the reconnect refuses append-drift with a "restart" message.
const accept = applyAppend ? (["identity", "append"] as const) : (["identity"] as const);
void (async () => {
try {
const result = await reconnectMcpServer({
host: target.host,
spec: target.spec,
beforeTools,
accept,
});
if (result.ok) {
if (result.kind === "append" && applyAppend) {
applyAppend(target, result.addedTools);
}
postInfo(
formatMcpLifecycleEvent({
state: "connected",
name: target.label,
tools: beforeTools.length,
tools: result.afterTools.length,
ms: result.ms,
}),
);
if (result.kind === "append") {
const names = result.addedTools.map((t) => t.name).join(", ");
postInfo(`▸ ${target.label}: added ${result.addedTools.length} tool(s) — ${names}`);
}
} else {
postInfo(
formatMcpLifecycleEvent({
Expand Down
14 changes: 12 additions & 2 deletions src/cli/ui/slash/handlers/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { readConfig, writeConfig } from "../../../../config.js";
import type { CacheFirstLoop } from "../../../../loop.js";
import { applyMcpAppend } from "../../mcp-append.js";
import { kickOffMcpReconnect } from "../../mcp-reconnect-kickoff.js";
import type { SlashHandler } from "../dispatch.js";
import { appendSection } from "../helpers.js";
Expand All @@ -13,7 +15,7 @@ const mcp: SlashHandler = (args, loop, ctx) => {
return toggleDisabled(sub, args[1], { servers, specs });
}
if (sub === "reconnect") {
return triggerReconnect(args[1], servers, ctx.postInfo);
return triggerReconnect(args[1], servers, ctx.postInfo, loop);
}
// `/mcp text` (or non-TTY) falls through to the printed-card path. The
// default `/mcp` opens the interactive browser modal.
Expand Down Expand Up @@ -132,6 +134,7 @@ function triggerReconnect(
rawName: string | undefined,
servers: ReadonlyArray<McpServerSummary>,
postInfo: ((text: string) => void) | undefined,
loop: CacheFirstLoop,
): { info: string } {
const name = rawName?.trim();
if (!name) {
Expand All @@ -150,7 +153,14 @@ function triggerReconnect(
if (!postInfo) {
return { info: "/mcp reconnect requires the interactive TUI (postInfo not wired)." };
}
return { info: kickOffMcpReconnect(target, postInfo) };
// Append-drift accepted automatically: server added new tools, we register them
// and call addTool on the prefix (cache miss only on the appended chunks per the
// benchmarks/spike-mcp-reconnect data — typically <5% loss).
return {
info: kickOffMcpReconnect(target, postInfo, (t, addedTools) =>
applyMcpAppend(loop, t, addedTools),
),
};
}

export const handlers: Record<string, SlashHandler> = { mcp };
4 changes: 3 additions & 1 deletion 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 { InspectionReport } from "../../../mcp/inspect.js";
import type { McpClientHost } from "../../../mcp/registry.js";
import type { BridgeEnv, McpClientHost } from "../../../mcp/registry.js";
import type { JobRegistry } from "../../../tools/jobs.js";
import type { PlanStep } from "../../../tools/plan.js";

Expand Down Expand Up @@ -122,6 +122,8 @@ export interface McpServerSummary {
report: InspectionReport;
/** Mutable client handle so `/mcp reconnect` can swap the underlying socket without re-bridging tools. */
host: McpClientHost;
/** Captured at first-bridge time so append-drift reconnects can register newly-added tools with the same options. */
bridgeEnv: BridgeEnv;
}

export interface SlashCommandSpec {
Expand Down
43 changes: 33 additions & 10 deletions src/mcp/reconnect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** `/mcp reconnect` — open a fresh client, accept identity drift only, refuse the rest cleanly. */
/** `/mcp reconnect` — open a fresh client, accept identity (always) and append (opt-in), refuse the rest cleanly. */

import { McpClient } from "./client.js";
import { classifyToolListDrift } from "./drift.js";
Expand All @@ -16,10 +16,19 @@ export interface ReconnectArgs {
spec: string;
/** The current tool list, used as the drift baseline. */
beforeTools: readonly McpTool[];
/** Drift kinds the caller is willing to accept. Default: ["identity"]. */
accept?: ReadonlyArray<"identity" | "append">;
}

export type ReconnectResult =
| { ok: true; afterTools: McpTool[]; ms: number }
| {
ok: true;
kind: "identity" | "append";
afterTools: McpTool[];
/** Tools present in `afterTools` but not in `beforeTools` (empty for identity). */
addedTools: McpTool[];
ms: number;
}
| {
ok: false;
reason:
Expand All @@ -35,6 +44,7 @@ export type ReconnectResult =

export async function reconnectMcpServer(args: ReconnectArgs): Promise<ReconnectResult> {
const t0 = Date.now();
const accept = args.accept ?? ["identity"];
let parsed: McpSpec;
try {
parsed = parseMcpSpec(args.spec);
Expand All @@ -57,24 +67,37 @@ export async function reconnectMcpServer(args: ReconnectArgs): Promise<Reconnect
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.
// Identity is always free — accept it regardless of `accept`. The opt-in
// controls only whether append-drift also gets through.
const acceptedKind: "identity" | "append" | null =
drift.kind === "identity"
? "identity"
: drift.kind === "append" && accept.includes("append")
? "append"
: null;
if (acceptedKind === null) {
await next.close().catch(() => {});
const refused = drift.kind as Exclude<typeof drift.kind, "identity">;
return {
ok: false,
reason: driftReason(drift.kind),
reason: driftReason(refused),
message: driftMessage(drift),
ms: Date.now() - t0,
};
}
// Identity drift — safe to swap.
const addedTools =
acceptedKind === "append" ? listed.tools.filter((t) => drift.added.includes(t.name)) : [];
// Swap.
const old = args.host.client;
args.host.client = next;
await old.close().catch(() => {});
return { ok: true, afterTools: listed.tools, ms: Date.now() - t0 };
return {
ok: true,
kind: acceptedKind,
afterTools: listed.tools,
addedTools,
ms: Date.now() - t0,
};
} catch (err) {
await next.close().catch(() => {});
return {
Expand Down
79 changes: 56 additions & 23 deletions src/mcp/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,49 @@ export interface BridgeResult {
skipped: Array<{ name: string; reason: string }>;
}

/** Resolved bridge environment that `registerSingleMcpTool` needs. Stored on summaries so reconnect can append new tools later. */
export interface BridgeEnv {
registry: ToolRegistry;
host: McpClientHost;
prefix: string;
maxResultChars: number;
tracker: LatencyTracker | null;
onProgress?: BridgeOptions["onProgress"];
}

/** Register one MCP tool's bridged closure into the registry. Returns the registered name (or "" if skipped). */
export function registerSingleMcpTool(
mcpTool: import("./types.js").McpTool,
env: BridgeEnv,
): string {
if (!mcpTool.name) return "";
const registeredName = `${env.prefix}${mcpTool.name}`;
env.registry.register({
name: registeredName,
description: mcpTool.description ?? "",
parameters: mcpTool.inputSchema as JSONSchema,
fn: async (args: Record<string, unknown>, ctx) => {
const t0 = env.tracker ? Date.now() : 0;
// Resolve client at call time via the host indirection so `/mcp reconnect`
// can swap a fresh client in without re-bridging tools.
const live = env.host.client;
const toolResult = await live.callTool(mcpTool.name, args, {
onProgress: env.onProgress
? (info) => env.onProgress!({ toolName: registeredName, ...info })
: undefined,
signal: ctx?.signal,
});
if (env.tracker) env.tracker.record(Date.now() - t0);
return flattenMcpResult(toolResult, { maxChars: env.maxResultChars });
},
});
return registeredName;
}

export async function bridgeMcpTools(
client: McpClient,
opts: BridgeOptions = {},
): Promise<BridgeResult> {
): Promise<BridgeResult & { env: BridgeEnv }> {
const registry = opts.registry ?? new ToolRegistry({ autoFlatten: opts.autoFlatten });
const prefix = opts.namePrefix ?? "";
const maxResultChars = opts.maxResultChars ?? DEFAULT_MAX_RESULT_CHARS;
Expand All @@ -62,35 +101,29 @@ export async function bridgeMcpTools(
const tracker = opts.onSlow
? new LatencyTracker(serverName, { thresholdMs: opts.slowThresholdMs, onSlow: opts.onSlow })
: null;
// Synthesize a host on the fly when the caller didn't provide one. Older
// callers (tests, single-shot non-reconnectable bridges) get the live
// `client` reference frozen in; reconnect-aware callers pass their own
// mutable host.
const host: McpClientHost = opts.host ?? { client };
const env: BridgeEnv = {
registry,
host,
prefix,
maxResultChars,
tracker,
onProgress: opts.onProgress,
};
const listed = await client.listTools();
for (const mcpTool of listed.tools) {
if (!mcpTool.name) {
result.skipped.push({ name: "?", reason: "empty tool name" });
continue;
}
const registeredName = `${prefix}${mcpTool.name}`;
registry.register({
name: registeredName,
description: mcpTool.description ?? "",
parameters: mcpTool.inputSchema as JSONSchema,
fn: async (args: Record<string, unknown>, ctx) => {
const t0 = tracker ? Date.now() : 0;
// 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,
signal: ctx?.signal,
});
if (tracker) tracker.record(Date.now() - t0);
return flattenMcpResult(toolResult, { maxChars: maxResultChars });
},
});
result.registeredNames.push(registeredName);
const registeredName = registerSingleMcpTool(mcpTool, env);
if (registeredName) result.registeredNames.push(registeredName);
}
return result;
return { ...result, env };
}

export interface FlattenOptions {
Expand Down
Loading
Loading