pi-mcp-adapter 2.19.0 → 2.20.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,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.20.0] - 2026-08-04
11
+
12
+ ### Added
13
+ - Added an MCP tool approval broker event so permission extensions can allow, deny, or abstain on proxy, direct, `mcpScript`, resource, and iframe-originated MCP calls before the built-in `approveTools` prompt runs. Thanks @geshido for issue #279.
14
+ - Added opt-in per-server MCP protocol selection with `protocolVersion: "legacy" | "auto" | "2026-07-28"`. Legacy remains the default; auto negotiates the modern era with conservative legacy fallback, while the pinned mode fails instead of falling back. Thanks @mjfaga for PR #272.
15
+ - Added a strict TypeScript typecheck command and CI gate.
16
+
17
+ ### Changed
18
+ - Migrated the MCP client from the monolithic SDK v1 package to the stable modular `@modelcontextprotocol/client` and `@modelcontextprotocol/core` v2 packages. The stable release restores conservative legacy discovery fallback and declared JSON Schema dialect support while retaining strict OAuth issuer validation.
19
+
20
+ ### Fixed
21
+ - Renamed the MCP scripting tool to camel-case `mcpScript` because Anthropic rejects the previous underscore-form name. Thanks @ritvij14 for issue #278 and @wierdbytes for confirmation and the workaround.
22
+ - Pinned the Chrome DevTools setup preset and README examples to `chrome-devtools-mcp@1.6.0` instead of `@latest`, so reviewed scaffolded commands stay stable. Thanks @fitchmultz for issue #274.
23
+ - Removed the adapter's throwaway Streamable HTTP initialize probe. HTTP connections now initialize once on the real client and use narrowly classified SSE fallback, avoiding duplicate sessions and preventing authentication, cancellation, timeout, negotiation, and server failures from being misclassified as transport incompatibility.
24
+ - Stopped tokenless discovery requests, sandboxed MCP app documents, unrelated child windows, and app-opened popups from gaining session authority; discovery now serves a non-sensitive landing page, app HTML loads with a separate resource-only token, host messages accept only the app frame as their source, and the app response enforces sandboxing even when opened as a top-level page.
25
+
10
26
  ## [2.19.0] - 2026-08-03
11
27
 
12
28
  ### Added
package/README.md CHANGED
@@ -45,7 +45,7 @@ Preferred project config: `.mcp.json`
45
45
  "mcpServers": {
46
46
  "chrome-devtools": {
47
47
  "command": "npx",
48
- "args": ["-y", "chrome-devtools-mcp@latest"]
48
+ "args": ["-y", "chrome-devtools-mcp@1.6.0"]
49
49
  }
50
50
  }
51
51
  }
@@ -191,6 +191,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
191
191
  | `lifecycle` | `"lazy"` (default), `"eager"`, `"keep-alive"`, or `"lazy-keep-alive"` |
192
192
  | `idleTimeout` | Minutes before idle disconnect (overrides global) |
193
193
  | `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
194
+ | `protocolVersion` | `"legacy"` (default), `"auto"`, or `"2026-07-28"`; modern negotiation is opt-in |
194
195
  | `exposeResources` | Expose MCP resources as tools (default: true) |
195
196
  | `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
196
197
  | `toolPrefix` | Override global `settings.toolPrefix` for this server (`"server"`, `"short"`, `"none"`, or `"mcp"`) |
@@ -200,6 +201,16 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
200
201
  | `trace` | Enable metadata-only JSONL protocol tracing for this server; payloads, prompts, tool arguments/results, authorization data, and URLs are never persisted |
201
202
  | `disabled` | Keep the server visible in config and status, but prevent connections, authentication, tools, and resource calls (only literal `true` disables it) |
202
203
 
204
+ #### Protocol version negotiation
205
+
206
+ The adapter defaults to `protocolVersion: "legacy"`. Omitting the field uses the classic MCP initialize sequence without `server/discover` or 2026 headers, preserving compatibility with deployed 2025-era servers.
207
+
208
+ Use `"auto"` to probe for MCP 2026-07-28 and conservatively fall back to the classic handshake when the server provides legacy evidence. For stdio servers, the SDK probes with a short-lived sibling process before starting the session process, so each fresh auto connection adds one process spawn and can wait for the configured request timeout. Explicit Unix sockets are custom transports and probe in place. HTTP auto negotiation uses the actual Streamable HTTP connection; the adapter falls back to legacy SSE only when the endpoint definitively rejects Streamable HTTP (for example 404/405/406/415), never for authentication failures, cancellation, timeouts, or server errors.
209
+
210
+ Use `"2026-07-28"` to pin that revision. Pinning has no legacy or SSE fallback and fails if the server does not offer the requested version.
211
+
212
+ The stable SDK handles era-specific request envelopes, result decoding, list-changed subscriptions, cancellation, and multi-round-trip sampling/elicitation. The adapter keeps strict OAuth issuer validation in every mode. Adapter-level roots support, standard MCP logging presentation, and configuration/UI for protocol cache hints are not yet implemented.
213
+
203
214
  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.
