pi-mcp-adapter 3.1.0 → 3.2.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/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
 
@@ -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";
@@ -408,7 +408,7 @@ export async function runMcpScript(
408
408
  try {
409
409
  const wasm = await Promise.race([loadMcpScriptWasm(), timeout, aborted]);
410
410
  worker = new Worker(new URL("./mcp-script-worker.mjs", import.meta.url), {
411
- workerData: { code, wasm, interrupt, outputMaxBytes: MCP_SCRIPT_OUTPUT_MAX_BYTES },
411
+ workerData: { code, wasm, quickjsUrl: resolveMcpScriptQuickJsUrl(), interrupt, outputMaxBytes: MCP_SCRIPT_OUTPUT_MAX_BYTES },
412
412
  env: {},
413
413
  // The sandbox cannot open files, and the host always terminates this worker.
414
414
  // 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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "3.1.0",
3
+ "version": "3.2.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",
@@ -86,6 +91,8 @@
86
91
  "ts-shape.ts",
87
92
  "search-ranking.ts",
88
93
  "tool-approval.ts",
94
+ "tool-parameters.ts",
95
+ "host-managed.ts",
89
96
  "session-approvals.ts",
90
97
  "project-server-trust.ts",
91
98
  "init.ts",
package/state.ts CHANGED
@@ -3,7 +3,7 @@ import type { ConsentManager } from "./consent-manager.ts";
3
3
  import type { McpLifecycleManager } from "./lifecycle.ts";
4
4
  import type { McpServerManager } from "./server-manager.ts";
5
5
  import type { AuthStorageOptions } from "./mcp-auth.ts";
6
- import type { ServerDefinition, ToolMetadata, PromptMetadata, McpConfig, UiSessionMessages, UiStreamSummary, McpStatusEventBus, UiServerHandle, ProjectServerBlock } from "./types.ts";
6
+ import type { ServerDefinition, ToolMetadata, PromptMetadata, ServerCacheEntry, McpConfig, UiSessionMessages, UiStreamSummary, McpStatusEventBus, UiServerHandle, ProjectServerBlock } from "./types.ts";
7
7
  import type { UiResourceHandler } from "./ui-resource-handler.ts";
8
8
  import type { McpRuntimeOwner } from "./runtime-owner.ts";
9
9
  import type { McpOAuthRuntime } from "./mcp-auth-flow.ts";
@@ -33,6 +33,7 @@ export interface McpExtensionState {
33
33
  manager: McpServerManager;
34
34
  lifecycle: McpLifecycleManager;
35
35
  toolMetadata: Map<string, ToolMetadata[]>;
36
+ sessionMetadata?: Map<string, ServerCacheEntry>;
36
37
  /** Number of tools currently registered directly with Pi, by server. */
37
38
  directToolCounts: Map<string, number>;
38
39
  /** Resource counts retained separately because tool metadata includes resource tools. */
package/tool-approval.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { abortable } from "./abort.ts";
3
4
  import { combineAbortSignals } from "./runtime-owner.ts";
4
5
  import type { McpExtensionState } from "./state.ts";
@@ -88,15 +89,19 @@ function isMcpToolApprovalDecision(value: unknown): value is McpToolApprovalDeci
88
89
  || value === "abstain";
89
90
  }
90
91
 
91
- async function requestBrokerApproval(
92
- state: McpExtensionState,
92
+ /**
93
+ * Returns "abstain" when no handler claims the request. A handler that throws
94
+ * or returns an unknown value counts as "deny". Rethrows only when `signal` aborted.
95
+ */
96
+ export async function requestBrokerApproval(
97
+ events: ExtensionAPI["events"] | undefined,
93
98
  serverName: string,
94
- toolMeta: ToolMetadata,
99
+ toolMeta: Pick<ToolMetadata, "name" | "originalName">,
95
100
  args: Record<string, unknown> | undefined,
96
101
  origin: McpToolApprovalOrigin,
97
102
  signal?: AbortSignal,
98
103
  ): Promise<McpToolApprovalDecision> {
99
- if (!state.approvalEvents) return "abstain";
104
+ if (!events) return "abstain";
100
105
 
101
106
  let acceptingClaim = true;
102
107
  let handler: McpToolApprovalHandler | undefined;
@@ -115,7 +120,7 @@ async function requestBrokerApproval(
115
120
  },
116
121
  };
117
122
 
118
- state.approvalEvents.emit(MCP_TOOL_APPROVAL_REQUEST_EVENT, request);
123
+ events.emit(MCP_TOOL_APPROVAL_REQUEST_EVENT, request);
119
124
  acceptingClaim = false;
120
125
  if (!handler) return "abstain";
121
126
 
@@ -140,7 +145,7 @@ export async function ensureToolCallApproved(
140
145
  const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
141
146
  const approvedServers = state.approvedServers ??= new Map();
142
147
  const serverIdentity = getServerApprovalIdentity(state, serverName);
143
- const brokerDecision = await requestBrokerApproval(state, serverName, toolMeta, args, origin, signal);
148
+ const brokerDecision = await requestBrokerApproval(state.approvalEvents, serverName, toolMeta, args, origin, signal);
144
149
  if (brokerDecision === "allow_once") return { ok: true };
145
150
  if (brokerDecision === "allow_for_session") {
146
151
  rememberToolApproval(state, serverName, toolMeta, args);
@@ -0,0 +1,11 @@
1
+ import { Type } from "typebox";
2
+
3
+ // OMP remaps `typebox` to a host shim that historically lacked Type.Unsafe.
4
+ // Prefer Unsafe when present (real TypeBox / fixed OMP shim); otherwise pass
5
+ // the normalized JSON Schema through as a plain object so toolWireSchema and
6
+ // validateToolArguments still treat it as JSON Schema.
7
+ export function toToolParameters(schema: Record<string, unknown>): unknown {
8
+ return typeof (Type as { Unsafe?: (value: never) => unknown }).Unsafe === "function"
9
+ ? (Type as { Unsafe: (value: never) => unknown }).Unsafe(schema as never)
10
+ : schema;
11
+ }