pi-mcp-adapter 2.9.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,39 @@ 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
+
32
+ ## [2.10.0] - 2026-06-13
33
+
34
+ ### Added
35
+ - Added manual remote/headless OAuth proxy actions for copying authorization URLs and completing pasted redirect URLs or codes. Thanks @Gabrielgvl for PR #120.
36
+
37
+ ### Fixed
38
+ - Honored user `tui.select.*` keybindings in MCP management, setup, and auth panels. Thanks @owenniles for PR #138.
39
+ - Included configured OAuth scopes in authorization-code flows while preserving token endpoint authentication method selection. Thanks @carlosdagos for PR #140.
40
+ - Fixed MCP elicitation on stock Pi, including form dialogs with validation and review, consent-based URL handling, URL-required errors, completion notifications, and TUI-only browser navigation. Thanks @dmmulroy for PR #139.
41
+ - Expanded MCP schema formatting for nested `anyOf`/`oneOf` variants, `const` discriminators, nested object properties, and array items.
42
+
10
43
  ## [2.9.0] - 2026-06-04
11
44
 
12
45
  ### 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`) |
@@ -142,6 +146,26 @@ Pi-specific files are the write targets for imported or shared global servers wh
142
146
 
143
147
  For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
144
148
 
149
+ ### Remote/headless OAuth
150
+
151
+ If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool:
152
+
153
+ ```js
154
+ mcp({ action: "auth-start", server: "linear-server" })
155
+ ```
156
+
157
+ Open the returned authorization URL in your local browser. After approval, your browser redirects to a localhost URL. On a remote server that local page may fail to load; copy the full URL from the browser address bar anyway and complete the flow in the same Pi session:
158
+
159
+ ```js
160
+ mcp({
161
+ action: "auth-complete",
162
+ server: "linear-server",
163
+ args: '{"redirectUrl":"http://localhost:19876/callback?code=...&state=..."}'
164
+ })
165
+ ```
166
+
167
+ You can also pass only the `code` query parameter with `args: '{"code":"..."}'`. Treat authorization URLs and codes as sensitive; they can grant access to the MCP server until the flow expires or completes.
168
+
145
169
  ### Lifecycle Modes
146
170
 
147
171
  - **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
@@ -154,7 +178,8 @@ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact c
154
178
  {
155
179
  "settings": {
156
180
  "toolPrefix": "server",
157
- "idleTimeout": 10
181
+ "idleTimeout": 10,
182
+ "requestTimeoutMs": 30000
158
183
  },
159
184
  "mcpServers": { }
160
185
  }
@@ -164,19 +189,42 @@ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact c
164
189
  |---------|-------------|
165
190
  | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
166
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) |
167
193
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
168
194
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
169
195
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
170
196
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
171
197
  | `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
172
- | `elicitation` | Allow MCP servers to request user input through Pi UI forms/URL prompts (default: true when Pi UI form support is available). |
173
- | `elicitationAutoOpenUrls` | Automatically open URL elicitations without prompting first (default: false). |
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
174
204
 
175
- Per-server `idleTimeout` overrides the global setting.
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
+ ```
220
+
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.
176
222
 
177
223
  ### MCP Elicitation
178
224
 
179
- When Pi exposes UI, the adapter advertises MCP elicitation support. Form elicitations are rendered with `ctx.ui.form()` and map Pi actions to MCP actions: submit → `accept`, secondary → `decline`, cancel → `cancel`. URL elicitations prompt before opening a browser unless `elicitationAutoOpenUrls` is enabled.
225
+ When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
226
+
227
+ URL mode is advertised only in TUI mode. The adapter displays the requesting server, target host, and full URL, and always requires consent before opening the browser. It also handles URL-required tool errors (`-32042`) and completion notifications; after completing the browser interaction, retry the original tool call.
180
228
 
181
229
  ### Direct Tools
182
230
 
@@ -345,6 +393,8 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
345
393
  | Call | `mcp({ tool: "...", args: '{"key": "value"}' })` |
346
394
  | Connect | `mcp({ connect: "server-name" })` |
347
395
  | UI messages | `mcp({ action: "ui-messages" })` |
396
+ | Auth start | `mcp({ action: "auth-start", server: "name" })` |
397
+ | Auth complete | `mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' })` |
348
398
 
349
399
  MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model.
350
400
 
@@ -367,7 +417,7 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
367
417
 
368
418
  If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls automatically run OAuth when needed and retry once.
369
419
 
370
- In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In non-interactive sessions, browser-based OAuth still requires `/mcp-auth <server>`. `/mcp-auth` without a server only opens a picker in the interactive UI.
420
+ In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In remote/headless sessions, use the proxy tool's `auth-start` and `auth-complete` actions to copy the authorization URL locally and paste the redirect URL back into Pi. `/mcp-auth` without a server only opens a picker in the interactive UI.
371
421
 
