pi-mcp-adapter 3.1.0 → 3.3.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/OAUTH.md +7 -7
  3. package/README.md +64 -6
  4. package/cli.js +1 -0
  5. package/direct-tools.ts +9 -1
  6. package/dist/elicitation-handler.d.ts +9 -1
  7. package/dist/elicitation-handler.js +65 -2
  8. package/dist/elicitation-handler.js.map +1 -1
  9. package/dist/jev-client.js +7 -5
  10. package/dist/jev-client.js.map +1 -1
  11. package/dist/jev-key-store.d.ts +1 -0
  12. package/dist/jev-key-store.js +11 -0
  13. package/dist/jev-key-store.js.map +1 -1
  14. package/dist/mcp-callback-server.js +1 -2
  15. package/dist/mcp-callback-server.js.map +1 -1
  16. package/dist/mcp-oauth-provider.d.ts +2 -1
  17. package/dist/mcp-oauth-provider.js +5 -2
  18. package/dist/mcp-oauth-provider.js.map +1 -1
  19. package/dist/metadata-cache.js +5 -1
  20. package/dist/metadata-cache.js.map +1 -1
  21. package/dist/output-shape.d.ts +23 -0
  22. package/dist/output-shape.js +194 -0
  23. package/dist/output-shape.js.map +1 -0
  24. package/dist/state.d.ts +6 -1
  25. package/dist/ts-shape.d.ts +3 -0
  26. package/dist/ts-shape.js +153 -0
  27. package/dist/ts-shape.js.map +1 -0
  28. package/dist/types.d.ts +13 -2
  29. package/dist/types.js.map +1 -1
  30. package/dist/unix-socket-transport.js +1 -1
  31. package/dist/unix-socket-transport.js.map +1 -1
  32. package/dist/utils.d.ts +6 -1
  33. package/dist/utils.js +17 -0
  34. package/dist/utils.js.map +1 -1
  35. package/elicitation-handler.ts +78 -1
  36. package/host-managed.ts +547 -0
  37. package/index.ts +19 -22
  38. package/init.ts +15 -9
  39. package/jev-client.ts +7 -4
  40. package/jev-key-store.ts +7 -0
  41. package/mcp-callback-server.ts +1 -1
  42. package/mcp-code.ts +12 -2
  43. package/mcp-oauth-provider.ts +5 -2
  44. package/mcp-script-wasm.ts +18 -1
  45. package/mcp-script-worker.mjs +9 -2
  46. package/metadata-cache.ts +5 -0
  47. package/namespace-tools.ts +22 -2
  48. package/output-shape.ts +209 -0
  49. package/package.json +9 -2
  50. package/prompts.ts +68 -0
  51. package/proxy-modes.ts +28 -1
  52. package/skills/mcp-scripting/SKILL.md +2 -0
  53. package/state.ts +6 -1
  54. package/tool-approval.ts +28 -10
  55. package/tool-metadata.ts +3 -1
  56. package/tool-parameters.ts +11 -0
  57. package/ts-shape.ts +1 -1
  58. package/types.ts +14 -2
  59. package/ui-server.ts +5 -2
  60. package/unix-socket-transport.ts +1 -1
  61. package/utils.ts +16 -1
package/init.ts CHANGED
@@ -3,7 +3,7 @@ import type { McpExtensionState } from "./state.ts";
3
3
  import { formatToolName, isServerDisabled, resolveToolPrefix, type McpAdapterOptions, type PromptMetadata, type ToolMetadata, type ToolSelectorCandidateIndex } from "./types.ts";
4
4
  import { existsSync } from "node:fs";
5
5
  import { cloneMcpConfig, loadMcpConfig, resolveConfiguredClaudePluginMcp } from "./config.ts";
6
- import { applyProjectServerTrustToConfig, describeProjectServerBlock } from "./project-server-trust.ts";
6
+ import { applyProjectServerTrustToConfig, describeProjectServerBlock, excludeProjectServersAtLoadTime } from "./project-server-trust.ts";
7
7
  import { ConsentManager } from "./consent-manager.ts";
8
8
  import { McpLifecycleManager } from "./lifecycle.ts";