204
215
 
205
216
  Secret values in `headers`, `bearerToken`, `oauth.clientSecret`, and stdio `env` may use a leading `!command` to obtain their value at connection or authentication time. The command runs with stdin and stderr suppressed, stdout is limited to 1 MiB and trimmed, and it must finish within 10 seconds with non-empty output; failures stop the connection or authentication flow. Commands are not run during OAuth discovery or while reading, merging, previewing, hashing, or rendering configuration. Use `!!` to escape a literal leading `!`; ordinary and escaped values retain environment interpolation.
@@ -288,7 +299,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
288
299
  | `mcpServers.<name>.oauth.authorizationParams` | Extra authorization URL parameters for provider-specific OAuth extensions. Flow-owned parameters such as `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `response_type`, and `resource` cannot be overridden. |
289
300
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
290
301
  | `freezeDirectTools` | Keep direct-tool registration stable after the initial sync so automatic reconnects and list-change notifications do not rebuild the system prompt. Use `mcp({ connect: "server" })` or `/mcp reconnect <server>` to refresh deliberately. Default: false. |
291
- | `scriptMode` | Register the MCP-only `mcp_script` plain-JavaScript tool (default: true). Set to `false` to hide it. |
302
+ | `scriptMode` | Register the MCP-only `mcpScript` plain-JavaScript tool (default: true). Set to `false` to hide it. |
292
303
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
293
304
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
294
305
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
@@ -317,6 +328,23 @@ Use `approveTools` when a tool should stay visible but not run without confirmat
317
328
 
318
329
  When a matching tool is called from the proxy tool, a direct MCP tool, a resource call, or an MCP UI iframe, Pi asks: **Allow once**, **Allow for session**, or **Deny**. Session approvals are kept in memory only. In headless sessions, matching calls fail closed with an `approval_required` result instead of running. `excludeTools` still removes tools entirely; `approveTools` only gates visible tools at call time.
319
330
 
331
+ Permission extensions can broker these decisions by listening on `pi-mcp-adapter:tool-approval-request` and claiming the request synchronously:
332
+
333
+ ```ts
334
+ import {
335
+ MCP_TOOL_APPROVAL_REQUEST_EVENT,
336
+ type McpToolApprovalRequest,
337
+ } from "pi-mcp-adapter";
338
+
339
+ pi.events.on(MCP_TOOL_APPROVAL_REQUEST_EVENT, (request: McpToolApprovalRequest) => {
340
+ request.claim(async () => {
341
+ return "allow_once"; // "allow_for_session" | "deny" | "abstain"
342
+ });
343
+ });
344
+ ```
345
+
346
+ The request includes `serverName`, `originalToolName`, `prefixedToolName`, `args`, `origin`, and optional `signal`. The first synchronous claim wins. `allow_for_session` updates the same in-memory approval cache as the built-in dialog; `deny` blocks the MCP call; `abstain` or no claim preserves the fallback behavior above. Brokered approval runs for every uncached MCP call regardless of `approveTools` configuration, across proxy, direct, `mcpScript`, resource, and iframe origins.
347
+
320
348
  ### Output Guard
321
349
 
322
350
  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:
@@ -339,7 +367,7 @@ Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — t
339
367
 
340
368
  ### MCP Scripting
341
369
 
342
- For multi-call MCP work, write ordinary JavaScript: discover, inspect, call, loop, filter, chain, or fan out, then return one result. Run that code with the default-on `mcp_script` tool. For a single MCP call, search, describe, status check, or auth action, use `mcp` instead. Set `settings.scriptMode` to `false` to hide the scripting tool.
370
+ For multi-call MCP work, write ordinary JavaScript: discover, inspect, call, loop, filter, chain, or fan out, then return one result. Run that code with the default-on `mcpScript` tool. For a single MCP call, search, describe, status check, or auth action, use `mcp` instead. Set `settings.scriptMode` to `false` to hide the scripting tool.
343
371
 
344
372
  The bundled `mcp-scripting` skill is a separate Pi package resource. To hide that skill while keeping the adapter extension installed, replace the package entry in Pi settings with the object form and disable package skills:
345
373
 
@@ -353,7 +381,7 @@ The bundled `mcp-scripting` skill is a separate Pi package resource. To hide tha
353
381
 
354
382
  Preserve any version pin in `source` if your existing package entry has one. You can also disable package resources through `pi config`.
355
383
 
356
- For example, this is the JavaScript passed as the `code` argument to `mcp_script`:
384
+ For example, this is the JavaScript passed as the `code` argument to `mcpScript`:
357
385
 
358
386
  ```js
