pi-mcp-adapter 2.17.0 → 2.19.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,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.19.0] - 2026-08-03
11
+
12
+ ### Added
13
+ - `mcp_script` now records each search, describe, and call with its input, outcome, and duration in result details; emitted, returned, and console values retain readable Maps, Sets, cycles, functions, symbols, and BigInts. Its docs now lead with the plain JavaScript agents write and position it as the primary MCP multi-call workflow surface.
14
+ - Documented how to hide the bundled `mcp-scripting` Pi skill while keeping the adapter extension installed. Thanks @aryzing for issue #267.
15
+ - Documented Linux revoked-keyring recovery in the OAuth guide and `_meta.ui.visibility` behavior in the MCP UI guide.
16
+
17
+ ### Changed
18
+ - `mcp_script` is now registered by default for trusted JavaScript MCP multi-call workflows, while `mcp` remains the right tool for status, discovery, auth, and single calls. Set `settings.scriptMode` to `false` to hide the tool.
19
+
20
+ ### Fixed
21
+ - `mcp_script` traces now include missing describe attempts, and shared acyclic values no longer render as circular in script output formatting.
22
+
23
+ ## [2.18.0] - 2026-08-02
24
+
25
+ ### Added
26
+ - Added `settings.freezeDirectTools` to keep direct MCP tool registration stable after initial sync while preserving explicit reconnect refreshes. Thanks @ddfourtwo for PR #254.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+ - `/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.
34
+
35
+ 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.
36
+
37
+ ### Fixed
38
+ - 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.
39
+ - 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.
40
+
10
41
  ## [2.17.0] - 2026-07-31
11
42
 
12
43
  ### 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" })
@@ -260,6 +262,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
260
262
  "showStatusIcon": true,
261
263
  "mcpFooterStatus": "full",
262
264
  "hostConfigDiscovery": "off",
265
+ "approveTools": ["github_delete_*", "notion_update_*"],
263
266
  "oauthDir": ".pi/mcp-oauth",
