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 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
- Per-server `idleTimeout` overrides the global setting.
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 isEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
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: { ...base.mcpServers, ...next.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: { ...importedServers, ...config.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: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
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
- let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
387
- if (spec.inputSchema) {
388
- errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
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: [{ type: "text" as const, text: `Error: ${errorText}` }],
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 resultText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "(empty result)";
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: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
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: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
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
- let errorText = `Failed to call tool: ${message}`;
426
- if (spec.inputSchema) {
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: [{ type: "text" as const, text: errorText }],
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) {
@@ -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 || { type: "object", properties: {} }) as never),
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
- }, _signal, _onUpdate, _ctx) {
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 print the URL first so remote/headless users can copy it
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
- console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
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
- // OAuth is enabled if auth is 'oauth' or not specified (auto-detect)
541
- return definition.auth === "oauth" || definition.auth === undefined
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
  /**