pi-mcp-adapter 2.10.0 → 2.11.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.
- package/CHANGELOG.md +22 -0
- package/README.md +30 -3
- package/abort.ts +34 -0
- package/cli.js +2 -1
- package/commands.ts +9 -1
- package/config.ts +13 -2
- package/direct-tools.ts +35 -29
- package/error-signal.ts +21 -0
- package/index.ts +9 -5
- package/init.ts +10 -5
- package/mcp-auth-flow.ts +17 -4
- package/mcp-output-guard.ts +403 -0
- package/mcp-panel.ts +74 -25
- package/metadata-cache.ts +1 -1
- package/npx-resolver.ts +4 -2
- package/package.json +4 -1
- package/proxy-modes.ts +61 -59
- package/server-manager.ts +77 -17
- package/tool-registrar.ts +23 -0
- package/types.ts +20 -1
- package/ui-server.ts +1 -1
- package/utils.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.11.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Restored the tracked npm lockfile for reproducible installs and downstream packaging. Thanks @fmoda3 for issue #71.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Added default-on MCP output guarding with temp-file spillover for oversized text results, compact summaries for large proxy result details, and `settings.outputGuard` tuning. Thanks @tmustier for PR #160.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Defaulted stdio MCP servers without an explicit `cwd` to the Pi session cwd so relative server output lands in the workspace. Thanks @TimoFreiberg for PR #152.
|
|
20
|
+
- Kept multiline/control MCP panel metadata from corrupting rows and made Keep & Close save dirty changes. Thanks @gpmarques for issue/PR #14, @Vahor for issue #115, and @markokocic for issue #134/PR #135.
|
|
21
|
+
- Preserved `--` separators when resolving `npx` wrapper commands so subcommand flags are not consumed by tools like `dotenv-cli`. Thanks @sherif-fanous for issue #15.
|
|
22
|
+
- Merged partial per-server Pi overrides into imported MCP server definitions instead of replacing the full server entry. Thanks @cfbraun for issue #94.
|
|
23
|
+
- Fixed the `pi-mcp-adapter` bin entrypoint when invoked through installed symlinks, so `init` runs instead of silently exiting. Thanks @cfbraun for issue #95.
|
|
24
|
+
- Normalized direct MCP tool schemas so draft metadata and strict top-level additional properties do not break Pi registration. Thanks @marchellodev for issue #2/PR #3 and @comtihon for PR #144.
|
|
25
|
+
- Routed interactive `/mcp-auth` OAuth URLs through Pi UI notifications so long authorization links remain intact instead of being truncated by raw terminal output. Thanks @feoh for issue #147/PR #148.
|
|
26
|
+
- Respected configured HTTP headers before implicit OAuth auto-detection so API-key/custom-header MCP servers do not trigger OAuth DCR. Thanks @OnlyXianzo for issue #158.
|
|
27
|
+
- Propagated Pi abort signals into MCP connect, resource, and tool requests so cancelled calls settle promptly. Thanks @xz-dev for PR #159.
|
|
28
|
+
- Re-flagged failed MCP tool calls (`tool_error`/`call_failed`) as errors so they are recorded as failures (`isError: true`) instead of successes. Thanks @ishinder for PR #157.
|
|
29
|
+
- Honored configured `requestTimeoutMs` during MCP connection, discovery, tool, resource, and UI proxy requests. Thanks @mizuikki for PR #155.
|
|
30
|
+
- Rendered successful MCP `structuredContent` when servers return it without `content`. Thanks @dovixman for PR #146.
|
|
31
|
+
|
|
10
32
|
## [2.10.0] - 2026-06-13
|
|
11
33
|
|
|
12
34
|
### Added
|
package/README.md
CHANGED
|
@@ -101,6 +101,8 @@ Use the shared MCP files when you want one setup to work across hosts, and Pi-ow
|
|
|
101
101
|
|
|
102
102
|
Pi-specific files are the write targets for imported or shared global servers when Pi needs to persist adapter-only settings such as `directTools`.
|
|
103
103
|
|
|
104
|
+
In the configuration examples below, `30000` is illustrative only. If `requestTimeoutMs` is omitted or set to `<= 0`, the MCP SDK default timeout is used.
|
|
105
|
+
|
|
104
106
|
### Server Options
|
|
105
107
|
|
|
106
108
|
```json
|
|
@@ -110,7 +112,8 @@ Pi-specific files are the write targets for imported or shared global servers wh
|
|
|
110
112
|
"command": "npx",
|
|
111
113
|
"args": ["-y", "some-mcp-server"],
|
|
112
114
|
"lifecycle": "lazy",
|
|
113
|
-
"idleTimeout": 10
|
|
115
|
+
"idleTimeout": 10,
|
|
116
|
+
"requestTimeoutMs": 30000
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
}
|
|
@@ -135,6 +138,7 @@ Pi-specific files are the write targets for imported or shared global servers wh
|
|
|
135
138
|
| `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation |
|
|
136
139
|
| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
|
|
137
140
|
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
|
141
|
+
| `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
138
142
|
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
|
139
143
|
| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
|
|
140
144
|
| `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) |
|
|
@@ -174,7 +178,8 @@ You can also pass only the `code` query parameter with `args: '{"code":"..."}'`.
|
|
|
174
178
|
{
|
|
175
179
|
"settings": {
|
|
176
180
|
"toolPrefix": "server",
|
|
177
|
-
"idleTimeout": 10
|
|
181
|
+
"idleTimeout": 10,
|
|
182
|
+
"requestTimeoutMs": 30000
|
|
178
183
|
},
|
|
179
184
|
"mcpServers": { }
|
|
180
185
|
}
|
|
@@ -184,14 +189,36 @@ You can also pass only the `code` query parameter with `args: '{"code":"..."}'`.
|
|
|
184
189
|
|---------|-------------|
|
|
185
190
|
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
|
|
186
191
|
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
|
192
|
+
| `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
187
193
|
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
|
188
194
|
| `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
|
|
189
195
|
| `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
|
|
190
196
|
| `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
|
|
191
197
|
| `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
|
|
192
198
|
| `elicitation` | Allow MCP servers to request user input through Pi dialogs (default: true when Pi UI is available). |
|
|
199
|
+
| `outputGuard` | Guard oversized MCP output: `true` (default), `false`, or `{ maxBytes, maxLines, detailsMaxBytes }`. See [Output Guard](#output-guard). |
|
|
200
|
+
|
|
201
|
+
Per-server `idleTimeout` and `requestTimeoutMs` override the global settings.
|
|
202
|
+
|
|
203
|
+
### Output Guard
|
|
204
|
+
|
|
205
|
+
Oversized MCP tool/resource results are guarded by default so a single huge response can't blow up the model context window or the session file:
|
|
206
|
+
|
|
207
|
+
- Inline text output is capped at **50 KiB / 2,000 lines** (matching Pi's built-in `bash` guard). Larger output is truncated to a head preview and the full text is saved to a temp file whose path is included in the result, so the agent can `read`/`grep` it.
|
|
208
|
+
- **Image content blocks pass through unchanged** — only text output is guarded. Images are delivered to the provider as native image content.
|
|
209
|
+
- In proxy mode, `details.mcpResult` is kept raw when its JSON is **≤ 16 KiB**; larger results are replaced with a compact summary (block counts, sizes, key previews) and the raw JSON is saved to a temp file. Direct tools keep their lean details and never carry `mcpResult`.
|
|
210
|
+
|
|
211
|
+
Tune the limits with the object form:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"settings": {
|
|
216
|
+
"outputGuard": { "maxBytes": 51200, "maxLines": 2000, "detailsMaxBytes": 16384 }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
193
220
|
|
|
194
|
-
|
|
221
|
+
Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — to disable the guard and restore raw output behavior. Saved temp files are created with mode `0600` under the system temp directory and are not cleaned up automatically; note that spilled MCP output may contain sensitive data.
|
|
195
222
|
|
|
196
223
|
### MCP Elicitation
|
|
197
224
|
|
package/abort.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function throwIfAborted(signal?: AbortSignal): void {
|
|
2
|
+
if (!signal?.aborted) return;
|
|
3
|
+
throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "MCP request aborted"));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
7
|
+
if (!signal) return promise;
|
|
8
|
+
throwIfAborted(signal);
|
|
9
|
+
return await new Promise<T>((resolve, reject) => {
|
|
10
|
+
let settled = false;
|
|
11
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
12
|
+
const onAbort = () => {
|
|
13
|
+
if (settled) return;
|
|
14
|
+
settled = true;
|
|
15
|
+
cleanup();
|
|
16
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "MCP request aborted")));
|
|
17
|
+
};
|
|
18
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
19
|
+
promise.then(
|
|
20
|
+
value => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
cleanup();
|
|
24
|
+
resolve(value);
|
|
25
|
+
},
|
|
26
|
+
error => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true;
|
|
29
|
+
cleanup();
|
|
30
|
+
reject(error);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
package/cli.js
CHANGED
|
@@ -172,7 +172,8 @@ export async function main(argv = process.argv.slice(2), log = console.log, erro
|
|
|
172
172
|
return 1;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
const
|
|
175
|
+
const resolvedEntrypoint = process.argv[1] ? fs.realpathSync(process.argv[1]) : undefined;
|
|
176
|
+
const isEntrypoint = resolvedEntrypoint && import.meta.url === pathToFileURL(resolvedEntrypoint).href;
|
|
176
177
|
|
|
177
178
|
if (isEntrypoint) {
|
|
178
179
|
main().then((code) => {
|
package/commands.ts
CHANGED
|
@@ -168,7 +168,15 @@ export async function authenticateServer(
|
|
|
168
168
|
|
|
169
169
|
try {
|
|
170
170
|
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
|
|
171
|
-
const status = await authenticate(serverName, definition.url, definition
|
|
171
|
+
const status = await authenticate(serverName, definition.url, definition, {
|
|
172
|
+
onAuthorizationUrl: (authorizationUrl) => {
|
|
173
|
+
ctx.ui.notify(
|
|
174
|
+
`Open this URL to authenticate ${serverName}:\n\n${authorizationUrl}\n\n` +
|
|
175
|
+
"After approving, return to Pi; the local callback will complete automatically.",
|
|
176
|
+
"info"
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
172
180
|
|
|
173
181
|
if (status === "authenticated") {
|
|
174
182
|
const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
|
package/config.ts
CHANGED
|
@@ -250,12 +250,23 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
|
|
|
250
250
|
|
|
251
251
|
function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
|
|
252
252
|
return {
|
|
253
|
-
mcpServers:
|
|
253
|
+
mcpServers: mergeServerMaps(base.mcpServers, next.mcpServers),
|
|
254
254
|
imports: mergeImports(base.imports, next.imports),
|
|
255
255
|
settings: next.settings ? { ...base.settings, ...next.settings } : base.settings,
|
|
256
256
|
};
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
function mergeServerMaps(
|
|
260
|
+
base: Record<string, ServerEntry>,
|
|
261
|
+
next: Record<string, ServerEntry>,
|
|
262
|
+
): Record<string, ServerEntry> {
|
|
263
|
+
const merged = { ...base };
|
|
264
|
+
for (const [name, definition] of Object.entries(next)) {
|
|
265
|
+
merged[name] = { ...(merged[name] ?? {}), ...definition };
|
|
266
|
+
}
|
|
267
|
+
return merged;
|
|
268
|
+
}
|
|
269
|
+
|
|
259
270
|
function mergeImports(left: ImportKind[] | undefined, right: ImportKind[] | undefined): ImportKind[] | undefined {
|
|
260
271
|
const merged = [...(left ?? []), ...(right ?? [])];
|
|
261
272
|
if (merged.length === 0) return undefined;
|
|
@@ -286,7 +297,7 @@ function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig {
|
|
|
286
297
|
return {
|
|
287
298
|
imports: config.imports,
|
|
288
299
|
settings: config.settings,
|
|
289
|
-
mcpServers:
|
|
300
|
+
mcpServers: mergeServerMaps(importedServers, config.mcpServers),
|
|
290
301
|
};
|
|
291
302
|
}
|
|
292
303
|
|
package/direct-tools.ts
CHANGED
|
@@ -4,9 +4,11 @@ import type { McpExtensionState } from "./state.ts";
|
|
|
4
4
|
import type { DirectToolSpec, McpConfig, McpContent } from "./types.ts";
|
|
5
5
|
import type { MetadataCache } from "./metadata-cache.ts";
|
|
6
6
|
import { lazyConnect, getFailureAgeSeconds } from "./init.ts";
|
|
7
|
+
import { abortable, throwIfAborted } from "./abort.ts";
|
|
7
8
|
import { isServerCacheValid } from "./metadata-cache.ts";
|
|
8
9
|
import { formatSchema } from "./tool-metadata.ts";
|
|
9
|
-
import { transformMcpContent } from "./tool-registrar.ts";
|
|
10
|
+
import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
|
|
11
|
+
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
10
12
|
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
|
11
13
|
import { formatToolName, isToolExcluded } from "./types.ts";
|
|
12
14
|
import { resourceNameToToolName } from "./resource-tools.ts";
|
|
@@ -276,7 +278,8 @@ export function createDirectToolExecutor(
|
|
|
276
278
|
getInitPromise: () => Promise<McpExtensionState> | null,
|
|
277
279
|
spec: DirectToolSpec
|
|
278
280
|
): DirectToolExecute {
|
|
279
|
-
return async function execute(_toolCallId, params) {
|
|
281
|
+
return async function execute(_toolCallId, params, signal) {
|
|
282
|
+
throwIfAborted(signal);
|
|
280
283
|
let state = getState();
|
|
281
284
|
const initPromise = getInitPromise();
|
|
282
285
|
|
|
@@ -298,7 +301,7 @@ export function createDirectToolExecutor(
|
|
|
298
301
|
};
|
|
299
302
|
}
|
|
300
303
|
|
|
301
|
-
let connected = await lazyConnect(state, spec.serverName);
|
|
304
|
+
let connected = await lazyConnect(state, spec.serverName, signal);
|
|
302
305
|
let autoAuthAttempted = false;
|
|
303
306
|
|
|
304
307
|
if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
|
|
@@ -313,7 +316,7 @@ export function createDirectToolExecutor(
|
|
|
313
316
|
if (autoAuth.status === "success") {
|
|
314
317
|
await state.manager.close(spec.serverName);
|
|
315
318
|
state.failureTracker.delete(spec.serverName);
|
|
316
|
-
connected = await lazyConnect(state, spec.serverName);
|
|
319
|
+
connected = await lazyConnect(state, spec.serverName, signal);
|
|
317
320
|
}
|
|
318
321
|
}
|
|
319
322
|
|
|
@@ -342,20 +345,24 @@ export function createDirectToolExecutor(
|
|
|
342
345
|
}
|
|
343
346
|
|
|
344
347
|
let uiSession: UiSessionRuntime | null = null;
|
|
348
|
+
const requestOptions = state.manager.getRequestOptions?.(spec.serverName, signal) ?? (signal ? { signal } : undefined);
|
|
349
|
+
|
|
350
|
+
const outputGuardOptions = resolveMcpOutputGuardOptions(state.config.settings);
|
|
345
351
|
|
|
346
352
|
try {
|
|
347
353
|
state.manager.touch(spec.serverName);
|
|
348
354
|
state.manager.incrementInFlight(spec.serverName);
|
|
349
355
|
|
|
350
356
|
if (spec.resourceUri) {
|
|
351
|
-
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
|
357
|
+
const result = await connection.client.readResource({ uri: spec.resourceUri }, requestOptions);
|
|
352
358
|
const content = (result.contents ?? []).map(c => ({
|
|
353
359
|
type: "text" as const,
|
|
354
360
|
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
355
361
|
}));
|
|
362
|
+
const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
|
|
356
363
|
return {
|
|
357
|
-
content:
|
|
358
|
-
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
|
364
|
+
content: guarded.content,
|
|
365
|
+
details: { server: spec.serverName, resourceUri: spec.resourceUri, ...guardedMcpDetails(guarded) },
|
|
359
366
|
};
|
|
360
367
|
}
|
|
361
368
|
|
|
@@ -374,39 +381,40 @@ export function createDirectToolExecutor(
|
|
|
374
381
|
name: spec.originalName,
|
|
375
382
|
arguments: params ?? {},
|
|
376
383
|
_meta: uiSession?.requestMeta,
|
|
377
|
-
});
|
|
384
|
+
}, undefined, requestOptions);
|
|
378
385
|
|
|
379
|
-
const result = await resultPromise;
|
|
386
|
+
const result = await abortable(resultPromise, signal);
|
|
380
387
|
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
|
381
388
|
|
|
382
|
-
const mcpContent = (result.content ?? []) as McpContent[];
|
|
383
|
-
const content = transformMcpContent(mcpContent);
|
|
384
|
-
|
|
385
389
|
if (result.isError) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
+
const mcpContent = (result.content ?? []) as McpContent[];
|
|
391
|
+
const content = transformMcpContent(mcpContent);
|
|
392
|
+
const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
|
|
393
|
+
const schemaText = spec.inputSchema ? `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` : "";
|
|
394
|
+
const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed" });
|
|
390
395
|
return {
|
|
391
|
-
content:
|
|
392
|
-
details: { error: "tool_error", server: spec.serverName },
|
|
396
|
+
content: guarded.content,
|
|
397
|
+
details: { error: "tool_error", server: spec.serverName, ...guardedMcpDetails(guarded) },
|
|
393
398
|
};
|
|
394
399
|
}
|
|
395
400
|
|
|
396
|
-
const
|
|
401
|
+
const content = resolveMcpResultContent(result as Record<string, unknown>);
|
|
402
|
+
const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
|
|
397
403
|
if (hasUi) {
|
|
398
404
|
const uiMessage = uiSession?.reused
|
|
399
405
|
? "Updated the open UI."
|
|
400
406
|
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
|
407
|
+
const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, suffix: `\n\n${uiMessage}` });
|
|
401
408
|
return {
|
|
402
|
-
content:
|
|
403
|
-
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true },
|
|
409
|
+
content: guarded.content,
|
|
410
|
+
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true, ...guardedMcpDetails(guarded) },
|
|
404
411
|
};
|
|
405
412
|
}
|
|
406
413
|
|
|
414
|
+
const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions });
|
|
407
415
|
return {
|
|
408
|
-
content:
|
|
409
|
-
details: { server: spec.serverName, tool: spec.originalName },
|
|
416
|
+
content: guarded.content,
|
|
417
|
+
details: { server: spec.serverName, tool: spec.originalName, ...guardedMcpDetails(guarded) },
|
|
410
418
|
};
|
|
411
419
|
} catch (error) {
|
|
412
420
|
if (error instanceof UrlElicitationRequiredError) {
|
|
@@ -422,13 +430,11 @@ export function createDirectToolExecutor(
|
|
|
422
430
|
}
|
|
423
431
|
const message = error instanceof Error ? error.message : String(error);
|
|
424
432
|
uiSession?.sendToolCancelled(message);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
428
|
-
}
|
|
433
|
+
const schemaText = spec.inputSchema ? `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` : "";
|
|
434
|
+
const guarded = await guardMcpOutput([{ type: "text" as const, text: message }], { ...outputGuardOptions, prefix: "Failed to call tool: ", suffix: schemaText });
|
|
429
435
|
return {
|
|
430
|
-
content:
|
|
431
|
-
details: { error: "call_failed", server: spec.serverName },
|
|
436
|
+
content: guarded.content,
|
|
437
|
+
details: { error: "call_failed", server: spec.serverName, ...guardedMcpDetails(guarded) },
|
|
432
438
|
};
|
|
433
439
|
} finally {
|
|
434
440
|
if (uiSession?.reused) {
|
package/error-signal.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide the `isError` override for a finished tool result in the `tool_result` hook.
|
|
3
|
+
*
|
|
4
|
+
* A failed MCP tool call is *returned* (not thrown), tagged `details.error: "tool_error"` (the server
|
|
5
|
+
* returned an error result) or `"call_failed"` (the call itself threw and was caught). pi never reads a
|
|
6
|
+
* result-level `isError`, so without this such a call is recorded as a success. Returning
|
|
7
|
+
* `{ isError: true }` (and nothing else) flips the flag; pi's field-by-field merge keeps the original
|
|
8
|
+
* `content` and `details` intact.
|
|
9
|
+
*
|
|
10
|
+
* Limited to those two codes: the adapter's other `details.error` values (`auth_required`, connection
|
|
11
|
+
* states, search/validation feedback, ...) are not failed tool calls, so they get no override.
|
|
12
|
+
*/
|
|
13
|
+
export function toolErrorOverride(details: unknown): { isError: true } | undefined {
|
|
14
|
+
if (details && typeof details === "object" && "error" in details) {
|
|
15
|
+
const code = (details as { error?: unknown }).error;
|
|
16
|
+
if (code === "tool_error" || code === "call_failed") {
|
|
17
|
+
return { isError: true };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
package/index.ts
CHANGED
|
@@ -7,9 +7,10 @@ import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDi
|
|
|
7
7
|
import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
|
|
8
8
|
import { loadMetadataCache } from "./metadata-cache.ts";
|
|
9
9
|
import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
|
|
10
|
-
import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
|
|
10
|
+
import { getConfigPathFromArgv, normalizeDirectToolInputSchema, truncateAtWord } from "./utils.ts";
|
|
11
11
|
import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
|
|
12
12
|
import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
|
|
13
|
+
import { toolErrorOverride } from "./error-signal.ts";
|
|
13
14
|
|
|
14
15
|
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
15
16
|
let state: McpExtensionState | null = null;
|
|
@@ -72,7 +73,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
72
73
|
label: `MCP: ${spec.originalName}`,
|
|
73
74
|
description: spec.description || "(no description)",
|
|
74
75
|
promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
|
|
75
|
-
parameters: Type.Unsafe((spec.inputSchema
|
|
76
|
+
parameters: Type.Unsafe(normalizeDirectToolInputSchema(spec.inputSchema) as never),
|
|
76
77
|
execute: createDirectToolExecutor(() => state, () => initPromise, spec),
|
|
77
78
|
renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
|
|
78
79
|
renderResult: renderMcpToolResult,
|
|
@@ -153,6 +154,9 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
153
154
|
}
|
|
154
155
|
});
|
|
155
156
|
|
|
157
|
+
// Re-flag returned MCP tool failures so pi registers them as errors (see toolErrorOverride).
|
|
158
|
+
pi.on("tool_result", (event) => toolErrorOverride(event.details));
|
|
159
|
+
|
|
156
160
|
pi.registerCommand("mcp", {
|
|
157
161
|
description: "Show MCP server status",
|
|
158
162
|
handler: async (args, ctx) => {
|
|
@@ -276,7 +280,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
276
280
|
includeSchemas?: boolean;
|
|
277
281
|
server?: string;
|
|
278
282
|
action?: string;
|
|
279
|
-
},
|
|
283
|
+
}, signal, _onUpdate, _ctx) {
|
|
280
284
|
let parsedArgs: Record<string, unknown> | undefined;
|
|
281
285
|
if (params.args) {
|
|
282
286
|
try {
|
|
@@ -340,10 +344,10 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
340
344
|
return executeAuthComplete(state, params.server, input);
|
|
341
345
|
}
|
|
342
346
|
if (params.tool) {
|
|
343
|
-
return executeCall(state, params.tool, parsedArgs, params.server, getPiTools);
|
|
347
|
+
return executeCall(state, params.tool, parsedArgs, params.server, getPiTools, signal);
|
|
344
348
|
}
|
|
345
349
|
if (params.connect) {
|
|
346
|
-
return executeConnect(state, params.connect);
|
|
350
|
+
return executeConnect(state, params.connect, signal);
|
|
347
351
|
}
|
|
348
352
|
if (params.describe) {
|
|
349
353
|
return executeDescribe(state, params.describe);
|
package/init.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { UiResourceHandler } from "./ui-resource-handler.ts";
|
|
|
22
22
|
import { openUrl, parallelLimit } from "./utils.ts";
|
|
23
23
|
import { logger } from "./logger.ts";
|
|
24
24
|
import { getMissingConfiguredDirectToolServers } from "./direct-tools.ts";
|
|
25
|
+
import { throwIfAborted } from "./abort.ts";
|
|
25
26
|
|
|
26
27
|
const FAILURE_BACKOFF_MS = 60 * 1000;
|
|
27
28
|
|
|
@@ -36,7 +37,8 @@ export async function initializeMcp(
|
|
|
36
37
|
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
|
37
38
|
const config = loadMcpConfig(configPath, ctx.cwd);
|
|
38
39
|
|
|
39
|
-
const manager = new McpServerManager();
|
|
40
|
+
const manager = new McpServerManager(ctx.cwd);
|
|
41
|
+
manager.setDefaultRequestTimeoutMs(config.settings?.requestTimeoutMs);
|
|
40
42
|
const samplingAutoApprove = config.settings?.samplingAutoApprove === true;
|
|
41
43
|
if (config.settings?.sampling !== false && (ctx.hasUI || samplingAutoApprove)) {
|
|
42
44
|
manager.setSamplingConfig({
|
|
@@ -129,7 +131,7 @@ export async function initializeMcp(
|
|
|
129
131
|
|
|
130
132
|
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
131
133
|
try {
|
|
132
|
-
const connection = await manager.connect(name, definition);
|
|
134
|
+
const connection = await manager.connect(name, definition, ctx.signal);
|
|
133
135
|
if (connection.status === "needs-auth") {
|
|
134
136
|
return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
|
|
135
137
|
}
|
|
@@ -183,7 +185,7 @@ export async function initializeMcp(
|
|
|
183
185
|
async (name) => {
|
|
184
186
|
const definition = config.mcpServers[name];
|
|
185
187
|
try {
|
|
186
|
-
const connection = await manager.connect(name, definition);
|
|
188
|
+
const connection = await manager.connect(name, definition, ctx.signal);
|
|
187
189
|
if (connection.status === "needs-auth") {
|
|
188
190
|
return { name, ok: false };
|
|
189
191
|
}
|
|
@@ -297,7 +299,7 @@ export function getFailureAgeSeconds(state: McpExtensionState, serverName: strin
|
|
|
297
299
|
return Math.round(ageMs / 1000);
|
|
298
300
|
}
|
|
299
301
|
|
|
300
|
-
export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
|
302
|
+
export async function lazyConnect(state: McpExtensionState, serverName: string, signal?: AbortSignal): Promise<boolean> {
|
|
301
303
|
const connection = state.manager.getConnection(serverName);
|
|
302
304
|
if (connection?.status === "needs-auth") {
|
|
303
305
|
return false;
|
|
@@ -317,7 +319,7 @@ export async function lazyConnect(state: McpExtensionState, serverName: string):
|
|
|
317
319
|
if (state.ui) {
|
|
318
320
|
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
319
321
|
}
|
|
320
|
-
const newConnection = await state.manager.connect(serverName, definition);
|
|
322
|
+
const newConnection = await state.manager.connect(serverName, definition, signal);
|
|
321
323
|
if (newConnection.status === "needs-auth") {
|
|
322
324
|
return false;
|
|
323
325
|
}
|
|
@@ -327,6 +329,9 @@ export async function lazyConnect(state: McpExtensionState, serverName: string):
|
|
|
327
329
|
updateStatusBar(state);
|
|
328
330
|
return true;
|
|
329
331
|
} catch (error) {
|
|
332
|
+
if (signal?.aborted) {
|
|
333
|
+
throwIfAborted(signal);
|
|
334
|
+
}
|
|
330
335
|
state.failureTracker.set(serverName, Date.now());
|
|
331
336
|
const message = error instanceof Error ? error.message : String(error);
|
|
332
337
|
logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
|
package/mcp-auth-flow.ts
CHANGED
|
@@ -36,6 +36,10 @@ import type { ServerEntry } from "./types.ts"
|
|
|
36
36
|
/** Auth status for a server */
|
|
37
37
|
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
|
38
38
|
|
|
39
|
+
export interface AuthenticateOptions {
|
|
40
|
+
onAuthorizationUrl?: (authorizationUrl: string) => void | Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
// Track pending transports for auth completion
|
|
40
44
|
const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
|
|
41
45
|
const pendingAuthStates = new Map<string, string>()
|
|
@@ -372,6 +376,7 @@ export async function authenticate(
|
|
|
372
376
|
serverName: string,
|
|
373
377
|
serverUrl: string,
|
|
374
378
|
definition?: ServerEntry,
|
|
379
|
+
options: AuthenticateOptions = {},
|
|
375
380
|
): Promise<AuthStatus> {
|
|
376
381
|
const inFlight = pendingAuthentications.get(serverName)
|
|
377
382
|
if (inFlight) {
|
|
@@ -397,9 +402,13 @@ export async function authenticate(
|
|
|
397
402
|
const callbackPromise = waitForCallback(oauthState)
|
|
398
403
|
|
|
399
404
|
try {
|
|
400
|
-
// Open browser. Always
|
|
405
|
+
// Open browser. Always surface the URL first so remote/headless users can copy it
|
|
401
406
|
// even when the OS browser handoff is unavailable or invisible.
|
|
402
|
-
|
|
407
|
+
if (options.onAuthorizationUrl) {
|
|
408
|
+
await options.onAuthorizationUrl(authorizationUrl)
|
|
409
|
+
} else {
|
|
410
|
+
console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
|
|
411
|
+
}
|
|
403
412
|
try {
|
|
404
413
|
await open(authorizationUrl)
|
|
405
414
|
} catch (error) {
|
|
@@ -536,9 +545,13 @@ export function supportsOAuth(definition: ServerEntry): boolean {
|
|
|
536
545
|
// Explicitly disabled via auth: false or oauth: false
|
|
537
546
|
if (definition.auth === false) return false
|
|
538
547
|
if (definition.oauth === false) return false
|
|
548
|
+
if (definition.auth === "oauth") return true
|
|
539
549
|
|
|
540
|
-
//
|
|
541
|
-
|
|
550
|
+
// Configured custom headers take precedence over implicit OAuth auto-detection.
|
|
551
|
+
if (definition.headers && Object.keys(definition.headers).length > 0) return false
|
|
552
|
+
|
|
553
|
+
// OAuth is enabled when auth is not specified (auto-detect)
|
|
554
|
+
return definition.auth === undefined
|
|
542
555
|
}
|
|
543
556
|
|
|
544
557
|
/**
|