UNPKG

pi-mcp-extension

Version:

Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.

699 lines (642 loc) • 31 kB
/** * Tool bridge for pi-mcp. * * Converts MCP tools to Pi tools and manages their lifecycle: * - Paginated tools/list (cursor loop per spec) * - JSON Schema → TypeBox conversion (common types + Type.Any() fallback) * - Tool name sanitization (Pi-compatible identifiers) * - Tool annotations → description hints + structured Pi `annotations` * - AbortSignal → SDK's built-in cancellation (notifications/cancelled) * - notifications/progress → sanitized, redacted live tool updates + inactivity timeout reset * - Protocol error vs tool execution error distinction * - Activate/deactivate pattern (register once, toggle on server state change) * - Images and embedded image resources → ImageContent; resource links → read hints */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { CallToolResultSchema, type Progress } from "@modelcontextprotocol/sdk/types.js"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { McpError, AuthCancelledError } from "./errors.js"; import { redactSecrets } from "./secret-redaction.js"; import { sanitizeTerminalText } from "./terminal-text.js"; import type { Settings } from "./config.js"; import { isCancellationError, isOAuthUnauthorizedError, isSdkTimeoutError } from "./connect-policy.js"; import { convertJsonSchemaToTypebox } from "./schema-convert.js"; import { listAllTools, type McpToolDefinition } from "./tool-discovery.js"; import { buildToolAnnotations, buildToolCallContent, buildToolDescription, toolErrorText, type ToolCallOutcome, type ToolCallResponse, } from "./tool-content.js"; import { orderToolsForNameAssignment, resolveUniqueToolName, } from "./tool-names.js"; import { orderOwnedEntries, orderPayloadToolEntries, sameToolList, } from "./tool-order.js"; export { buildToolName } from "./tool-names.js"; export { convertMcpContent } from "./tool-content.js"; // ─── Types ──────────────────────────────────────────────────────────────────── /** Subset of Pi's ExtensionAPI used by the bridge. */ export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools" | "setActiveTools">; export type ToolRefreshOptions = { requestTimeoutMs?: number | undefined; signal?: AbortSignal | undefined; }; // ─── Tool Call Execution ────────────────────────────────────────────────────── /** Pi's partial-result callback — the `onUpdate` argument of a tool's `execute`. */ export type ToolUpdateCallback = (update: ToolCallOutcome) => void; /** Per-call streaming and cancellation options for one `tools/call`. */ interface ToolCallOptions { signal: AbortSignal | undefined; onUpdate: ToolUpdateCallback | undefined; } /** Character budget for one progress line; Pi renders the latest line while the tool runs. */ const PROGRESS_MESSAGE_MAX_LENGTH = 200; /** * Render one MCP `notifications/progress` payload as a single Pi-visible line. * * `message` is server-controlled and lands in Pi's UI stream, so it is sanitized (ANSI and * control sequences stripped, length bounded) and stripped of token-like substrings first. * A server that sends no usable text gets a numeric `Progress 3/10` line instead. * * WHY: sanitize before redacting, as `sanitizeLogText` does — the sanitizer deletes escape * sequences, and an escape *inside* a credential (`\u001b[1mtoken\u001b[0m=sk-…`) hides the * `key=value` shape from `redactSecrets`, which would then pass the token through verbatim. */ export function formatProgressText(progress: Progress): string { const rawMessage = typeof progress.message === "string" ? progress.message : ""; // WHY: decide usability on the full text (`maxLength` = its own length disables truncation) and // trim surrounding whitespace first. `sanitizeTerminalText` appends `…` when it truncates and // turns trailing newlines into spaces, so untrimmed padding would either defeat the fallback, // consume the 200-char budget into a blank ` …` row, or split dedupe across `msg` vs `msg\n`. const message = sanitizeTerminalText(rawMessage, { maxLength: rawMessage.length }).trim(); if (message.length > 0) { // WHY: bound after redaction — a `[REDACTED]` mask is longer than the value it replaces, so // capping first would let the emitted line grow past the budget (or slice a token before the // redactor can recognise it). return sanitizeTerminalText(redactSecrets(message), { maxLength: PROGRESS_MESSAGE_MAX_LENGTH, }); } const current = Number.isFinite(progress.progress) ? String(progress.progress) : "?"; const total = Number.isFinite(progress.total) ? `/${progress.total}` : ""; return `Progress ${current}${total}`; } /** * Build the SDK `onprogress` handler for one `tools/call`. * * WHY: the handler is installed even when Pi passes no `onUpdate` callback. The SDK attaches * `_meta.progressToken` only when `onprogress` is set, and the timer reset that keeps a long, * actively-reporting tool alive (`resetTimeoutOnProgress`) hangs off that token (#254). * * Consecutive identical lines are dropped: some servers repeat one message on every tick, and * each emission re-renders Pi's in-progress tool row. */ function createProgressReporter( onUpdate: ToolUpdateCallback | undefined, ): (progress: Progress) => void { let lastText: string | undefined; return (progress: Progress): void => { if (!onUpdate) return; const text = formatProgressText(progress); if (text === lastText) return; lastText = text; onUpdate({ content: [{ type: "text", text }], details: {} }); }; } /** Call tools/call through the typed helper when present, else a raw JSON-RPC request. */ function callToolWithClient( client: Client, toolName: string, params: unknown, timeoutMs: number, options: ToolCallOptions, ): Promise<ToolCallResponse> { const requestOptions = { timeout: timeoutMs, // WHY: without these two the SDK never attaches a progress token, so it drops the server's // `notifications/progress` as an unknown token and keeps the original inactivity deadline — // a tool that reports progress every few seconds was still killed at `requestTimeoutMs` (#254). resetTimeoutOnProgress: true, onprogress: createProgressReporter(options.onUpdate), // Pass AbortSignal to SDK — it will automatically send // notifications/cancelled when the signal fires ...(options.signal ? { signal: options.signal } : {}), }; if (typeof client.callTool === "function") { return client.callTool( { name: toolName, arguments: params as Record<string, unknown> }, CallToolResultSchema, requestOptions, ); } return client.request( { method: "tools/call", params: { name: toolName, arguments: params }, }, CallToolResultSchema, requestOptions, ); } /** Normalize `refreshTools`' overloaded argument shapes (callback vs. options object). */ function normalizeRefreshArgs( isCurrentOrOptions?: (() => boolean) | ToolRefreshOptions, maybeOptions?: ToolRefreshOptions, ): { isCurrent: () => boolean; options: ToolRefreshOptions | undefined } { const isCurrent = typeof isCurrentOrOptions === "function" ? isCurrentOrOptions : () => true; const options = typeof isCurrentOrOptions === "object" ? isCurrentOrOptions : maybeOptions; return { isCurrent, options }; } // ─── Tool Listing ───────────────────────────────────────────────────────────── export { listAllTools, type McpToolDefinition } from "./tool-discovery.js"; // ─── Tool Bridge ────────────────────────────────────────────────────────────── /** * Manages MCP tools as Pi tools for a set of servers. * Tools are registered once and activated/deactivated as servers connect/disconnect. */ export class ToolBridge { private settings: Settings; private readonly pi: PiExtensionAPI; /** Tracks which Pi tool names belong to which MCP server. */ private readonly serverToolNames = new Map<string, Set<string>>(); /** Tracks which MCP server owns each registered Pi tool name. */ private readonly toolOwners = new Map<string, string>(); /** * Servers whose tools this bridge has activated and not deactivated since. * A server that is still in this set may have had individual tools removed from Pi's * active set by another extension or a user policy, and a refresh must not undo that. */ private readonly activeServers = new Set<string>(); /** * Per server, whether each raw MCP tool name was active in Pi when the current connection * stopped advertising it. A re-advertised tool is not "new", so it must come back in the * state it left in — a tool a policy had deactivated stays inactive; one the bridge * deactivated when the server dropped it returns active. */ private readonly droppedToolState = new Map<string, Map<string, boolean>>(); /** * Per server, the raw MCP tool name behind each Pi tool name in the latest snapshot, so * tool state follows the MCP tool's identity even when a colliding sibling shifts its Pi * name or a new tool recycles a dropped tool's Pi name. */ private readonly rawToolNames = new Map<string, Map<string, string>>(); /** Monotonic per-server generation counter to discard out-of-order refreshTools responses. */ private readonly refreshGenerations = new Map<string, number>(); /** Per-server request timeout overrides resolved during refreshTools. */ private readonly serverTimeouts = new Map<string, number>(); private onAuthError?: ((serverName: string, error: unknown) => Promise<void> | void) | undefined; constructor(settings: Settings, pi: PiExtensionAPI) { this.settings = settings; this.pi = pi; } /** Register callback invoked when a mid-session tools/list or tools/call fails with 401/403. */ setAuthErrorCallback(cb: (serverName: string, error: unknown) => Promise<void> | void): void { this.onAuthError = cb; } /** Update bridge settings when session config is reloaded on session_start. */ updateSettings(settings: Settings): void { const prevDefaultTimeoutMs = this.settings.requestTimeoutMs; this.settings = settings; for (const [serverName, timeoutMs] of this.serverTimeouts.entries()) { if (timeoutMs === prevDefaultTimeoutMs) { this.serverTimeouts.delete(serverName); } } } /** * Refresh tools for a server — called on initial connect and on list_changed. * Always re-registers tools with the current client reference so that * tool execute closures capture the latest client after reconnection. * Deactivates tools that are no longer in the server's list. * Note: Pi's registerTool() overwrites by name (Map.set), so re-registration is safe. */ async refreshTools( serverName: string, client: Client, isCurrentOrOptions?: (() => boolean) | ToolRefreshOptions, maybeOptions?: ToolRefreshOptions, ): Promise<void> { const { isCurrent, options } = normalizeRefreshArgs(isCurrentOrOptions, maybeOptions); const effectiveTimeoutMs = this._recordServerTimeout(serverName, options?.requestTimeoutMs); const gen = this._bumpRefreshGeneration(serverName); const tools = await this._fetchToolsForServer( serverName, client, effectiveTimeoutMs, options?.signal, ); // WHY: Discard stale tools/list responses if a newer refreshTools(), // deactivateServer(), or removeServerTools() occurred while listAllTools() was in flight. if (this.refreshGenerations.get(serverName) !== gen || !isCurrent()) return; this._reconcileTools(serverName, tools, client); } /** Report discovery separately from the tools currently exposed by Pi. */ getToolActivity(serverName: string): { discovered: number; active: number } { const names = this.serverToolNames.get(serverName) ?? new Set<string>(); const active = new Set(this.pi.getActiveTools()); return { discovered: names.size, active: [...names].filter((name) => active.has(name)).length }; } /** * Re-apply this bridge's deterministic tool order to Pi's active list when owned tools have * been reordered externally, and do nothing when already ordered. * * WHY: `/tree` navigation (`session_tree`) restores active tools from the transcript's * first-appearance order without re-running `refreshTools`, and another extension or tool * (e.g. `tool_search`) may call `pi.setActiveTools` idle or mid-turn; re-normalizing on * `session_tree`, `input`, `before_agent_start`, and `tool_execution_end` keeps `sections.tools` * in the system prompt aligned with `orderPayloadTools` without re-activating inactive tools (#278). */ orderActiveTools(): void { const current = this.pi.getActiveTools(); const ordered = orderOwnedEntries( current, (name) => name, (name) => this.toolOwners.has(name), ); if (ordered === undefined) return; this.pi.setActiveTools(ordered); } /** * Order this bridge's tools in an outgoing provider payload the same way they are ordered in * Pi's active list — across top-level `tools` (Anthropic, OpenAI Chat Completions & Responses, * Mistral), AWS Bedrock Converse `toolConfig.tools`, Google/Vertex * `config.tools[].functionDeclarations`, and `pi-messages` `context.messages[].toolsAdded` — * and return the payload untouched when they already are. Only entries this bridge owns move, * and only among the slots they already occupy: another extension's tool and Pi's reserved * trailing declarations (`__pi_deferred_placeholder__`, `cachePoint`, non-function Gemini tool * groups, non-system messages) keep their positions. * * WHY: request tool arrays are not built from the active list — Pi replays the transcript's * tool declarations in first-appearance order and emits no delta for a pure reorder, so a * server that connects after the first turn leaves its tools at the end, in connect order. * Sorting the payload makes the declared tools a function of the tool set, so the prompt head * stays stable across sessions and a server-side prefix cache keeps its KV entries (#278). */ orderPayloadTools(payload: unknown): unknown { return orderPayloadToolEntries(payload, (name) => this.toolOwners.has(name)); } /** Deactivate all Pi tools belonging to a server (called on disconnect). */ deactivateServer(serverName: string): void { this._bumpRefreshGeneration(serverName); this.activeServers.delete(serverName); // WHY: stopping the server turns every tool off, so what each one "left in" belongs to a // connection that no longer exists; a fresh connect rediscovers the state from scratch. this.droppedToolState.delete(serverName); this.rawToolNames.delete(serverName); this._deactivateServerTools(serverName); } /** Remove all tracking data for a server (called when config changes remove a server). */ removeServer(serverName: string): void { this.removeServerTools(serverName); } /** Remove all tools and ownership tracking for a server. */ removeServerTools(serverName: string): void { this._bumpRefreshGeneration(serverName); this.serverTimeouts.delete(serverName); this.activeServers.delete(serverName); this.droppedToolState.delete(serverName); this.rawToolNames.delete(serverName); this._deactivateServerTools(serverName); const existing = this.serverToolNames.get(serverName); if (existing) { for (const toolName of existing) { if (this.toolOwners.get(toolName) === serverName) { this.toolOwners.delete(toolName); } } this.serverToolNames.delete(serverName); } } // ─── Internal ─────────────────────────────────────────────────────────────── /** Advance the per-server refresh generation so in-flight refreshes are discarded. */ private _bumpRefreshGeneration(serverName: string): number { const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1; this.refreshGenerations.set(serverName, gen); return gen; } /** * Record (or clear) the per-server request timeout override and return the * timeout this refresh must use. */ private _recordServerTimeout(serverName: string, explicitTimeoutMs: number | undefined): number { if (explicitTimeoutMs !== undefined) { this.serverTimeouts.set(serverName, explicitTimeoutMs); } else { this.serverTimeouts.delete(serverName); } return explicitTimeoutMs ?? this.settings.requestTimeoutMs; } /** * Discover a server's tools, normalizing aborts into {@link AuthCancelledError}, * SDK request timeouts into `McpError("timeout")`, and every other failure into * `McpError("protocol")`. */ private async _fetchToolsForServer( serverName: string, client: Client, effectiveTimeoutMs: number, effectiveSignal: AbortSignal | undefined, ): Promise<McpToolDefinition[]> { try { return await listAllTools(client, effectiveTimeoutMs, effectiveSignal, serverName); } catch (err) { if (isCancellationError(err, effectiveSignal)) { throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName); } if (isOAuthUnauthorizedError(err)) { await this.onAuthError?.(serverName, err); } // WHY: an SDK request timeout is retryable — `classifyConnectFailure` fast-fails on // "protocol", so mislabelling it would skip `maxRetries` and report a discovery // timeout as a protocol violation (#209). const code = isSdkTimeoutError(err) ? "timeout" : "protocol"; throw new McpError( `Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`, serverName, code, err, ); } } /** * Register the freshly discovered tools, release ownership of any Pi names the * server no longer uses, and reconcile Pi's active tool set. */ private _reconcileTools(serverName: string, tools: McpToolDefinition[], client: Client): void { const registeredForServer = this.serverToolNames.get(serverName) ?? new Set<string>(); const ownedPreviousNames = this._ownedTools(serverName, registeredForServer); // WHY: Copy before calling `this._registerTool` — Pi's `registerTool()` runs // `_refreshToolRegistry()`, which auto-activates newly registered tool names and // `--tools` allowlist matches while the registration loop is still running. const activeBefore = [...this.pi.getActiveTools()]; const isLiveRefresh = this.activeServers.has(serverName); const previousStateByRawName = isLiveRefresh ? this._snapshotActiveStateByRawName(serverName, ownedPreviousNames, new Set(activeBefore)) : undefined; const currentToolNames = new Set<string>(); const currentRawNames = new Map<string, string>(); // WHY: `tools/list` order carries no meaning (a server may reorder it freely), so colliding // names are assigned over a deterministic order of the tool set — otherwise the exact-match // tool could lose the unhashed name to a lossy one and a later refresh would swap which MCP // tool that name executes (#213). for (const tool of orderToolsForNameAssignment(this.settings.toolPrefix, serverName, tools)) { const piName = resolveUniqueToolName({ prefix: this.settings.toolPrefix, serverName, rawToolName: tool.name, seenInBatch: currentToolNames, toolOwners: this.toolOwners, }); currentToolNames.add(piName); currentRawNames.set(piName, tool.name); this.toolOwners.set(piName, serverName); // Always re-register — on reconnect the client reference changes and // Pi's registerTool overwrites by name, so this is idempotent. this._registerTool(piName, serverName, tool, client); } for (const existingName of ownedPreviousNames) { if (!currentToolNames.has(existingName)) { this.toolOwners.delete(existingName); } } // WHY: `pi.setActiveTools` is shared with every other extension and the user, so an already // connected server's tool set may have been narrowed on purpose. A live `tools/list_changed` // therefore preserves each raw tool's active/inactive state (including across collision // re-settlement and drop/re-advertisement), while a first connect or a reconnect after // `deactivateServer` activates the whole discovered set (#214). const desiredActive = previousStateByRawName ? this._resolveLiveDesiredActive(serverName, currentRawNames, previousStateByRawName) : currentToolNames; this.serverToolNames.set(serverName, currentToolNames); this.rawToolNames.set(serverName, currentRawNames); this.activeServers.add(serverName); this._reconcileActiveTools(ownedPreviousNames, desiredActive, activeBefore); } private _registerTool( piName: string, serverName: string, tool: McpToolDefinition, client: Client, ): void { const annotations = tool.annotations; const description = buildToolDescription(tool, annotations); const toolAnnotations = buildToolAnnotations(annotations); const runTool = ( params: unknown, signal: AbortSignal | undefined, onUpdate: ToolUpdateCallback | undefined, ) => this._executeToolCall(serverName, tool.name, client, params, { signal, onUpdate }); // WHY: `execute` runs with `this` bound to the registered-tool literal rather than the // bridge, so the rethrow helper is reached through a captured closure. const rethrowToolCallError = (err: unknown): Promise<never> => this._rethrowToolCallError(serverName, err); this.pi.registerTool({ name: piName, label: annotations?.title ?? tool.name, description, promptSnippet: description.slice(0, 120), parameters: convertJsonSchemaToTypebox(tool.inputSchema), ...(toolAnnotations ? { annotations: toolAnnotations } : {}), async execute(_toolCallId, params, signal, onUpdate, _ctx) { try { return await runTool(params, signal, onUpdate); } catch (err) { return await rethrowToolCallError(err); } }, }); } /** * Normalize a `tools/call` failure into the `McpError` callers see, after notifying the * OAuth handler when the failure means the server needs authorization. */ private async _rethrowToolCallError(serverName: string, err: unknown): Promise<never> { if (err instanceof McpError) throw err; if (isOAuthUnauthorizedError(err)) { // WHY: read the handler through `this` at call time — src/index.ts installs it after the // bridge is constructed, and it is the caller for every server, so it takes the name. await this.onAuthError?.(serverName, err); } // Protocol-level errors (JSON-RPC error response, malformed payload) keep // "protocol"; an SDK request timeout keeps its own code so callers can tell // "the server answered badly" from "the server did not answer in time" (#209). throw new McpError( err instanceof Error ? err.message : String(err), serverName, isSdkTimeoutError(err) ? "timeout" : "protocol", err, ); } /** * Run one `tools/call`: dispatch through the SDK client, convert the result * into Pi content blocks, and surface `isError` as `McpError(..., "tool")`. */ private async _executeToolCall( serverName: string, toolName: string, client: Client, params: unknown, options: ToolCallOptions, ): Promise<ToolCallOutcome> { if (options.signal?.aborted) { throw new McpError("Tool execution cancelled", serverName, "protocol"); } let result: ToolCallResponse; try { result = await callToolWithClient( client, toolName, params, this.serverTimeouts.get(serverName) ?? this.settings.requestTimeoutMs, options, ); } catch (err) { // WHY: an abort mid-call is rejected by the SDK with its RequestTimeout error, so the // cancellation has to be recognised here — otherwise a caller cancellation is reported // as a server timeout, and blamed on the server (#209). if (isCancellationError(err, options.signal)) { throw new McpError("Tool execution cancelled", serverName, "protocol"); } throw err; } const content = buildToolCallContent(result); // Tool execution errors (isError: true) — distinct from protocol errors if (result.isError) { throw new McpError(toolErrorText(content) || "Tool reported an error", serverName, "tool"); } return { content, details: {} }; } /** Filter a set of Pi tool names to those still owned by `serverName`. */ private _ownedTools(serverName: string, toolNames: ReadonlySet<string>): Set<string> { const owned = new Set<string>(); for (const toolName of toolNames) { const owner = this.toolOwners.get(toolName); if (owner === undefined || owner === serverName) { owned.add(toolName); } } return owned; } /** * Map each raw MCP tool name from the previous snapshot to whether Pi currently has * its assigned tool name active. */ private _snapshotActiveStateByRawName( serverName: string, ownedPreviousNames: ReadonlySet<string>, activeBefore: ReadonlySet<string>, ): Map<string, boolean> { const rawNamesBefore = this.rawToolNames.get(serverName); const stateByRawName = new Map<string, boolean>(); for (const piName of ownedPreviousNames) { const rawName = rawNamesBefore?.get(piName) ?? piName; stateByRawName.set(rawName, activeBefore.has(piName)); } return stateByRawName; } /** * Compute which Pi tool names in `currentRawNames` should be active after a live refresh, * and update the server's dropped-tool memory. * * A tool present in the previous snapshot keeps the active/inactive state Pi currently * reports for it (even if a colliding sibling shifted its Pi name). A tool absent from the * previous snapshot restores the state it left in when dropped on this connection, or * activates as newly discovered. */ private _resolveLiveDesiredActive( serverName: string, currentRawNames: ReadonlyMap<string, string>, previousStateByRawName: ReadonlyMap<string, boolean>, ): Set<string> { const dropped = this.droppedToolState.get(serverName); const desiredActive = new Set<string>(); const seenRawNames = new Set<string>(); for (const [piName, rawName] of currentRawNames) { seenRawNames.add(rawName); const shouldActivate = previousStateByRawName.get(rawName) ?? dropped?.get(rawName) ?? true; if (shouldActivate) { desiredActive.add(piName); } } this._updateDroppedTools(serverName, previousStateByRawName, seenRawNames); return desiredActive; } /** * Record the active state of tools dropped in this live refresh and forget tools that are * currently advertised. */ private _updateDroppedTools( serverName: string, previousStateByRawName: ReadonlyMap<string, boolean>, currentRawNames: ReadonlySet<string>, ): void { const dropped = this.droppedToolState.get(serverName) ?? new Map<string, boolean>(); for (const [rawName, wasActive] of previousStateByRawName) { if (!currentRawNames.has(rawName)) { dropped.set(rawName, wasActive); } } for (const rawName of currentRawNames) { dropped.delete(rawName); } if (dropped.size > 0) { this.droppedToolState.set(serverName, dropped); } else { this.droppedToolState.delete(serverName); } } /** * Reconcile a server's active tools in Pi in one write: drop any previously owned Pi tool * names that are not in `desiredActive`, append any `desiredActive` names not yet active, * order the result, and skip `pi.setActiveTools` altogether when the active list already * holds exactly that set in that order. */ private _reconcileActiveTools( ownedPreviousNames: ReadonlySet<string>, desiredActive: ReadonlySet<string>, activeBefore: readonly string[], ): void { const activeSet = new Set(activeBefore); const remaining = activeBefore.filter( (name) => !ownedPreviousNames.has(name) || desiredActive.has(name), ); const newlyActive = [...desiredActive].filter((name) => !activeSet.has(name)); const nextActive = this._orderActiveTools(new Set([...remaining, ...newlyActive])); // WHY: `pi.registerTool()` inside the loop may have auto-activated a newly registered hashed // Pi name or a `--tools` allowlist entry, and a resumed session restores the order it ended // with — so compare against a fresh read and write whenever the set *or* the order differs. if (sameToolList(this.pi.getActiveTools(), nextActive)) return; this.pi.setActiveTools(nextActive); } /** * Order the active set so this bridge's tools are a pure function of the tool set: they are * sorted by Pi name among the positions they already occupy, while every entry another * extension owns keeps its position. * * WHY: a server's tools are registered as its connection completes, and `desiredActive` * follows `tools/list` order, which the MCP spec does not define. Without a fixed order the * active list — and with it the system prompt's tool section and the tools declared to the * provider — depends on eager-connect timing, so a server-side prefix cache keyed on the * prompt head misses on every session start or resume (#278). */ private _orderActiveTools(active: ReadonlySet<string>): string[] { const names = [...active]; const ordered = orderOwnedEntries(names, (name) => name, (name) => this.toolOwners.has(name)); return ordered ?? names; } private _deactivateServerTools(serverName: string): void { const serverTools = this.serverToolNames.get(serverName); if (!serverTools || serverTools.size === 0) return; const ownedByServer = this._ownedTools(serverName, serverTools); if (ownedByServer.size === 0) return; this._reconcileActiveTools(ownedByServer, new Set(), this.pi.getActiveTools()); } }