359
387
  const { items } = await tools.search({ query: "search issues", server: "github" });
@@ -371,9 +399,9 @@ return result.data;
371
399
 
372
400
  See the bundled `mcp-scripting` skill for the complete workflow guide. The API is `await tools.search({ query, server?, limit?, offset? })`, `await tools.describe({ path })`, `tools.call(path, args)`, direct flat calls, `emit(value)`, and a captured `console`. Use ordinary JavaScript loops and Promise utilities for composition; fluent helpers such as `tools.find(...).one()`, `tools.parallel(...)`, and `tools.retry(...)` are not provided. MCP calls return `{ ok: true, data }` or `{ ok: false, error: { code, message } }`, so a failed call does not stop the rest of the script. Result details include a concise `calls` trace with each operation, its path or query, outcome, and duration. Emitted values and console output appear before the script's final return value, and the combined result uses the normal MCP output guard. The default timeout is 30 seconds; each script runs in a worker thread that is terminated at the deadline, including for infinite loops.
373
401
 
374
- For a tool-restricted subagent, launch the child Pi with its tool allowlist set to `["mcp_script"]`. Have the parent discover MCP tool names with `mcp({ search: "..." })` and include the relevant prefixed names in the child's task; the child can then loop, filter, and chain those MCP calls without filesystem, shell, or edit tools. The adapter's ordinary lazy connection, authentication, output guard, abort handling, and approval gates still apply to every call.
402
+ For a tool-restricted subagent, launch the child Pi with its tool allowlist set to `["mcpScript"]`. Have the parent discover MCP tool names with `mcp({ search: "..." })` and include the relevant prefixed names in the child's task; the child can then loop, filter, and chain those MCP calls without filesystem, shell, or edit tools. The adapter's ordinary lazy connection, authentication, output guard, abort handling, and approval gates still apply to every call.
375
403
 
376
- `mcp_script` is a trusted agent-authored MCP scripting layer, not an isolation boundary. If you need isolation, run Pi in an isolated environment. It is distinct from Pi's code-mode skill: Pi's skill batches general Pi tools, while `mcp_script` exposes MCP calls only and can be the child's sole tool.
404
+ `mcpScript` is a trusted agent-authored MCP scripting layer, not an isolation boundary. If you need isolation, run Pi in an isolated environment. It is distinct from Pi's code-mode skill: Pi's skill batches general Pi tools, while `mcpScript` exposes MCP calls only and can be the child's sole tool.
377
405
 
378
406
  ### MCP Prompts
379
407
 
@@ -404,7 +432,7 @@ Per-server:
404
432
  "mcpServers": {
405
433
  "chrome-devtools": {
406
434
  "command": "npx",
407
- "args": ["-y", "chrome-devtools-mcp@latest"],
435
+ "args": ["-y", "chrome-devtools-mcp@1.6.0"],
408
436
  "directTools": true
409
437
  },
410
438
  "github": {
package/commands.ts CHANGED
@@ -277,8 +277,8 @@ export async function authenticateServer(
277
277
  "info"
278
278
  );
279
279
  },
280
- signal,
281
- runtime,
280
+ ...(signal ? { signal } : {}),
281
+ ...(runtime ? { runtime } : {}),
282
282
  });
283
283
  if (signal?.aborted) signal.throwIfAborted();
284
284
 
package/config.ts CHANGED
@@ -56,7 +56,7 @@ export const KNOWN_SERVER_PRESETS: readonly KnownServerPreset[] = [
56
56
  id: "chrome-devtools",
57
57
  name: "Chrome DevTools",
58
58
  summary: "Inspect and automate a local Chrome browser.",
59
- entry: { command: "npx", args: ["-y", "chrome-devtools-mcp@latest"] },
59
+ entry: { command: "npx", args: ["-y", "chrome-devtools-mcp@1.6.0"] },
60
60
  },
