pi-mcp-adapter 2.26.0 → 2.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +6 -4
- package/commands.ts +26 -0
- package/index.ts +71 -27
- package/init.ts +26 -2
- package/lifecycle.ts +257 -24
- package/package.json +1 -1
- package/server-manager.ts +121 -1
- package/session-recovery.ts +7 -0
- package/tool-approval.ts +15 -2
- package/tool-result-renderer.ts +44 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.26.1] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Scoped “Allow for session” tool approvals to the approved arguments, so one approval no longer applies to later calls with different inputs. Thanks to [@spaceshipmike](https://github.com/spaceshipmike) for #367.
|
|
14
|
+
- Stopped MCP panel commands from hanging in RPC, JSON, and print modes when terminal-only UI is unavailable. Thanks to [@shixin-guo](https://github.com/shixin-guo) for PR #365.
|
|
15
|
+
- Kept compact MCP result rows useful by showing a short input preview and skipping leading blank output in collapsed previews.
|
|
16
|
+
- Recovered MCP gateway requests nested inside proxy `args` instead of showing status, and now rejects invalid nested gateway requests with guidance. Thanks to [@ibrmora](https://github.com/ibrmora) for #363.
|
|
17
|
+
- Kept remote keep-alive tool catalogs fresh across server restarts, so long-lived Pi sessions can discover replacement tools without restarting. Thanks to [@dmorn](https://github.com/dmorn) for #369 and PR #370.
|
|
18
|
+
|
|
10
19
|
## [2.26.0] - 2026-08-14
|
|
11
20
|
|
|
12
21
|
### Added
|
package/README.md
CHANGED
|
@@ -293,8 +293,10 @@ You can also pass only the `code` query parameter with `args: { code: "..." }`.
|
|
|
293
293
|
|
|
294
294
|
- **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
|
|
295
295
|
- **`eager`** — Connect at startup but don't auto-reconnect if the connection drops. No idle timeout by default (set `idleTimeout` explicitly to enable).
|
|
296
|
-
- **`keep-alive`** — Connect at startup.
|
|
297
|
-
- **`lazy-keep-alive`** — Don't connect at startup. Connect on first tool call (like `lazy`). Once spawned, never idle-shut down and
|
|
296
|
+
- **`keep-alive`** — Connect at startup. Remote HTTP servers refresh their tool catalog during health checks, before user input, and before adapter-triggered turns, reconnecting when the server reports that the session expired. No idle timeout. Use for servers you always need available.
|
|
297
|
+
- **`lazy-keep-alive`** — Don't connect at startup. Connect on first tool call (like `lazy`). Once spawned, never idle-shut down and use the same catalog refresh and reconnect checks as `keep-alive`. Use for servers that are expensive to start but should stay resident after their first use.
|
|
298
|
+
|
|
299
|
+
For remote HTTP keep-alive servers, the authoritative `tools/list` refresh is also the fallback when `list_changed` notifications are unavailable or their stream is lost. Each `tools/list` or `ping` request is capped at 5 seconds, up to 10 servers are checked concurrently, and transient failures use bounded backoff. A successful refresh updates metadata without reconnecting; a response proving that the HTTP session expired triggers a full reconnect and reinstalls the notification handlers. Dynamic direct-tool registration follows the refreshed metadata unless `freezeDirectTools` is enabled.
|
|
298
300
|
|
|
299
301
|
When any enabled server uses `eager` or `keep-alive`, initialization also starts when the extension loads. This supports hosts that embed Pi programmatically and never emit `session_start`; if a session does start later, the session-owned runtime supersedes the load-time runtime.
|
|
300
302
|
|
|
@@ -658,7 +660,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
|
|
|
658
660
|
|
|
659
661
|
`mcp({ connect: "server-name" })` refreshes an already connected server, so new tools, resources, prompts, and instructions can load without restarting Pi.
|
|
660
662
|
|
|
661
|
-
MCP proxy and direct-tool results use compact self-rendered rows by default. Collapsed success output shows the call title and the first result line, with a `Ctrl+O to expand` hint when more text is hidden. The full result remains available when expanded and is still returned unchanged to the model. Set `settings.toolResultRendering` to `"boxed"` to restore the legacy boxed Pi row, or set `settings.collapsedResultLines` to `2` or `3` when you want more collapsed text.
|
|
663
|
+
MCP proxy and direct-tool results use compact self-rendered rows by default. Collapsed success output shows the call title, a bounded one-line input preview when arguments exist, and the first result line, with a `Ctrl+O to expand` hint when more text is hidden. The full result remains available when expanded and is still returned unchanged to the model. Set `settings.toolResultRendering` to `"boxed"` to restore the legacy boxed Pi row, or set `settings.collapsedResultLines` to `2` or `3` when you want more collapsed text.
|
|
662
664
|
|
|
663
665
|
Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are ranked by weighted matches across name, server, description, and any configured `searchKeywords`, then returned one page at a time (`limit` defaults to 12). Use `details.nextOffset` for the next page. Regex search is still available with `regex: true`, but regex results are paginated without ranking.
|
|
664
666
|
|
|
@@ -725,7 +727,7 @@ Advertised tool `outputSchema` values support JSON Schema draft-07 and 2020-12.
|
|
|
725
727
|
- Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use
|
|
726
728
|
- npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
|
|
727
729
|
- MCP server validates arguments, not the adapter
|
|
728
|
-
-
|
|
730
|
+
- Remote keep-alive servers force-refresh their tool catalog during health checks, before user input, and before adapter-triggered turns, with bounded reconnect backoff
|
|
729
731
|
- Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
|
|
730
732
|
|
|
731
733
|
## Limitations
|
package/commands.ts
CHANGED
|
@@ -28,6 +28,19 @@ function terminalHyperlink(label: string, url: string): string {
|
|
|
28
28
|
return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* True when this run mode can display a `ctx.ui.custom()` overlay.
|
|
33
|
+
*
|
|
34
|
+
* `ctx.hasUI` only reports that *some* UI context is bound. In rpc/print mode
|
|
35
|
+
* that context is a headless stub whose `custom()` returns immediately without
|
|
36
|
+
* ever invoking the factory or the `done` callback, so a panel awaited through
|
|
37
|
+
* `new Promise(resolve => ctx.ui.custom(...))` never settles and the command
|
|
38
|
+
* hangs. Overlay panels therefore additionally require `ctx.mode === "tui"`.
|
|
39
|
+
*/
|
|
40
|
+
function canRenderPanel(ctx: ExtensionContext): boolean {
|
|
41
|
+
return ctx.hasUI && ctx.mode === "tui";
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
32
45
|
if (!ctx.hasUI) return;
|
|
33
46
|
|
|
@@ -399,6 +412,10 @@ export async function openMcpSetup(
|
|
|
399
412
|
options: { includeHostConfigs?: boolean } = {},
|
|
400
413
|
): Promise<PanelFlowResult> {
|
|
401
414
|
if (!ctx.hasUI) return { configChanged: false };
|
|
415
|
+
if (!canRenderPanel(ctx)) {
|
|
416
|
+
ctx.ui.notify(`The interactive MCP setup panel is only available in the terminal UI (current mode: ${ctx.mode}). Edit .mcp.json directly, or run /mcp status to review servers.`, "info");
|
|
417
|
+
return { configChanged: false };
|
|
418
|
+
}
|
|
402
419
|
if (state.programmaticConfig) {
|
|
403
420
|
ctx.ui.notify("MCP setup is unavailable when config is supplied by createMcpAdapter().", "info");
|
|
404
421
|
return { configChanged: false };
|
|
@@ -533,6 +550,11 @@ export async function openMcpPanel(
|
|
|
533
550
|
}
|
|
534
551
|
return { configChanged: false };
|
|
535
552
|
}
|
|
553
|
+
if (!canRenderPanel(ctx)) {
|
|
554
|
+
// No overlay here, but the same information is available as text.
|
|
555
|
+
await showStatus(state, ctx);
|
|
556
|
+
return { configChanged: false };
|
|
557
|
+
}
|
|
536
558
|
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
537
559
|
return openMcpSetup(state, pi, ctx, configOverridePath, "empty", { includeHostConfigs: false });
|
|
538
560
|
}
|
|
@@ -587,6 +609,10 @@ export async function openMcpAuthPanel(
|
|
|
587
609
|
configOverridePath?: string,
|
|
588
610
|
): Promise<PanelFlowResult> {
|
|
589
611
|
if (!ctx.hasUI) return { configChanged: false };
|
|
612
|
+
if (!canRenderPanel(ctx)) {
|
|
613
|
+
ctx.ui.notify(`The interactive MCP auth panel is only available in the terminal UI (current mode: ${ctx.mode}). Use /mcp-auth <server> to authenticate a specific server.`, "info");
|
|
614
|
+
return { configChanged: false };
|
|
615
|
+
}
|
|
590
616
|
if (state.programmaticConfig) {
|
|
591
617
|
ctx.ui.notify("Use /mcp-auth <server> to authenticate a server from the in-memory SDK config.", "info");
|
|
592
618
|
return { configChanged: false };
|
package/index.ts
CHANGED
|
@@ -417,6 +417,29 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
417
417
|
}
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
+
pi.on("input", async () => {
|
|
421
|
+
const inputOwner = currentOwner;
|
|
422
|
+
if (!inputOwner?.isActive()) return;
|
|
423
|
+
|
|
424
|
+
if (!state && initPromise) {
|
|
425
|
+
try {
|
|
426
|
+
await awaitWithTimeout(initPromise, INIT_WAIT_TIMEOUT_MS);
|
|
427
|
+
} catch {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const inputState = state;
|
|
433
|
+
if (!inputState || !inputOwner.isActive()) return;
|
|
434
|
+
try {
|
|
435
|
+
await inputState.lifecycle.ensureConverged(inputOwner.signal);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (!isAbortError(error, inputOwner.signal)) {
|
|
438
|
+
logger.debug(`MCP: keep-alive convergence failed before input: ${formatTerminalError(error)}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
420
443
|
pi.on("session_shutdown", async () => {
|
|
421
444
|
++lifecycleGeneration;
|
|
422
445
|
const currentState = state;
|
|
@@ -737,12 +760,12 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
737
760
|
action?: string;
|
|
738
761
|
}, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, _ctx: ExtensionContext) {
|
|
739
762
|
const executeOwner = currentOwner;
|
|
740
|
-
|
|
741
|
-
|
|
763
|
+
const parseArgs = (value: string | Record<string, unknown> | undefined): Record<string, unknown> | undefined => {
|
|
764
|
+
if (value === undefined || value === "") return undefined;
|
|
742
765
|
let args: unknown;
|
|
743
|
-
if (typeof
|
|
766
|
+
if (typeof value === "string") {
|
|
744
767
|
try {
|
|
745
|
-
args = JSON.parse(
|
|
768
|
+
args = JSON.parse(value);
|
|
746
769
|
} catch (error) {
|
|
747
770
|
if (error instanceof SyntaxError) {
|
|
748
771
|
throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
|
|
@@ -750,14 +773,35 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
750
773
|
throw error;
|
|
751
774
|
}
|
|
752
775
|
} else {
|
|
753
|
-
args =
|
|
776
|
+
args = value;
|
|
754
777
|
}
|
|
755
778
|
|
|
756
779
|
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
757
780
|
const gotType = Array.isArray(args) ? "array" : args === null ? "null" : typeof args;
|
|
758
781
|
throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
|
|
759
782
|
}
|
|
760
|
-
|
|
783
|
+
return args as Record<string, unknown>;
|
|
784
|
+
};
|
|
785
|
+
let parsedArgs = parseArgs(params.args);
|
|
786
|
+
let dispatchParams = params;
|
|
787
|
+
const hasGatewayMode = (value: typeof params): boolean =>
|
|
788
|
+
value.tool !== undefined
|
|
789
|
+
|| value.connect !== undefined
|
|
790
|
+
|| value.describe !== undefined
|
|
791
|
+
|| value.instructions !== undefined
|
|
792
|
+
|| value.search !== undefined
|
|
793
|
+
|| value.server !== undefined
|
|
794
|
+
|| value.action !== undefined;
|
|
795
|
+
if (!hasGatewayMode(params) && parsedArgs) {
|
|
796
|
+
const nestedParams = parsedArgs as typeof params;
|
|
797
|
+
if (hasGatewayMode(nestedParams)) {
|
|
798
|
+
dispatchParams = nestedParams;
|
|
799
|
+
parsedArgs = parseArgs(nestedParams.args);
|
|
800
|
+
} else {
|
|
801
|
+
throw new Error("Gateway params were nested inside `args`; pass them top-level (for example, mcp({ search: \"...\" }) or mcp({ tool: \"...\", args: {} })).");
|
|
802
|
+
}
|
|
803
|
+
} else if (!hasGatewayMode(params) && params.args !== undefined) {
|
|
804
|
+
throw new Error("Gateway params were nested inside `args`; pass them top-level (for example, mcp({ search: \"...\" }) or mcp({ tool: \"...\", args: {} })).");
|
|
761
805
|
}
|
|
762
806
|
|
|
763
807
|
if (!state && initPromise) {
|
|
@@ -788,22 +832,22 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
788
832
|
}
|
|
789
833
|
executeOwner?.throwIfInactive();
|
|
790
834
|
|
|
791
|
-
if (
|
|
835
|
+
if (dispatchParams.action === "ui-messages") {
|
|
792
836
|
return executeUiMessages(state);
|
|
793
837
|
}
|
|
794
|
-
if (
|
|
795
|
-
if (!
|
|
838
|
+
if (dispatchParams.action === "auth-start") {
|
|
839
|
+
if (!dispatchParams.server) {
|
|
796
840
|
return {
|
|
797
841
|
content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
|
|
798
842
|
details: { mode: "auth-start", error: "missing_server" },
|
|
799
843
|
};
|
|
800
844
|
}
|
|
801
845
|
return signal
|
|
802
|
-
? executeAuthStart(state,
|
|
803
|
-
: executeAuthStart(state,
|
|
846
|
+
? executeAuthStart(state, dispatchParams.server, signal)
|
|
847
|
+
: executeAuthStart(state, dispatchParams.server);
|
|
804
848
|
}
|
|
805
|
-
if (
|
|
806
|
-
if (!
|
|
849
|
+
if (dispatchParams.action === "auth-complete") {
|
|
850
|
+
if (!dispatchParams.server) {
|
|
807
851
|
return {
|
|
808
852
|
content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
|
|
809
853
|
details: { mode: "auth-complete", error: "missing_server" },
|
|
@@ -817,28 +861,28 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
817
861
|
};
|
|
818
862
|
}
|
|
819
863
|
return signal
|
|
820
|
-
? executeAuthComplete(state,
|
|
821
|
-
: executeAuthComplete(state,
|
|
864
|
+
? executeAuthComplete(state, dispatchParams.server, input, signal)
|
|
865
|
+
: executeAuthComplete(state, dispatchParams.server, input);
|
|
822
866
|
}
|
|
823
|
-
if (
|
|
824
|
-
return executeCall(state,
|
|
867
|
+
if (dispatchParams.tool) {
|
|
868
|
+
return executeCall(state, dispatchParams.tool, parsedArgs, dispatchParams.server, getPiTools, signal);
|
|
825
869
|
}
|
|
826
|
-
if (
|
|
827
|
-
const result = await executeConnect(state,
|
|
870
|
+
if (dispatchParams.connect) {
|
|
871
|
+
const result = await executeConnect(state, dispatchParams.connect, signal);
|
|
828
872
|
syncToolSurface(_ctx as ExtensionContext);
|
|
829
873
|
return result;
|
|
830
874
|
}
|
|
831
|
-
if (
|
|
832
|
-
return executeDescribe(state,
|
|
875
|
+
if (dispatchParams.describe) {
|
|
876
|
+
return executeDescribe(state, dispatchParams.describe);
|
|
833
877
|
}
|
|
834
|
-
if (
|
|
835
|
-
return executeInstructions(state,
|
|
878
|
+
if (dispatchParams.instructions) {
|
|
879
|
+
return executeInstructions(state, dispatchParams.instructions);
|
|
836
880
|
}
|
|
837
|
-
if (
|
|
838
|
-
return executeSearch(state,
|
|
881
|
+
if (dispatchParams.search !== undefined) {
|
|
882
|
+
return executeSearch(state, dispatchParams.search, dispatchParams.regex, dispatchParams.server, dispatchParams.includeSchemas, dispatchParams.limit, dispatchParams.offset);
|
|
839
883
|
}
|
|
840
|
-
if (
|
|
841
|
-
return executeList(state,
|
|
884
|
+
if (dispatchParams.server) {
|
|
885
|
+
return executeList(state, dispatchParams.server);
|
|
842
886
|
}
|
|
843
887
|
return executeStatus(state);
|
|
844
888
|
},
|
package/init.ts
CHANGED
|
@@ -179,8 +179,20 @@ export async function initializeMcp(
|
|
|
179
179
|
},
|
|
180
180
|
...(ui !== undefined ? { ui } : {}),
|
|
181
181
|
sendMessage: (message, options) => {
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
const deliver = () => {
|
|
183
|
+
if (!owner.isActive()) return;
|
|
184
|
+
pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options);
|
|
185
|
+
};
|
|
186
|
+
if (!options?.triggerTurn) {
|
|
187
|
+
deliver();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
void lifecycle.ensureConverged(owner.signal).then(deliver, error => {
|
|
191
|
+
if (!owner.isActive() || isAbortError(error, owner.signal)) return;
|
|
192
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
193
|
+
logger.debug(`MCP: pre-turn keep-alive convergence failed: ${sanitizeTerminalText(detail)}`);
|
|
194
|
+
deliver();
|
|
195
|
+
});
|
|
184
196
|
},
|
|
185
197
|
...(options.statusEvents !== undefined ? { statusEvents: options.statusEvents } : {}),
|
|
186
198
|
};
|
|
@@ -419,6 +431,18 @@ export async function initializeMcp(
|
|
|
419
431
|
updateStatusBar(state);
|
|
420
432
|
});
|
|
421
433
|
|
|
434
|
+
lifecycle.setHealthRestoredCallback((serverName) => {
|
|
435
|
+
if (!owner.isActive()) return;
|
|
436
|
+
clearFailure(state, serverName);
|
|
437
|
+
updateStatusBar(state);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
lifecycle.setAuthRequiredCallback((serverName) => {
|
|
441
|
+
if (!owner.isActive()) return;
|
|
442
|
+
clearFailure(state, serverName);
|
|
443
|
+
updateStatusBar(state);
|
|
444
|
+
});
|
|
445
|
+
|
|
422
446
|
lifecycle.setIdleShutdownCallback((serverName) => {
|
|
423
447
|
if (!owner.isActive()) return;
|
|
424
448
|
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
|
package/lifecycle.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
|
|
1
2
|
import { isServerDisabled, type ServerDefinition } from "./types.ts";
|
|
2
|
-
import type { McpServerManager } from "./server-manager.ts";
|
|
3
|
+
import type { McpServerManager, ServerConnection } from "./server-manager.ts";
|
|
3
4
|
import { hasPendingAuth } from "./mcp-auth-flow.ts";
|
|
4
5
|
import { logger } from "./logger.ts";
|
|
5
|
-
import { formatTerminalError, sanitizeTerminalText } from "./utils.ts";
|
|
6
|
+
import { formatTerminalError, parallelLimit, sanitizeTerminalText } from "./utils.ts";
|
|
7
|
+
import { isTerminatedSession } from "./session-recovery.ts";
|
|
6
8
|
|
|
7
|
-
export type ReconnectCallback = (serverName: string) => void
|
|
9
|
+
export type ReconnectCallback = (serverName: string) => void | Promise<void>;
|
|
8
10
|
export type ReconnectFailureCallback = (serverName: string, error: unknown) => void;
|
|
11
|
+
export type HealthRestoredCallback = (serverName: string) => void | Promise<void>;
|
|
12
|
+
export type AuthRequiredCallback = (serverName: string) => void | Promise<void>;
|
|
13
|
+
|
|
14
|
+
const KEEP_ALIVE_RETRY_BASE_MS = 30_000;
|
|
15
|
+
const KEEP_ALIVE_RETRY_MAX_MS = 5 * 60_000;
|
|
16
|
+
const KEEP_ALIVE_CHECK_CONCURRENCY = 10;
|
|
17
|
+
|
|
18
|
+
interface RetryState {
|
|
19
|
+
attempts: number;
|
|
20
|
+
nextAttemptAt: number;
|
|
21
|
+
connection: ServerConnection | undefined;
|
|
22
|
+
status: ServerConnection["status"] | undefined;
|
|
23
|
+
}
|
|
9
24
|
|
|
10
25
|
export class McpLifecycleManager {
|
|
11
26
|
private keepAliveServers = new Map<string, ServerDefinition>();
|
|
@@ -15,9 +30,14 @@ export class McpLifecycleManager {
|
|
|
15
30
|
private healthCheckInterval: NodeJS.Timeout | undefined;
|
|
16
31
|
private onReconnect: ReconnectCallback | undefined;
|
|
17
32
|
private onReconnectFailure: ReconnectFailureCallback | undefined;
|
|
33
|
+
private onHealthRestored: HealthRestoredCallback | undefined;
|
|
34
|
+
private onAuthRequired: AuthRequiredCallback | undefined;
|
|
18
35
|
private onIdleShutdown: ((serverName: string) => void) | undefined;
|
|
19
36
|
private activeHealthCheck: Promise<void> | undefined;
|
|
37
|
+
private activeConvergence: Promise<void> | undefined;
|
|
20
38
|
private shutdownPromise: Promise<void> | undefined;
|
|
39
|
+
private retryStates = new Map<string, RetryState>();
|
|
40
|
+
private pendingMetadataPublications = new Set<string>();
|
|
21
41
|
private stopped = false;
|
|
22
42
|
private removeHealthAbortListener: (() => void) | undefined;
|
|
23
43
|
|
|
@@ -34,6 +54,14 @@ export class McpLifecycleManager {
|
|
|
34
54
|
this.onReconnectFailure = callback;
|
|
35
55
|
}
|
|
36
56
|
|
|
57
|
+
setHealthRestoredCallback(callback: HealthRestoredCallback): void {
|
|
58
|
+
this.onHealthRestored = callback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setAuthRequiredCallback(callback: AuthRequiredCallback): void {
|
|
62
|
+
this.onAuthRequired = callback;
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
markKeepAlive(name: string, definition: ServerDefinition): void {
|
|
38
66
|
if (isServerDisabled(definition)) return;
|
|
39
67
|
this.keepAliveServers.set(name, definition);
|
|
@@ -82,29 +110,23 @@ export class McpLifecycleManager {
|
|
|
82
110
|
this.healthCheckInterval.unref();
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
async ensureConverged(signal?: AbortSignal): Promise<void> {
|
|
86
114
|
if (this.stopped || signal?.aborted) return;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
await this.manager.connect(name, definition, signal);
|
|
97
|
-
if (this.stopped || signal?.aborted) return;
|
|
98
|
-
logger.debug(`Reconnected to ${name}`);
|
|
99
|
-
this.onReconnect?.(name);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
if (this.stopped || signal?.aborted) return;
|
|
102
|
-
this.onReconnectFailure?.(name, error);
|
|
103
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
-
console.error(`MCP: Failed to reconnect to ${name}: ${sanitizeTerminalText(message)}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
115
|
+
if (this.activeConvergence) return this.activeConvergence;
|
|
116
|
+
|
|
117
|
+
const check = this.checkKeepAliveConnections(signal);
|
|
118
|
+
this.activeConvergence = check;
|
|
119
|
+
try {
|
|
120
|
+
await check;
|
|
121
|
+
} finally {
|
|
122
|
+
if (this.activeConvergence === check) this.activeConvergence = undefined;
|
|
107
123
|
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async checkConnections(signal?: AbortSignal): Promise<void> {
|
|
127
|
+
if (this.stopped || signal?.aborted) return;
|
|
128
|
+
await this.ensureConverged(signal);
|
|
129
|
+
if (this.stopped || signal?.aborted) return;
|
|
108
130
|
|
|
109
131
|
for (const [name] of this.allServers) {
|
|
110
132
|
if (this.keepAliveServers.has(name)) continue;
|
|
@@ -117,6 +139,205 @@ export class McpLifecycleManager {
|
|
|
117
139
|
}
|
|
118
140
|
}
|
|
119
141
|
|
|
142
|
+
private async checkKeepAliveConnections(signal?: AbortSignal): Promise<void> {
|
|
143
|
+
if (this.stopped || signal?.aborted) return;
|
|
144
|
+
await parallelLimit(
|
|
145
|
+
[...this.keepAliveServers],
|
|
146
|
+
KEEP_ALIVE_CHECK_CONCURRENCY,
|
|
147
|
+
([name, definition]) => this.checkKeepAliveConnection(name, definition, signal),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async checkKeepAliveConnection(
|
|
152
|
+
name: string,
|
|
153
|
+
definition: ServerDefinition,
|
|
154
|
+
signal?: AbortSignal,
|
|
155
|
+
retrySuperseded = true,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
if (isServerDisabled(definition) || this.stopped || signal?.aborted) return;
|
|
158
|
+
const connection = this.manager.getConnection(name);
|
|
159
|
+
if (connection?.status === "needs-auth") {
|
|
160
|
+
this.pendingMetadataPublications.delete(name);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!this.shouldAttemptConnection(name, connection)) return;
|
|
164
|
+
if (!connection || connection.status !== "connected") {
|
|
165
|
+
if (this.hasPendingAuthForServer(name)) {
|
|
166
|
+
logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let freshConnection: ServerConnection;
|
|
170
|
+
try {
|
|
171
|
+
freshConnection = await this.manager.connect(name, definition, signal);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (this.stopped || signal?.aborted) return;
|
|
174
|
+
this.reportConnectionFailure(name, error, "reconnect", this.manager.getConnection(name));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (this.stopped || signal?.aborted) return;
|
|
178
|
+
if (freshConnection.status === "needs-auth") {
|
|
179
|
+
await this.notifyAuthRequired(name);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (freshConnection.status !== "connected") {
|
|
183
|
+
this.reportConnectionFailure(
|
|
184
|
+
name,
|
|
185
|
+
new Error(`MCP server ${name} did not return a connected session`),
|
|
186
|
+
"reconnect",
|
|
187
|
+
freshConnection,
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
logger.debug(`Reconnected to ${name}`);
|
|
192
|
+
await this.publishConnectedMetadata(name, freshConnection);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (this.pendingMetadataPublications.has(name)) {
|
|
197
|
+
await this.publishConnectedMetadata(name, connection);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!definition.url) return;
|
|
201
|
+
const hadSessionId = (connection.transport as { sessionId?: string } | undefined)?.sessionId != null;
|
|
202
|
+
let refreshResult: Awaited<ReturnType<McpServerManager["refreshTools"]>>;
|
|
203
|
+
try {
|
|
204
|
+
refreshResult = await this.manager.refreshTools(name, connection, signal);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (this.stopped || signal?.aborted) return;
|
|
207
|
+
const current = this.manager.getConnection(name);
|
|
208
|
+
if (current !== connection || connection.status !== "connected") {
|
|
209
|
+
await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!shouldReconnectAfterRefresh(error, hadSessionId)) {
|
|
213
|
+
this.reportConnectionFailure(name, error, "refresh", connection);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (this.hasPendingAuthForServer(name)) {
|
|
217
|
+
logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let freshConnection: ServerConnection;
|
|
221
|
+
try {
|
|
222
|
+
freshConnection = await this.manager.reconnect(name, definition, connection, signal);
|
|
223
|
+
} catch (reconnectError) {
|
|
224
|
+
if (this.stopped || signal?.aborted) return;
|
|
225
|
+
this.reportConnectionFailure(name, reconnectError, "reconnect", this.manager.getConnection(name));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (this.stopped || signal?.aborted) return;
|
|
229
|
+
if (freshConnection.status === "needs-auth") {
|
|
230
|
+
await this.notifyAuthRequired(name);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (freshConnection.status !== "connected") {
|
|
234
|
+
this.reportConnectionFailure(
|
|
235
|
+
name,
|
|
236
|
+
new Error(`MCP server ${name} did not return a connected session`),
|
|
237
|
+
"reconnect",
|
|
238
|
+
freshConnection,
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
logger.debug(`Reconnected stale MCP session for ${name}`);
|
|
243
|
+
await this.publishConnectedMetadata(name, freshConnection);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (refreshResult === "superseded") {
|
|
248
|
+
await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (this.retryStates.delete(name)) {
|
|
252
|
+
await this.onHealthRestored?.(name);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async handleSupersededConnection(
|
|
257
|
+
name: string,
|
|
258
|
+
definition: ServerDefinition,
|
|
259
|
+
staleConnection: ServerConnection,
|
|
260
|
+
signal: AbortSignal | undefined,
|
|
261
|
+
retrySuperseded: boolean,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const current = this.manager.getConnection(name);
|
|
264
|
+
if (current === staleConnection && current.status === "connected") {
|
|
265
|
+
if (this.retryStates.delete(name)) await this.onHealthRestored?.(name);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (current?.status === "connected") {
|
|
269
|
+
await this.publishConnectedMetadata(name, current);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (current?.status === "needs-auth") {
|
|
273
|
+
await this.notifyAuthRequired(name);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (retrySuperseded) {
|
|
277
|
+
await this.checkKeepAliveConnection(name, definition, signal, false);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async publishConnectedMetadata(name: string, connection: ServerConnection): Promise<void> {
|
|
282
|
+
this.pendingMetadataPublications.add(name);
|
|
283
|
+
try {
|
|
284
|
+
await this.onReconnect?.(name);
|
|
285
|
+
this.pendingMetadataPublications.delete(name);
|
|
286
|
+
this.retryStates.delete(name);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (this.stopped) return;
|
|
289
|
+
this.reportConnectionFailure(name, error, "publish", connection);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async notifyAuthRequired(name: string): Promise<void> {
|
|
294
|
+
this.pendingMetadataPublications.delete(name);
|
|
295
|
+
this.retryStates.delete(name);
|
|
296
|
+
try {
|
|
297
|
+
await this.onAuthRequired?.(name);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
300
|
+
logger.debug(`MCP: auth-required callback failed for ${name}: ${sanitizeTerminalText(message)}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private shouldAttemptConnection(name: string, connection: ServerConnection | undefined): boolean {
|
|
305
|
+
const retry = this.retryStates.get(name);
|
|
306
|
+
if (!retry) return true;
|
|
307
|
+
if (retry.connection !== connection || retry.status !== connection?.status) {
|
|
308
|
+
this.retryStates.delete(name);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return Date.now() >= retry.nextAttemptAt;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private reportConnectionFailure(
|
|
315
|
+
name: string,
|
|
316
|
+
error: unknown,
|
|
317
|
+
action: "refresh" | "reconnect" | "publish",
|
|
318
|
+
connection: ServerConnection | undefined,
|
|
319
|
+
): void {
|
|
320
|
+
const attempts = (this.retryStates.get(name)?.attempts ?? 0) + 1;
|
|
321
|
+
const delay = Math.min(
|
|
322
|
+
KEEP_ALIVE_RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 10),
|
|
323
|
+
KEEP_ALIVE_RETRY_MAX_MS,
|
|
324
|
+
);
|
|
325
|
+
this.retryStates.set(name, {
|
|
326
|
+
attempts,
|
|
327
|
+
nextAttemptAt: Date.now() + delay,
|
|
328
|
+
connection,
|
|
329
|
+
status: connection?.status,
|
|
330
|
+
});
|
|
331
|
+
this.onReconnectFailure?.(name, error);
|
|
332
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
333
|
+
const target = action === "reconnect"
|
|
334
|
+
? `reconnect to ${name}`
|
|
335
|
+
: action === "publish"
|
|
336
|
+
? `publish metadata for ${name}`
|
|
337
|
+
: `refresh ${name}`;
|
|
338
|
+
console.error(`MCP: Failed to ${target}: ${sanitizeTerminalText(message)}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
120
341
|
private getIdleTimeout(name: string): number {
|
|
121
342
|
const perServer = this.serverSettings.get(name)?.idleTimeout;
|
|
122
343
|
if (perServer !== undefined) return perServer * 60 * 1000;
|
|
@@ -137,11 +358,23 @@ export class McpLifecycleManager {
|
|
|
137
358
|
this.removeHealthAbortListener = undefined;
|
|
138
359
|
await this.activeHealthCheck;
|
|
139
360
|
this.activeHealthCheck = undefined;
|
|
361
|
+
await this.activeConvergence;
|
|
362
|
+
this.activeConvergence = undefined;
|
|
140
363
|
this.onReconnect = undefined;
|
|
141
364
|
this.onReconnectFailure = undefined;
|
|
365
|
+
this.onHealthRestored = undefined;
|
|
366
|
+
this.onAuthRequired = undefined;
|
|
142
367
|
this.onIdleShutdown = undefined;
|
|
368
|
+
this.retryStates.clear();
|
|
369
|
+
this.pendingMetadataPublications.clear();
|
|
143
370
|
if (typeof this.manager.closeAll === "function") {
|
|
144
371
|
await this.manager.closeAll();
|
|
145
372
|
}
|
|
146
373
|
}
|
|
147
374
|
}
|
|
375
|
+
|
|
376
|
+
function shouldReconnectAfterRefresh(error: unknown, hadSessionId: boolean): boolean {
|
|
377
|
+
if (isTerminatedSession(error, hadSessionId)) return true;
|
|
378
|
+
return error instanceof SdkError
|
|
379
|
+
&& (error.code === SdkErrorCode.NotConnected || error.code === SdkErrorCode.ConnectionClosed);
|
|
380
|
+
}
|
package/package.json
CHANGED
package/server-manager.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import {
|
|
3
4
|
Client,
|
|
4
5
|
SdkHttpError,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
UnauthorizedError,
|
|
8
9
|
type GetPromptResult,
|
|
9
10
|
type ReadResourceResult,
|
|
11
|
+
type CacheableRequestOptions,
|
|
10
12
|
type RequestOptions,
|
|
11
13
|
type UrlElicitationRequiredError,
|
|
12
14
|
type VersionNegotiationOptions,
|
|
@@ -123,6 +125,8 @@ export interface ServerConnection {
|
|
|
123
125
|
transport: Transport;
|
|
124
126
|
definition: ServerDefinition;
|
|
125
127
|
tools: McpTool[];
|
|
128
|
+
/** Monotonic guard against older refresh responses replacing newer notifications. */
|
|
129
|
+
toolsRevision?: number;
|
|
126
130
|
resources: McpResource[];
|
|
127
131
|
prompts: McpPrompt[];
|
|
128
132
|
/** True when prompts were advertised but prompts/list failed. */
|
|
@@ -139,6 +143,10 @@ export interface ServerConnection {
|
|
|
139
143
|
type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
|
|
140
144
|
type MetadataListChangedListener = (serverName: string, reason: string) => void;
|
|
141
145
|
|
|
146
|
+
export type ToolRefreshResult = "updated" | "unchanged" | "superseded";
|
|
147
|
+
|
|
148
|
+
const KEEP_ALIVE_REFRESH_TIMEOUT_MS = 5_000;
|
|
149
|
+
|
|
142
150
|
export class McpServerManager {
|
|
143
151
|
private connections = new Map<string, ServerConnection>();
|
|
144
152
|
private connectPromises = new Map<string, Promise<ServerConnection>>();
|
|
@@ -146,6 +154,10 @@ export class McpServerManager {
|
|
|
146
154
|
private uiStreamListeners = new Map<string, UiStreamListener>();
|
|
147
155
|
private samplingConfig: ServerSamplingConfig | undefined;
|
|
148
156
|
private metadataListChangedListener: MetadataListChangedListener | undefined;
|
|
157
|
+
private pendingMetadataPublications = new Map<
|
|
158
|
+
string,
|
|
159
|
+
{ connection: ServerConnection; reason: string }
|
|
160
|
+
>();
|
|
149
161
|
private elicitationConfig: ServerElicitationConfig | undefined;
|
|
150
162
|
private authStorageOptions: AuthStorageOptions = {};
|
|
151
163
|
private oauthRuntime: McpOAuthRuntime | undefined;
|
|
@@ -170,6 +182,25 @@ export class McpServerManager {
|
|
|
170
182
|
this.metadataListChangedListener = listener;
|
|
171
183
|
}
|
|
172
184
|
|
|
185
|
+
publishMetadataChanged(
|
|
186
|
+
name: string,
|
|
187
|
+
expectedConnection: ServerConnection,
|
|
188
|
+
reason: string,
|
|
189
|
+
): boolean {
|
|
190
|
+
const current = this.connections.get(name);
|
|
191
|
+
if (current !== expectedConnection || current.status !== "connected") return false;
|
|
192
|
+
try {
|
|
193
|
+
this.metadataListChangedListener?.(name, reason);
|
|
194
|
+
this.pendingMetadataPublications.delete(name);
|
|
195
|
+
return true;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.pendingMetadataPublications.set(name, { connection: expectedConnection, reason });
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
199
|
+
logger.debug(`MCP: metadata publication failed for ${name}; queued for retry: ${message}`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
173
204
|
setElicitationConfig(config: ServerElicitationConfig | undefined): void {
|
|
174
205
|
this.elicitationConfig = config;
|
|
175
206
|
}
|
|
@@ -303,6 +334,88 @@ export class McpServerManager {
|
|
|
303
334
|
return abortable(promise, ownedSignal);
|
|
304
335
|
}
|
|
305
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Fetch the authoritative tool catalog for a connection that still appears
|
|
339
|
+
* connected. The SDK response cache is explicitly bypassed so this request
|
|
340
|
+
* also proves the remote Streamable HTTP session is usable. Results are
|
|
341
|
+
* identity-guarded: a late response from a replaced connection is ignored.
|
|
342
|
+
*/
|
|
343
|
+
async refreshTools(
|
|
344
|
+
name: string,
|
|
345
|
+
expectedConnection: ServerConnection,
|
|
346
|
+
signal?: AbortSignal,
|
|
347
|
+
): Promise<ToolRefreshResult> {
|
|
348
|
+
if (this.stopped) throw new Error("MCP server manager is closed");
|
|
349
|
+
const ownedSignal = combineAbortSignals(this.runtimeSignal, signal);
|
|
350
|
+
throwIfAborted(ownedSignal);
|
|
351
|
+
|
|
352
|
+
const current = this.connections.get(name);
|
|
353
|
+
if (current !== expectedConnection || current.status !== "connected") {
|
|
354
|
+
return "superseded";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const requestOptions = this.buildRequestOptions(expectedConnection.definition, signal);
|
|
358
|
+
const timeout = Math.min(requestOptions?.timeout ?? KEEP_ALIVE_REFRESH_TIMEOUT_MS, KEEP_ALIVE_REFRESH_TIMEOUT_MS);
|
|
359
|
+
const healthOptions = {
|
|
360
|
+
...requestOptions,
|
|
361
|
+
timeout,
|
|
362
|
+
};
|
|
363
|
+
if (!expectedConnection.client.getServerCapabilities?.()?.tools) {
|
|
364
|
+
await expectedConnection.client.ping(healthOptions);
|
|
365
|
+
throwIfAborted(ownedSignal);
|
|
366
|
+
if (this.connections.get(name) !== expectedConnection || expectedConnection.status !== "connected") {
|
|
367
|
+
return "superseded";
|
|
368
|
+
}
|
|
369
|
+
this.retryPendingMetadataPublication(name, expectedConnection);
|
|
370
|
+
return "unchanged";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const toolsRevision = expectedConnection.toolsRevision ?? 0;
|
|
374
|
+
const refreshSignal = combineAbortSignals(healthOptions.signal, AbortSignal.timeout(timeout));
|
|
375
|
+
const tools = await this.fetchAllTools(expectedConnection.client, {
|
|
376
|
+
...healthOptions,
|
|
377
|
+
...(refreshSignal ? { signal: refreshSignal } : {}),
|
|
378
|
+
cacheMode: "refresh",
|
|
379
|
+
});
|
|
380
|
+
throwIfAborted(ownedSignal);
|
|
381
|
+
|
|
382
|
+
if (this.connections.get(name) !== expectedConnection || expectedConnection.status !== "connected") {
|
|
383
|
+
return "superseded";
|
|
384
|
+
}
|
|
385
|
+
if ((expectedConnection.toolsRevision ?? 0) !== toolsRevision) {
|
|
386
|
+
return "superseded";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (isDeepStrictEqual(expectedConnection.tools, tools)) {
|
|
390
|
+
this.retryPendingMetadataPublication(name, expectedConnection);
|
|
391
|
+
return "unchanged";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const previousTools = expectedConnection.tools;
|
|
395
|
+
expectedConnection.tools = tools;
|
|
396
|
+
expectedConnection.toolsRevision = toolsRevision + 1;
|
|
397
|
+
try {
|
|
398
|
+
await this.metadataListChangedListener?.(name, "keep-alive-refresh");
|
|
399
|
+
this.pendingMetadataPublications.delete(name);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (this.connections.get(name) === expectedConnection && expectedConnection.tools === tools) {
|
|
402
|
+
expectedConnection.tools = previousTools;
|
|
403
|
+
expectedConnection.toolsRevision = toolsRevision;
|
|
404
|
+
}
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
return "updated";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private retryPendingMetadataPublication(name: string, connection: ServerConnection): void {
|
|
411
|
+
const pendingPublication = this.pendingMetadataPublications.get(name);
|
|
412
|
+
if (pendingPublication?.connection !== connection) return;
|
|
413
|
+
this.metadataListChangedListener?.(name, pendingPublication.reason);
|
|
414
|
+
if (this.pendingMetadataPublications.get(name) === pendingPublication) {
|
|
415
|
+
this.pendingMetadataPublications.delete(name);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
306
419
|
private async doReconnect(
|
|
307
420
|
name: string,
|
|
308
421
|
definition: ServerDefinition,
|
|
@@ -440,6 +553,7 @@ export class McpServerManager {
|
|
|
440
553
|
transport,
|
|
441
554
|
definition,
|
|
442
555
|
tools: [],
|
|
556
|
+
toolsRevision: 0,
|
|
443
557
|
resources: [],
|
|
444
558
|
prompts: [],
|
|
445
559
|
...(instructions !== undefined ? { instructions } : {}),
|
|
@@ -641,7 +755,9 @@ export class McpServerManager {
|
|
|
641
755
|
const connection = this.connections.get(serverName);
|
|
642
756
|
if (!connection || connection.client !== client || connection.status !== "connected") return;
|
|
643
757
|
connection.tools = tools;
|
|
758
|
+
connection.toolsRevision = (connection.toolsRevision ?? 0) + 1;
|
|
644
759
|
this.metadataListChangedListener?.(serverName, "tools-list-changed");
|
|
760
|
+
this.pendingMetadataPublications.delete(serverName);
|
|
645
761
|
}
|
|
646
762
|
|
|
647
763
|
private handlePromptsListChanged(
|
|
@@ -660,6 +776,7 @@ export class McpServerManager {
|
|
|
660
776
|
connection.prompts = prompts;
|
|
661
777
|
connection.promptDiscoveryFailed = false;
|
|
662
778
|
this.metadataListChangedListener?.(serverName, "prompts-list-changed");
|
|
779
|
+
this.pendingMetadataPublications.delete(serverName);
|
|
663
780
|
}
|
|
664
781
|
|
|
665
782
|
private handleResourcesListChanged(
|
|
@@ -677,6 +794,7 @@ export class McpServerManager {
|
|
|
677
794
|
if (!connection || connection.client !== client || connection.status !== "connected") return;
|
|
678
795
|
connection.resources = resources;
|
|
679
796
|
this.metadataListChangedListener?.(serverName, "resources-list-changed");
|
|
797
|
+
this.pendingMetadataPublications.delete(serverName);
|
|
680
798
|
}
|
|
681
799
|
|
|
682
800
|
async handleUrlElicitationRequired(
|
|
@@ -851,7 +969,7 @@ export class McpServerManager {
|
|
|
851
969
|
}
|
|
852
970
|
}
|
|
853
971
|
|
|
854
|
-
private async fetchAllTools(client: Client, requestOptions?:
|
|
972
|
+
private async fetchAllTools(client: Client, requestOptions?: CacheableRequestOptions): Promise<McpTool[]> {
|
|
855
973
|
const allTools: McpTool[] = [];
|
|
856
974
|
let cursor: string | undefined;
|
|
857
975
|
|
|
@@ -979,6 +1097,7 @@ export class McpServerManager {
|
|
|
979
1097
|
async close(name: string): Promise<void> {
|
|
980
1098
|
this.closeGenerations.set(name, (this.closeGenerations.get(name) ?? 0) + 1);
|
|
981
1099
|
this.connectAttempts.get(name)?.abort(new Error(`MCP connection ${name} was closed`));
|
|
1100
|
+
this.pendingMetadataPublications.delete(name);
|
|
982
1101
|
|
|
983
1102
|
const connection = this.connections.get(name);
|
|
984
1103
|
if (!connection) {
|
|
@@ -1042,6 +1161,7 @@ export class McpServerManager {
|
|
|
1042
1161
|
.filter(error => this.containsCleanupFailure(error));
|
|
1043
1162
|
this.uiStreamListeners.clear();
|
|
1044
1163
|
this.acceptedUrlElicitations.clear();
|
|
1164
|
+
this.pendingMetadataPublications.clear();
|
|
1045
1165
|
this.samplingConfig = undefined;
|
|
1046
1166
|
this.elicitationConfig = undefined;
|
|
1047
1167
|
await this.traceWriter?.flush();
|
package/session-recovery.ts
CHANGED
|
@@ -146,6 +146,13 @@ export async function withSessionRecovery<T>(
|
|
|
146
146
|
throw err;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
try {
|
|
150
|
+
deps.manager.publishMetadataChanged(serverName, freshConnection, "session-reconnect");
|
|
151
|
+
} catch (publicationError) {
|
|
152
|
+
const message = publicationError instanceof Error ? publicationError.message : String(publicationError);
|
|
153
|
+
logger.debug(`MCP metadata publication after reconnect failed for "${serverName}": ${message}`);
|
|
154
|
+
}
|
|
155
|
+
throwIfAborted(deps.signal);
|
|
149
156
|
return fn(freshConnection);
|
|
150
157
|
}
|
|
151
158
|
}
|
package/tool-approval.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { abortable } from "./abort.ts";
|
|
3
3
|
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
4
4
|
import type { McpExtensionState } from "./state.ts";
|
|
@@ -20,6 +20,18 @@ export type ToolCallApprovalResult =
|
|
|
20
20
|
| { ok: true }
|
|
21
21
|
| { ok: false; reason: "denied" | "approval_required_headless" };
|
|
22
22
|
|
|
23
|
+
function stableStringify(value: unknown): string {
|
|
24
|
+
if (value === null || value === undefined || typeof value !== "object") {
|
|
25
|
+
const serialized = JSON.stringify(value);
|
|
26
|
+
return serialized === undefined ? "undefined" : serialized;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return `[${value.map(item => stableStringify(item)).join(",")}]`;
|
|
30
|
+
}
|
|
31
|
+
const object = value as Record<string, unknown>;
|
|
32
|
+
return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
export function isToolCallApprovalRequired(
|
|
24
36
|
config: McpConfig,
|
|
25
37
|
serverName: string,
|
|
@@ -136,7 +148,8 @@ export async function ensureToolCallApproved(
|
|
|
136
148
|
origin: McpToolApprovalOrigin = toolMeta.resourceUri ? "resource" : "proxy",
|
|
137
149
|
approvalMetadata?: ReadonlyMap<string, readonly ToolMetadata[]>,
|
|
138
150
|
): Promise<ToolCallApprovalResult> {
|
|
139
|
-
const
|
|
151
|
+
const argsHash = createHash("sha256").update(stableStringify(args ?? {})).digest("hex");
|
|
152
|
+
const cacheKey = `${serverName}\u0000${toolMeta.originalName}\u0000${argsHash}`;
|
|
140
153
|
const approvedToolCalls = state.approvedToolCalls ??= new Map<string, true>();
|
|
141
154
|
if (approvedToolCalls.has(cacheKey)) {
|
|
142
155
|
return { ok: true };
|
package/tool-result-renderer.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface McpProxyToolCallInput {
|
|
|
25
25
|
|
|
26
26
|
interface McpToolRenderState {
|
|
27
27
|
compactTitle?: string;
|
|
28
|
+
compactInputPreview?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
interface McpToolRenderContext {
|
|
@@ -52,6 +53,7 @@ export interface McpToolResultDisplay {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const DEFAULT_MAX_CALL_INPUT_CHARS = 1500;
|
|
56
|
+
const DEFAULT_MAX_COMPACT_INPUT_CHARS = 240;
|
|
55
57
|
const DEFAULT_BOXED_COLLAPSED_LINES = 3;
|
|
56
58
|
const DEFAULT_COMPACT_COLLAPSED_LINES = 1;
|
|
57
59
|
const DEFAULT_MAX_COLLAPSED_CHARS = 8000;
|
|
@@ -70,6 +72,7 @@ class CompactMcpToolResult implements Component {
|
|
|
70
72
|
|
|
71
73
|
constructor(
|
|
72
74
|
private readonly title: string,
|
|
75
|
+
private readonly inputPreview: string,
|
|
73
76
|
private readonly display: McpToolResultDisplay,
|
|
74
77
|
private readonly theme: RenderTheme,
|
|
75
78
|
) {}
|
|
@@ -83,7 +86,7 @@ class CompactMcpToolResult implements Component {
|
|
|
83
86
|
});
|
|
84
87
|
const lines = resultLines.length > 0 ? resultLines : [""];
|
|
85
88
|
const bodies = lines.map((line, index) => {
|
|
86
|
-
const prefix = index === 0
|
|
89
|
+
const prefix = index === 0 ? this.renderPrefix(safeWidth) : "";
|
|
87
90
|
return `${prefix}${this.theme.fg("toolOutput", line)}`;
|
|
88
91
|
});
|
|
89
92
|
const hiddenText = this.display.truncated || bodies.some((body) => visibleWidth(body) > safeWidth);
|
|
@@ -106,6 +109,22 @@ class CompactMcpToolResult implements Component {
|
|
|
106
109
|
invalidate(): void {
|
|
107
110
|
this.rendered = null;
|
|
108
111
|
}
|
|
112
|
+
|
|
113
|
+
private renderPrefix(width: number): string {
|
|
114
|
+
if (!this.title) return "";
|
|
115
|
+
const arrow = " → ";
|
|
116
|
+
if (!this.inputPreview) return `${this.theme.fg("toolTitle", this.title)}${arrow}`;
|
|
117
|
+
|
|
118
|
+
const maxPrefixWidth = Math.max(12, Math.floor(width * 0.55));
|
|
119
|
+
const titleWidth = visibleWidth(this.title);
|
|
120
|
+
const inputWidth = Math.max(0, maxPrefixWidth - titleWidth - 1);
|
|
121
|
+
if (inputWidth <= 3) {
|
|
122
|
+
return `${this.theme.fg("toolTitle", truncateToWidth(this.title, maxPrefixWidth, "…"))}${arrow}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const input = truncateToWidth(this.inputPreview, inputWidth, "…");
|
|
126
|
+
return `${this.theme.fg("toolTitle", this.title)} ${this.theme.fg("muted", input)}${arrow}`;
|
|
127
|
+
}
|
|
109
128
|
}
|
|
110
129
|
|
|
111
130
|
class CollapsibleText implements Component {
|
|
@@ -239,6 +258,10 @@ function renderToolCallLines(lines: string[], theme?: RenderTheme) {
|
|
|
239
258
|
return new Text([styledTitle, ...styledRest].join("\n"), 0, 0);
|
|
240
259
|
}
|
|
241
260
|
|
|
261
|
+
function formatCompactInputPreview(lines: string[], maxChars = DEFAULT_MAX_COMPACT_INPUT_CHARS): string {
|
|
262
|
+
return truncateText(lines.slice(1).join(" ").replace(/\s+/g, " ").trim(), maxChars);
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
export function resolveMcpToolRenderOptions(settings?: McpToolRenderSettings): McpToolRenderOptions {
|
|
243
266
|
const resultRendering = settings?.toolResultRendering === "boxed" ? "boxed" : "compact";
|
|
244
267
|
const collapsedLines = settings?.collapsedResultLines;
|
|
@@ -263,7 +286,10 @@ function renderToolCall(
|
|
|
263
286
|
context: McpToolRenderContext | undefined,
|
|
264
287
|
options: McpToolRenderOptions,
|
|
265
288
|
) {
|
|
266
|
-
if (context?.state)
|
|
289
|
+
if (context?.state) {
|
|
290
|
+
context.state.compactTitle = lines[0] ?? "mcp";
|
|
291
|
+
context.state.compactInputPreview = formatCompactInputPreview(lines);
|
|
292
|
+
}
|
|
267
293
|
if (shouldUseCompactFinalRender(options, context)) return new EmptyComponent();
|
|
268
294
|
return renderToolCallLines(lines, theme);
|
|
269
295
|
}
|
|
@@ -307,6 +333,19 @@ function collectCollapsedResultLines(
|
|
|
307
333
|
let truncated = false;
|
|
308
334
|
|
|
309
335
|
const appendLine = (line: string) => {
|
|
336
|
+
if (lines.length === 0) {
|
|
337
|
+
const previewWidth = Math.min(line.length, remainingChars);
|
|
338
|
+
if (line.slice(0, previewWidth).trim() === "") {
|
|
339
|
+
if (line.length >= remainingChars) {
|
|
340
|
+
truncated = true;
|
|
341
|
+
remainingChars = 0;
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
remainingChars -= line.length + 1;
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
310
349
|
if (lines.length >= maxLines || remainingChars <= 0) {
|
|
311
350
|
truncated = true;
|
|
312
351
|
return false;
|
|
@@ -342,7 +381,7 @@ function collectCollapsedResultLines(
|
|
|
342
381
|
if (truncated) break;
|
|
343
382
|
}
|
|
344
383
|
|
|
345
|
-
if (lines.length === 0) lines.push("");
|
|
384
|
+
if (lines.length === 0) lines.push(truncated ? "(leading blank output omitted)" : "");
|
|
346
385
|
if (truncated && lines.length >= maxLines) lines.push("…");
|
|
347
386
|
return { lines, truncated };
|
|
348
387
|
}
|
|
@@ -393,7 +432,8 @@ export function renderMcpToolResult(
|
|
|
393
432
|
if (!expanded && renderOptions.resultRendering === "compact") {
|
|
394
433
|
const display = formatMcpToolResultLines(result, false, renderOptions.collapsedResultLines);
|
|
395
434
|
const title = context?.state?.compactTitle ?? formatMcpToolResultIdentity(result.details) ?? "";
|
|
396
|
-
|
|
435
|
+
const inputPreview = context?.state?.compactInputPreview ?? "";
|
|
436
|
+
return new CompactMcpToolResult(title, inputPreview, display, activeTheme);
|
|
397
437
|
}
|
|
398
438
|
|
|
399
439
|
const display = formatMcpToolResultLines(result, expanded, renderOptions.collapsedResultLines);
|