pi-mcp-adapter 2.18.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,19 @@ 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
+
10
23
  ## [2.18.0] - 2026-08-02
11
24
 
12
25
  ### Added
package/README.md CHANGED
@@ -263,7 +263,6 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
263
263
  "mcpFooterStatus": "full",
264
264
  "hostConfigDiscovery": "off",
265
265
  "approveTools": ["github_delete_*", "notion_update_*"],
266
- "scriptMode": true,
267
266
  "oauthDir": ".pi/mcp-oauth",
268
267
  "trace": {
269
268
  "enabled": true,
@@ -289,7 +288,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
289
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. |
290
289
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
291
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. |
292
- | `scriptMode` | Register the MCP-only `mcp_script` plain-JavaScript tool (default: false). |
291
+ | `scriptMode` | Register the MCP-only `mcp_script` plain-JavaScript tool (default: true). Set to `false` to hide it. |
293
292
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
294
293
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
295
294
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
@@ -340,17 +339,39 @@ Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — t
340
339
 
341
340
  ### MCP Scripting
342
341
 
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.
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`:
344
357
 
345
358
  ```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;
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;
351
370
  ```
352
371
 
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.
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.
354
375
 
355
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.
356
377
 
@@ -510,6 +531,7 @@ Returns accumulated messages from UI sessions. Each message includes `type`, `se
510
531
  **Technical notes:**
511
532
 
512
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.
513
535
  - Works with both stdio and HTTP MCP servers
514
536
  - Uses a local 408KB AppBridge bundle (MCP SDK + Zod) for browser↔server communication
515
537
  - Enforces CSP from standard `_meta.ui.csp` and OpenAI-compatible `_meta["openai/widgetCSP"]` metadata in the response header while preserving provider HTML.
package/direct-tools.ts CHANGED
@@ -207,7 +207,7 @@ export function buildProxyDescription(
207
207
  directSpecs: DirectToolSpec[],
208
208
  ): string {
209
209
  const prefix = config.settings?.toolPrefix ?? "server";
210
- 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`;
211
211
 
212
212
  const directByServer = new Map<string, number>();
213
213
  for (const spec of directSpecs) {
package/index.ts CHANGED
@@ -607,12 +607,12 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
607
607
  },
608
608
  });
609
609
 
610
- if (earlyConfig.settings?.scriptMode === true) {
610
+ if (earlyConfig.settings?.scriptMode !== false) {
611
611
  (pi.registerTool as (tool: unknown) => unknown)({
612
612
  name: "mcp_script",
613
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",
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
616
  parameters: Type.Object({
617
617
  code: Type.String({ description: "Trusted JavaScript MCP script. Use tools.<prefixedToolName>(args) and emit(value)." }),
618
618
  // Raw JSON schema: host TypeBox shims may omit Type.Number (see index-lifecycle shim test).
@@ -658,7 +658,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
658
658
  name: "mcp",
659
659
  label: "MCP",
660
660
  description,
661
- promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
661
+ promptSnippet: "MCP gateway — status, search, describe, auth, and single MCP tool calls",
662
662
  renderCall: renderMcpProxyToolCall,
663
663
  parameters: Type.Object({
664
664
  tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
package/mcp-code.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import { formatWithOptions } from "node:util";
2
3
  import { Worker } from "node:worker_threads";
3
4
  import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
4
5
  import { executeCall } from "./proxy-modes.ts";
@@ -30,17 +31,29 @@ type WorkerMessage =
30
31
 
31
32
  type WorkerResultMessage = { type: "result"; id: number; envelope: unknown };
32
33
 
34
+ function needsInspectableFormatting(value: unknown, stack = new WeakSet<object>()): boolean {
35
+ if (value === undefined || typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") return true;
36
+ if (typeof value !== "object" || value === null) return false;
37
+ if (stack.has(value)) return true;
38
+ if (value instanceof Map || value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) return true;
39
+ stack.add(value);
40
+ try {
41
+ return Object.values(value).some((entry) => needsInspectableFormatting(entry, stack));
42
+ } finally {
43
+ stack.delete(value);
44
+ }
45
+ }
46
+
33
47
  function formatValue(value: unknown): string {
34
48
  if (typeof value === "string") return value;
35
- if (value === undefined) return "undefined";
36
49
  try {
37
- return JSON.stringify(value, null, 2);
38
- } catch {
39
- try {
40
- return String(value);
41
- } catch {
42
- return "[unserializable value]";
50
+ if (!needsInspectableFormatting(value)) {
51
+ const json = JSON.stringify(value, null, 2);
52
+ if (json !== undefined) return json;
43
53
  }
54
+ return formatWithOptions({ colors: false, depth: 6 }, value);
55
+ } catch {
56
+ return "[unserializable value]";
44
57
  }
45
58
  }
46
59
 
@@ -106,25 +119,45 @@ export async function runMcpScript(
106
119
  const timeoutController = new AbortController();
107
120
  const callSignal = combineAbortSignals(externalSignal, timeoutController.signal);
108
121
 
109
- type ScriptCall = { path: string; ok: true } | { path: string; ok: false; error: string };
110
- const calls: ScriptCall[] = [];
111
- let callsSnapshot: ScriptCall[] | undefined;
122
+ type ScriptOperation =
123
+ | { operation: "call"; path: string; ok: true; durationMs: number }
124
+ | { operation: "call"; path: string; ok: false; error: string; durationMs: number }
125
+ | { operation: "search"; query: string; ok: true; durationMs: number }
126
+ | { operation: "search"; query: string; ok: false; error: string; durationMs: number }
127
+ | { operation: "describe"; path: string; ok: true; durationMs: number }
128
+ | { operation: "describe"; path: string; ok: false; error: string; durationMs: number };
129
+ type TrackedScriptOperation = ScriptOperation & { startedAt: number };
130
+ const calls: TrackedScriptOperation[] = [];
131
+ const snapshotCalls = (): ScriptOperation[] => calls.map(({ startedAt, ...operation }) => ({
132
+ ...operation,
133
+ durationMs: "error" in operation && operation.error === "incomplete"
134
+ ? Math.max(0, Date.now() - startedAt)
135
+ : operation.durationMs,
136
+ }));
137
+ let callsSnapshot: ScriptOperation[] | undefined;
112
138
  const callTool = async (path: string, args?: Record<string, unknown>) => {
113
139
  // Record before dispatch so calls still in flight at timeout/abort appear in the trace.
114
- const index = calls.push({ path, ok: false, error: "incomplete" }) - 1;
140
+ const startedAt = Date.now();
141
+ const index = calls.push({ operation: "call", path, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
115
142
  const result = await executeCall(state, path, args, undefined, getPiTools, callSignal);
116
143
  const details = result.details;
117
144
  if (details.error !== undefined) {
118
- const message = typeof details.message === "string"
119
- ? details.message
120
- : textFromContent(result.content);
121
- calls[index] = { path, ok: false, error: String(details.error) };
145
+ const errorCode = String(details.error);
146
+ const suggestions = Array.isArray(details.suggestions)
147
+ ? details.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string")
148
+ : [];
149
+ const message = errorCode === "tool_not_found"
150
+ ? `Tool "${path}" not found. Use await tools.search({ query: "..." }) inside mcp_script.${suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : ""}`
151
+ : typeof details.message === "string"
152
+ ? details.message
153
+ : textFromContent(result.content);
154
+ calls[index] = { operation: "call", path, ok: false, error: errorCode, durationMs: Date.now() - startedAt, startedAt };
122
155
  return {
123
156
  ok: false as const,
124
- error: { code: String(details.error), message },
157
+ error: { code: errorCode, message },
125
158
  };
126
159
  }
127
- calls[index] = { path, ok: true };
160
+ calls[index] = { operation: "call", path, ok: true, durationMs: Date.now() - startedAt, startedAt };
128
161
  return {
129
162
  ok: true as const,
130
163
  data: details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
@@ -132,48 +165,72 @@ export async function runMcpScript(
132
165
  };
133
166
 
134
167
  const searchTools = (input?: SearchInput) => {
135
- if (typeof input?.query !== "string" || input.query.trim() === "") {
136
- return { items: [], total: 0, hasMore: false, nextOffset: null };
168
+ const startedAt = Date.now();
169
+ const query = typeof input?.query === "string" ? input.query : "";
170
+ let error: unknown;
171
+ try {
172
+ if (query.trim() === "") {
173
+ return { items: [], total: 0, hasMore: false, nextOffset: null };
174
+ }
175
+ const server = typeof input.server === "string" ? input.server : undefined;
176
+ const limit = typeof input.limit === "number" ? input.limit : 12;
177
+ const offset = typeof input.offset === "number" ? input.offset : 0;
178
+ const page = paginate(rankToolMatches(state, query, server), offset, limit);
179
+ return {
180
+ ...page,
181
+ items: page.items.map(({ server: matchServer, tool, score }) => ({
182
+ path: tool.name,
183
+ name: tool.originalName,
184
+ server: matchServer,
185
+ ...(tool.description ? { description: tool.description } : {}),
186
+ score,
187
+ })),
188
+ };
189
+ } catch (caught) {
190
+ error = caught;
191
+ throw caught;
192
+ } finally {
193
+ calls.push(error === undefined
194
+ ? { operation: "search", query, ok: true, durationMs: Date.now() - startedAt, startedAt }
195
+ : { operation: "search", query, ok: false, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startedAt, startedAt });
137
196
  }
138
- const server = typeof input.server === "string" ? input.server : undefined;
139
- const limit = typeof input.limit === "number" ? input.limit : 12;
140
- const offset = typeof input.offset === "number" ? input.offset : 0;
141
- const page = paginate(rankToolMatches(state, input.query, server), offset, limit);
142
- return {
143
- ...page,
144
- items: page.items.map(({ server: matchServer, tool, score }) => ({
145
- path: tool.name,
146
- name: tool.originalName,
147
- server: matchServer,
148
- ...(tool.description ? { description: tool.description } : {}),
149
- score,
150
- })),
151
- };
152
197
  };
153
198
 
154
199
  const describeTool = (input?: DescribeInput) => {
200
+ const startedAt = Date.now();
155
201
  const path = typeof input?.path === "string" ? input.path : "";
156
- for (const [server, metadata] of state.toolMetadata) {
157
- const tool = findToolByName(metadata, path);
158
- if (!tool) continue;
159
- const inputTypeScript = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
202
+ let error: unknown;
203
+ try {
204
+ for (const [server, metadata] of state.toolMetadata) {
205
+ const tool = findToolByName(metadata, path);
206
+ if (!tool) continue;
207
+ const inputTypeScript = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
208
+ return {
209
+ path: tool.name,
210
+ name: tool.originalName,
211
+ server,
212
+ ...(tool.description ? { description: tool.description } : {}),
213
+ ...(inputTypeScript ? { inputTypeScript } : {}),
214
+ };
215
+ }
216
+ const suggestions = path ? rankSuggestions(state, path, 5) : [];
217
+ error = "tool_not_found";
160
218
  return {
161
- path: tool.name,
162
- name: tool.originalName,
163
- server,
164
- ...(tool.description ? { description: tool.description } : {}),
165
- ...(inputTypeScript ? { inputTypeScript } : {}),
219
+ path,
220
+ error: {
221
+ code: "tool_not_found",
222
+ message: `Tool not found: ${path}`,
223
+ suggestions,
224
+ },
166
225
  };
226
+ } catch (caught) {
227
+ error = caught;
228
+ throw caught;
229
+ } finally {
230
+ calls.push(error === undefined
231
+ ? { operation: "describe", path, ok: true, durationMs: Date.now() - startedAt, startedAt }
232
+ : { operation: "describe", path, ok: false, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startedAt, startedAt });
167
233
  }
168
- const suggestions = path ? rankSuggestions(state, path, 5) : [];
169
- return {
170
- path,
171
- error: {
172
- code: "tool_not_found",
173
- message: `Tool not found: ${path}`,
174
- suggestions,
175
- },
176
- };
177
234
  };
178
235
 
179
236
  let worker: Worker | undefined;
@@ -234,7 +291,7 @@ export async function runMcpScript(
234
291
  const timeoutError = new McpScriptTimeoutError(resolvedTimeoutMs);
235
292
  const timeout = new Promise<never>((_resolve, reject) => {
236
293
  timer = setTimeout(() => {
237
- callsSnapshot = [...calls];
294
+ callsSnapshot = snapshotCalls();
238
295
  timeoutController.abort(timeoutError);
239
296
  void activeWorker.terminate();
240
297
  reject(timeoutError);
@@ -243,7 +300,7 @@ export async function runMcpScript(
243
300
  const aborted = externalSignal
244
301
  ? new Promise<never>((_resolve, reject) => {
245
302
  const onAbort = () => {
246
- callsSnapshot = [...calls];
303
+ callsSnapshot = snapshotCalls();
247
304
  void activeWorker.terminate();
248
305
  reject(abortReasonError(externalSignal.reason));
249
306
  };
@@ -270,7 +327,7 @@ export async function runMcpScript(
270
327
  removeAbortListener();
271
328
  // "incomplete" means the call had not settled when the script finished
272
329
  // (deadline, abort, or early return). Snapshot before aborting stragglers.
273
- callsSnapshot ??= [...calls];
330
+ callsSnapshot ??= snapshotCalls();
274
331
  // A script may finish without awaiting every call; abort leftovers so
275
332
  // parent-side dispatches do not outlive the script.
276
333
  timeoutController.abort(new Error("mcp_script finished"));
@@ -288,7 +345,7 @@ export async function runMcpScript(
288
345
  mode: "script",
289
346
  ...(errorCode ? { error: errorCode, message: errorMessage } : {}),
290
347
  timeoutMs: resolvedTimeoutMs,
291
- ...((callsSnapshot ?? calls).length > 0 ? { calls: [...(callsSnapshot ?? calls)] } : {}),
348
+ ...(callsSnapshot.length > 0 ? { calls: callsSnapshot } : {}),
292
349
  ...guardedMcpDetails(guarded),
293
350
  },
294
351
  };
@@ -5,17 +5,30 @@ import vm from "node:vm";
5
5
  const TOOLS_ENUMERATION_ERROR = "tools is not enumerable — use tools.search({ query })";
6
6
  const RESERVED_TOOL_PROPS = new Set(["then", "catch", "finally", "toJSON", "toString", "valueOf"]);
7
7
 
8
+ // Keep this formatting logic in sync with mcp-code.ts; the standalone worker cannot import the TypeScript host module.
9
+ function needsInspectableFormatting(value, stack = new WeakSet()) {
10
+ if (value === undefined || typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") return true;
11
+ if (typeof value !== "object" || value === null) return false;
12
+ if (stack.has(value)) return true;
13
+ if (value instanceof Map || value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) return true;
14
+ stack.add(value);
15
+ try {
16
+ return Object.values(value).some((entry) => needsInspectableFormatting(entry, stack));
17
+ } finally {
18
+ stack.delete(value);
19
+ }
20
+ }
21
+
8
22
  function formatValue(value) {
9
23
  if (typeof value === "string") return value;
10
- if (value === undefined) return "undefined";
11
24
  try {
12
- return JSON.stringify(value, null, 2);
13
- } catch {
14
- try {
15
- return String(value);
16
- } catch {
17
- return "[unserializable value]";
25
+ if (!needsInspectableFormatting(value)) {
26
+ const json = JSON.stringify(value, null, 2);
27
+ if (json !== undefined) return json;
18
28
  }
29
+ return formatWithOptions({ colors: false, depth: 6 }, value);
30
+ } catch {
31
+ return "[unserializable value]";
19
32
  }
20
33
  }
21
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -5,13 +5,9 @@ description: Write mcp_script JavaScript for discovering, inspecting, and callin
5
5
 
6
6
  # MCP scripting
7
7
 
8
- Use `mcp_script` when a task needs to discover, filter, or orchestrate MCP tools in one JavaScript request.
8
+ For multi-call MCP work, write ordinary JavaScript with loops, filtering, chaining, fan-out, or other logic between calls. Run that source with `mcp_script`; it is the primary MCP orchestration surface. For a single MCP search, describe, status check, auth action, or tool call, use `mcp` instead.
9
9
 
10
- ## Workflow
11
-
12
- 1. Find candidate tools with `await tools.search({ query, server?, limit?, offset? })`.
13
- 2. Inspect the exact returned path with `await tools.describe({ path })`.
14
- 3. Call it with `tools.call(path, args)`.
10
+ Write the source naturally, then pass it as `mcp_script`'s `code` argument:
15
11
 
16
12
  ```js
17
13
  const { items } = await tools.search({ query: "search issues", server: "github" });
@@ -27,10 +23,16 @@ emit({ tool: details.path, completed: true });
27
23
  return result.data;
28
24
  ```
29
25
 
26
+ ## Workflow
27
+
28
+ 1. Find candidate tools with `await tools.search({ query, server?, limit?, offset? })`.
29
+ 2. Inspect the exact returned path with `await tools.describe({ path })`.
30
+ 3. Call it with `tools.call(path, args)`.
31
+
30
32
  Calls resolve to `{ ok: true, data }` or `{ ok: false, error }`; handle failed calls instead of expecting them to stop the script. `emit(value)` adds user-visible output before the final `return` value. `console` output is captured too.
31
33
 
32
34
  `tools` is a non-enumerable proxy: `Object.keys(tools)` throws. Always use `tools.search` for discovery. When a known flat path is a valid identifier, direct calls such as `tools.github_search_issues(args)` are supported; use bracket syntax for hyphenated names: `tools["server_tool-name"](args)`. `search`, `call`, `describe`, and promise/serialization names (`then`, `catch`, `finally`, `toJSON`, `toString`, `valueOf`) are reserved on the proxy; if a flat path collides with one, call it via `tools.call("exact-path", args)`.
33
35
 
34
- `tools.search` and `tools.describe` are asynchronous and must be awaited. The default script timeout is 30 seconds; the worker is terminated at the deadline, including for infinite loops. Every invocation still uses normal lazy connection, authentication, output guarding, and approval gates. Result details contain a concise `calls` trace with each path and outcome.
36
+ `tools.search` and `tools.describe` are asynchronous and must be awaited. The default script timeout is 30 seconds; the worker is terminated at the deadline, including for infinite loops. Every invocation still uses normal lazy connection, authentication, output guarding, and approval gates. Result details contain a concise `calls` trace with every search, describe, and call operation; each entry includes its query or path, outcome, and duration.
35
37
 
36
38
  Use plain JavaScript loops and Promise utilities for composition. Fluent helpers such as `tools.find(...).one()`, `tools.parallel(...)`, and `tools.retry(...)` are not provided.
package/types.ts CHANGED
@@ -433,7 +433,7 @@ export interface McpSettings {
433
433
  idleTimeout?: number; // minutes, default 10, 0 to disable
434
434
  requestTimeoutMs?: number; // milliseconds, overrides the SDK request timeout when > 0
435
435
  directTools?: boolean;
436
- /** Register the trusted MCP-only JavaScript scripting tool. Defaults to false. */
436
+ /** Register the trusted MCP-only JavaScript scripting tool. Defaults to true; set false to hide it. */
437
437
  scriptMode?: boolean;
438
438
  /** Default approval gate for matching tools/resources; per-server settings override it. */
439
439
  approveTools?: boolean | string[];