61
61
  ];
62
62
 
@@ -417,10 +417,12 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
417
417
  }
418
418
 
419
419
  function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
420
+ const imports = mergeImports(base.imports, next.imports);
421
+ const settings = next.settings ? { ...base.settings, ...next.settings } : base.settings;
420
422
  return {
421
423
  mcpServers: mergeServerMaps(base.mcpServers, next.mcpServers),
422
- imports: mergeImports(base.imports, next.imports),
423
- settings: next.settings ? { ...base.settings, ...next.settings } : base.settings,
424
+ ...(imports !== undefined ? { imports } : {}),
425
+ ...(settings !== undefined ? { settings } : {}),
424
426
  };
425
427
  }
426
428
 
@@ -499,7 +501,7 @@ function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig {
499
501
 
500
502
  return {
501
503
  imports: config.imports,
502
- settings: config.settings,
504
+ ...(config.settings !== undefined ? { settings: config.settings } : {}),
503
505
  mcpServers: mergeServerMaps(importedServers, config.mcpServers),
504
506
  };
505
507
  }
@@ -609,8 +611,8 @@ function validateConfig(raw: unknown): McpConfig {
609
611
 
610
612
  return {
611
613
  mcpServers: servers as Record<string, ServerEntry>,
612
- imports: Array.isArray(obj.imports) ? (obj.imports as ImportKind[]) : undefined,
613
- settings: obj.settings as McpSettings | undefined,
614
+ ...(Array.isArray(obj.imports) ? { imports: obj.imports as ImportKind[] } : {}),
615
+ ...(obj.settings !== undefined ? { settings: obj.settings as McpSettings } : {}),
614
616
  };
615
617
  }
616
618
 
@@ -708,8 +710,10 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
708
710
 
709
711
  if (raw.type === "local" && Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((value): value is string => typeof value === "string")) {
710
712
  const env = toStringRecord(raw.environment);
713
+ const command = raw.command[0];
714
+ if (command === undefined) continue;
711
715
  const mapped: ServerEntry = {
712
- command: raw.command[0],
716
+ command,
713
717
  args: raw.command.slice(1),
714
718
  ...(env ? { env } : {}),
715
719
  ...(typeof raw.cwd === "string" ? { cwd: raw.cwd } : {}),
@@ -789,9 +793,12 @@ function buildUnifiedDiff(beforeText: string, afterText: string): string {
789
793
 
790
794
  for (let i = rows - 1; i >= 0; i--) {
791
795
  for (let j = cols - 1; j >= 0; j--) {
792
- lcs[i][j] = before[i] === after[j]
793
- ? lcs[i + 1][j + 1] + 1
794
- : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
796
+ const row = lcs[i];
797
+ const nextRow = lcs[i + 1];
798
+ if (!row || !nextRow) continue;
799
+ row[j] = before[i] === after[j]
800
+ ? (nextRow[j + 1] ?? 0) + 1
801
+ : Math.max(nextRow[j] ?? 0, row[j + 1] ?? 0);
795
802
  }
796
803
  }
797
804
 
@@ -805,7 +812,7 @@ function buildUnifiedDiff(beforeText: string, afterText: string): string {
805
812
  j++;
806
813
  continue;
807
814
  }
808
- if (j < cols && (i === rows || lcs[i][j + 1] >= lcs[i + 1][j])) {
815
+ if (j < cols && (i === rows || (lcs[i]?.[j + 1] ?? 0) >= (lcs[i + 1]?.[j] ?? 0))) {
809
816
  lines.push(`+ ${after[j]}`);
810
817
  j++;
811
818
  continue;
@@ -1108,7 +1115,7 @@ export function getServerProvenance(overridePath?: string, cwd = process.cwd()):
1108
1115
  provenance.set(name, {
1109
1116
  path: source.writePath,
1110
1117
  kind: source.kind,
1111
- importKind: source.importKind,
1118
+ ...(source.importKind !== undefined ? { importKind: source.importKind } : {}),
1112
1119
  });
1113
1120
  }
1114
1121
  }
package/direct-tools.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
2
+ import { UrlElicitationRequiredError, type Client } from "@modelcontextprotocol/client";
3
3
  import type { McpExtensionState } from "./state.ts";
4
4
  import type { DirectToolSpec, McpConfig, McpContent, ToolPrefix } from "./types.ts";
5
5
  import type { MetadataCache } from "./metadata-cache.ts";
@@ -19,6 +19,8 @@ import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session
19
19
  import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
20
20
  import { ensureToolCallApproved } from "./tool-approval.ts";
21
21
 
22
+ type ClientCallToolResult = Awaited<ReturnType<Client["callTool"]>>;
23
+
22
24
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
23
25
  const INSTRUCTIONS_SNIPPET_LENGTH = 150;
24
26
  export const DIRECT_TOOLS_ADVISORY_THRESHOLD = 75;
@@ -92,7 +94,10 @@ async function attemptDirectAutoAuth(
92
94
  : { authStorageOptions: state.authStorageOptions, runtime: state.oauthRuntime },
93
95
  );
94
96
  } else {
95
- await authenticate(serverName, serverUrl, definition, { signal, runtime: state.oauthRuntime });
97
+ await authenticate(serverName, serverUrl, definition, {
98
+ ...(signal ? { signal } : {}),
99
+ runtime: state.oauthRuntime,
100
+ });
96
101
  }
97
102
  return { status: "success" };
98
103
  } catch (error) {
@@ -162,9 +167,9 @@ export function resolveDirectTools(
162
167
  originalName: tool.name,
163
168
  prefixedName,
164
169
  description: tool.description ?? "",
165
- inputSchema: tool.inputSchema,
166
- uiResourceUri: tool.uiResourceUri,
167
- uiStreamMode: tool.uiStreamMode,
170
+ ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
171
+ ...(tool.uiResourceUri !== undefined ? { uiResourceUri: tool.uiResourceUri } : {}),
172
+ ...(tool.uiStreamMode !== undefined ? { uiStreamMode: tool.uiStreamMode } : {}),
168
173
  });
169
174
  }
