@fitchmultz/pi-mcp-adapter 6.0.0 → 7.1.0

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/OAUTH.md +1 -1
  3. package/README.md +27 -18
  4. package/dist/app-bridge.bundle.js +14 -14
  5. package/dist/config.js +41 -16
  6. package/dist/direct-tools.js +1 -1
  7. package/dist/gateway-arguments.d.ts +1 -1
  8. package/dist/index.js +38 -59
  9. package/dist/lifecycle.d.ts +2 -2
  10. package/dist/lifecycle.js +2 -2
  11. package/dist/mcp-auth-flow.js +2 -2
  12. package/dist/mcp-auth.d.ts +1 -1
  13. package/dist/mcp-auth.js +3 -3
  14. package/dist/mcp-code.d.ts +6 -6
  15. package/dist/mcp-code.js +11 -10
  16. package/dist/mcp-oauth-provider.d.ts +5 -5
  17. package/dist/mcp-oauth-provider.js +5 -5
  18. package/dist/mcp-output-guard.d.ts +1 -1
  19. package/dist/mcp-output-guard.js +1 -1
  20. package/dist/mcp-panel.d.ts +2 -2
  21. package/dist/mcp-panel.js +2 -2
  22. package/dist/mcp-setup-panel.d.ts +5 -4
  23. package/dist/mcp-setup-panel.js +15 -7
  24. package/dist/mcp-trace.d.ts +1 -1
  25. package/dist/mcp-trace.js +1 -1
  26. package/dist/npx-resolver.js +8 -4
  27. package/dist/proxy-modes.d.ts +2 -2
  28. package/dist/proxy-modes.js +20 -19
  29. package/dist/sampling-handler.js +1 -0
  30. package/dist/search-ranking.js +6 -2
  31. package/dist/server-manager.d.ts +2 -2
  32. package/dist/server-manager.js +44 -27
  33. package/dist/session-recovery.d.ts +7 -4
  34. package/dist/session-recovery.js +76 -10
  35. package/dist/tool-loader.d.ts +7 -2
  36. package/dist/tool-loader.js +56 -5
  37. package/dist/tool-metadata.d.ts +43 -43
  38. package/dist/tool-result-renderer.d.ts +3 -3
  39. package/dist/tool-result-renderer.js +3 -3
  40. package/dist/ui-resource-handler.d.ts +2 -2
  41. package/dist/ui-resource-handler.js +2 -2
  42. package/dist/ui-server.js +5 -4
  43. package/dist/ui-stream-types.d.ts +8 -8
  44. package/dist/unix-socket-transport.d.ts +1 -1
  45. package/dist/unix-socket-transport.js +1 -1
  46. package/dist/utils.d.ts +1 -0
  47. package/dist/utils.js +14 -0
  48. package/package.json +34 -98
  49. package/skills/mcp-scripting/SKILL.md +2 -0
  50. package/abort.ts +0 -34
  51. package/agent-dir.ts +0 -24
  52. package/app-bridge.bundle.js +0 -109
  53. package/banner.png +0 -0
  54. package/checkpoint.ts +0 -55
  55. package/commands.ts +0 -622
  56. package/config.ts +0 -1204
  57. package/consent-manager.ts +0 -25
  58. package/direct-tools.ts +0 -75
  59. package/elicitation-handler.ts +0 -349
  60. package/error-signal.ts +0 -21
  61. package/gateway-arguments.ts +0 -73
  62. package/glimpse-ui.ts +0 -80
  63. package/host-html-template.ts +0 -456
  64. package/index.ts +0 -767
  65. package/init.ts +0 -613
  66. package/legacy-migration.ts +0 -47
  67. package/lifecycle.ts +0 -165
  68. package/logger.ts +0 -127
  69. package/mcp-auth-flow.ts +0 -975
  70. package/mcp-auth.ts +0 -793
  71. package/mcp-callback-server.ts +0 -450
  72. package/mcp-code.ts +0 -412
  73. package/mcp-keyring-helper.cjs +0 -89
  74. package/mcp-oauth-provider.ts +0 -774
  75. package/mcp-output-guard.ts +0 -530
  76. package/mcp-panel.ts +0 -907
  77. package/mcp-script-worker.mjs +0 -131
  78. package/mcp-setup-panel.ts +0 -666
  79. package/mcp-status.ts +0 -110
  80. package/mcp-trace.ts +0 -295
  81. package/metadata-cache.ts +0 -271
  82. package/npx-resolver.ts +0 -488
  83. package/onboarding-state.ts +0 -58
  84. package/panel-keys.ts +0 -37
  85. package/prompts.ts +0 -337
  86. package/proxy-modes.ts +0 -1109
  87. package/resource-tools.ts +0 -52
  88. package/runtime-owner.ts +0 -123
  89. package/sampling-handler.ts +0 -276
  90. package/scripts/build-app-bridge.mjs +0 -15
  91. package/scripts/build.mjs +0 -132
  92. package/scripts/prepare.mjs +0 -103
  93. package/search-ranking.ts +0 -132
  94. package/server-manager.ts +0 -1320
  95. package/session-recovery.ts +0 -316
  96. package/state.ts +0 -64
  97. package/tool-approval.ts +0 -78
  98. package/tool-loader.ts +0 -153
  99. package/tool-metadata.ts +0 -240
  100. package/tool-registrar.ts +0 -74
  101. package/tool-result-renderer.ts +0 -294
  102. package/tsconfig.build.json +0 -13
  103. package/tsconfig.json +0 -16
  104. package/types.ts +0 -785
  105. package/ui-resource-handler.ts +0 -249
  106. package/ui-server.ts +0 -808
  107. package/ui-session.ts +0 -568
  108. package/ui-stream-types.ts +0 -89
  109. package/ui-tool-visibility.ts +0 -25
  110. package/unix-socket-transport.ts +0 -85
  111. package/utils.ts +0 -307
