pi-mcp-adapter 2.16.0 → 2.18.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,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.18.0] - 2026-08-02
11
+
12
+ ### Added
13
+ - Added `settings.freezeDirectTools` to keep direct MCP tool registration stable after initial sync while preserving explicit reconnect refreshes. Thanks @ddfourtwo for PR #254.
14
+ - Added best-effort Linux OAuth credential recovery when Pi inherits a revoked session keyring, allowing explicit re-authentication through a fresh `keyctl` session helper. Thanks @anthod0 for issue #248 and the validation prototype.
15
+ - Ranked, paginated MCP tool search: best matches come first in a short page of 12 instead of an unranked dump of every match with full schemas, so the model stops guessing and each search costs a fraction of the tokens. Misses on describe/call now return top-5 "Did you mean" suggestions, letting the model self-correct a typo or missing prefix in the same turn instead of burning a round trip.
16
+ - Optional `approveTools` patterns (global and per-server) add the missing middle tier between "tool runs instantly" and "tool hidden entirely": flag risky tools and Pi asks before running them — Allow once / Allow for session / Deny — across proxy, direct, resource, and iframe-originated calls. Safe tools keep full speed; a deny is a normal result the model adapts to, not a crash.
17
+ - Opt-in `mcp_script` trusted JavaScript MCP scripting turns N-step jobs into one call: loop, filter, and chain tools for tool-restricted subagents where every round trip costs child context. Scripts discover tools with `await tools.search({ query })`, inspect exact shapes with `await tools.describe({ path })`, and call them with `tools.call(path, args)` — no more guessing names from outside the script. A runaway script can never freeze Pi itself: scripts run isolated from the main process and are force-stopped at their time limit, even if stuck in an infinite loop. Result details include a `calls` trace of every invoked path and outcome, and a bundled `mcp-scripting` skill teaches the full workflow on demand. Every scripted call still goes through auth, output guarding, and the approval gate.
18
+ - HTTP connection failures are now probe-classified into a plain-language diagnosis (for example "endpoint returned HTML (200) — this URL does not appear to speak MCP") instead of an opaque "fetch failed", so setup mistakes are fixed in seconds. Healthy connections are never probed.
19
+ - Tool parameters render as compact TypeScript shapes (`{ query: string; limit?: number }`) in describe and search, replacing multi-line schema dumps — the model reads less and acts sooner, with the previous formatting kept as a fallback for exotic schemas.
20
+ - `/mcp setup` gained curated one-click presets (DeepWiki, Context7, Notion, GitHub, Chrome DevTools): pick, preview the exact config write, confirm — new servers in under a minute with no hand-typed setup.
21
+
22
+ The ranked search scoring, did-you-mean suggestions, approval patterns, endpoint shape probe, TypeScript-shaped schemas, and codemode design in this release are adapted from [Executor](https://github.com/UsefulSoftwareCo/executor) by Rhys Sullivan (@RhysSullivan). Thanks Rhys.
23
+
24
+ ### Fixed
25
+ - Brought MCP Apps UI hosting in line with the current spec: provider HTML now runs in a real sandbox, gets a restrictive default CSP even when the resource omits one, and `_meta.ui.visibility` is honored so app-only tools stay out of the model tool list while model-only tools cannot be called from the UI.
26
+ - MCP Apps UI sessions are now easier to open from Moshi and remote terminals: the local UI server uses Moshi-discoverable low ports, answers preview discovery probes, serves a loopback-only landing shell, prints Moshi/SSH access hints for remote sessions, and fits the host shell better in narrow in-app browsers. UI-submitted model context is now captured as a bounded handoff, wakes the agent like prompts and intents, and remains available through `mcp({ action: "ui-messages" })` after the UI closes.
27
+
28
+ ## [2.17.0] - 2026-07-31
29
+
30
+ ### Added
31
+ - Added `settings.mcpFooterStatus` to compact or hide the persistent MCP footer status. Thanks @jwintz for issue #5.
32
+ - Added per-server OAuth `authorizationParams` for provider-specific authorization URL parameters such as Google's `access_type=offline`, while rejecting OAuth flow-owned parameter overrides. Thanks @hank-warren for issue #238.
33
+
34
+ ### Fixed
35
+ - Added a best-effort absolute-path fallback for loading the `@napi-rs/keyring` native binding when compiled Pi/Bun cannot resolve the package loader. Thanks @sgiath for issue #230.
36
+ - Bound collapsed MCP tool result rendering by character count as well as line count, preventing huge single-line results from slowing long TUI sessions. Thanks @Whisperfall for issue #249.
37
+ - Let configured `oauth.scope` override OAuth discovery scopes during authorization flows. Thanks @viggy28 for issue #225 and @adity982 for PR #226.
38
+
10
39
  ## [2.16.0] - 2026-07-30
11
40
 
12
41
  ### Added
package/README.md CHANGED
@@ -32,7 +32,7 @@ The adapter reads standard MCP files automatically. No extra setup needed if you
32
32
  |---------------------|--------------|
33
33
  | `.mcp.json` or `~/.config/mcp/mcp.json` | Pi uses it immediately. The first time you open `/mcp`, you'll see a short heads-up explaining which file Pi detected and that Pi only writes adapter-specific overrides to its own files. |
34
34
  | Host-specific configs (Cursor, Claude Code, Codex, etc.) but no standard MCP files | Run `/mcp setup` to adopt those host configs into Pi. The setup flow shows exactly what it found, lets you pick which ones to import, and previews the exact file changes before writing. |
35
- | Nothing configured yet | Run `/mcp setup` to scaffold a minimal `.mcp.json`, quick-add RepoPrompt, or inspect what the adapter discovered on your machine. |
35
+ | Nothing configured yet | Run `/mcp setup` to scaffold a minimal `.mcp.json`, add a curated known server, quick-add RepoPrompt, or inspect what the adapter discovered on your machine. |
36
36
 
37
37
  If you prefer the terminal, you can also run `pi-mcp-adapter init` after install to scan for host-specific configs and add missing compatibility imports to the Pi agent dir (`~/.pi/agent/mcp.json` by default, or `$PI_CODING_AGENT_DIR/mcp.json` when set).
38
38
 
@@ -222,7 +222,9 @@ The adapter owns only its client socket and closes that connection when the Pi r
222
222
 
223
223
  ### Remote/headless OAuth
224
224
 
225
- If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool. Persistent OAuth still requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable:
225
+ If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool. Persistent OAuth still requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable.
226
+
227
+ On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter uses a best-effort recovery path through `keyctl session - node <packaged helper>` so explicit re-authentication can write fresh credentials without killing a long-lived tmux server. This path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
226
228
 
227
229
  ```js
228
230
  mcp({ action: "auth-start", server: "linear-server" })
@@ -258,7 +260,10 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
258
260
  "idleTimeout": 10,
259
261
  "requestTimeoutMs": 30000,
260
262
  "showStatusIcon": true,
263
+ "mcpFooterStatus": "full",
261
264
  "hostConfigDiscovery": "off",
265
+ "approveTools": ["github_delete_*", "notion_update_*"],
266
+ "scriptMode": true,
262
267
  "oauthDir": ".pi/mcp-oauth",
263
268
  "trace": {
264
269
  "enabled": true,
@@ -277,9 +282,14 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
277
282
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
278
283
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
279
284
  | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
285
+ | `mcpFooterStatus` | MCP footer verbosity: `"full"` (default), `"compact"` for `MCP connected/enabled`, or `"off"` to clear the persistent footer status. `/mcp status` remains available. |
280
286
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
287
+ | `approveTools` | `true` to require approval before every MCP tool call, or an array of glob patterns such as `["github_delete_*", "notion_update_*"]`. Per-server `approveTools` overrides this. |
281
288
  | `oauthDir` | Legacy OAuth `tokens.json` import directory for this MCP config. Relative paths resolve from the active project cwd. `MCP_OAUTH_DIR` still wins when set. Persistent OAuth credentials are stored in the OS credential store, not this directory. |
289
+ | `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. |
282
290
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
291
+ | `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. |
292
+ | `scriptMode` | Register the MCP-only `mcp_script` plain-JavaScript tool (default: false). |
283
293
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
284
294
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
285
295
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
@@ -288,7 +298,25 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
288
298
  | `outputGuard` | Guard oversized MCP output: `true` (default), `false`, or `{ maxBytes, maxLines, detailsMaxBytes }`. See [Output Guard](#output-guard). |
289
299
  | `trace` | Opt-in metadata-only protocol tracing. Set `{ enabled: true }` globally or `trace: true` on a server. The per-session JSONL file defaults to `.pi/mcp-traces/`; `file`, `maxBytes` (default 262144), and `maxEvents` (default 10000) can be set. Raw MCP payloads, prompts, tool arguments/results, auth data, and URLs are never persisted. |
290
300
 
291
- Per-server `idleTimeout` and `requestTimeoutMs` override the global settings. `debug` remains stderr display and is unrelated to protocol tracing.
301
+ Per-server `idleTimeout`, `requestTimeoutMs`, and `approveTools` override the global settings. `debug` remains stderr display and is unrelated to protocol tracing.
302
+
303
+ ### Tool Approval
304
+
305
+ Use `approveTools` when a tool should stay visible but not run without confirmation. This is useful for destructive or high-cost actions where hiding the tool would make planning harder, but running it silently is too risky.
306
+
307
+ ```json
308
+ {
309
+ "settings": {
310
+ "approveTools": ["github_delete_*", "notion_update_*"]
311
+ },
312
+ "mcpServers": {
313
+ "github": { "approveTools": ["delete_*", "merge_pull_request"] },
314
+ "docs": { "approveTools": false }
315
+ }
316
+ }
317
+ ```
318
+
319
+ 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.
292
320
 
293
321
  ### Output Guard
294
322
 
@@ -310,6 +338,22 @@ Tune the limits with the object form:
310
338
 
311
339
  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.
312
340
 
341
+ ### MCP Scripting
342
+
343
+ Set `settings.scriptMode` to `true` to register `mcp_script({ code, timeoutMs? })`, a trusted agent-authored JavaScript layer for orchestrating MCP tools. See the bundled `mcp-scripting` skill for the complete workflow guide. The canonical API is ordinary JavaScript with `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 invoked path and outcome. 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.
344
+
345
+ ```js
346
+ const first = await tools.github_search_issues({ query: "is:open label:bug" });
347
+ if (!first.ok) return first;
348
+ const selected = first.data.content.filter((item) => item.type === "text");
349
+ emit({ searched: true });
350
+ return selected;
351
+ ```
352
+
353
+ For a tool-restricted subagent, enable script mode in the adapter configuration, then 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.
354
+
355
+ `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.
356
+
313
357
  ### MCP Prompts
314
358
 
315
359
  MCP servers can advertise prompt templates alongside tools and resources. The adapter registers cached prompt definitions as Pi slash commands under `/mcp__<server>__<prompt>`, and refreshes their metadata whenever a server connects. Arguments support positional and `key=value` forms with quoting; required arguments are validated before `prompts/get` is called.
@@ -412,11 +456,13 @@ Each direct tool costs ~150-300 tokens in the system prompt (name + description
412
456
 
413
457
  Direct tools register from the metadata cache in the Pi agent dir (`~/.pi/agent/mcp-cache.json` by default, or `$PI_CODING_AGENT_DIR/mcp-cache.json` when set), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only while the cache populates, then the extension hot-loads the refreshed direct tools into the current session. Servers that advertise MCP list-change notifications refresh the current session when their tool or resource list changes. On Pi versions that expose `pi.unregisterTool()`, stale direct tools are removed from the registry during refresh; older Pi versions still deactivate them from the active tool set. To force a refresh: `/mcp reconnect <server>`.
414
458
 
459
+ If prompt-cache stability matters more than automatic direct-tool hot-loading, set `settings.freezeDirectTools` to `true`. The initial direct-tool sync still runs, but later automatic reconnects, lazy-connects, and list-change notifications keep the registered tool surface unchanged. Deliberate refreshes through `mcp({ connect: "server" })` or `/mcp reconnect <server>` still update direct tools.
460
+
415
461
  When you change direct-tool toggles in `/mcp`, the extension updates direct tool registration in the current session. Broader setup writes from `/mcp setup` still use Pi's normal reload flow because they can add or restructure MCP config files.
416
462
 
417
463
  **Interactive configuration:** Run `/mcp` to open an interactive panel showing all servers with connection status, tools, and direct/proxy toggles. You can reconnect servers and toggle tools between direct and proxy from the same overlay. For OAuth, press Enter on a server that needs auth or `ctrl+a` on any OAuth server.
418
464
 
419
- **Guided first-run setup:** Run `/mcp setup` to inspect detected shared MCP files, adopt compatibility imports from other hosts, open discovered config paths, preview exact before/after file diffs for writes, scaffold a minimal project `.mcp.json`, or quick-add RepoPrompt into a standard/shared MCP file.
465
+ **Guided first-run setup:** Run `/mcp setup` to inspect detected shared MCP files, adopt compatibility imports from other hosts, open discovered config paths, preview exact before/after file diffs for writes, scaffold a minimal project `.mcp.json`, add a curated known server (DeepWiki, Context7, Notion, GitHub, or Chrome DevTools), or quick-add RepoPrompt into a standard/shared MCP file.
420
466
 
421
467
  **Subagent integration:** If you use the subagent extension, agents can request direct MCP tools in their frontmatter with `mcp:server-name` syntax. See the subagent README for details.
422
468
 
@@ -505,7 +551,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
505
551
  |------|---------|
506
552
  | Status | `mcp({ })` |
507
553
  | List server | `mcp({ server: "name" })` |
508
- | Search | `mcp({ search: "screenshot navigate" })` |
554
+ | Search | `mcp({ search: "screenshot navigate", limit: 12, offset: 0 })` |
509
555
  | Describe | `mcp({ describe: "tool_name" })` |
510
556
  | Instructions | `mcp({ instructions: "name" })` |
511
557
  | Call | `mcp({ tool: "...", args: { key: "value" } })` |
@@ -518,9 +564,13 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
518
564
 
519
565
  MCP proxy and direct-tool results render compactly by default: long text shows the first three terminal-wrapped lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model.
520
566
 
521
- Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd.
567
+ Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are ranked by weighted matches across name, server, and description, then returned one page at a time (`limit` defaults to 12). Use `details.nextOffset` for the next page. Regex search is still available with `regex: true`, but regex results are paginated without ranking.
568
+
569
+ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`. When `describe` or `tool` cannot resolve a name, the result includes top suggestions so the agent can correct a typo or missing prefix in the same turn.
570
+
571
+ When `includeSchemas` is enabled, search and describe render common JSON Schema parameters as compact TypeScript shapes like `{ query: string; limit?: number; }`, with the older schema formatter retained as a fallback for unsupported schemas.
522
572
 
523
- Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`.
573
+ For HTTP servers, failed connects run a one-request shape probe that can turn opaque transport errors into setup hints such as `endpoint returned HTML (200) — this URL does not appear to speak MCP`. Healthy connections are not probed.
524
574
 
525
575
  Servers that provide usage guidance via the MCP `instructions` field surface it at three levels: a truncated head in the `mcp` proxy tool description itself (so the model sees it without any call), a longer preview at the end of `mcp({ server: "name" })` listings, and the full text via `mcp({ instructions: "name" })`. Instructions are captured at connect time and cached alongside tool metadata, so they stay available without a live connection.
526
576
 
@@ -529,7 +579,7 @@ Servers that provide usage guidance via the MCP `instructions` field surface it
529
579
  | Command | What it does |
530
580
  |---------|--------------|
531
581
  | `/mcp` | Interactive panel and first-run onboarding surface |
532
- | `/mcp setup` | Guided setup for imports, a minimal `.mcp.json`, RepoPrompt quick-add, and config-path inspection |
582
+ | `/mcp setup` | Guided setup for imports, a minimal `.mcp.json`, curated known servers, RepoPrompt quick-add, and config-path inspection |
533
583
  | `/mcp tools` | List all tools |
534
584
  | `/mcp prompts` | List all MCP prompts registered as slash commands |
535
585
  | `/mcp reconnect` | Reconnect all servers |
package/commands.ts CHANGED
@@ -4,6 +4,8 @@ import { isServerDisabled, type McpAuthResult, type McpConfig, type McpPanelCall
4
4
  import {
5
5
  ensureCompatibilityImports,
6
6
  getMcpDiscoverySummary,
7
+ getProjectConfigPath,
8
+ type KnownServerPreset,
7
9
  getServerProvenance,
8
10
  previewCompatibilityImports,
9
11
  previewSharedServerEntry,
@@ -395,6 +397,7 @@ export async function openMcpSetup(
395
397
  if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) return null;
396
398
  return previewSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
397
399
  },
400
+ previewKnownServer: (preset: KnownServerPreset) => previewSharedServerEntry(getProjectConfigPath(ctx.cwd), preset.id, preset.entry),
398
401
  adoptImports: async (imports: ImportKind[]) => {
399
402
  const result = ensureCompatibilityImports(imports, configOverridePath);
400
403
  if (result.added.length > 0) configChanged = true;
@@ -414,6 +417,11 @@ export async function openMcpSetup(
414
417
  configChanged = true;
415
418
  return { path, serverName: repoPrompt.serverName };
416
419
  },
420
+ addKnownServer: async (preset: KnownServerPreset) => {
421
+ const path = writeSharedServerEntry(getProjectConfigPath(ctx.cwd), preset.id, preset.entry);
422
+ configChanged = true;
423
+ return { path, serverName: preset.name };
424
+ },
417
425
  openPath: async (targetPath: string) => {
418
426
  await openPath(pi, targetPath);
419
427
  },
package/config.ts CHANGED
@@ -20,6 +20,46 @@ const REPOPROMPT_BINARY_CANDIDATES = [
20
20
  "/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp",
21
21
  ];
22
22
 
23
+ export interface KnownServerPreset {
24
+ id: string;
25
+ name: string;
26
+ summary: string;
27
+ entry: ServerEntry;
28
+ }
29
+
30
+ export const KNOWN_SERVER_PRESETS: readonly KnownServerPreset[] = [
31
+ {
32
+ id: "deepwiki",
33
+ name: "DeepWiki",
34
+ summary: "Ask questions about public GitHub repositories.",
35
+ entry: { url: "https://mcp.deepwiki.com/mcp" },
36
+ },
37
+ {
38
+ id: "context7",
39
+ name: "Context7",
40
+ summary: "Look up current library documentation and examples.",
41
+ entry: { url: "https://mcp.context7.com/mcp" },
42
+ },
43
+ {
44
+ id: "notion",
45
+ name: "Notion",
46
+ summary: "Search and work with your Notion workspace.",
47
+ entry: { url: "https://mcp.notion.com/mcp", auth: "oauth" },
48
+ },
49
+ {
50
+ id: "github",
51
+ name: "GitHub",
52
+ summary: "Work with GitHub through your Copilot account.",
53
+ entry: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
54
+ },
55
+ {
56
+ id: "chrome-devtools",
57
+ name: "Chrome DevTools",
58
+ summary: "Inspect and automate a local Chrome browser.",
59
+ entry: { command: "npx", args: ["-y", "chrome-devtools-mcp@latest"] },
60
+ },
61
+ ];
62
+
23
63
  const IMPORT_PATHS: Record<ImportKind, string[]> = {
24
64
  cursor: [join(homedir(), ".cursor", "mcp.json")],
25
65
  "claude-code": [
package/direct-tools.ts CHANGED
@@ -17,6 +17,7 @@ import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
17
17
  import { formatAuthRequiredMessage, resolveServerUrl, truncateAtWord } from "./utils.ts";
18
18
  import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.ts";
19
19
  import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
20
+ import { ensureToolCallApproved } from "./tool-approval.ts";
20
21
 
21
22
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
22
23
  const INSTRUCTIONS_SNIPPET_LENGTH = 150;
@@ -372,6 +373,30 @@ export function createDirectToolExecutor(
372
373
  };
373
374
  }
374
375
 
376
+ const approval = await ensureToolCallApproved(state, spec.serverName, {
377
+ name: spec.prefixedName,
378
+ originalName: spec.originalName,
379
+ description: spec.description,
380
+ inputSchema: spec.inputSchema,
381
+ resourceUri: spec.resourceUri,
382
+ uiResourceUri: spec.uiResourceUri,
383
+ uiStreamMode: spec.uiStreamMode,
384
+ }, params, ownedSignal);
385
+ if (approval.ok === false) {
386
+ const denied = approval.reason === "denied";
387
+ const message = denied
388
+ ? `The user declined approval to run MCP tool "${spec.originalName}" on server "${spec.serverName}".`
389
+ : `MCP tool "${spec.originalName}" on server "${spec.serverName}" is approval-gated and requires an interactive session.`;
390
+ return {
391
+ content: [{ type: "text" as const, text: message }],
392
+ details: {
393
+ error: denied ? "approval_denied" : "approval_required",
394
+ server: spec.serverName,
395
+ tool: spec.originalName,
396
+ },
397
+ };
398
+ }
399
+
375
400
  let uiSession: UiSessionRuntime | null = null;
376
401
  const requestOptions = state.manager.getRequestOptions?.(spec.serverName, ownedSignal) ?? (ownedSignal ? { signal: ownedSignal } : undefined);
377
402
 
@@ -63,8 +63,8 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
63
63
  }
64
64
  * { box-sizing: border-box; }
65
65
  html, body { margin: 0; padding: 0; height: 100%; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
66
- body { display: flex; flex-direction: column; min-height: 100vh; }
67
- header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
66
+ body { display: flex; flex-direction: column; min-height: 100vh; min-height: 100dvh; }
67
+ header { background: var(--surface); border-bottom: 1px solid var(--border); padding: calc(10px + env(safe-area-inset-top, 0px)) calc(14px + env(safe-area-inset-right, 0px)) 10px calc(14px + env(safe-area-inset-left, 0px)); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
68
68
  .title { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
69
69
  .server { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; }
70
70
  .tool { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -75,13 +75,24 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
75
75
  button.primary { border-color: color-mix(in srgb, var(--good) 40%, var(--border) 60%); color: var(--good); }
76
76
  button.danger { border-color: color-mix(in srgb, var(--bad) 40%, var(--border) 60%); color: var(--bad); }
77
77
  button:hover { background: color-mix(in srgb, var(--surface) 75%, var(--accent) 25%); }
78
- main { flex: 1; min-height: 0; padding: 10px; display: flex; }
78
+ main { flex: 1; min-height: 0; padding: 10px; padding-inline: calc(10px + env(safe-area-inset-left, 0px)) calc(10px + env(safe-area-inset-right, 0px)); padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); display: flex; }
79
79
  iframe { width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 10px; background: white; }
80
- .overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--bg) 90%, black 10%); display: none; align-items: center; justify-content: center; z-index: 2; }
80
+ .overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--bg) 90%, black 10%); display: none; align-items: center; justify-content: center; z-index: 2; padding: 16px; }
81
81
  .overlay.visible { display: flex; }
82
82
  .panel { width: min(680px, calc(100vw - 40px)); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
83
83
  .panel h2 { margin: 0 0 8px; font-size: 16px; }
84
84
  .panel p { margin: 0; color: var(--muted); line-height: 1.4; font-size: 14px; white-space: pre-wrap; }
85
+ @media (max-width: 640px) {
86
+ header { align-items: stretch; flex-direction: column; gap: 8px; }
87
+ .title { flex-wrap: wrap; row-gap: 4px; }
88
+ .server { flex-basis: 100%; }
89
+ .controls { width: 100%; }
90
+ .status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
91
+ button { min-height: 44px; padding: 10px 14px; }
92
+ main { padding: 6px; padding-inline: calc(6px + env(safe-area-inset-left, 0px)) calc(6px + env(safe-area-inset-right, 0px)); padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px)); }
93
+ iframe { border-radius: 6px; }
94
+ .panel { width: 100%; }
95
+ }
85
96
  </style>
86
97
  </head>
87
98
  <body>
@@ -98,7 +109,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
98
109
  </div>
99
110
  </header>
100
111
  <main>
101
- <iframe id="mcp-app" referrerpolicy="no-referrer"></iframe>
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>
102
113
  </main>
103
114
  <div class="overlay" id="error-overlay">
104
115
  <div class="panel">
@@ -106,6 +117,12 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
106
117
  <p id="error-message"></p>
107
118
  </div>
108
119
  </div>
120
+ <div class="overlay" id="completion-overlay">
121
+ <div class="panel">
122
+ <h2>Done</h2>
123
+ <p>MCP UI session finished. You can close this page and return to Pi.</p>
124
+ </div>
125
+ </div>
109
126
  <script type="module">
110
127
  import { AppBridge, PostMessageTransport } from ${moduleUrl};
111
128
 
@@ -125,6 +142,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
125
142
  const doneBtn = document.getElementById("done-btn");
126
143
  const cancelBtn = document.getElementById("cancel-btn");
127
144
  const errorOverlay = document.getElementById("error-overlay");
145
+ const completionOverlay = document.getElementById("completion-overlay");
128
146
  const errorMessage = document.getElementById("error-message");
129
147
 
130
148
  document.getElementById("server-name").textContent = SERVER_NAME;
@@ -141,6 +159,26 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
141
159
  setStatus("Error", true);
142
160
  };
143
161
 
162
+ let completionPending = false;
163
+ const showCompletion = () => {
164
+ completionOverlay.classList.add("visible");
165
+ setStatus("Complete");
166
+ };
167
+ const closeOrShowDone = () => {
168
+ completionPending = true;
169
+ window.close();
170
+ setTimeout(() => {
171
+ if (!document.hidden) {
172
+ showCompletion();
173
+ }
174
+ }, 1000);
175
+ };
176
+ document.addEventListener("visibilitychange", () => {
177
+ if (completionPending && !document.hidden) {
178
+ showCompletion();
179
+ }
180
+ });
181
+
144
182
  const post = async (endpoint, params) => {
145
183
  const response = await fetch(endpoint, {
146
184
  method: "POST",
@@ -315,7 +353,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
315
353
  eventSource.addEventListener("session-complete", async () => {
316
354
  await bridge.teardownResource({}).catch(() => {});
317
355
  eventSource.close();
318
- window.close();
356
+ closeOrShowDone();
319
357
  });
320
358
  eventSource.onerror = () => {
321
359
  setStatus("Connection lost", true);
@@ -334,7 +372,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
334
372
  } catch {}
335
373
  clearInterval(heartbeat);
336
374
  eventSource.close();
337
- window.close();
375
+ closeOrShowDone();
338
376
  };
339
377
 
340
378
  doneBtn.addEventListener("click", () => complete("done"));
@@ -353,13 +391,11 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
353
391
  </html>`;
354
392
  }