9
9
  import {
@@ -104,6 +104,8 @@ type McpInitializationOptions = McpAdapterOptions & {
104
104
  oauthRuntime?: McpOAuthRuntime;
105
105
  statusEvents?: McpExtensionState["statusEvents"];
106
106
  onProjectTrustResolved?: () => void;
107
+ /** Load-time runs have no Pi context, so project trust is unknown; session_start decides later. */
108
+ excludeProjectServers?: boolean;
107
109
  };
108
110
 
109
111
  export async function initializeMcp(
@@ -142,7 +144,9 @@ export async function initializeMcp(
142
144
  const runtimeSignal = combineAbortSignals(owner.signal, initialSignal);
143
145
  const trustResult = options.config !== undefined
144
146
  ? { config: resolveConfiguredClaudePluginMcp(cloneMcpConfig(options.config), cwd), blockedServers: new Map() }
145
- : await applyProjectServerTrustToConfig(loadMcpConfig(configPath, cwd), ctx);
147
+ : options.excludeProjectServers
148
+ ? { config: excludeProjectServersAtLoadTime(loadMcpConfig(configPath, cwd)), blockedServers: new Map() }
149
+ : await applyProjectServerTrustToConfig(loadMcpConfig(configPath, cwd), ctx);
146
150
  options.onProjectTrustResolved?.();
147
151
  const config = trustResult.config;
148
152
  const authStorageOptions = getAuthStorageOptions(config.settings?.oauthDir, cwd, config.settings?.oauthCredentialStore);
@@ -593,6 +597,7 @@ export function updateMetadataCache(
593
597
  if (!definition || isServerDisabled(definition)) return;
594
598
 
595
599
  const configHash = computeServerHash(definition);
600
+ if (connection.definition && computeServerHash(connection.definition) !== configHash) return;
596
601
  const existing = loadMetadataCache();
597
602
  const existingEntry = existing?.servers?.[serverName];
598
603
 
@@ -602,13 +607,13 @@ export function updateMetadataCache(
602
607
  ? existingEntry?.configHash === configHash ? existingEntry.prompts : undefined
603
608
  : serializePrompts(connection.prompts ?? []);
604
609
 
605
- if (
606
- definition.exposeResources !== false &&
607
- connection.resourceDiscoveryFailed === true &&
608
- existingEntry?.resources?.length &&
609
- isServerCacheValid(existingEntry, definition)
610
- ) {
611
- resources = existingEntry.resources;
610
+ if (definition.exposeResources !== false && connection.resourceDiscoveryFailed === true) {
611
+ const sessionEntry = state.sessionMetadata?.get(serverName);
612
+ if (sessionEntry?.configHash === configHash) {
613
+ resources = sessionEntry.resources ?? [];
614
+ } else if (existingEntry?.resources?.length && isServerCacheValid(existingEntry, definition)) {
615
+ resources = existingEntry.resources;
616
+ }
612
617
  }
613
618
 
614
619
  const entry: ServerCacheEntry = {
@@ -622,6 +627,7 @@ export function updateMetadataCache(
622
627
  cachedAt: Date.now(),
623
628
  };
624
629
 
630
+ (state.sessionMetadata ??= new Map()).set(serverName, entry);
625
631
  saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
626
632
  }
627
633
 
package/jev-client.ts CHANGED
@@ -2,7 +2,7 @@ import { TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import type { McpExtensionState } from "./state.ts";
3
3
  import { isServerDisabled } from "./types.ts";
4
4
  import { combineAbortSignals } from "./runtime-owner.ts";
5
- import { JEV_SDK_PATH, resolveJevCredential, resolveJevEndpoint, type JevCredentialResolution, type ResolvedJevEndpoint } from "./jev-key-store.ts";
5
+ import { JEV_SDK_PATH, OPENROUTER_ORIGIN, resolveJevCredential, resolveJevEndpoint, type JevCredentialResolution, type ResolvedJevEndpoint } from "./jev-key-store.ts";
6
6
  import type { JevAnswer, JevBudget, JevEvaluateInput, JevEvaluationData, JevEvaluationEnvelope, JevJson, JevQuestion, ResolvedJevSettings } from "./jev-contracts.ts";
7
7
 
8
8
  export type { ResolvedJevSettings } from "./jev-contracts.ts";
@@ -15,6 +15,8 @@ const DEFAULTS: ResolvedJevSettings = {
15
15
  semanticMinProbability: 0.2,
16
16
  };
17
17
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
18
+ /** OpenRouter's name for Jev, used there when `settings.jev.model` is not configured. */
19
+ const OPENROUTER_DEFAULT_MODEL = "typesafe/jev-1.13";
18
20
 
19
21
  type JevSettingsInput = Partial<ResolvedJevSettings>;
20
22
  type Host = { endpoint: ResolvedJevEndpoint; client: TypeSafeClient };
@@ -192,8 +194,8 @@ function sameKeys(actual: Record<string, unknown>, expected: string[]): boolean
192
194
  return keys.length === expected.length && expected.every(key => Object.hasOwn(actual, key));
193
195
  }
194
196
  function validateResponse(value: unknown, input: JevEvaluateInput): JevEvaluationData {
197
+ // Providers may add metadata such as `id`, `provider`, or `usage.cost`; only the fields validated here are kept.
195
198
  const response = record(value, "response");
196
- exactKeys(response, ["model", "answers", "usage"], "response");
197
199
  const answers = record(response.answers, "response answers");
198
200
  const questionNames = Object.keys(input.questions);
199
201
  if (!sameKeys(answers, questionNames)) throw new Error("Response answer keys do not match questions");
@@ -224,7 +226,6 @@ function validateResponse(value: unknown, input: JevEvaluateInput): JevEvaluatio
224
226
  }
225
227
  if (typeof response.model !== "string" || response.model.length === 0 || response.model.length > 128) throw new Error("Invalid response model");
226
228
  const usage = record(response.usage, "response usage");
227
- if (!sameKeys(usage, ["input_tokens", "output_tokens"])) throw new Error("Invalid response usage");
228
229
  return { answers: validated, model: response.model, usage: { inputTokens: safeNumber(usage.input_tokens, "input tokens", true), outputTokens: safeNumber(usage.output_tokens, "output tokens", true) } };
229
230
  }
230
231
 
@@ -291,7 +292,9 @@ export async function evaluateJev(state: McpExtensionState, value: JevEvaluateIn
291
292
  const timer = setTimeout(() => { timedOut = true; deadline.abort(new Error("Jev evaluation deadline exceeded")); }, settings.requestTimeoutMs);
292
293
  const signal = combineAbortSignals(options.signal, state.owner.signal, deadline.signal);
293
294
  try {
294
- const request = { state: input.state, questions: input.questions, model: settings.model };
295
+ const configured = state.config.settings?.jev;
296
+ const model = resolved.endpoint.origin === OPENROUTER_ORIGIN && (!configured || configured.model === undefined) ? OPENROUTER_DEFAULT_MODEL : settings.model;
297
+ const request = { state: input.state, questions: input.questions, model };
295
298
  const raw = await resolved.client.systemOne(request as Parameters<TypeSafeClient["systemOne"]>[0], { ...(signal ? { signal } : {}), timeout: settings.requestTimeoutMs, retry: { maxRetries: settings.maxRetries } });
296
299
  try { return { ok: true, data: validateResponse(raw, input) }; }
297
300
  catch { return { ok: false, error: { code: "invalid_response", message: "Jev returned an invalid response." } }; }
package/jev-key-store.ts CHANGED
@@ -6,6 +6,9 @@ const SYSTEMONE_ENDPOINT_ENV = "SYSTEMONE_ENDPOINT";
6
6
  const SYSTEMONE_API_KEY_ENV = "SYSTEMONE_API_KEY";
7
7
  /** TypeSafe-issued key: honored only for the default endpoint and never sent anywhere else. */
8
8
  const LEGACY_TYPESAFE_API_KEY_ENV = "TYPESAFE_API_KEY";
9
+ /** OpenRouter's general-purpose key: honored only for an OpenRouter endpoint and never sent anywhere else. */
10
+ const OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY";
11
+ export const OPENROUTER_ORIGIN = "https://openrouter.ai";
9
12
  /** Origin recorded in version 1 credential records. */
10
13
  export const TYPESAFE_API_ORIGIN = "https://api.typesafe.ai";
11
14
  /** Version 1 credential account, still read for the default endpoint. */
@@ -158,6 +161,10 @@ export function resolveJevCredential(
158
161
  try { return { status: "present", source: "environment", apiKey: validateApiKey(env[LEGACY_TYPESAFE_API_KEY_ENV]) }; }
159
162
  catch { return { status: "unavailable", message: `${LEGACY_TYPESAFE_API_KEY_ENV} is present but invalid.` }; }
160
163
  }
164
+ if (target.origin === OPENROUTER_ORIGIN && Object.hasOwn(env, OPENROUTER_API_KEY_ENV)) {
165
+ try { return { status: "present", source: "environment", apiKey: validateApiKey(env[OPENROUTER_API_KEY_ENV]) }; }
166
+ catch { return { status: "unavailable", message: `${OPENROUTER_API_KEY_ENV} is present but invalid.` }; }
167
+ }
161
168
  let stored: string | undefined;
162
169
  try { stored = readStoredKey(secretStore, target); }
163
170
  catch (error) {
@@ -8,6 +8,7 @@
8
8
  import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
9
9
  import { getAppName } from "./agent-dir.ts"
10
10
  import {
11
+ DEFAULT_OAUTH_CALLBACK_HOST,
11
12
  DEFAULT_OAUTH_CALLBACK_PATH,
12
13
  getConfiguredOAuthCallbackPort,
13
14
  getOAuthCallbackPath,
@@ -197,7 +198,6 @@ interface EnsureCallbackServerOptions {
197
198
  reserveState?: boolean
198
199
  }
199
200
 
200
- const DEFAULT_OAUTH_CALLBACK_HOST = "localhost"
201
201
  let callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
202
202
 
203
203
  /**
package/mcp-code.ts CHANGED
@@ -3,7 +3,7 @@ import { formatWithOptions } from "node:util";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { throwIfAborted } from "./abort.ts";
5
5
  import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
6
- import { loadMcpScriptWasm } from "./mcp-script-wasm.ts";
6
+ import { loadMcpScriptWasm, resolveMcpScriptQuickJsUrl } from "./mcp-script-wasm.ts";
7
7
  import { evaluateJev, validateJevSettings } from "./jev-client.ts";
8
8
  import type { JevErrorCode, JevEvaluateInput, JevEvaluationEnvelope } from "./jev-contracts.ts";
9
9
  import { executeCall } from "./proxy-modes.ts";
@@ -14,6 +14,7 @@ import { isServerInActiveFailureBackoff } from "./failure-backoff.ts";
14
14
  import type { McpExtensionState } from "./state.ts";
15
15
  import { findToolByName, formatSchema, hasSchemaDescriptions } from "./tool-metadata.ts";
16
16
  import { renderTsShape } from "./ts-shape.ts";
17
+ import { getObservedOutput, renderOutputShape } from "./output-shape.ts";
17
18
  import type { ContentBlock } from "./types.ts";
18
19
 
19
20
  export const DEFAULT_MCP_SCRIPT_TIMEOUT_MS = 30_000;
@@ -338,6 +339,7 @@ export async function runMcpScript(
338
339
  if (!tool) continue;
339
340
  const inputShape = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
340
341
  const inputTypeScript = inputShape ?? (tool.inputSchema ? formatSchema(tool.inputSchema) : null);
342
+ const observed = tool.resourceUri ? undefined : getObservedOutput(state, server, tool);
341
343
  return {
342
344
  path: tool.name,
343
345
  name: tool.originalName,
@@ -350,6 +352,14 @@ export async function runMcpScript(
350
352
  outputSchemaTarget: "data.structuredContent",
351
353
  outputSchema: tool.outputSchema,
352
354
  } : {}),
355
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
356
+ ...(observed ? {
357
+ observedOutput: {
358
+ target: observed.source === "structuredContent" ? "data.structuredContent" : "JSON.parse(data.content[0].text)",
359
+ typeScript: renderOutputShape(observed.shape),
360
+ calls: observed.calls,
361
+ },
362
+ } : {}),
353
363
  };
354
364
  }
355
365
  const suggestions = path ? rankSuggestions(state, path, 5) : [];
@@ -408,7 +418,7 @@ export async function runMcpScript(
408
418
  try {
409
419
  const wasm = await Promise.race([loadMcpScriptWasm(), timeout, aborted]);
410
420
  worker = new Worker(new URL("./mcp-script-worker.mjs", import.meta.url), {
411
- workerData: { code, wasm, interrupt, outputMaxBytes: MCP_SCRIPT_OUTPUT_MAX_BYTES },
421
+ workerData: { code, wasm, quickjsUrl: resolveMcpScriptQuickJsUrl(), interrupt, outputMaxBytes: MCP_SCRIPT_OUTPUT_MAX_BYTES },
412
422
  env: {},
413
423
  // The sandbox cannot open files, and the host always terminates this worker.
414
424
  // Disable Node's unmanaged FD bookkeeping, which emits false warnings when
@@ -89,6 +89,9 @@ function toOAuthTokens(tokens: StoredTokens): IssuerBoundTokens {
89
89
  }
90
90
 
91
91
  // Callback server configuration
92
+ // RFC 8252 section 7.3: use the loopback IP literal, not "localhost". The
93
+ // callback listener binds this same host so the advertised redirect reaches it.
94
+ const DEFAULT_OAUTH_CALLBACK_HOST = "127.0.0.1"
92
95
  const DEFAULT_OAUTH_CALLBACK_PORT = 19876
93
96
  const DEFAULT_OAUTH_CALLBACK_PATH = "/callback"
94
97
 
@@ -287,7 +290,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
287
290
  this.flowState = initialState
288
291
  this.redirectUrlSnapshot = config.grantType === "client_credentials"
289
292
  ? undefined
290
- : config.redirectUri ?? `http://localhost:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`
293
+ : config.redirectUri ?? `http://${DEFAULT_OAUTH_CALLBACK_HOST}:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`
291
294
  }
292
295
 
293
296
  setAuthFetch(fetchFn: OAuthFetch): void {
@@ -816,4 +819,4 @@ export class McpOAuthProvider implements OAuthClientProvider {
816
819
  }
817
820
  }
818
821
 
819
- export { DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }
822
+ export { DEFAULT_OAUTH_CALLBACK_HOST, DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }
@@ -1,12 +1,29 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
3
4
 
4
5
  export type McpScriptWasmModule = object;
5
6
 
6
7
  const cached = new Map<string, Promise<McpScriptWasmModule>>();
7
8
 
9
+ function resolveQuickJsWasmPath(): string {
10
+ return createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
11
+ }
12
+
13
+ /**
14
+ * Resolve the quickjs-wasi entry on the host thread. The worker imports this
15
+ * file URL because Bun-compiled executables cannot resolve bare package names
16
+ * from a worker file loaded from disk.
17
+ * Those executables also fail to resolve the bare `quickjs-wasi` root on the
18
+ * host thread, while package subpaths resolve. So the entry is located next to
19
+ * the wasm file, which is how quickjs-wasi's `"."` export lays it out.
20
+ */
21
+ export function resolveMcpScriptQuickJsUrl(): string {
22
+ return new URL("./dist/index.js", pathToFileURL(resolveQuickJsWasmPath())).href;
23
+ }
24
+
8
25
  /** Load and compile the packaged QuickJS runtime once per host process. */
9
- export function loadMcpScriptWasm(path = createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm")): Promise<McpScriptWasmModule> {
26
+ export function loadMcpScriptWasm(path = resolveQuickJsWasmPath()): Promise<McpScriptWasmModule> {
10
27
  let module = cached.get(path);
11
28
  if (!module) {
12
29
  const webAssembly = (globalThis as unknown as {
@@ -1,16 +1,18 @@
1
1
  import { parentPort, workerData } from "node:worker_threads";
2
- import { JSException, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
3
2
 
4
3
  const MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
5
4
  const ERROR_MAX_BYTES = 64 * 1024;
6
5
  const ERROR_TRUNCATION_MARKER = "\n...[mcpScript error truncated]";
7
6
 
7
+ // Set once main() imports quickjs-wasi from the host-resolved file URL.
8
+ let JSException;
9
+
8
10
  function post(message) {
9
11
  parentPort?.postMessage(message);
10
12
  }
11
13
 
12
14
  function errorText(error) {
13
- if (error instanceof JSException) {
15
+ if (JSException && error instanceof JSException) {
14
16
  const head = error.message ? `${error.name}: ${error.message}` : error.name;
15
17
  return error.stack ? `${head}\n${error.stack}` : head;
16
18
  }
@@ -172,6 +174,11 @@ const PRELUDE_SOURCE = `(function (bridge) {
172
174
  })`;
173
175
 
174
176
  async function main() {
177
+ // Bun-compiled executables cannot resolve bare package names from this
178
+ // worker file, so import quickjs-wasi from the URL the host resolved.
179
+ const quickjs = await import(workerData.quickjsUrl);
180
+ JSException = quickjs.JSException;
181
+ const { MAX_STACK_SIZE, QuickJS } = quickjs;
175
182
  const interrupt = new Int32Array(workerData.interrupt);
176
183
  let outputBytes = 0;
177
184
  let outputExceeded = false;
package/metadata-cache.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  import { createToolSelectorCandidateIndex, formatPromptCommandName, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, resolveUniqueNameOwnership, type ToolPrefix, type ToolSelectorCandidateIndex } from "./types.ts";
24
24
  import { resourceNameToToolName } from "./resource-tools.ts";
25
25
  import {
26
+ extractToolAnnotations,
26
27
  extractToolUiStreamMode,
27
28
  interpolateEnvRecord,
28
29
  interpolateEnvVars,
@@ -225,6 +226,7 @@ export function reconstructToolMetadata(
225
226
  }
226
227
 
227
228
  const name = formatToolName(tool.name, serverName, effectivePrefix);
229
+ const annotations = extractToolAnnotations(tool.annotations);
228
230
  metadata.push({
229
231
  name,
230
232
  originalName: tool.name,
@@ -234,6 +236,7 @@ export function reconstructToolMetadata(
234
236
  ...(tool.uiResourceUri !== undefined ? { uiResourceUri: tool.uiResourceUri } : {}),
235
237
  ...(tool.uiVisibility !== undefined ? { uiVisibility: tool.uiVisibility } : {}),
236
238
  ...(tool.uiStreamMode !== undefined ? { uiStreamMode: tool.uiStreamMode } : {}),
239
+ ...(annotations !== undefined ? { annotations } : {}),
237
240
  });
238
241
  }
239
242
 
@@ -289,6 +292,7 @@ export function serializeTools(tools: McpTool[]): CachedTool[] {
289
292
  const uiResourceUri = tryGetToolUiResourceUri(t);
290
293
  const uiVisibility = extractUiToolVisibility(t._meta);
291
294
  const uiStreamMode = extractToolUiStreamMode(t._meta);
295
+ const annotations = extractToolAnnotations(t.annotations);
292
296
  return {
293
297
  name: t.name,
294
298
  ...(t.description !== undefined ? { description: t.description } : {}),
@@ -297,6 +301,7 @@ export function serializeTools(tools: McpTool[]): CachedTool[] {
297
301
  ...(uiResourceUri !== undefined ? { uiResourceUri } : {}),
298
302
  ...(uiVisibility !== undefined ? { uiVisibility } : {}),
299
303
  ...(uiStreamMode !== undefined ? { uiStreamMode } : {}),
304
+ ...(annotations !== undefined ? { annotations } : {}),
300
305
  };
301
306
  });
302
307
  }
@@ -190,6 +190,10 @@ export interface SyncNamespaceProxyToolsInput {
190
190
  activeDirectNames?: ReadonlySet<string>;
191
191
  existingNamespaceNames: Set<string>;
192
192
  unavailableServers?: ReadonlySet<string>;
193
+ // Names the adapter removed from Pi's active tools because Pi could not
194
+ // unregister them. Pi does not re-activate a name it already knows, so a
195
+ // later re-registration must add the name back.
196
+ fallbackDeactivatedNames?: Set<string>;
193
197
  pi: ExtensionAPI;
194
198
  getState: GetState;
195
199
  getInitPromise: GetInitPromise;
@@ -265,10 +269,19 @@ function registerNamespaceProxyTool(
265
269
  ),
266
270
  });
267
271
  input.guardReentrant?.();
272
+ if (input.fallbackDeactivatedNames?.delete(spec.toolName)) {
273
+ // Only undo a fallback deactivation the adapter still owns.
274
+ input.guardReentrant?.();
275
+ const activeTools = getActiveToolsIfReady(input.pi);
276
+ input.guardReentrant?.();
277
+ if (activeTools && !activeTools.includes(spec.toolName)) {
278
+ input.pi.setActiveTools([...activeTools, spec.toolName]);
279
+ input.guardReentrant?.();
280
+ }
281
+ }
268
282
  }
269
283
 
270
- function getActiveToolsForStaleCleanup(pi: ExtensionAPI, staleNames: string[]): string[] | undefined {
271
- if (staleNames.length === 0) return undefined;
284
+ function getActiveToolsIfReady(pi: ExtensionAPI): string[] | undefined {
272
285
  try {
273
286
  return pi.getActiveTools?.();
274
287
  } catch (error) {
@@ -277,6 +290,11 @@ function getActiveToolsForStaleCleanup(pi: ExtensionAPI, staleNames: string[]):
277
290
  }
278
291
  }
279
292
 
293
+ function getActiveToolsForStaleCleanup(pi: ExtensionAPI, staleNames: string[]): string[] | undefined {
294
+ if (staleNames.length === 0) return undefined;
295
+ return getActiveToolsIfReady(pi);
296
+ }
297
+
280
298
  function deactivateStaleNamespaceTools(input: SyncNamespaceProxyToolsInput, nextNames: Set<string>): string[] {
281
299
  const activeDirectNames = input.activeDirectNames ?? new Set<string>();
282
300
  const staleNames = [...input.existingNamespaceNames].filter(
@@ -298,12 +316,14 @@ function deactivateStaleNamespaceTools(input: SyncNamespaceProxyToolsInput, next
298
316
  if (!activeTools) return deactivated;
299
317
 
300
318
  const stale = new Set(staleNames);
319
+ const fallbackRemoved = staleNames.filter((name) => !deactivated.includes(name) && activeTools.includes(name));
301
320
  const nextActiveTools = activeTools.filter((name) => !stale.has(name));
302
321
  if (nextActiveTools.length === activeTools.length) return deactivated;
303
322
 
304
323
  input.guardReentrant?.();
305
324
  input.pi.setActiveTools(nextActiveTools);
306
325
  input.guardReentrant?.();
326
+ for (const name of fallbackRemoved) input.fallbackDeactivatedNames?.add(name);
307
327
  for (const name of staleNames) {
308
328
  if (!deactivated.includes(name)) deactivated.push(name);
309
329
  }
@@ -0,0 +1,209 @@
1
+ import type { McpExtensionState } from "./state.ts";
2
+ import { formatPropertyName } from "./ts-shape.ts";
3
+ import type { ToolMetadata } from "./types.ts";
4
+
5
+ /**
6
+ * Session-only output shapes for tools that declare no outputSchema.
7
+ * Field names and broad JSON types only, never values; every dimension is
8
+ * bounded because inference runs on arbitrary upstream payloads after each call.
9
+ */
10
+ export interface OutputShape {
11
+ type?: "null" | "boolean" | "number" | "string" | "object" | "array";
12
+ properties?: Record<string, OutputShape>;
13
+ required?: string[];
14
+ items?: OutputShape;
15
+ additionalProperties?: OutputShape;
16
+ anyOf?: OutputShape[];
17
+ }
18
+
19
+ export interface ObservedOutput {
20
+ source: "structuredContent" | "jsonText";
21
+ shape: OutputShape;
22
+ calls: number;
23
+ }
24
+
25
+ const MAX_DEPTH = 6;
26
+ const MAX_ARRAY_SAMPLE = 5;
27
+ // Objects wider than this, or with any key that does not look like a field name, are treated as maps
28
+ // keyed by data (ids, emails, dates), so their keys are not kept.
29
+ const MAX_OBJECT_KEYS = 40;
30
+ const FIELD_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,39}$/;
31
+ const MAX_UNION = 4;
32
+ const MAX_NODES_PER_CALL = 1000;
33
+ // Sizes count UTF-16 code units (string length), not bytes.
34
+ const MAX_SHAPE_CHARS = 4 * 1024;
35
+ const MAX_JSON_TEXT_CHARS = 256 * 1024;
36
+
37
+ export function recordObservedOutput(
38
+ state: McpExtensionState,
39
+ serverName: string,
40
+ toolName: string,
41
+ result: Record<string, unknown>,
42
+ ): void {
43
+ if (findTool(state, serverName, toolName)?.outputSchema !== undefined) return;
44
+ const observed = readResultValue(result);
45
+ if (!observed) return;
46
+ const definition = state.config.mcpServers[serverName];
47
+ if (!definition) return;
48
+ const shape = inferShape(observed.value, 0, { nodes: MAX_NODES_PER_CALL });
49
+ const observedOutputs = state.observedOutputs ??= new WeakMap();
50
+ let byTool = observedOutputs.get(definition);
51
+ if (!byTool) observedOutputs.set(definition, byTool = new Map());
52
+ const previous = byTool.get(toolName);
53
+ byTool.set(toolName, previous?.source === observed.source
54
+ ? { source: observed.source, shape: fitShape(mergeShapes(previous.shape, shape)), calls: previous.calls + 1 }
55
+ : { source: observed.source, shape: fitShape(shape), calls: 1 });
56
+ }
57
+
58
+ export function getObservedOutput(
59
+ state: McpExtensionState,
60
+ serverName: string,
61
+ tool: Pick<ToolMetadata, "originalName" | "outputSchema">,
62
+ ): ObservedOutput | undefined {
63
+ if (tool.outputSchema !== undefined) return undefined;
64
+ const definition = state.config.mcpServers[serverName];
65
+ const observed = definition && state.observedOutputs?.get(definition)?.get(tool.originalName);
66
+ return observed && !isUnknown(observed.shape) ? observed : undefined;
67
+ }
68
+
69
+ export function renderOutputShape(shape: OutputShape): string {
70
+ if (shape.anyOf) return shape.anyOf.map(renderOutputShape).join(" | ");
71
+ switch (shape.type) {
72
+ case undefined:
73
+ return "unknown";
74
+ case "object": {
75
+ if (shape.additionalProperties) return `Record<string, ${renderOutputShape(shape.additionalProperties)}>`;
76
+ const required = new Set(shape.required);
77
+ const properties = Object.entries(shape.properties ?? {})
78
+ .map(([name, property]) => `${formatPropertyName(name)}${required.has(name) ? "" : "?"}: ${renderOutputShape(property)};`);
79
+ return properties.length === 0 ? "{}" : `{ ${properties.join(" ")} }`;
80
+ }
81
+ case "array": {
82
+ if (!shape.items) return "unknown[]";
83
+ const item = renderOutputShape(shape.items);
84
+ return item.includes(" | ") ? `(${item})[]` : `${item}[]`;
85
+ }
86
+ default:
87
+ return shape.type;
88
+ }
89
+ }
90
+
91
+ function findTool(state: McpExtensionState, serverName: string, toolName: string): ToolMetadata | undefined {
92
+ return state.toolMetadata.get(serverName)?.find(tool => tool.originalName === toolName && !tool.resourceUri);
93
+ }
94
+
95
+ function readResultValue(result: Record<string, unknown>): { source: ObservedOutput["source"]; value: unknown } | undefined {
96
+ const structured = result.structuredContent;
97
+ if (typeof structured === "object" && structured !== null) return { source: "structuredContent", value: structured };
98
+ const content = result.content;
99
+ if (!Array.isArray(content) || content.length !== 1) return undefined;
100
+ const block = content[0] as { type?: unknown; text?: unknown };
101
+ if (block.type !== "text" || typeof block.text !== "string" || block.text.length > MAX_JSON_TEXT_CHARS) return undefined;
102
+ const text = block.text.trim();
103
+ if (!text.startsWith("{") && !text.startsWith("[")) return undefined;
104
+ try {
105
+ return { source: "jsonText", value: JSON.parse(text) };
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function inferShape(value: unknown, depth: number, budget: { nodes: number }): OutputShape {
112
+ if (--budget.nodes < 0) return {};
113
+ if (value === null) return { type: "null" };
114
+ if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") return { type: typeof value as "boolean" | "number" | "string" };
115
+ if (typeof value !== "object" || depth >= MAX_DEPTH) return {};
116
+ if (Array.isArray(value)) {
117
+ const items = value.slice(0, MAX_ARRAY_SAMPLE).map(item => inferShape(item, depth + 1, budget));
118
+ return items.length === 0 ? { type: "array" } : { type: "array", items: items.reduce(mergeShapes) };
119
+ }
120
+ const record = value as Record<string, unknown>;
121
+ const keys: string[] = [];
122
+ let isMap = false;
123
+ // Stops at the first key that makes this a map, so a huge object costs O(MAX_OBJECT_KEYS) here.
124
+ for (const key in record) {
125
+ if (!Object.hasOwn(record, key)) continue;
126
+ keys.push(key);
127
+ // An own "__proto__" key would set the prototype of the shape's properties object instead of adding a field.
128
+ if (keys.length > MAX_OBJECT_KEYS || !FIELD_NAME.test(key) || /\d{4}/.test(key) || key === "__proto__") {
129
+ isMap = true;
130
+ break;
131
+ }
132
+ }
133
+ if (isMap) {
134
+ const values = keys.slice(0, MAX_ARRAY_SAMPLE).map(key => inferShape(record[key], depth + 1, budget));
135
+ return { type: "object", additionalProperties: values.reduce(mergeShapes) };
136
+ }
137
+ const properties: Record<string, OutputShape> = {};
138
+ for (const key of keys) properties[key] = inferShape(record[key], depth + 1, budget);
139
+ return { type: "object", properties, required: keys };
140
+ }
141
+
142
+ function isUnknown(shape: OutputShape): boolean {
143
+ return shape.type === undefined && shape.anyOf === undefined;
144
+ }
145
+
146
+ function mergeShapes(left: OutputShape, right: OutputShape): OutputShape {
147
+ if (isUnknown(left) || isUnknown(right)) return {};
148
+ const variants = [...(left.anyOf ?? [left])];
149
+ for (const variant of right.anyOf ?? [right]) {
150
+ const index = variants.findIndex(existing => existing.type === variant.type);
151
+ if (index === -1) variants.push(variant);
152
+ else variants[index] = mergeSameType(variants[index]!, variant);
153
+ }
154
+ if (variants.length > MAX_UNION) return {};
155
+ return variants.length === 1 ? variants[0]! : { anyOf: variants };
156
+ }
157
+
158
+ function mergeSameType(left: OutputShape, right: OutputShape): OutputShape {
159
+ if (left.type === "array") {
160
+ const items = left.items && right.items ? mergeShapes(left.items, right.items) : left.items ?? right.items;
161
+ return items ? { type: "array", items } : { type: "array" };
162
+ }
163
+ if (left.type !== "object") return left;
164
+ const keys = new Set([...Object.keys(left.properties ?? {}), ...Object.keys(right.properties ?? {})]);
165
+ if (left.additionalProperties || right.additionalProperties || keys.size > MAX_OBJECT_KEYS) {
166
+ const values = [
167
+ left.additionalProperties,
168
+ right.additionalProperties,
169
+ ...Object.values(left.properties ?? {}),
170
+ ...Object.values(right.properties ?? {}),
171
+ ].filter((shape): shape is OutputShape => shape !== undefined);
172
+ return { type: "object", additionalProperties: values.reduce(mergeShapes) };
173
+ }
174
+ const properties: Record<string, OutputShape> = {};
175
+ for (const key of keys) {
176
+ const leftProperty = left.properties?.[key];
177
+ const rightProperty = right.properties?.[key];
178
+ properties[key] = leftProperty && rightProperty ? mergeShapes(leftProperty, rightProperty) : (leftProperty ?? rightProperty)!;
179
+ }
180
+ const rightRequired = new Set(right.required);
181
+ return { type: "object", properties, required: (left.required ?? []).filter(key => rightRequired.has(key)) };
182
+ }
183
+
184
+ /** Drops nesting from the deepest level up until the stored shape fits the size bound. */
185
+ function fitShape(shape: OutputShape): OutputShape {
186
+ for (let depth = MAX_DEPTH; depth > 0; depth--) {
187
+ const pruned = pruneShape(shape, depth);
188
+ if (JSON.stringify(pruned).length <= MAX_SHAPE_CHARS) return pruned;
189
+ }
190
+ return {};
191
+ }
192
+
193
+ function pruneShape(shape: OutputShape, depth: number): OutputShape {
194
+ if (shape.anyOf) {
195
+ const variants = shape.anyOf.map(variant => pruneShape(variant, depth));
196
+ return variants.some(isUnknown) ? {} : { anyOf: variants };
197
+ }
198
+ if (shape.type !== "object" && shape.type !== "array") return shape;
199
+ if (depth === 0) return {};
200
+ const prune = (child: OutputShape) => pruneShape(child, depth - 1);
201
+ return {
202
+ ...shape,
203
+ ...(shape.properties
204
+ ? { properties: Object.fromEntries(Object.entries(shape.properties).map(([key, child]) => [key, prune(child)])) }
205
+ : {}),
206
+ ...(shape.items ? { items: prune(shape.items) } : {}),
207
+ ...(shape.additionalProperties ? { additionalProperties: prune(shape.additionalProperties) } : {}),
208
+ };
209
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -20,6 +20,11 @@
20
20
  "import": "./oauth.ts",
21
21
  "default": "./oauth.ts"
22
22
  },
23
+ "./host-managed": {
24
+ "types": "./host-managed.ts",
25
+ "import": "./host-managed.ts",
26
+ "default": "./host-managed.ts"
27
+ },
23
28
  "./config": {
24
29
  "types": "./dist/config.d.ts",
25
30
  "import": "./dist/config.js",
@@ -84,8 +89,11 @@
84
89
  "runtime-owner.ts",
85
90
  "tool-metadata.ts",
86
91
  "ts-shape.ts",
92
+ "output-shape.ts",
87
93
  "search-ranking.ts",
88
94
  "tool-approval.ts",
95
+ "tool-parameters.ts",
96
+ "host-managed.ts",
89
97
  "session-approvals.ts",
90
98
  "project-server-trust.ts",
91
99
  "init.ts",
@@ -173,7 +181,6 @@
173
181
  "dependencies": {
174
182
  "@modelcontextprotocol/client": "2.0.0",
175
183
  "@modelcontextprotocol/core": "2.0.0",
176
- "@modelcontextprotocol/ext-apps": "^1.2.2",
177
184
  "@modelcontextprotocol/ext-tasks": "^0.1.0",
178
185
  "@napi-rs/keyring": "^2.1.0",
179
186
  "@typesafe-ai/sdk": "0.6.0",