package/proxy-modes.ts DELETED
@@ -1,1109 +0,0 @@
1
- import type { AgentToolResult, ToolInfo } from "@earendil-works/pi-coding-agent";
2
- import type { Client } from "@modelcontextprotocol/client";
3
- import { DEFAULT_REQUEST_TIMEOUT_MSEC, UrlElicitationRequiredError } from "@modelcontextprotocol/client";
4
- import type { McpExtensionState } from "./state.ts";
5
- import type { ServerConnection } from "./server-manager.ts";
6
- import type { ToolMetadata, McpOperationContext, McpToolCallEvent, McpToolCallIdentity } from "./types.ts";
7
- import { getServerPrefix, isServerDisabled, isNonInteractiveOAuth, parseUiPromptHandoff, resolveToolPrefix } from "./types.ts";
8
- import { lazyConnect, markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateServerMetadata, updateMetadataCache, getFailureAgeSeconds, updateStatusBar, clearFailure, recordFailure } from "./init.ts";
9
- import { abortable, throwIfAborted } from "./abort.ts";
10
- import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
11
- import { buildToolMetadata, catalogCoverage, findToolByName, formatSchema, toToolDescriptor } from "./tool-metadata.ts";
12
- import { reconstructPromptMetadata } from "./metadata-cache.ts";
13
- import { renderMcpResultContent } from "./tool-registrar.ts";
14
- import { guardMcpOutput, guardedMcpDetails, retainMcpResult, resolveMcpOutputGuardOptions, formatMcpResultReference } from "./mcp-output-guard.ts";
15
- import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
16
- import { formatAuthRequiredMessage, formatMcpStatus, resolveServerUrl, truncateAtWord } from "./utils.ts";
17
- import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
18
- import { isToolTransportFailure, SessionRecoveryAuthRequiredError, withSessionRecovery, type SessionRecoveryDeps } from "./session-recovery.ts";
19
- import { paginate, rankSuggestions, rankToolMatches } from "./search-ranking.ts";
20
- import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
21
-
22
- type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
23
- type ClientCallToolResult = Awaited<ReturnType<Client["callTool"]>>;
24
-
25
- const INSTRUCTIONS_PREVIEW_LENGTH = 300;
26
-
27
- type AutoAuthResult =
28
- | { status: "skipped" }
29
- | { status: "success" }
30
- | { status: "failed"; message: string };
31
-
32
- function disabledResult(mode: string, serverName: string): ProxyToolResult {
33
- const message = `Server "${serverName}" is disabled. Run /mcp enable ${serverName} and /reload to enable it.`;
34
- return {
35
- content: [{ type: "text" as const, text: message }],
36
- details: { mode, error: "server_disabled", server: serverName, message },
37
- };
38
- }
39
-
40
- function getAuthRequiredMessage(
41
- state: McpExtensionState,
42
- serverName: string,
43
- defaultMessage = `Server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
44
- ): string {
45
- const oauth = state.config.mcpServers[serverName]?.oauth;
46
- if (oauth && oauth.crossAppAccess) defaultMessage = `Server "${serverName}" requires enterprise authorization. Check oauth.crossAppAccess IdP configuration and ID-token source, then retry.`;
47
- return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
48
- }
49
-
50
- function getAuthFailedMessage(state: McpExtensionState, serverName: string, message: string): string {
51
- const customGuidance = state.config.settings?.authRequiredMessage;
52
- const oauth = state.config.mcpServers[serverName]?.oauth;
53
- if (customGuidance || (oauth && oauth.crossAppAccess)) {
54
- return `OAuth authentication failed for "${serverName}": ${message}. ${getAuthRequiredMessage(state, serverName)}`;
55
- }
56
- return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
57
- }
58
-
59
- function getRedirectPort(authorizationUrl: string): number | undefined {
60
- try {
61
- const redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
62
- if (!redirectUri) return undefined;
63
- const port = Number.parseInt(new URL(redirectUri).port, 10);
64
- return Number.isInteger(port) ? port : undefined;
65
- } catch {
66
- return undefined;
67
- }
68
- }
69
-
70
- function formatManualAuthInstructions(serverName: string, authorizationUrl: string): string {
71
- const port = getRedirectPort(authorizationUrl);
72
- const portNote = port
73
- ? `\nThe redirect URL will use local port ${port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
74
- : "";
75
-
76
- return [
77
- `MCP OAuth required for "${serverName}".`,
78
- "",
79
- "Open this URL in your local browser:",
80
- "",
81
- authorizationUrl,
82
- "",
83
- "After approval reaches this Pi session's callback, complete without copying the callback URL or code:",
84
- `mcp({ action: "auth-complete", server: "${serverName}" })`,
85
- "",
86
- "If the browser cannot reach the callback, copy the full redirected localhost URL from your address bar and send it back with:",
87
- `mcp({ action: "auth-complete", server: "${serverName}", args: { redirectUrl: "PASTE_REDIRECT_URL_HERE" } })`,
88
- "",
89
- 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`.',
90
- portNote.trimEnd(),
91
- ].filter(Boolean).join("\n");
92
- }
93
-
94
- async function attemptAutoAuth(
95
- state: McpExtensionState,
96
- serverName: string,
97
- signal?: AbortSignal,
98
- ): Promise<AutoAuthResult> {
99
- if (state.config.settings?.autoAuth !== true) {
100
- return { status: "skipped" };
101
- }
102
-
103
- const definition = state.config.mcpServers[serverName];
104
- if (!definition || isServerDisabled(definition) || !supportsOAuth(definition)) {
105
- return { status: "skipped" };
106
- }
107
-
108
- let serverUrl: string | undefined;
109
- try {
110
- serverUrl = resolveServerUrl(definition);
111
- } catch (error) {
112
- const message = error instanceof Error ? error.message : String(error);
113
- return { status: "failed", message: getAuthFailedMessage(state, serverName, message) };
114
- }
115
- if (!serverUrl) {
116
- return { status: "skipped" };
117
- }
118
-
119
- if (!state.ui && !isNonInteractiveOAuth(definition.oauth)) {
120
- return {
121
- status: "failed",
122
- message: getAuthRequiredMessage(
123
- state,
124
- serverName,
125
- `Server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
126
- ),
127
- };
128
- }
129
-
130
- try {
131
- if (state.authStorageOptions) {
132
- await authenticate(
133
- serverName,
134
- serverUrl,
135
- definition,
136
- signal
137
- ? { authStorageOptions: state.authStorageOptions, signal, runtime: state.oauthRuntime }
138
- : { authStorageOptions: state.authStorageOptions, runtime: state.oauthRuntime },
139
- );
140
- } else {
141
- if (signal) {
142
- await authenticate(serverName, serverUrl, definition, { signal, runtime: state.oauthRuntime });
143
- } else {
144
- await authenticate(serverName, serverUrl, definition, { runtime: state.oauthRuntime });
145
- }
146
- }
147
- return { status: "success" };
148
- } catch (error) {
149
- if (isAbortError(error, signal)) throw error;
150
- const message = error instanceof Error ? error.message : String(error);
151
- return {
152
- status: "failed",
153
- message: getAuthFailedMessage(state, serverName, message),
154
- };
155
- }
156
- }
157
-
158
- export function executeUiMessages(state: McpExtensionState): ProxyToolResult {
159
- const sessions = state.completedUiSessions;
160
-
161
- if (sessions.length === 0) {
162
- return {
163
- content: [{ type: "text" as const, text: "No UI session messages available." }],
164
- details: { sessions: 0 },
165
- };
166
- }
167
-
168
- const output: string[] = [];
169
- output.push(`UI Session Messages (${sessions.length} session${sessions.length > 1 ? "s" : ""}):\n`);
170
-
171
- const allPrompts: string[] = [];
172
- const allIntents = sessions.flatMap((session) => session.messages.intents);
173
- const allContexts = sessions.flatMap((session) => session.messages.contexts);
174
- const parsedHandoffs: Array<{ intent: string; params: Record<string, unknown>; raw: string }> = [];
175
-
176
- for (const session of sessions) {
177
- const timestamp = session.completedAt.toLocaleTimeString();
178
- output.push(`\n## ${session.serverName} / ${session.toolName} (${timestamp}, ${session.reason})`);
179
-
180
- const plainPrompts: string[] = [];
181
- for (const prompt of session.messages.prompts) {
182
- allPrompts.push(prompt);
183
- const handoff = parseUiPromptHandoff(prompt);
184
- if (handoff) {
185
- parsedHandoffs.push(handoff);
186
- } else {
187
- plainPrompts.push(prompt);
188
- }
189
- }
190
-
191
- if (plainPrompts.length > 0) {
192
- output.push("\n### Prompts:");
193
- for (const prompt of plainPrompts) {
194
- output.push(`- ${prompt}`);
195
- }
196
- }
197
-
198
- const intentsForSession = [
199
- ...session.messages.intents,
200
- ...session.messages.prompts
201
- .map((prompt) => parseUiPromptHandoff(prompt))
202
- .filter((handoff): handoff is NonNullable<typeof handoff> => !!handoff)
203
- .map((handoff) => ({ intent: handoff.intent, params: handoff.params })),
204
- ];
205
-
206
- if (intentsForSession.length > 0) {
207
- output.push("\n### Intents:");
208
- for (const intent of intentsForSession) {
209
- const params = intent.params ? ` (${JSON.stringify(intent.params)})` : "";
210
- output.push(`- ${intent.intent}${params}`);
211
- }
212
- }
213
-
214
- const contexts = session.messages.contexts;
215
- if (contexts.length > 0) {
216
- output.push("\n### Context updates:");
217
- for (const context of contexts) {
218
- output.push(`- ${context.summary}${context.truncated ? " (truncated)" : ""}`);
219
- }
220
- }
221
-
222
- if (session.messages.notifications.length > 0) {
223
- output.push("\n### Notifications:");
224
- for (const notification of session.messages.notifications) {
225
- output.push(`- ${notification}`);
226
- }
227
- }
228
- }
229
-
230
- const count = sessions.length;
231
- state.completedUiSessions = [];
232
-
233
- return {
234
- content: [{ type: "text" as const, text: output.join("\n") }],
235
- details: {
236
- sessions: count,
237
- prompts: allPrompts,
238
- intents: [...allIntents, ...parsedHandoffs.map(({ intent, params }) => ({ intent, params }))],
239
- contexts: allContexts,
240
- handoffs: parsedHandoffs,
241
- cleared: true,
242
- },
243
- };
244
- }
245
-
246
- export function executeStatus(state: McpExtensionState): ProxyToolResult {
247
- const servers: Array<{ name: string; status: string; toolCount: number; failedAgo: number | null; disabled?: boolean }> = [];
248
-
249
- for (const name of Object.keys(state.config.mcpServers)) {
250
- const definition = state.config.mcpServers[name];
251
- const disabled = isServerDisabled(definition);
252
- const connection = disabled ? undefined : state.manager.getConnection(name);
253
- const metadata = disabled ? undefined : state.toolMetadata.get(name);
254
- const toolCount = metadata?.filter(tool => !tool.resourceUri).length ?? 0;
255
- const failedAgo = disabled ? null : getFailureAgeSeconds(state, name);
256
- let status = disabled ? "disabled" : "not connected";
257
- if (!disabled && connection?.status === "connected") {
258
- status = "connected";
259
- } else if (!disabled && connection?.status === "needs-auth") {
260
- status = "needs-auth";
261
- } else if (!disabled && failedAgo !== null) {
262
- status = "failed";
263
- } else if (!disabled && metadata !== undefined) {
264
- status = "cached";
265
- }
266
-
267
- servers.push({ name, status, toolCount, failedAgo, ...(disabled ? { disabled: true } : {}) });
268
- }
269
-
270
- const disabledCount = servers.filter(s => s.disabled).length;
271
- const enabledServers = servers.filter(s => !s.disabled);
272
- const totalTools = enabledServers.reduce((sum, s) => sum + s.toolCount, 0);
273
- const connectedCount = enabledServers.filter(s => s.status === "connected").length;
274
- const coverage = catalogCoverage(state);
275
-
276
- let text = `MCP: ${connectedCount}/${enabledServers.length} connected, ${totalTools} tools available (calls connect lazily)`;
277
- if (disabledCount > 0) text += ` (${disabledCount} disabled)`;
278
- text += "\n\n";
279
- for (const server of servers) {
280
- if (server.disabled) {
281
- text += `⊘ ${server.name} (disabled)\n`;
282
- continue;
283
- }
284
- if (server.status === "connected") {
285
- text += `✓ ${server.name} (${server.toolCount} tools)\n`;
286
- continue;
287
- }
288
- if (server.status === "needs-auth") {
289
- text += `⚠ ${server.name} (needs auth)\n`;
290
- continue;
291
- }
292
- if (server.status === "cached") {
293
- text += `○ ${server.name} (${server.toolCount} tools, cached)\n`;
294
- continue;
295
- }
296
- if (server.status === "failed") {
297
- text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
298
- continue;
299
- }
300
- text += `○ ${server.name} (not discovered)\n`;
301
- }
302
-
303
- if (servers.length > 0) {
304
- text += `\nmcp_search({ query: "...", server: "name" }) to discover tools; mcp({ action: "resources", server: "name" }) for resources.`;
305
- if (!coverage.complete) text += `\nPartial catalog: ${coverage.unknownServers.length} undiscovered server(s).`;
306
- }
307
-
308
- return {
309
- content: [{ type: "text" as const, text: text.trim() }],
310
- details: { mode: "status", servers, totalTools, connectedCount, disabledCount, coverage },
311
- };
312
- }
313
-
314
- export async function executeAuthStart(state: McpExtensionState, serverName: string, signal?: AbortSignal): Promise<ProxyToolResult> {
315
- const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
316
- throwIfAborted(ownedSignal);
317
- const definition = state.config.mcpServers[serverName];
318
- if (!definition) {
319
- return {
320
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
321
- details: { mode: "auth-start", error: "not_found", server: serverName },
322
- };
323
- }
324
- if (isServerDisabled(definition)) return disabledResult("auth-start", serverName);
325
-
326
- try {
327
- const serverUrl = resolveServerUrl(definition);
328
- if (!serverUrl || !supportsOAuth(definition)) {
329
- return {
330
- content: [{ type: "text" as const, text: `Server "${serverName}" is not configured for OAuth over HTTP.` }],
331
- details: { mode: "auth-start", error: "oauth_not_supported", server: serverName },
332
- };
333
- }
334
-
335
- const { authorizationUrl } = state.authStorageOptions
336
- ? ownedSignal
337
- ? await startAuth(serverName, serverUrl, definition, { authStorageOptions: state.authStorageOptions, signal: ownedSignal, runtime: state.oauthRuntime })
338
- : await startAuth(serverName, serverUrl, definition, { authStorageOptions: state.authStorageOptions, runtime: state.oauthRuntime })
339
- : ownedSignal
340
- ? await startAuth(serverName, serverUrl, definition, { signal: ownedSignal, runtime: state.oauthRuntime })
341
- : await startAuth(serverName, serverUrl, definition, { runtime: state.oauthRuntime });
342
- if (!authorizationUrl) {
343
- return {
344
- content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}".` }],
345
- details: { mode: "auth-start", server: serverName, authenticated: true },
346
- };
347
- }
348
-
349
- return {
350
- content: [{ type: "text" as const, text: formatManualAuthInstructions(serverName, authorizationUrl) }],
351
- details: { mode: "auth-start", server: serverName, authorizationUrl },
352
- };
353
- } catch (error) {
354
- const message = error instanceof Error ? error.message : String(error);
355
- return {
356
- content: [{ type: "text" as const, text: `Failed to start OAuth for "${serverName}": ${message}` }],
357
- details: { mode: "auth-start", error: "auth_start_failed", server: serverName, message },
358
- };
359
- }
360
- }
361
-
362
- export async function executeAuthComplete(state: McpExtensionState, serverName: string, input?: string, signal?: AbortSignal): Promise<ProxyToolResult> {
363
- const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
364
- throwIfAborted(ownedSignal);
365
- const definition = state.config.mcpServers[serverName];
366
- if (!definition) {
367
- return {
368
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
369
- details: { mode: "auth-complete", error: "not_found", server: serverName },
370
- };
371
- }
372
- if (isServerDisabled(definition)) return disabledResult("auth-complete", serverName);
373
-
374
- try {
375
- const previousConnection = state.manager.getConnection(serverName);
376
- const status = state.authStorageOptions
377
- ? ownedSignal
378
- ? await completeAuthFromInput(serverName, input, { authStorageOptions: state.authStorageOptions, signal: ownedSignal, runtime: state.oauthRuntime })
379
- : await completeAuthFromInput(serverName, input, { authStorageOptions: state.authStorageOptions, runtime: state.oauthRuntime })
380
- : ownedSignal
381
- ? await completeAuthFromInput(serverName, input, { signal: ownedSignal, runtime: state.oauthRuntime })
382
- : await completeAuthFromInput(serverName, input, { runtime: state.oauthRuntime });
383
- if (status !== "authenticated") {
384
- return {
385
- content: [{ type: "text" as const, text: `OAuth authentication did not complete for "${serverName}".` }],
386
- details: { mode: "auth-complete", error: "not_authenticated", server: serverName, status },
387
- };
388
- }
389
-
390
- if (previousConnection) state.manager.retire(serverName, previousConnection);
391
- clearFailure(state, serverName);
392
- updateStatusBar(state);
393
- return {
394
- content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}". Run mcp({ action: "connect", server: "${serverName}" }) to connect with the new token.` }],
395
- details: { mode: "auth-complete", server: serverName, authenticated: true },
396
- };
397
- } catch (error) {
398
- const message = error instanceof Error ? error.message : String(error);
399
- return {
400
- content: [{ type: "text" as const, text: `Failed to complete OAuth for "${serverName}": ${message}` }],
401
- details: { mode: "auth-complete", error: "auth_complete_failed", server: serverName, message },
402
- };
403
- }
404
- }
405
-
406
- export function executeDescribe(state: McpExtensionState, toolName: string, serverFilter?: string): ProxyToolResult {
407
- if (serverFilter && isServerDisabled(state.config.mcpServers[serverFilter])) return disabledResult("describe", serverFilter);
408
- const catalog = [...state.toolMetadata].flatMap(([server, tools]) =>
409
- (!serverFilter || serverFilter === server) && state.config.mcpServers[server] && !isServerDisabled(state.config.mcpServers[server])
410
- ? tools.map(tool => ({ server, tool })) : []);
411
- const originals = serverFilter ? catalog.filter(({ tool }) => tool.originalName === toolName) : [];
412
- const exact = originals.length ? originals : catalog.filter(({ tool }) => tool.name === toolName);
413
- const matches = exact.length ? exact : catalog.filter(({ tool }) => tool.name.replace(/-/g, "_") === toolName.replace(/-/g, "_"));
414
- if (matches.length > 1) return {
415
- content: [{ type: "text", text: `Tool ${JSON.stringify(toolName)} is ambiguous. Specify server and original tool name: ${JSON.stringify(matches.map(({ server, tool }) => ({ server, tool: tool.originalName })))}` }],
416
- details: { mode: "describe", error: "ambiguous_tool", requestedTool: toolName },
417
- };
418
- const match = matches[0];
419
- if (!match) {
420
- const disabled = [...state.toolMetadata].find(([server, tools]) => isServerDisabled(state.config.mcpServers[server]) && findToolByName(tools, toolName));
421
- if (disabled) return disabledResult("describe", disabled[0]);
422
- const suggestions = rankSuggestions(state, toolName, 5, serverFilter);
423
- const hint = suggestions.length > 0
424
- ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: "${suggestions[0]}" }).`
425
- : `Use mcp_search({ query: "..." }) to search.`;
426
- return {
427
- content: [{ type: "text", text: `Tool "${toolName}" not found. ${hint}` }],
428
- details: { mode: "describe", error: "tool_not_found", requestedTool: toolName, suggestions },
429
- };
430
- }
431
- const { server: serverName, tool: toolMeta } = match;
432
- const descriptor = toToolDescriptor(serverName, toolMeta);
433
- const approvalRequired = isToolCallApprovalRequired(state.config, serverName, toolMeta);
434
- return {
435
- content: [{ type: "text", text: JSON.stringify({ ...descriptor, ...(toolMeta.resourceUri ? { resourceUri: toolMeta.resourceUri, readWith: { action: "read-resource", server: serverName, uri: toolMeta.resourceUri } } : {}), ...(approvalRequired ? { approvalRequired: true } : {}) }, null, 2) }],
436
- details: { mode: "describe", tool: toolMeta, server: serverName },
437
- };
438
- }
439
-
440
- export function executeSearch(
441
- state: McpExtensionState,
442
- query: string,
443
- server?: string,
444
- includeSchemas?: boolean,
445
- limit = 12,
446
- offset = 0,
447
- ): ProxyToolResult {
448
- const showSchemas = includeSchemas !== false;
449
- const searchCall = (nextOffset: number | null) =>
450
- `mcp({ action: "search", query: ${JSON.stringify(query)}${server ? `, server: ${JSON.stringify(server)}` : ""}${showSchemas ? "" : ", includeSchemas: false"}, limit: ${limit}, offset: ${nextOffset} })`;
451
- if (server && isServerDisabled(state.config.mcpServers[server])) return disabledResult("search", server);
452
-
453
- let matches: Array<{ server: string; tool: ToolMetadata; score: number }>;
454
- if (query.trim().length === 0) {
455
- if (!server) {
456
- return {
457
- content: [{ type: "text" as const, text: "Search query cannot be empty" }],
458
- details: { mode: "search", error: "empty_query" },
459
- };
460
- }
461
- matches = (state.toolMetadata.get(server) ?? []).filter(tool => !tool.resourceUri)
462
- .map(tool => ({ server, tool, score: 0 }))
463
- .sort((a, b) => a.tool.name.localeCompare(b.tool.name));
464
- } else {
465
- matches = rankToolMatches(state, query, server);
466
- }
467
-
468
- const page = paginate(matches, offset, limit);
469
- const coverage = catalogCoverage(state, server);
470
- const coverageNotice = coverage.complete ? "" : `\nCatalog is incomplete. Undiscovered servers: ${coverage.unknownServers.join(", ")}. Search with server to discover one.`;
471
- if (page.total === 0) {
472
- const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"`;
473
- return {
474
- content: [{ type: "text" as const, text: msg + coverageNotice }],
475
- details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query, coverage },
476
- };
477
- }
478
- if (page.items.length === 0) {
479
- const retry = searchCall(0);
480
- return {
481
- content: [{ type: "text" as const, text: `No search results at offset ${offset}; ${page.total} tools match. Retry with ${retry}.` }],
482
- details: { mode: "search", error: "offset_out_of_range", matches: [], count: page.total, hasMore: false, nextOffset: null, query },
483
- };
484
- }
485
-
486
- let text = `Found ${page.total} tool${page.total === 1 ? "" : "s"} matching "${query}":\n\n`;
487
- for (const match of page.items) {
488
- const approvalMarker = isToolCallApprovalRequired(state.config, match.server, match.tool)
489
- ? " (requires approval)"
490
- : "";
491
- if (showSchemas) {
492
- text += `${JSON.stringify({ ...toToolDescriptor(match.server, match.tool), ...(approvalMarker ? { approvalRequired: true } : {}) }, null, 2)}\n\n`;
493
- } else {
494
- text += `- ${match.tool.name}${approvalMarker}`;
495
- if (match.tool.description) text += ` - ${truncateAtWord(match.tool.description, 50)}`;
496
- text += "\n";
497
- }
498
- }
499
- const first = (Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0) + 1;
500
- const nextSearch = searchCall(page.nextOffset);
501
- text += page.hasMore
502
- ? `\n${first}-${first + page.items.length - 1} of ${page.total} — ${nextSearch} for more\n`
503
- : `\n${first}-${first + page.items.length - 1} of ${page.total} — end\n`;
504
-
505
- return {
506
- content: [{ type: "text" as const, text: text.trim() + coverageNotice }],
507
- details: {
508
- mode: "search",
509
- coverage,
510
- matches: page.items.map(match => ({ server: match.server, tool: match.tool.name, score: match.score })),
511
- count: page.total,
512
- hasMore: page.hasMore,
513
- nextOffset: page.nextOffset,
514
- query,
515
- },
516
- };
517
- }
518
-
519
- export function executeList(state: McpExtensionState, server: string, limit = 12, offset = 0): ProxyToolResult {
520
- const definition = state.config.mcpServers[server];
521
- if (!definition) {
522
- return {
523
- content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
524
- details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
525
- };
526
- }
527
- if (isServerDisabled(definition)) return disabledResult("list", server);
528
-
529
- const metadata = state.toolMetadata.get(server)?.filter(tool => !tool.resourceUri);
530
- const connection = state.manager.getConnection(server);
531
- const instructions = state.serverInstructions.get(server);
532
- let instructionsText = "";
533
- if (instructions) {
534
- const preview = truncateAtWord(instructions, INSTRUCTIONS_PREVIEW_LENGTH);
535
- instructionsText = `\n\nServer instructions:\n${preview}`;
536
- if (preview !== instructions) {
537
- instructionsText += `\nUse mcp({ action: "instructions", server: "${server}" }) for the full text.`;
538
- }
539
- }
540
-
541
- if (!metadata?.length) {
542
- if (connection?.status === "connected") {
543
- return {
544
- content: [{ type: "text" as const, text: `Server "${server}" has no tools.${instructionsText}` }],
545
- details: { mode: "list", server, tools: [], count: 0, hasInstructions: Boolean(instructions) },
546
- };
547
- }
548
- if (metadata !== undefined) {
549
- return {
550
- content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).${instructionsText}` }],
551
- details: { mode: "list", server, tools: [], count: 0, cached: true, hasInstructions: Boolean(instructions) },
552
- };
553
- }
554
- return {
555
- content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ action: "connect", server: "${server}" }) or /mcp reconnect ${server} to retry.${instructionsText}` }],
556
- details: { mode: "list", server, tools: [], count: 0, error: "not_connected", hasInstructions: Boolean(instructions) },
557
- };
558
- }
559
-
560
- const page = paginate(metadata, offset, limit);
561
- if (page.items.length === 0) {
562
- return {
563
- content: [{ type: "text" as const, text: `No tools at offset ${offset}; "${server}" has ${page.total} tools. Retry with mcp({ action: "list", server: ${JSON.stringify(server)}, limit: ${limit}, offset: 0 }).` }],
564
- details: { mode: "list", error: "offset_out_of_range", server, tools: [], count: page.total, hasMore: false, nextOffset: null, hasInstructions: Boolean(instructions) },
565
- };
566
- }
567
-
568
- const cachedNote = connection?.status === "connected" ? "" : ", not connected, cached";
569
- let text = `${server} (${page.total} tools${cachedNote}):\n\n`;
570
-
571
- for (const tool of page.items) {
572
- const description = truncateAtWord(tool.description, 50);
573
- text += `- ${tool.name}`;
574
- if (description) text += ` - ${description}`;
575
- text += "\n";
576
- }
577
- const first = (Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0) + 1;
578
- text += page.hasMore
579
- ? `\n${first}-${first + page.items.length - 1} of ${page.total} — mcp({ action: "list", server: ${JSON.stringify(server)}, limit: ${limit}, offset: ${page.nextOffset} }) for more`
580
- : `\n${first}-${first + page.items.length - 1} of ${page.total} — end`;
581
- text += instructionsText;
582
-
583
- return {
584
- content: [{ type: "text" as const, text: text.trim() }],
585
- details: {
586
- mode: "list",
587
- server,
588
- tools: page.items.map(tool => tool.name),
589
- count: page.total,
590
- hasMore: page.hasMore,
591
- nextOffset: page.nextOffset,
592
- hasInstructions: Boolean(instructions),
593
- },
594
- };
595
- }
596
-
597
- export function executeInstructions(state: McpExtensionState, server: string): ProxyToolResult {
598
- const definition = state.config.mcpServers[server];
599
- if (!definition) {
600
- return {
601
- content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
602
- details: { mode: "instructions", server, error: "not_found" },
603
- };
604
- }
605
- if (isServerDisabled(definition)) return disabledResult("instructions", server);
606
-
607
- const instructions = state.serverInstructions.get(server);
608
- if (instructions) {
609
- return {
610
- content: [{ type: "text" as const, text: `${server} instructions:\n\n${instructions}` }],
611
- details: { mode: "instructions", server, length: instructions.length },
612
- };
613
- }
614
-
615
- const connection = state.manager.getConnection(server);
616
- if (connection?.status === "connected") {
617
- return {
618
- content: [{ type: "text" as const, text: `Server "${server}" does not provide instructions.` }],
619
- details: { mode: "instructions", server, error: "no_instructions" },
620
- };
621
- }
622
-
623
- return {
624
- content: [{ type: "text" as const, text: `No instructions cached for "${server}". Use mcp({ action: "connect", server: "${server}" }) to connect and refresh.` }],
625
- details: { mode: "instructions", server, error: "not_connected" },
626
- };
627
- }
628
-
629
- export async function executeConnect(
630
- state: McpExtensionState,
631
- serverName: string,
632
- signal?: AbortSignal,
633
- limit?: number,
634
- offset?: number,
635
- ): Promise<ProxyToolResult> {
636
- const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
637
- throwIfAborted(ownedSignal);
638
- const definition = state.config.mcpServers[serverName];
639
- if (!definition) {
640
- return {
641
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
642
- details: { mode: "connect", error: "not_found", server: serverName },
643
- };
644
- }
645
- if (isServerDisabled(definition)) return disabledResult("connect", serverName);
646
-
647
- try {
648
- if (state.ui) {
649
- state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
650
- }
651
- const currentConnection = state.manager.getConnection(serverName);
652
- let connection = currentConnection?.status === "connected"
653
- ? await state.manager.reconnect(serverName, definition, currentConnection, ownedSignal)
654
- : await state.manager.connect(serverName, definition, ownedSignal);
655
- if (connection.status === "needs-auth") {
656
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
657
- if (autoAuth.status === "failed") {
658
- return {
659
- content: [{ type: "text" as const, text: autoAuth.message }],
660
- details: { mode: "connect", error: "auth_required", server: serverName, message: autoAuth.message },
661
- };
662
- }
663
- if (autoAuth.status === "success") {
664
- throwIfAborted(ownedSignal);
665
- connection = await state.manager.reconnect(serverName, definition, connection, ownedSignal);
666
- }
667
- if (connection.status === "needs-auth") {
668
- const message = getAuthRequiredMessage(state, serverName);
669
- return {
670
- content: [{ type: "text" as const, text: message }],
671
- details: { mode: "connect", error: "auth_required", server: serverName, message },
672
- };
673
- }
674
- }
675
- const prefix = state.config.settings?.toolPrefix ?? "server";
676
- const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
677
- state.toolMetadata.set(serverName, metadata);
678
- state.resourceCounts?.set(serverName, connection.resources.length);
679
- if (!connection.promptDiscoveryFailed) {
680
- state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
681
- state.promptMetadataLive?.add(serverName);
682
- }
683
- if (connection.instructions) {
684
- state.serverInstructions.set(serverName, connection.instructions);
685
- } else {
686
- state.serverInstructions.delete(serverName);
687
- }
688
- updateMetadataCache(state, serverName);
689
- notifyToolMetadataUpdated(state, serverName, "proxy-connect");
690
- markKeepAliveAfterConnect(state, serverName);
691
- clearFailure(state, serverName);
692
- updateStatusBar(state);
693
- return executeList(state, serverName, limit, offset);
694
- } catch (error) {
695
- const message = error instanceof Error ? error.message : String(error);
696
- if (!isAbortError(error, ownedSignal)) recordFailure(state, serverName, message);
697
- updateStatusBar(state);
698
- return {
699
- content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
700
- details: { mode: "connect", error: isAbortError(error, ownedSignal) ? "aborted" : "connect_failed", server: serverName, message },
701
- };
702
- }
703
- }
704
-
705
- /** The part of a tool call that is identical for proxy and direct tools. */
706
- type ToolCallTarget = Pick<ToolMetadata, "originalName" | "inputSchema" | "annotations" | "resourceUri" | "uiResourceUri" | "uiStreamMode">;
707
-
708
- interface ToolCallOptions extends McpToolCallIdentity {
709
- beforeDispatch?: (signal: AbortSignal | undefined, operation: McpOperationContext) => Promise<void>;
710
- /** Spread into every details payload: identity, and `mode` for proxy calls. */
711
- detailsBase: Record<string, unknown>;
712
- ownedSignal: AbortSignal | undefined;
713
- /** Caller-supplied signal, passed to the UI session rather than the MCP call. */
714
- signal: AbortSignal | undefined;
715
- recoverAuthConnection: NonNullable<SessionRecoveryDeps["onNeedsAuth"]>;
716
- authRequiredMessage: () => string;
717
- autoAuthAttempted: () => boolean;
718
- raw?: boolean;
719
- }
720
-
721
- /**
722
- * Executes an already-approved tool call against a connected server.
723
- *
724
- * Direct, gateway, resource and script calls share executeCall preparation.
725
- * Raw scripts skip content rendering but retain protocol results and artifacts.
726
- */
727
- export async function runToolCall(
728
- state: McpExtensionState,
729
- serverName: string,
730
- target: ToolCallTarget,
731
- args: Record<string, unknown> | undefined,
732
- options: ToolCallOptions,
733
- ): Promise<ProxyToolResult> {
734
- const { detailsBase, signal, recoverAuthConnection, authRequiredMessage, autoAuthAttempted } = options;
735
- const configuredOptions = state.manager.getRequestOptions?.(serverName, options.ownedSignal);
736
- const callerSignal = combineAbortSignals(configuredOptions?.signal, options.ownedSignal);
737
- const outputGuardOptions = resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory);
738
- // Lazy: formatSchema recurses, so only pay for it (and only risk it throwing)
739
- // on the error paths that actually print it.
740
- const schemaSuffix = () => target.inputSchema ? `\n\nExpected parameters:\n${formatSchema(target.inputSchema)}` : "";
741
- let uiSession: UiSessionRuntime | null = null;
742
- const identity = {
743
- server: serverName, tool: target.originalName,
744
- ...(options.toolCallId !== undefined ? { toolCallId: options.toolCallId } : {}),
745
- ...(options.innerCallId !== undefined ? { innerCallId: options.innerCallId } : {}),
746
- };
747
- let beforeDispatchPending = false;
748
- let captureFailure: McpToolCallEvent | undefined;
749
- const capture = async (event: McpToolCallEvent) => {
750
- if (!state.onToolCall) return;
751
- try {
752
- const run = () => state.onToolCall!(event);
753
- await abortable(state.owner ? state.owner.runCallback("onToolCall", run) : run(), event.signal);
754
- throwIfAborted(event.signal);
755
- } catch (error) {
756
- captureFailure = event;
757
- throw error;
758
- }
759
- };
760
-
761
- try {
762
- state.manager.touch(serverName);
763
- state.manager.incrementInFlight(serverName);
764
-
765
- uiSession = !target.resourceUri && target.uiResourceUri
766
- ? await maybeStartUiSession(state, {
767
- serverName,
768
- toolName: target.originalName,
769
- toolArgs: args ?? {},
770
- uiResourceUri: target.uiResourceUri,
771
- ...(target.uiStreamMode !== undefined ? { streamMode: target.uiStreamMode } : {}),
772
- ...(signal ? { signal } : {}),
773
- onNeedsAuth: recoverAuthConnection,
774
- })
775
- : null;
776
-
777
- // UI preparation can replace the connection; use its current annotations.
778
- const liveTools = state.manager.getConnection(serverName)?.tools;
779
- const annotations = liveTools
780
- ? liveTools.find(tool => tool.name === target.originalName)?.annotations
781
- : target.annotations;
782
- const operation = {
783
- ...identity, args: args ?? {},
784
- ...(annotations !== undefined ? { annotations } : {}),
785
- ...(target.resourceUri ? { resourceUri: target.resourceUri } : {}),
786
- };
787
- if (options.beforeDispatch) {
788
- beforeDispatchPending = true;
789
- const run = () => options.beforeDispatch!(callerSignal, {
790
- ...operation, annotationsTrusted: state.config.mcpServers[serverName]?.retryOnTransportFailure === true,
791
- });
792
- await abortable(state.owner ? state.owner.runCallback("beforeExecute", run) : run(), callerSignal);
793
- beforeDispatchPending = false;
794
- }
795
- throwIfAborted(callerSignal);
796
-
797
- // Host checkpoints and UI preparation do not consume the service deadline.
798
- const ownedSignal = combineAbortSignals(callerSignal, AbortSignal.timeout(Math.ceil(configuredOptions?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC)))!;
799
- const requestOptions = { ...configuredOptions, signal: ownedSignal };
800
- const recovery = { manager: state.manager, config: state.config, signal: ownedSignal, onNeedsAuth: recoverAuthConnection };
801
- const call = { ...operation, signal: ownedSignal };
802
- await capture({ ...call, phase: "before" });
803
- throwIfAborted(ownedSignal);
804
- const dispatch = async <T>(fn: () => Promise<T>): Promise<T> => {
805
- let result: T;
806
- try {
807
- result = await fn();
808
- } catch (error) {
809
- await capture({ ...call, phase: "after", error });
810
- throw error;
811
- }
812
- await capture({ ...call, phase: "after", result });
813
- return result;
814
- };
815
-
816
- const result = target.resourceUri
817
- ? await dispatch(() => withSessionRecovery(
818
- recovery, serverName,
819
- conn => abortable(conn.client.readResource({ uri: target.resourceUri! }, requestOptions), ownedSignal),
820
- ))
821
- : await dispatch(() => withSessionRecovery<ClientCallToolResult>(
822
- { ...recovery, retryOnTransportFailure: annotations?.readOnlyHint === true || annotations?.idempotentHint === true },
823
- serverName,
824
- conn => abortable(conn.client.callTool({ name: target.originalName, arguments: args ?? {}, _meta: uiSession?.requestMeta }, requestOptions), ownedSignal),
825
- ));
826
- if (!target.resourceUri) uiSession?.sendToolResult(result as ClientCallToolResult);
827
- const record = result as Record<string, unknown>;
828
- const isError = record.isError === true;
829
- const uiSummary = target.uiResourceUri ? summarizeUiSessionResult(uiSession) : undefined;
830
- const details = {
831
- ...detailsBase,
832
- ...(isError ? { error: "tool_error" } : {}),
833
- ...(uiSummary ? { uiOpen: uiSummary.uiOpen, uiViewer: uiSummary.uiViewer, uiUrl: uiSummary.uiUrl } : {}),
834
- };
835
- const retained = await retainMcpResult(result, outputGuardOptions, options.raw);
836
- if (options.raw) {
837
- const message = isError
838
- ? (Array.isArray(record.content) ? record.content.filter(block => block.type === "text").map(block => block.text).join("\n") : "") || "Tool execution failed" : undefined;
839
- return { content: [], details: { ...details, ...retained, ...(message ? { message } : {}) } };
840
- }
841
- const content = renderMcpResultContent(record, retained.payloadFiles);
842
- const guarded = await guardMcpOutput(content.length ? content : [{ type: "text", text: target.resourceUri ? "(empty resource)" : "(empty result)" }], {
843
- ...outputGuardOptions, retainedMcpResult: retained,
844
- ...(isError ? { prefix: "Error: ", suffix: schemaSuffix(), emptyTextFallback: "Tool execution failed" }
845
- : uiSummary ? { suffix: `\n\n${uiSummary.message}` } : {}),
846
- });
847
- return { content: guarded.content, details: { ...details, ...guardedMcpDetails(guarded) } };
848
- } catch (error) {
849
- // Direct/proxy host errors throw; scripts retain their failed-call envelope.
850
- if (beforeDispatchPending && options.innerCallId === undefined) throw error;
851
- if (captureFailure) {
852
- const { signal: _signal, ...context } = captureFailure;
853
- const message = captureFailure.phase === "before"
854
- ? "MCP call capture failed before dispatch; the tool did not run. Restore capture before continuing."
855
- : "MCP outcome capture failed. Use the retained outcome to continue the original work; do not repeat a completed call or rerun the script.";
856
- uiSession?.sendToolCancelled(message);
857
- const retained = "result" in captureFailure && captureFailure.result !== undefined
858
- ? await retainMcpResult(captureFailure.result, { ...outputGuardOptions, enabled: true, detailsMaxBytes: 1 }) : {};
859
- return {
860
- content: [{ type: "text", text: message }, ...(retained.resultRef ? [{ type: "text" as const, text: formatMcpResultReference(retained.resultRef) }] : [])],
861
- details: { ...detailsBase, ...retained, error: isAbortError(error, callerSignal) ? "aborted" : "call_capture_failed", message, recovery: context },
862
- };
863
- }
864
- if (isToolTransportFailure(error)) {
865
- const message = `The outcome of MCP tool "${target.originalName}" on "${serverName}" is unknown after transport loss. Read back the original operation using its saved arguments and provider identity before continuing. Do not blindly repeat the call or rerun its script.`;
866
- uiSession?.sendToolCancelled(message);
867
- return {
868
- content: [{ type: "text", text: message }],
869
- details: { ...detailsBase, error: "ambiguous_outcome", message, recovery: { ...identity, action: "readback" } },
870
- };
871
- }
872
- if (error instanceof SessionRecoveryAuthRequiredError) {
873
- const message = error.authMessage ?? authRequiredMessage();
874
- uiSession?.sendToolCancelled(message);
875
- return {
876
- content: [{ type: "text" as const, text: message }],
877
- details: { ...detailsBase, error: "auth_required", message, autoAuthAttempted: autoAuthAttempted() },
878
- };
879
- }
880
- if (error instanceof UrlElicitationRequiredError) {
881
- const action = await state.manager.handleUrlElicitationRequired(serverName, error);
882
- const message = action === "accept"
883
- ? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
884
- : `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
885
- uiSession?.sendToolCancelled(message);
886
- return {
887
- content: [{ type: "text" as const, text: message }],
888
- details: { ...detailsBase, error: "url_elicitation_required", action },
889
- };
890
- }
891
- const message = error instanceof Error ? error.message : String(error);
892
- uiSession?.sendToolCancelled(message);
893
- const guarded = await guardMcpOutput([{ type: "text" as const, text: message }], { ...outputGuardOptions, prefix: "Failed to call tool: ", suffix: schemaSuffix() });
894
- return {
895
- content: guarded.content,
896
- details: {
897
- ...detailsBase,
898
- error: isAbortError(error, callerSignal) ? "aborted" : "call_failed",
899
- message: guarded.outputGuard ? "output truncated; see outputGuard.fullOutputPath" : message,
900
- ...guardedMcpDetails(guarded),
901
- },
902
- };
903
- } finally {
904
- if (uiSession?.reused) {
905
- uiSession.close();
906
- }
907
- state.manager.decrementInFlight(serverName);
908
- state.manager.touch(serverName);
909
- }
910
- }
911
-
912
- export interface ExecuteCallOptions {
913
- exactOriginalName?: boolean;
914
- resourceUri?: string;
915
- /** Scripts receive the raw protocol result without model-facing content transformation. */
916
- raw?: boolean;
917
- }
918
-
919
- /** Connect/auth once for calls and resource discovery. Recovery stays bounded by withSessionRecovery. */
920
- export async function prepareMcpConnection(state: McpExtensionState, serverName: string, signal?: AbortSignal) {
921
- const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
922
- throwIfAborted(ownedSignal);
923
- let autoAuthAttempted = false;
924
- const recoverAuthConnection: NonNullable<SessionRecoveryDeps["onNeedsAuth"]> = async (_name, recoverySignal = ownedSignal, challenge) => {
925
- throwIfAborted(recoverySignal);
926
- const current = state.manager.getConnection(serverName);
927
- if (current?.status === "connected" && current !== challenge?.connection) return current;
928
- if (!autoAuthAttempted) {
929
- autoAuthAttempted = true;
930
- const auth = await attemptAutoAuth(state, serverName, recoverySignal);
931
- throwIfAborted(recoverySignal);
932
- if (auth.status === "failed") throw new SessionRecoveryAuthRequiredError(serverName, auth.message);
933
- if (auth.status === "success" && current) {
934
- clearFailure(state, serverName);
935
- const definition = state.config.mcpServers[serverName];
936
- if (!definition) return undefined;
937
- state.ui?.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
938
- await state.manager.reconnect(serverName, definition, current, recoverySignal);
939
- if (await lazyConnect(state, serverName, recoverySignal)) {
940
- updateStatusBar(state);
941
- return state.manager.getConnection(serverName);
942
- }
943
- }
944
- }
945
- return challenge ? undefined : state.manager.getConnection(serverName);
946
- };
947
- const failure = (error: string, message: string) => ({
948
- content: [{ type: "text" as const, text: message }],
949
- details: { mode: "call", error, server: serverName, message },
950
- });
951
- const definition = state.config.mcpServers[serverName];
952
- if (!definition) return { error: failure("server_not_found", `Server "${serverName}" not found.`) };
953
- if (isServerDisabled(definition)) return { error: disabledResult("call", serverName) };
954
- try {
955
- // Connected metadata is read below, so it does not require another connection attempt.
956
- let connection = state.manager.getConnection(serverName);
957
- if (connection?.status !== "connected") {
958
- await lazyConnect(state, serverName, ownedSignal);
959
- connection = state.manager.getConnection(serverName);
960
- } else {
961
- markKeepAliveAfterConnect(state, serverName);
962
- }
963
- if (connection?.status === "needs-auth") connection = await recoverAuthConnection(serverName, ownedSignal);
964
- if (connection?.status === "needs-auth") throw new SessionRecoveryAuthRequiredError(serverName);
965
- if (connection?.status !== "connected") {
966
- const failedAgo = getFailureAgeSeconds(state, serverName);
967
- return { error: failure(failedAgo === null ? "server_unavailable" : "server_backoff", `Server "${serverName}" not available${failedAgo === null ? "" : ` (last failed ${failedAgo}s ago)`}`) };
968
- }
969
- // Use the live descriptor for visibility, filtering, approval, schema and UI hints.
970
- if (connection.tools) {
971
- const { metadata } = buildToolMetadata(connection.tools, connection.resources ?? [], definition, serverName, state.config.settings?.toolPrefix ?? "server");
972
- state.toolMetadata.set(serverName, metadata);
973
- }
974
- return { connection, recoverAuthConnection, autoAuthAttempted: () => autoAuthAttempted };
975
- } catch (error) {
976
- if (isAbortError(error, ownedSignal)) throw error;
977
- const message = error instanceof SessionRecoveryAuthRequiredError
978
- ? error.authMessage ?? getAuthRequiredMessage(state, serverName)
979
- : error instanceof Error ? error.message : String(error);
980
- if (!(error instanceof SessionRecoveryAuthRequiredError)) {
981
- recordFailure(state, serverName, message);
982
- updateStatusBar(state);
983
- }
984
- return { error: failure(error instanceof SessionRecoveryAuthRequiredError ? "auth_required" : "connect_failed", message) };
985
- }
986
- }
987
-
988
- export async function executeCall(
989
- state: McpExtensionState,
990
- toolName: string,
991
- args?: Record<string, unknown>,
992
- serverOverride?: string,
993
- getPiTools?: () => ToolInfo[],
994
- signal?: AbortSignal,
995
- identity: McpToolCallIdentity = {},
996
- beforeDispatch?: (signal: AbortSignal | undefined, operation: McpOperationContext) => Promise<void>,
997
- options: ExecuteCallOptions = {},
998
- ): Promise<ProxyToolResult> {
999
- const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
1000
- throwIfAborted(ownedSignal);
1001
- if (typeof toolName !== "string" || !toolName.trim() || (serverOverride !== undefined && (typeof serverOverride !== "string" || !serverOverride))) return {
1002
- content: [{ type: "text", text: "Specify a non-empty tool name and, when supplied, server." }],
1003
- details: { mode: "call", error: "invalid_arguments" },
1004
- };
1005
- if (args !== undefined && (args === null || typeof args !== "object" || Array.isArray(args))) return {
1006
- content: [{ type: "text", text: "Tool arguments must be an object." }],
1007
- details: { mode: "call", error: "invalid_arguments", requestedTool: toolName },
1008
- };
1009
- let serverName = serverOverride;
1010
- const matches = (metadata: ToolMetadata[] = []) => {
1011
- if (options.resourceUri !== undefined) return metadata.filter(tool => tool.resourceUri === options.resourceUri);
1012
- if (options.exactOriginalName) return metadata.filter(tool => tool.originalName === toolName && !tool.resourceUri);
1013
- if (serverOverride) {
1014
- const originals = metadata.filter(tool => tool.originalName === toolName);
1015
- if (originals.length) return originals;
1016
- }
1017
- const exact = metadata.filter(tool => tool.name === toolName);
1018
- if (exact.length) return exact;
1019
- return metadata.filter(tool => tool.name.replace(/-/g, "_") === toolName.replace(/-/g, "_"));
1020
- };
1021
- const ambiguous = (): ProxyToolResult => ({
1022
- content: [{ type: "text", text: `Tool "${toolName}" is ambiguous. Specify its exact server and tool name; no call was dispatched.` }],
1023
- details: { mode: "call", error: "ambiguous_tool", requestedTool: toolName, ...(serverName ? { server: serverName } : {}) },
1024
- });
1025
- if (!serverName) {
1026
- const candidates = [...state.toolMetadata].flatMap(([server, metadata]) =>
1027
- isServerDisabled(state.config.mcpServers[server]) ? [] : matches(metadata).map(tool => ({ server, tool })));
1028
- if (candidates.length > 1) return ambiguous();
1029
- serverName = candidates[0]?.server;
1030
- if (!serverName) {
1031
- const disabled = [...state.toolMetadata].find(([server, metadata]) => isServerDisabled(state.config.mcpServers[server]) && matches(metadata).length > 0);
1032
- if (disabled) return disabledResult("call", disabled[0]);
1033
- const prefixes = Object.entries(state.config.mcpServers)
1034
- .filter(([, definition]) => !isServerDisabled(definition))
1035
- .map(([server, definition]) => ({ server, prefix: getServerPrefix(server, resolveToolPrefix(definition, state.config.settings?.toolPrefix ?? "server")) }))
1036
- .filter(({ prefix }) => prefix && toolName.startsWith(`${prefix}_`))
1037
- .sort((a, b) => b.prefix.length - a.prefix.length);
1038
- if (prefixes.length > 1 && prefixes[0]?.prefix === prefixes[1]?.prefix) return ambiguous();
1039
- serverName = prefixes[0]?.server;
1040
- }
1041
- }
1042
- const missing = (): ProxyToolResult => {
1043
- const nativeTool = !serverOverride && getPiTools?.().find(tool => tool.name === toolName && tool.name !== "mcp");
1044
- if (nativeTool) return {
1045
- content: [{ type: "text", text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ action: "call", tool: "${toolName}" }).` }],
1046
- details: { mode: "call", error: "native_tool", requestedTool: toolName },
1047
- };
1048
- const normalizedName = toolName.replace(/-/g, "_");
1049
- const localOriginal = serverOverride && state.toolMetadata.get(serverOverride)?.some(tool => tool.originalName.replace(/-/g, "_") === normalizedName);
1050
- const owners = serverOverride && !localOriginal
1051
- ? [...state.toolMetadata].filter(([server, metadata]) => server !== serverOverride && !isServerDisabled(state.config.mcpServers[server]) && matches(metadata).length === 1)
1052
- : [];
1053
- const suggestedOwner = owners.length === 1 ? owners[0] : undefined;
1054
- if (suggestedOwner) return {
1055
- content: [{ type: "text", text: `Tool ${JSON.stringify(toolName)} is on server ${JSON.stringify(suggestedOwner[0])}, not ${JSON.stringify(serverOverride)}. Retry the same call with server: ${JSON.stringify(suggestedOwner[0])} or omit server.` }],
1056
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer: serverName, suggestedServer: suggestedOwner[0], suggestions: matches(suggestedOwner[1]).map(tool => tool.name) },
1057
- };
1058
- const suggestions = rankSuggestions(state, toolName, 5, serverName);
1059
- let hint = suggestions.length
1060
- ? ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: ${JSON.stringify(suggestions[0])} }).`
1061
- : serverName ? ` Search with mcp({ action: "search", query: ${JSON.stringify(toolName)}, server: ${JSON.stringify(serverName)} }).` : ' Use mcp({ action: "search", query: "..." }) to search.';
1062
- if (serverName && state.toolMetadata.has(serverName) && state.manager.getConnection(serverName)?.status !== "connected") hint += ` Refresh a stale catalog with mcp({ action: "connect", server: ${JSON.stringify(serverName)} }).`;
1063
- return {
1064
- content: [{ type: "text", text: `Tool "${toolName}" not found.${hint}` }],
1065
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer: serverName, suggestions },
1066
- };
1067
- };
1068
- if (!serverName) return missing();
1069
- const definition = state.config.mcpServers[serverName];
1070
- if (!definition) return { content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }], details: { mode: "call", error: "server_not_found", server: serverName, requestedTool: toolName } };
1071
- if (isServerDisabled(definition)) return disabledResult("call", serverName);
1072
- const cached = state.toolMetadata.get(serverName);
1073
- if (matches(cached).length > 1) return ambiguous();
1074
- // Misspellings in a known catalog do not cause connection churn or guessed execution.
1075
- if (cached && !matches(cached).length && !options.exactOriginalName && options.resourceUri === undefined) return missing();
1076
-
1077
- const prepared = await prepareMcpConnection(state, serverName, ownedSignal);
1078
- if (prepared.error) {
1079
- const cachedTarget = matches(cached)[0];
1080
- return { ...prepared.error, details: { ...prepared.error.details, ...(cachedTarget
1081
- ? cachedTarget.resourceUri ? { resourceUri: cachedTarget.resourceUri } : { tool: cachedTarget.originalName }
1082
- : { requestedTool: toolName }) } };
1083
- }
1084
- const targets = matches(state.toolMetadata.get(serverName));
1085
- if (targets.length > 1) return ambiguous();
1086
- const toolMeta = targets[0];
1087
- if (!toolMeta) return missing();
1088
- if (isServerDisabled(state.config.mcpServers[serverName])) return disabledResult("call", serverName);
1089
- const approval = await ensureToolCallApproved(state, serverName, toolMeta, args, ownedSignal);
1090
- if (!approval.ok) {
1091
- const denied = approval.reason === "denied";
1092
- return {
1093
- content: [{ type: "text", text: denied
1094
- ? `The user declined approval to run MCP tool "${toolMeta.originalName}" on server "${serverName}".`
1095
- : `MCP tool "${toolMeta.originalName}" on server "${serverName}" is approval-gated and requires an interactive session.` }],
1096
- details: { mode: "call", error: denied ? "approval_denied" : "approval_required", server: serverName, tool: toolMeta.originalName },
1097
- };
1098
- }
1099
- return runToolCall(state, serverName, toolMeta, args, {
1100
- ...identity,
1101
- ...(beforeDispatch ? { beforeDispatch } : {}),
1102
- ...(options.raw !== undefined ? { raw: options.raw } : {}),
1103
- detailsBase: { mode: "call", server: serverName, ...(toolMeta.resourceUri ? { resourceUri: toolMeta.resourceUri } : { tool: toolMeta.originalName }) },
1104
- ownedSignal, signal,
1105
- recoverAuthConnection: prepared.recoverAuthConnection!,
1106
- authRequiredMessage: () => getAuthRequiredMessage(state, serverName),
1107
- autoAuthAttempted: prepared.autoAuthAttempted!,
1108
- });
1109
- }