372
422
  ## How It Works
373
423
 
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.`;
@@ -286,8 +294,8 @@ export async function openMcpSetup(
286
294
 
287
295
  return new Promise<PanelFlowResult>((resolve) => {
288
296
  ctx.ui.custom(
289
- (tui, _theme, _keybindings, done) => {
290
- return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState }, tui, () => {
297
+ (tui, _theme, keybindings, done) => {
298
+ return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState, keybindings }, tui, () => {
291
299
  done(undefined);
292
300
  resolve({ configChanged });
293
301
  });
@@ -358,7 +366,7 @@ export async function openMcpPanel(
358
366
 
359
367
  await new Promise<void>((resolve) => {
360
368
  ctx.ui.custom(
361
- (tui, _theme, _keybindings, done) => {
369
+ (tui, _theme, keybindings, done) => {
362
370
  return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
363
371
  if (!result.cancelled && result.changes.size > 0) {
364
372
  writeDirectToolsConfig(result.changes, provenanceMap, config);
@@ -367,7 +375,7 @@ export async function openMcpPanel(
367
375
  }
368
376
  done(undefined);
369
377
  resolve();
370
- }, { noticeLines });
378
+ }, { noticeLines, keybindings });
371
379
  },
372
380
  { overlay: true, overlayOptions: { anchor: "center", width: 82 } },
373
381
  );
@@ -403,12 +411,13 @@ export async function openMcpAuthPanel(
403
411
 
404
412
  await new Promise<void>((resolve) => {
405
413
  ctx.ui.custom(
406
- (tui, _theme, _keybindings, done) => {
414
+ (tui, _theme, keybindings, done) => {
407
415
  return createMcpPanel(config, cache, provenanceMap, callbacks, tui, () => {
408
416
  done(undefined);
409
417
  resolve();
410
418
  }, {
411
419
  authOnly: true,
420
+ keybindings,
412
421
  noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."],
413
422
  });
414
423
  },
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
@@ -1,11 +1,14 @@
1
1
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
2
3
  import type { McpExtensionState } from "./state.ts";
3
4
  import type { DirectToolSpec, McpConfig, McpContent } from "./types.ts";
4
5
  import type { MetadataCache } from "./metadata-cache.ts";
5
6
  import { lazyConnect, getFailureAgeSeconds } from "./init.ts";
7
+ import { abortable, throwIfAborted } from "./abort.ts";
6
8
  import { isServerCacheValid } from "./metadata-cache.ts";
7
9
  import { formatSchema } from "./tool-metadata.ts";
8
- import { transformMcpContent } from "./tool-registrar.ts";
10
+ import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
11
+ import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
9
12
  import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
10
13
  import { formatToolName, isToolExcluded } from "./types.ts";
11
14
  import { resourceNameToToolName } from "./resource-tools.ts";
@@ -22,7 +25,7 @@ type DirectAutoAuthResult =
22
25
  function getDirectAuthRequiredMessage(
23
26
  state: McpExtensionState,
24
27
  serverName: string,
25
- defaultMessage = `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.`,
28
+ defaultMessage = `MCP 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.`,
26
29
  ): string {
27
30
  return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
28
31
  }
@@ -32,7 +35,7 @@ function getDirectAuthFailedMessage(state: McpExtensionState, serverName: string
32
35
  if (customGuidance) {
33
36
  return `OAuth authentication failed for "${serverName}": ${message}. ${getDirectAuthRequiredMessage(state, serverName)}`;
34
37
  }
35
- return `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName} first.`;
38
+ 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.`;
36
39
  }
37
40
 
38
41
  async function attemptDirectAutoAuth(
@@ -55,7 +58,7 @@ async function attemptDirectAutoAuth(
55
58
  message: getDirectAuthRequiredMessage(
56
59
  state,
57
60
  serverName,
58
- `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`,
61
+ `MCP 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.`,
59
62
  ),
60
63
  };
61
64
  }
@@ -255,7 +258,9 @@ export function buildProxyDescription(
255
258
  desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
256
259
  desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
257
260
  desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n`;
258
- desc += `\nMode: tool (call) > connect > describe > search > server (list) > action > nothing (status)`;
261
+ desc += ` mcp({ action: "auth-start", server: "name" }) → Start manual OAuth and get a browser URL\n`;
262
+ desc += ` mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' }) → Complete manual OAuth\n`;
263
+ desc += `\nMode: action > tool (call) > connect > describe > search > server (list) > nothing (status)`;
259
264
 
260
265
  return desc;
261
266
  }