170
175
 
@@ -207,7 +212,7 @@ export function buildProxyDescription(
207
212
  directSpecs: DirectToolSpec[],
208
213
  ): string {
209
214
  const prefix = config.settings?.toolPrefix ?? "server";
210
- let desc = `MCP gateway — server status, tool search/describe, auth, and single MCP tool calls. When one request needs several MCP calls with logic between them, use mcp_script. Non-MCP Pi tools should be called directly, not through mcp.\n`;
215
+ let desc = `MCP gateway — server status, tool search/describe, auth, and single MCP tool calls. When one request needs several MCP calls with logic between them, use mcpScript. Non-MCP Pi tools should be called directly, not through mcp.\n`;
211
216
 
212
217
  const directByServer = new Map<string, number>();
213
218
  for (const spec of directSpecs) {
@@ -223,7 +228,7 @@ export function buildProxyDescription(
223
228
  const serverSummaries: string[] = [];
224
229
  for (const serverName of Object.keys(config.mcpServers)) {
225
230
  const definition = config.mcpServers[serverName];
226
- if (isServerDisabled(definition)) continue;
231
+ if (!definition || isServerDisabled(definition)) continue;
227
232
  const entry = cache?.servers?.[serverName];
228
233
  const effectivePrefix = resolveToolPrefix(definition, prefix);
229
234
  const toolCount = (entry?.tools ?? []).filter(
@@ -377,11 +382,11 @@ export function createDirectToolExecutor(
377
382
  name: spec.prefixedName,
378
383
  originalName: spec.originalName,
379
384
  description: spec.description,
380
- inputSchema: spec.inputSchema,
381
- resourceUri: spec.resourceUri,
382
- uiResourceUri: spec.uiResourceUri,
383
- uiStreamMode: spec.uiStreamMode,
384
- }, params, ownedSignal);
385
+ ...(spec.inputSchema !== undefined ? { inputSchema: spec.inputSchema } : {}),
386
+ ...(spec.resourceUri !== undefined ? { resourceUri: spec.resourceUri } : {}),
387
+ ...(spec.uiResourceUri !== undefined ? { uiResourceUri: spec.uiResourceUri } : {}),
388
+ ...(spec.uiStreamMode !== undefined ? { uiStreamMode: spec.uiStreamMode } : {}),
389
+ }, params, ownedSignal, spec.resourceUri ? "resource" : "direct");
385
390
  if (approval.ok === false) {
386
391
  const denied = approval.reason === "denied";
387
392
  const message = denied
@@ -431,7 +436,12 @@ export function createDirectToolExecutor(
431
436
 
432
437
  if (spec.resourceUri) {
433
438
  const result = await withSessionRecovery(
434
- { manager: state.manager, config: state.config, signal: ownedSignal, onNeedsAuth: recoverAuthConnection },
439
+ {
440
+ manager: state.manager,
441
+ config: state.config,
442
+ ...(ownedSignal ? { signal: ownedSignal } : {}),
443
+ onNeedsAuth: recoverAuthConnection,
444
+ },
435
445
  spec.serverName,
436
446
  (conn) => conn.client.readResource({ uri: spec.resourceUri! }, requestOptions),
437
447
  );
@@ -453,22 +463,27 @@ export function createDirectToolExecutor(
453
463
  toolName: spec.originalName,
454
464
  toolArgs: params ?? {},
455
465
  uiResourceUri: spec.uiResourceUri!,
456
- streamMode: spec.uiStreamMode,
457
- signal,
466
+ ...(spec.uiStreamMode !== undefined ? { streamMode: spec.uiStreamMode } : {}),
467
+ ...(signal ? { signal } : {}),
458
468
  onNeedsAuth: recoverAuthConnection,
459
469
  })
460
470
  : null;
461
471
 
462
- const result = await withSessionRecovery(
463
- { manager: state.manager, config: state.config, signal: ownedSignal, onNeedsAuth: recoverAuthConnection },
472
+ const result = await withSessionRecovery<ClientCallToolResult>(
473
+ {
474
+ manager: state.manager,
475
+ config: state.config,
476
+ ...(ownedSignal ? { signal: ownedSignal } : {}),
477
+ onNeedsAuth: recoverAuthConnection,
478
+ },
464
479
  spec.serverName,
465
480
  (conn) => abortable(conn.client.callTool({
466
481
  name: spec.originalName,
467
482
  arguments: params ?? {},
468
483
  _meta: uiSession?.requestMeta,
469
- }, undefined, requestOptions), ownedSignal),
484
+ }, requestOptions), ownedSignal),
470
485
  );
471
- uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
486
+ uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult);
472
487
 
473
488
  if (result.isError) {
474
489
  const mcpContent = (result.content ?? []) as McpContent[];
@@ -1,16 +1,15 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import type { Client } from "@modelcontextprotocol/client";
3
3
  import {
4
- ElicitRequestSchema,
5
- ErrorCode,
6
- McpError,
4
+ ProtocolError,
5
+ ProtocolErrorCode,
7
6
  type ElicitRequest,
8
7
  type ElicitRequestFormParams,
9
8
  type ElicitRequestURLParams,
10
9
  type ElicitResult,
11
- } from "@modelcontextprotocol/sdk/types.js";
12
- import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
13
- import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
10
+ } from "@modelcontextprotocol/client";
11
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
12
+ import type { JsonSchemaType } from "@modelcontextprotocol/client";
14
13
  import open from "open";
15
14
 
16
15
  export type ElicitationValue = string | number | boolean | string[] | undefined;
@@ -28,7 +27,7 @@ export interface ElicitationHandlerOptions {
28
27
  export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
29
28
 
30
29
  export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
31
- client.setRequestHandler(ElicitRequestSchema, request =>
30
+ client.setRequestHandler("elicitation/create", request =>
32
31
  handleElicitationRequest(options, request));
33
32
  }
34
33
 
@@ -305,16 +304,16 @@ export async function handleUrlElicitation(
305
304
  options: ElicitationHandlerOptions,
306
305
  params: ElicitRequestURLParams,
307
306
  ): Promise<ElicitResult> {
308
- if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
307
+ if (!options.allowUrl) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation is not supported");
309
308
 
310
309
  let parsed: URL;
311
310
  try {
312
311
  parsed = new URL(params.url);
313
312
  } catch {
314
- throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
313
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
315
314
  }
316
315
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
317
- throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
316
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
318
317
  }
319
318
 
320
319
  const decision = await options.ui.select([
package/errors.ts CHANGED
@@ -17,8 +17,8 @@ export interface McpUiErrorContext {
17
17
  export class McpUiError extends Error {
18
18
  readonly code: string;
19
19
  readonly context: McpUiErrorContext;
20
- readonly recoveryHint?: string;
21
- readonly cause?: Error;
20
+ readonly recoveryHint: string | undefined;
21
+ readonly cause: Error | undefined;
22
22
 
23
23
  constructor(
24
24
  message: string,
@@ -65,9 +65,9 @@ export class ResourceFetchError extends McpUiError {
65
65
  ) {
66
66
  super(`Failed to fetch UI resource "${uri}": ${reason}`, {
67
67
  code: "RESOURCE_FETCH_ERROR",
68
- context: { uri, server: options?.server },
68
+ context: { uri, ...(options?.server !== undefined ? { server: options.server } : {}) },
69
69
  recoveryHint: "Check that the MCP server is connected and the resource URI is valid.",
70
- cause: options?.cause,
70
+ ...(options?.cause !== undefined ? { cause: options.cause } : {}),
71
71
  });
72
72
  this.name = "ResourceFetchError";
73
73
  }
@@ -84,7 +84,11 @@ export class ResourceParseError extends McpUiError {
84
84
  ) {
85
85
  super(`Invalid UI resource "${uri}": ${reason}`, {
86
86
  code: "RESOURCE_PARSE_ERROR",
87
- context: { uri, server: options?.server, mimeType: options?.mimeType },
87
+ context: {
88
+ uri,
89
+ ...(options?.server !== undefined ? { server: options.server } : {}),
90
+ ...(options?.mimeType !== undefined ? { mimeType: options.mimeType } : {}),
91
+ },
88
92
  recoveryHint: "Ensure the resource returns valid HTML with the correct MIME type.",
89
93
  });
90
94
  this.name = "ResourceParseError";
@@ -98,9 +102,9 @@ export class BridgeConnectionError extends McpUiError {
98
102
  constructor(reason: string, options?: { session?: string; cause?: Error }) {
99
103
  super(`AppBridge connection failed: ${reason}`, {
100
104
  code: "BRIDGE_CONNECTION_ERROR",
101
- context: { session: options?.session },
105
+ context: options?.session !== undefined ? { session: options.session } : {},
102
106
  recoveryHint: "Check browser console for detailed errors. The iframe may have failed to load.",
103
- cause: options?.cause,
107
+ ...(options?.cause !== undefined ? { cause: options.cause } : {}),
104
108
  });
105
109
  this.name = "BridgeConnectionError";
106
110
  }
@@ -142,9 +146,9 @@ export class SessionError extends McpUiError {
142
146
  ) {
143
147
  super(`Session error: ${reason}`, {
144
148
  code: "SESSION_ERROR",
145
- context: { session: options?.session },
149
+ context: options?.session !== undefined ? { session: options.session } : {},
146
150
  recoveryHint: "The session may have expired or been closed. Try opening the UI again.",
147
- cause: options?.cause,
151
+ ...(options?.cause !== undefined ? { cause: options.cause } : {}),
148
152
  });
149
153
  this.name = "SessionError";
150
154
  }
@@ -160,9 +164,9 @@ export class ServerError extends McpUiError {
160
164
  ) {
161
165
  super(`UI server error: ${reason}`, {
162
166
  code: "SERVER_ERROR",
163
- context: { port: options?.port },
167
+ context: options?.port !== undefined ? { port: options.port } : {},
164
168
  recoveryHint: "Check if the port is available. Another process may be using it.",
165
- cause: options?.cause,
169
+ ...(options?.cause !== undefined ? { cause: options.cause } : {}),
166
170
  });
167
171
  this.name = "ServerError";
168
172
  }
@@ -179,9 +183,9 @@ export class McpServerError extends McpUiError {
179
183
  ) {
180
184
  super(`MCP server "${server}" error: ${reason}`, {
181
185
  code: "MCP_SERVER_ERROR",
182
- context: { server, tool: options?.tool },
186
+ context: { server, ...(options?.tool !== undefined ? { tool: options.tool } : {}) },
183
187
  recoveryHint: "Check that the MCP server is running and responsive.",
184
- cause: options?.cause,
188
+ ...(options?.cause !== undefined ? { cause: options.cause } : {}),
185
189
  });
186
190
  this.name = "McpServerError";
187
191
  }
@@ -196,8 +200,8 @@ export function wrapError(error: unknown, context?: McpUiErrorContext): McpUiErr
196
200
  return new McpUiError(error.message, {
197
201
  code: error.code,
198
202
  context: { ...error.context, ...context },
199
- recoveryHint: error.recoveryHint,
200
- cause: error.cause,
203
+ ...(error.recoveryHint !== undefined ? { recoveryHint: error.recoveryHint } : {}),
204
+ ...(error.cause !== undefined ? { cause: error.cause } : {}),
201
205
  });
202
206
  }
203
207
 
@@ -206,8 +210,8 @@ export function wrapError(error: unknown, context?: McpUiErrorContext): McpUiErr
206
210
 
207
211
  return new McpUiError(message, {
208
212
  code: "UNKNOWN_ERROR",
209
- context,
210
- cause,
213
+ ...(context !== undefined ? { context } : {}),
214
+ ...(cause !== undefined ? { cause } : {}),
211
215
  });
212
216
  }
213
217
 
@@ -2,9 +2,11 @@ import type { UiHostContext, UiResourceContent, UiResourceCsp } from "./types.ts
2
2
 
3
3
  // Use locally bundled AppBridge to avoid CDN Zod bundling issues
4
4
  const DEFAULT_APP_BRIDGE_MODULE_URL = "/app-bridge.bundle.js";
5
+ const APP_SANDBOX = "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
5
6
 
6
7
  export interface HostHtmlTemplateInput {
7
8
  sessionToken: string;
9
+ uiResourceToken: string;
8
10
  serverName: string;
9
11
  toolName: string;
10
12
  toolArgs: Record<string, unknown>;
@@ -20,6 +22,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
20
22
  const hostContext = input.hostContext ?? {};
21
23
 
22
24
  const sessionToken = safeInlineJSON(input.sessionToken);
25
+ const uiResourceToken = safeInlineJSON(input.uiResourceToken);
23
26
  const toolArgs = safeInlineJSON(input.toolArgs);
24
27
  const serverName = safeInlineJSON(input.serverName);
25
28
  const toolName = safeInlineJSON(input.toolName);
@@ -109,7 +112,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
109
112
  </div>
110
113
  </header>
111
114
  <main>
112
- <iframe id="mcp-app" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads" referrerpolicy="no-referrer"></iframe>
115
+ <iframe id="mcp-app" sandbox="${APP_SANDBOX}" referrerpolicy="no-referrer"></iframe>
113
116
  </main>
114
117
  <div class="overlay" id="error-overlay">
115
118
  <div class="panel">
@@ -127,6 +130,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
127
130
  import { AppBridge, PostMessageTransport } from ${moduleUrl};
128
131
 
129
132
  const SESSION_TOKEN = ${sessionToken};
133
+ const UI_RESOURCE_TOKEN = ${uiResourceToken};
130
134
  const SERVER_NAME = ${serverName};
131
135
  const TOOL_NAME = ${toolName};
132
136
  const TOOL_ARGS = ${toolArgs};
@@ -236,6 +240,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
236
240
  // Also listen for raw postMessage events with custom types (notify, prompt, intent, etc.)
237
241
  // These bypass the AppBridge protocol but are used by some MCP UI implementations
238
242
  window.addEventListener("message", async (event) => {
243
+ if (event.source !== iframe.contentWindow) return;
239
244
  const data = event.data;
240
245
  if (!data || typeof data !== "object") return;
241
246
 
@@ -300,7 +305,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
300
305
 
301
306
  // Connect bridge BEFORE loading iframe to ensure we're listening when the app sends ui/initialize
302
307
  try {
303
- const transport = new PostMessageTransport(iframe.contentWindow, null);
308
+ const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow);
304
309
  await bridge.connect(transport);
305
310
  } catch (error) {
306
311
  console.error("[host] Bridge connection failed:", error);
@@ -310,7 +315,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
310
315
  const iframeLoaded = new Promise((resolve) => {
311
316
  iframe.onload = resolve;
312
317
  });
313
- iframe.src = "/ui-app?session=" + encodeURIComponent(SESSION_TOKEN);
318
+ iframe.src = "/ui-app?resource=" + encodeURIComponent(UI_RESOURCE_TOKEN);
314
319
  await iframeLoaded;
315
320
 
316
321
  const eventSource = new EventSource("/events?session=" + encodeURIComponent(SESSION_TOKEN));
@@ -399,6 +404,7 @@ export function buildCspMetaContent(csp: UiResourceCsp | undefined): string {
399
404
 
400
405
  return [
401
406
  "default-src 'none'",
407
+ `sandbox ${APP_SANDBOX}`,
402
408
  toDirective("script-src", ["'self'", "'unsafe-inline'"], resourceDomains),
403
409
  toDirective("style-src", ["'self'", "'unsafe-inline'"], resourceDomains),
404
410
  toDirective("font-src", ["'self'"], resourceDomains),