355
393
 
356
- export function buildCspMetaContent(csp: UiResourceCsp | undefined): string | undefined {
357
- if (!csp) return undefined;
358
-
359
- const resourceDomains = sanitizeCspDomains(csp.resourceDomains);
360
- const connectDomains = sanitizeCspDomains(csp.connectDomains);
361
- const frameDomains = sanitizeCspDomains(csp.frameDomains);
362
- const baseUriDomains = sanitizeCspDomains(csp.baseUriDomains);
394
+ export function buildCspMetaContent(csp: UiResourceCsp | undefined): string {
395
+ const resourceDomains = sanitizeCspDomains(csp?.resourceDomains);
396
+ const connectDomains = sanitizeCspDomains(csp?.connectDomains);
397
+ const frameDomains = sanitizeCspDomains(csp?.frameDomains);
398
+ const baseUriDomains = sanitizeCspDomains(csp?.baseUriDomains);
363
399
 
364
400
  return [
365
401
  "default-src 'none'",
@@ -368,11 +404,13 @@ export function buildCspMetaContent(csp: UiResourceCsp | undefined): string | un
368
404
  toDirective("font-src", ["'self'"], resourceDomains),
369
405
  toDirective("img-src", ["'self'", "data:"], resourceDomains),
370
406
  toDirective("media-src", ["'self'", "data:"], resourceDomains),
371
- toDirective("connect-src", ["'self'"], connectDomains),
407
+ connectDomains.length > 0
408
+ ? `connect-src ${connectDomains.join(" ")}`
409
+ : "connect-src 'none'",
372
410
  frameDomains.length > 0
373
411
  ? `frame-src ${frameDomains.join(" ")}`
374
412
  : "frame-src 'none'",
375
- toDirective("worker-src", ["'self'", "blob:"], resourceDomains),
413
+ "worker-src 'none'",
376
414
  "object-src 'none'",
377
415
  baseUriDomains.length > 0
378
416
  ? `base-uri ${baseUriDomains.join(" ")}`
package/index.ts CHANGED
@@ -17,6 +17,7 @@ import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolR
17
17
  import { toolErrorOverride } from "./error-signal.ts";
18
18
  import { createMcpRuntimeOwner, createOwnedUi, isAbortError, type McpRuntimeOwner } from "./runtime-owner.ts";
19
19
  import { publishMcpStatusShutdown } from "./mcp-status.ts";
20
+ import { runMcpScript } from "./mcp-code.ts";
20
21
 
21
22
  export type { McpAdapterOptions } from "./types.ts";
22
23
  export {
@@ -106,6 +107,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
106
107
  const fallbackDeactivatedTools = new Set<string>();
107
108
  let proxyToolRegistered = false;
108
109
  let proxyToolDescription: string | null = null;
110
+ let directToolsFrozen = false;
109
111
 
110
112
  // OMP remaps `typebox` to a host shim that historically lacked Type.Unsafe.
111
113
  // Prefer Unsafe when present (real TypeBox / fixed OMP shim); otherwise pass
@@ -278,12 +280,20 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
278
280
  nextState.onToolMetadataUpdated = (_serverName, _reason) => {
279
281
  if (state !== nextState || !owner.isActive()) return;
280
282
  syncPromptCommands();
283
+ if (directToolsFrozen) {
284
+ logger.debug(`MCP: metadata update for ${_serverName} (${_reason}) skipped — directTools frozen`);
285
+ return;
286
+ }
281
287
  syncToolSurface(ctx);
282
288
  };
283
289
  syncPromptCommands();
284
290
  syncToolSurface(ctx);
285
291
  updateStatusBar(nextState);
286
292
  initPromise = null;
293
+ if (earlyConfig.settings?.freezeDirectTools === true) {
294
+ directToolsFrozen = true;
295
+ logger.info("MCP: direct tools frozen after initial sync — reconnects won't rebuild the system prompt; use mcp({ connect: \"server\" }) to rediscover");
296
+ }
287
297
  }).catch(async err => {
288
298
  if (!owner.isActive() || generation !== lifecycleGeneration) {
289
299
  return;
@@ -462,6 +472,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
462
472
  case "reconnect":
463
473
  commandOwner?.throwIfInactive();
464
474
  await reconnectServers(state, commandCtx, targetServer);
475
+ if (directToolsFrozen) syncToolSurface(commandCtx);
465
476
  break;
466
477
  case "tools":
467
478
  await showTools(state, commandCtx);
@@ -596,6 +607,52 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
596
607
  },
597
608
  });
598
609
 
610
+ if (earlyConfig.settings?.scriptMode === true) {
611
+ (pi.registerTool as (tool: unknown) => unknown)({
612
+ name: "mcp_script",
613
+ label: "MCP Script",
614
+ description: "Run a trusted JavaScript MCP script to orchestrate MCP tools. Discover with await tools.search({ query }) — resolves to { items: [{ path, name, server, description? }], total, hasMore, nextOffset }, not an { ok, data } envelope. Inspect with await tools.describe({ path }) — resolves to the tool descriptor with inputTypeScript, or { path, error: { code, message, suggestions } }. Then call tools.call(path, args) — resolves to { ok: true, data } or { ok: false, error: { code, message } } — or use direct flat calls when the name is already known; use emit(value) for user-visible output. Load the mcp-scripting skill for the full workflow guide.",
615
+ promptSnippet: "Run a JavaScript MCP script to chain and filter tool calls in one request",
616
+ parameters: Type.Object({
617
+ code: Type.String({ description: "Trusted JavaScript MCP script. Use tools.<prefixedToolName>(args) and emit(value)." }),
618
+ // Raw JSON schema: host TypeBox shims may omit Type.Number (see index-lifecycle shim test).
619
+ timeoutMs: Type.Optional({ type: "number", minimum: 1, description: "Execution timeout in milliseconds (default: 30000)" } as any),
620
+ }),
621
+ renderResult: renderMcpToolResult,
622
+ async execute(_toolCallId, params: { code: string; timeoutMs?: number }, signal) {
623
+ const executeOwner = currentOwner;
624
+ if (!state && initPromise) {
625
+ try {
626
+ const initialized = await awaitWithTimeout(initPromise, INIT_WAIT_TIMEOUT_MS);
627
+ if (initialized === INIT_WAIT_TIMED_OUT) {
628
+ return {
629
+ content: [{ type: "text" as const, text: "MCP initialization is still in progress. Try again shortly." }],
630
+ details: { mode: "script", error: "init_timeout", timeoutMs: INIT_WAIT_TIMEOUT_MS },
631
+ };
632
+ }
633
+ executeOwner?.throwIfInactive();
634
+ state = initialized;
635
+ } catch (error) {
636
+ if (executeOwner && isAbortError(error, executeOwner.signal)) throw error;
637
+ const message = error instanceof Error ? error.message : String(error);
638
+ return {
639
+ content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
640
+ details: { mode: "script", error: "init_failed", message },
641
+ };
642
+ }
643
+ }
644
+ if (!state) {
645
+ return {
646
+ content: [{ type: "text" as const, text: "MCP not initialized" }],
647
+ details: { mode: "script", error: "not_initialized" },
648
+ };
649
+ }
650
+ executeOwner?.throwIfInactive();
651
+ return runMcpScript(state, params.code, params.timeoutMs, getPiTools, signal);
652
+ },
653
+ });
654
+ }
655
+
599
656
  function registerProxyTool(description: string): void {
600
657
  (pi.registerTool as (tool: unknown) => unknown)({
601
658
  name: "mcp",
@@ -618,6 +675,9 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
618
675
  search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
619
676
  regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
620
677
  includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
678
+ // Raw JSON schema: host TypeBox shims may omit Type.Number (see index-lifecycle shim test).
679
+ limit: Type.Optional({ type: "number", minimum: 1, description: "Maximum search results to return (default: 12)" } as any),
680
+ offset: Type.Optional({ type: "number", minimum: 0, description: "Search result offset (default: 0)" } as any),
621
681
  server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
622
682
  action: Type.Optional(Type.String({ description: "Action: 'ui-messages', 'auth-start', or 'auth-complete'" })),
623
683
  }),
@@ -631,6 +691,8 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
631
691
  search?: string;
632
692
  regex?: boolean;
633
693
  includeSchemas?: boolean;
694
+ limit?: number;
695
+ offset?: number;
634
696
  server?: string;
635
697
  action?: string;
636
698
  }, signal, _onUpdate, _ctx) {
@@ -732,8 +794,8 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
732
794
  if (params.instructions) {
733
795
  return executeInstructions(state, params.instructions);
734
796
  }
735
- if (params.search) {
736
- return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
797
+ if (params.search !== undefined) {
798
+ return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, params.limit, params.offset);
737
799
  }
738
800
  if (params.server) {
739
801
  return executeList(state, params.server);