@@ -273,7 +278,8 @@ export function createDirectToolExecutor(
273
278
  getInitPromise: () => Promise<McpExtensionState> | null,
274
279
  spec: DirectToolSpec
275
280
  ): DirectToolExecute {
276
- return async function execute(_toolCallId, params) {
281
+ return async function execute(_toolCallId, params, signal) {
282
+ throwIfAborted(signal);
277
283
  let state = getState();
278
284
  const initPromise = getInitPromise();
279
285
 
@@ -295,7 +301,7 @@ export function createDirectToolExecutor(
295
301
  };
296
302
  }
297
303
 
298
- let connected = await lazyConnect(state, spec.serverName);
304
+ let connected = await lazyConnect(state, spec.serverName, signal);
299
305
  let autoAuthAttempted = false;
300
306
 
301
307
  if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
@@ -310,7 +316,7 @@ export function createDirectToolExecutor(
310
316
  if (autoAuth.status === "success") {
311
317
  await state.manager.close(spec.serverName);
312
318
  state.failureTracker.delete(spec.serverName);
313
- connected = await lazyConnect(state, spec.serverName);
319
+ connected = await lazyConnect(state, spec.serverName, signal);
314
320
  }
315
321
  }
316
322
 
@@ -339,20 +345,24 @@ export function createDirectToolExecutor(
339
345
  }
340
346
 
341
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);
342
351
 
343
352
  try {
344
353
  state.manager.touch(spec.serverName);
345
354
  state.manager.incrementInFlight(spec.serverName);
346
355
 
347
356
  if (spec.resourceUri) {
348
- const result = await connection.client.readResource({ uri: spec.resourceUri });
357
+ const result = await connection.client.readResource({ uri: spec.resourceUri }, requestOptions);
349
358
  const content = (result.contents ?? []).map(c => ({
350
359
  type: "text" as const,
351
360
  text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
352
361
  }));
362
+ const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
353
363
  return {
354
- content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
355
- details: { server: spec.serverName, resourceUri: spec.resourceUri },
364
+ content: guarded.content,
365
+ details: { server: spec.serverName, resourceUri: spec.resourceUri, ...guardedMcpDetails(guarded) },
356
366
  };
357
367
  }
358
368
 
@@ -371,50 +381,60 @@ export function createDirectToolExecutor(
371
381
  name: spec.originalName,
372
382
  arguments: params ?? {},
373
383
  _meta: uiSession?.requestMeta,
374
- });
384
+ }, undefined, requestOptions);
375
385
 
376
- const result = await resultPromise;
386
+ const result = await abortable(resultPromise, signal);
377
387
  uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
378
388
 
379
- const mcpContent = (result.content ?? []) as McpContent[];
380
- const content = transformMcpContent(mcpContent);
381
-
382
389
  if (result.isError) {
383
- let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
384
- if (spec.inputSchema) {
385
- errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
386
- }
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" });
387
395
  return {
388
- content: [{ type: "text" as const, text: `Error: ${errorText}` }],
389
- details: { error: "tool_error", server: spec.serverName },
396
+ content: guarded.content,
397
+ details: { error: "tool_error", server: spec.serverName, ...guardedMcpDetails(guarded) },
390
398
  };
391
399
  }
392
400
 
393
- 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)" }];
394
403
  if (hasUi) {
395
404
  const uiMessage = uiSession?.reused
396
405
  ? "Updated the open UI."
397
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}` });
398
408
  return {
399
- content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
400
- 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) },
401
411
  };
402
412
  }
403
413
 
414
+ const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions });
404
415
  return {
405
- content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
406
- details: { server: spec.serverName, tool: spec.originalName },
416
+ content: guarded.content,
417
+ details: { server: spec.serverName, tool: spec.originalName, ...guardedMcpDetails(guarded) },
407
418
  };
408
419
  } catch (error) {
420
+ if (error instanceof UrlElicitationRequiredError) {
421
+ const action = await state.manager.handleUrlElicitationRequired(spec.serverName, error);
422
+ const message = action === "accept"
423
+ ? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
424
+ : `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
425
+ uiSession?.sendToolCancelled(message);
426
+ return {
427
+ content: [{ type: "text" as const, text: message }],
428
+ details: { error: "url_elicitation_required", server: spec.serverName, action },
429
+ };
430
+ }
409
431
  const message = error instanceof Error ? error.message : String(error);
410
432
  uiSession?.sendToolCancelled(message);
411
- let errorText = `Failed to call tool: ${message}`;
412
- if (spec.inputSchema) {
413
- errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
414
- }
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 });
415
435
  return {
416
- content: [{ type: "text" as const, text: errorText }],
417
- details: { error: "call_failed", server: spec.serverName },
436
+ content: guarded.content,
437
+ details: { error: "call_failed", server: spec.serverName, ...guardedMcpDetails(guarded) },
418
438
  };
419
439
  } finally {
420
440
  if (uiSession?.reused) {