264
267
  "trace": {
265
268
  "enabled": true,
@@ -280,9 +283,12 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
280
283
  | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
281
284
  | `mcpFooterStatus` | MCP footer verbosity: `"full"` (default), `"compact"` for `MCP connected/enabled`, or `"off"` to clear the persistent footer status. `/mcp status` remains available. |
282
285
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
286
+ | `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. |
283
287
  | `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. |
284
288
  | `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. |
285
289
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
290
+ | `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. |
286
292
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
287
293
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
288
294
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
@@ -291,7 +297,25 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
291
297
  | `outputGuard` | Guard oversized MCP output: `true` (default), `false`, or `{ maxBytes, maxLines, detailsMaxBytes }`. See [Output Guard](#output-guard). |
292
298
  | `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. |
293
299
 
294
- Per-server `idleTimeout` and `requestTimeoutMs` override the global settings. `debug` remains stderr display and is unrelated to protocol tracing.
300
+ Per-server `idleTimeout`, `requestTimeoutMs`, and `approveTools` override the global settings. `debug` remains stderr display and is unrelated to protocol tracing.
301
+
302
+ ### Tool Approval
303
+
304
+ 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.
305
+
306
+ ```json
307
+ {
308
+ "settings": {
309
+ "approveTools": ["github_delete_*", "notion_update_*"]
310
+ },
311
+ "mcpServers": {
312
+ "github": { "approveTools": ["delete_*", "merge_pull_request"] },
313
+ "docs": { "approveTools": false }
314
+ }
315
+ }
316
+ ```
317
+
318
+ 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.
295
319
 
296
320
  ### Output Guard
297
321
 
@@ -313,6 +337,44 @@ Tune the limits with the object form:
313
337
 
314
338
  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.
315
339
 
340
+ ### MCP Scripting
341
+
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.
343
+
344
+ 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
+
346
+ ```json
347
+ {
348
+ "packages": [
349
+ { "source": "npm:pi-mcp-adapter", "skills": [] }
350
+ ]
351
+ }
352
+ ```
353
+
354
+ Preserve any version pin in `source` if your existing package entry has one. You can also disable package resources through `pi config`.
355
+
356
+ For example, this is the JavaScript passed as the `code` argument to `mcp_script`:
357
+
358
+ ```js
359
+ const { items } = await tools.search({ query: "search issues", server: "github" });
360
+ const candidate = items[0];
361
+ if (!candidate) return { error: "No matching tool" };
362
+
363
+ const details = await tools.describe({ path: candidate.path });
364
+ if (details.error) return details;
365
+
366
+ const result = await tools.call(details.path, { query: "is:open label:bug" });
367
+ if (!result.ok) return result;
368
+ emit({ tool: details.path, completed: true });
369
+ return result.data;
370
+ ```
371
+
372
+ 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
+
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.
375
+
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.
377
+
316
378
  ### MCP Prompts
317
379
 
318
380
  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.
@@ -415,11 +477,13 @@ Each direct tool costs ~150-300 tokens in the system prompt (name + description
415
477
 
416
478
  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>`.
417
479
 
480
+ 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.
481
+
418
482
  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.
419
483
 
420
484
  **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.
421
485
 
422
- **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.
486
+ **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.
423
487
 
424
488
  **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.
425
489
 
@@ -467,6 +531,7 @@ Returns accumulated messages from UI sessions. Each message includes `type`, `se
467
531
  **Technical notes:**
468
532
 
469
533
  - Tool consent gates whether UIs can call MCP tools (never/once-per-server/always)
534
+ - `_meta.ui.visibility` controls audience: tools marked app-only stay out of the model tool list, and tools marked model-only cannot be called from the UI iframe.
470
535
  - Works with both stdio and HTTP MCP servers
471
536
  - Uses a local 408KB AppBridge bundle (MCP SDK + Zod) for browser↔server communication
472
537
  - Enforces CSP from standard `_meta.ui.csp` and OpenAI-compatible `_meta["openai/widgetCSP"]` metadata in the response header while preserving provider HTML.
@@ -508,7 +573,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
508
573
  |------|---------|
509
574
  | Status | `mcp({ })` |
510
575
  | List server | `mcp({ server: "name" })` |
511
- | Search | `mcp({ search: "screenshot navigate" })` |
576
+ | Search | `mcp({ search: "screenshot navigate", limit: 12, offset: 0 })` |
512
577
  | Describe | `mcp({ describe: "tool_name" })` |
513
578
  | Instructions | `mcp({ instructions: "name" })` |
514
579
  | Call | `mcp({ tool: "...", args: { key: "value" } })` |
@@ -521,9 +586,13 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
521
586
 
522
587
  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.
523
588
 
524
- Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd.
589
+ 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.
590
+
591
+ 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.
592
+
593
+ 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.
525
594
 
526
- Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`.
595
+ 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.
527
596
 
528
597
  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.
529
598
 
@@ -532,7 +601,7 @@ Servers that provide usage guidance via the MCP `instructions` field surface it
532
601
  | Command | What it does |
533
602
  |---------|--------------|
534
603
  | `/mcp` | Interactive panel and first-run onboarding surface |
535
- | `/mcp setup` | Guided setup for imports, a minimal `.mcp.json`, RepoPrompt quick-add, and config-path inspection |
604
+ | `/mcp setup` | Guided setup for imports, a minimal `.mcp.json`, curated known servers, RepoPrompt quick-add, and config-path inspection |
536
605
  | `/mcp tools` | List all tools |
537
606
  | `/mcp prompts` | List all MCP prompts registered as slash commands |
538
607
  | `/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;
@@ -206,7 +207,7 @@ export function buildProxyDescription(
206
207
  directSpecs: DirectToolSpec[],
207
208
  ): string {
208
209
  const prefix = config.settings?.toolPrefix ?? "server";
209
- let desc = `MCP gateway - connect to MCP servers and call their tools. Non-MCP Pi tools should be called directly, not through mcp.\n`;
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`;
210
211
 
211
212
  const directByServer = new Map<string, number>();
212
213
  for (const spec of directSpecs) {
@@ -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,12 +607,58 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
596
607
  },
597
608
  });
598
609
 
610
+ if (earlyConfig.settings?.scriptMode !== false) {
611
+ (pi.registerTool as (tool: unknown) => unknown)({
612
+ name: "mcp_script",
613
+ label: "MCP Script",
614
+ description: "Run trusted JavaScript that makes multiple MCP tool calls in one request — loop, filter, chain, or fan out between calls. For a single MCP call, search, describe, status check, or auth action, use the mcp tool instead. 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: "Batch multiple MCP tool calls in one JavaScript request (loop, filter, chain)",
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",
602
659
  label: "MCP",
603
660
  description,
604
- promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
661
+ promptSnippet: "MCP gateway — status, search, describe, auth, and single MCP tool calls",
605
662
  renderCall: renderMcpProxyToolCall,
606
663
  parameters: Type.Object({
607
664
  tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
@@ -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);
package/init.ts CHANGED
@@ -147,6 +147,7 @@ export async function initializeMcp(
147
147
  const serverInstructions = new Map<string, string>();
148
148
  const failureTracker = new Map<string, number>();
149
149
  const failureMessages = new Map<string, string>();
150
+ const approvedToolCalls = new Map<string, true>();
150
151
  const uiResourceHandler = new UiResourceHandler(manager, config);
151
152
  const consentManager = new ConsentManager("once-per-server");
152
153
  const state: McpExtensionState = {
@@ -164,6 +165,7 @@ export async function initializeMcp(
164
165
  authStorageOptions,
165
166
  failureTracker,
166
167
  failureMessages,
168
+ approvedToolCalls,
167
169
  uiResourceHandler,
168
170
  consentManager,
169
171
  uiServer: null,