pi-mcp-adapter 3.0.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 {
@@ -43,7 +43,7 @@ import {
43
43
  createSessionApprovalWriter,
44
44
  restoreSessionApprovalState,
45
45
  } from "./session-approvals.ts";
46
- export { getFailureAgeSeconds, getFailureMessage, isServerInActiveFailureBackoff } from "./failure-backoff.ts";
46
+ export { getFailureAgeSeconds, getFailureMessage } from "./failure-backoff.ts";
47
47
 
48
48
  const MAX_FAILURE_MESSAGE_CHARS = 8 * 1024;
49
49
  const failureExpiryTimers = new WeakMap<McpExtensionState, Map<string, ReturnType<typeof setTimeout>>>();
@@ -103,6 +103,9 @@ export function isTuiMode(ctx: Pick<ExtensionContext, "hasUI" | "mode">): boolea
103
103
  type McpInitializationOptions = McpAdapterOptions & {
104
104
  oauthRuntime?: McpOAuthRuntime;
105
105
  statusEvents?: McpExtensionState["statusEvents"];
106
+ onProjectTrustResolved?: () => void;
107
+ /** Load-time runs have no Pi context, so project trust is unknown; session_start decides later. */
108
+ excludeProjectServers?: boolean;
106
109
  };
107
110
 
108
111
  export async function initializeMcp(
@@ -141,7 +144,10 @@ export async function initializeMcp(
141
144
  const runtimeSignal = combineAbortSignals(owner.signal, initialSignal);
142
145
  const trustResult = options.config !== undefined
143
146
  ? { config: resolveConfiguredClaudePluginMcp(cloneMcpConfig(options.config), cwd), blockedServers: new Map() }
144
- : 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);
150
+ options.onProjectTrustResolved?.();
145
151
  const config = trustResult.config;
146
152
  const authStorageOptions = getAuthStorageOptions(config.settings?.oauthDir, cwd, config.settings?.oauthCredentialStore);
147
153
 
@@ -591,6 +597,7 @@ export function updateMetadataCache(
591
597
  if (!definition || isServerDisabled(definition)) return;
592
598
 
593
599
  const configHash = computeServerHash(definition);
600
+ if (connection.definition && computeServerHash(connection.definition) !== configHash) return;
594
601
  const existing = loadMetadataCache();
595
602
  const existingEntry = existing?.servers?.[serverName];
596
603
 
@@ -600,13 +607,13 @@ export function updateMetadataCache(
600
607
  ? existingEntry?.configHash === configHash ? existingEntry.prompts : undefined
601
608
  : serializePrompts(connection.prompts ?? []);
602
609
 
603
- if (
604
- definition.exposeResources !== false &&
605
- connection.resourceDiscoveryFailed === true &&
606
- existingEntry?.resources?.length &&
607
- isServerCacheValid(existingEntry, definition)
608
- ) {
609
- 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
+ }
610
617
  }
611
618
 
612
619
  const entry: ServerCacheEntry = {
@@ -620,6 +627,7 @@ export function updateMetadataCache(
620
627
  cachedAt: Date.now(),
621
628
  };
622
629
 
630
+ (state.sessionMetadata ??= new Map()).set(serverName, entry);
623
631
  saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
624
632
  }
625
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 }
package/mcp-panel.ts CHANGED
@@ -612,12 +612,13 @@ class McpPanel {
612
612
 
613
613
  if (query && !this.authOnly) {
614
614
  this.visibleItems = this.visibleItems.filter((item) => {
615
- if (item.type === "server") {
616
- return this.visibleItems.some(
617
- (other) => other.type === "tool" && other.serverIndex === item.serverIndex,
618
- );
619
- }
620
- return true;
615
+ if (item.type !== "server") return true;
616
+ const server = this.servers[item.serverIndex];
617
+ // Servers without cached tools (disabled, never connected) can only match by name.
618
+ if (mode === "name" && server && fuzzyScore(query, server.name) > 0) return true;
619
+ return this.visibleItems.some(
620
+ (other) => other.type === "tool" && other.serverIndex === item.serverIndex,
621
+ );
621
622
  });
622
623
  }
623
624
  }
@@ -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;
@@ -185,8 +185,11 @@ class McpSetupPanelView implements Component {
185
185
  }
186
186
  lines.push("");
187
187
 
188
- const preview = this.getActionPreview(state, actions[state.actionCursor], this.previewWidth(innerWidth));
189
- lines.push(...preview);
188
+ const previewWidth = this.previewWidth(innerWidth);
189
+ lines.push(...this.previewOrError(
190
+ () => this.getActionPreview(state, actions[state.actionCursor], previewWidth),
191
+ previewWidth,
192
+ ));
190
193
  lines.push("");
191
194
  const hint = compact ? "Enter select · Esc back" : "Enter selects, Esc goes back, Ctrl+C closes.";
192
195
  lines.push(this.theme.description(hint));
@@ -208,8 +211,11 @@ class McpSetupPanelView implements Component {
208
211
  const selected = state.discovery.imports
209
212
  .filter((entry) => state.selectedImports.has(entry.kind))
210
213
  .map((entry) => entry.kind);
211
- const preview = this.callbacks.previewImports(selected);
212
- lines.push(...this.formatWritePreview("Compatibility import write preview", preview, [], this.previewWidth(innerWidth)));
214
+ const previewWidth = this.previewWidth(innerWidth);
215
+ lines.push(...this.previewOrError(
216
+ () => this.formatWritePreview("Compatibility import write preview", this.callbacks.previewImports(selected), [], previewWidth),
217
+ previewWidth,
218
+ ));
213
219
  return lines;
214
220
  }
215
221
 
@@ -391,6 +397,14 @@ class McpSetupPanelView implements Component {
391
397
  return preview;
392
398
  }
393
399
 
400
+ private previewOrError(renderPreview: () => string[], width: number): string[] {
401
+ try {
402
+ return renderPreview();
403
+ } catch (error) {
404
+ return this.formatPreview([`Preview unavailable: ${error instanceof Error ? error.message : String(error)}`], width);
405
+ }
406
+ }
407
+
394
408
  private formatWritePreview(title: string, preview: ConfigWritePreview, intro: string[] = [], width = DESKTOP_PREVIEW_WIDTH): string[] {
395
409
  const lines: string[] = [];
396
410
  for (const line of intro) lines.push(...wrapText(line, width));
package/metadata-cache.ts CHANGED
@@ -85,11 +85,14 @@ export function computeServerHash(definition: ServerEntry, environment: NodeJS.P
85
85
  // Hash only fields that affect server identity and tool/resource output.
86
86
  // Exclude lifecycle, idleTimeout, requestTimeoutMs, debug — those are runtime behavior settings
87
87
  // that don't change which tools a server exposes.
88
+ const isStdio = !!definition.command;
89
+ const literalEnv = isBuiltInAgentPlugin(definition, "env") || (isStdio && definition.literalEnv === true);
88
90
  const identity: Record<string, unknown> = {
89
91
  command: resolveConfigPath(definition.command, environment),
90
92
  args: definition.args,
91
93
  socket: resolveConfigPath(definition.socket, environment),
92
- env: isBuiltInAgentPlugin(definition, "env") ? definition.env : interpolateEnvRecord(definition.env, environment),
94
+ env: literalEnv ? definition.env : interpolateEnvRecord(definition.env, environment),
95
+ ...(isStdio ? { inheritEnv: definition.inheritEnv !== false, literalEnv } : {}),
93
96
  cwd: isBuiltInAgentPlugin(definition, "cwd") ? definition.cwd : resolveConfigPath(definition.cwd, environment),
94
97
  url: resolveServerUrl(definition, environment),
95
98
  headers: isBuiltInAgentPlugin(definition, "headers") ? definition.headers : interpolateEnvRecord(definition.headers, environment),
@@ -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.0.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",
@@ -183,7 +190,7 @@
183
190
  "open": "^10.2.0",
184
191
  "quickjs-wasi": "3.6.2",
185
192
  "recheck": "4.6.0-beta.3",
186
- "smol-toml": "^1.6.1",
193
+ "smol-toml": "^1.9.0",
187
194
  "strip-json-comments": "^5.0.3",
188
195
  "undici": "^6.28.1",
189
196
  "zod": "^3.25.0 || ^4.0.0"
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { getAgentPath } from "./agent-dir.ts";
6
6
  import type { LoadedMcpConfig } from "./config.ts";
7
- import type { McpConfig, ProjectServerBlock, ProjectServerBlockReason, ServerDefinition } from "./types.ts";
7
+ import { isServerDisabled, type McpConfig, type ProjectServerBlock, type ProjectServerBlockReason, type ServerDefinition } from "./types.ts";
8
8
 
9
9
  const APPROVALS_VERSION = 1;
10
10
  const APPROVALS_FILE = "mcp-project-approvals.json";
@@ -82,6 +82,45 @@ export function canonicalProjectRoot(cwd: string): string {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Git worktrees share approvals per repository, keyed by the same relative path. A `.git`
87
+ * file counts only when it is a regular file (not a symlink to a real worktree's) and git's
88
+ * admin entry for it links back to it; otherwise a directory could claim another checkout's
89
+ * approvals. Worktrees of a regular checkout use that checkout's path, so its existing
90
+ * approvals still apply. Bare repositories (even one stored as a `.git` folder, whose
91
+ * parent is not a checkout) and `--separate-git-dir` ones get a `git-dir:` key, which no
92
+ * canonical path equals, so an admin entry planted in a checkout's tracked files cannot
93
+ * borrow that checkout's approvals.
94
+ * A `--separate-git-dir` main checkout keeps its own path: git records no link back to it.
95
+ */
96
+ function projectApprovalScope(cwd: string): string {
97
+ const root = canonicalProjectRoot(cwd);
98
+ for (let dir = root; ; dir = dirname(dir)) {
99
+ const dotGit = join(dir, ".git");
100
+ if (existsSync(dotGit)) {
101
+ const repoScope = linkedWorktreeRepoScope(dotGit);
102
+ return repoScope ? join(repoScope, relative(dir, root)) : root;
103
+ }
104
+ if (dirname(dir) === dir) return root;
105
+ }
106
+ }
107
+
108
+ function linkedWorktreeRepoScope(dotGit: string): string | undefined {
109
+ try {
110
+ if (!lstatSync(dotGit).isFile()) return undefined;
111
+ const pointer = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
112
+ if (!pointer) return undefined;
113
+ const adminDir = realpathSync(resolve(dirname(dotGit), pointer));
114
+ const backLink = readFileSync(join(adminDir, "gitdir"), "utf8").trim();
115
+ if (realpathSync(resolve(adminDir, backLink)) !== realpathSync(dotGit)) return undefined;
116
+ const commonDir = dirname(dirname(adminDir));
117
+ const bare = /^\s*bare\s*=\s*(true|yes|on|1)\s*$/im.test(readFileSync(join(commonDir, "config"), "utf8"));
118
+ return basename(commonDir) === ".git" && !bare ? dirname(commonDir) : `git-dir:${commonDir}`;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
85
124
  function approvalPath(): string {
86
125
  return getAgentPath(APPROVALS_FILE);
87
126
  }
@@ -119,7 +158,7 @@ function saveApproval(record: ApprovalRecord): void {
119
158
 
120
159
  export function approveProjectServer(cwd: string, serverName: string, definition: ServerDefinition): void {
121
160
  saveApproval({
122
- projectRoot: canonicalProjectRoot(cwd),
161
+ projectRoot: projectApprovalScope(cwd),
123
162
  serverName,
124
163
  definitionHash: hashProjectServerDefinition(definition),
125
164
  approvedAt: new Date().toISOString(),
@@ -149,12 +188,12 @@ export async function applyProjectServerTrust(
149
188
  } catch {
150
189
  projectTrusted = false;
151
190
  }
152
- const projectRoot = canonicalProjectRoot(ctx.cwd);
191
+ const projectRoot = projectApprovalScope(ctx.cwd);
153
192
  const approvals = loadApprovals();
154
193
 
155
194
  for (const [name, source] of loaded.projectServers) {
156
195
  const definition = config.mcpServers[name];
157
- if (!definition) continue;
196
+ if (!definition || isServerDisabled(definition)) continue;
158
197
  const definitionHash = hashProjectServerDefinition(definition);
159
198
  const approved = approvals.approvals.some(entry =>
160
199
  entry.projectRoot === projectRoot && entry.serverName === name && entry.definitionHash === definitionHash);
@@ -167,9 +206,9 @@ export async function applyProjectServerTrust(
167
206
  reason = "approval-required";
168
207
  } else if (await ctx.ui.confirm(
169
208
  `Allow project MCP server “${name}”?`,
170
- `Source: ${source.path}\nEndpoint: ${describeServer(definition)}\n\nThis server can run local commands or make network requests with your user permissions.`,
209
+ `Project config: ${source.path}\nEndpoint: ${describeServer(definition)}\n\nThis server can run local commands or make network requests with your user permissions.`,
171
210
  )) {
172
- approveProjectServer(projectRoot, name, definition);
211
+ approveProjectServer(ctx.cwd, name, definition);
173
212
  continue;
174
213
  } else {
175
214
  reason = "denied";
package/proxy-modes.ts CHANGED
@@ -19,7 +19,7 @@ import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session
19
19
  import { callToolViaTaskSession } from "./mcp-tasks.ts";
20
20
  import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords } from "./search-ranking.ts";
21
21
  import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
22
- import { isServerInActiveFailureBackoff } from "./failure-backoff.ts";
22
+ import { describeFailure, isServerInActiveFailureBackoff } from "./failure-backoff.ts";
23
23
  import { semanticSearch, type SemanticSearchBackend, type SemanticSearchEvaluator } from "./semantic-search.ts";
24
24
  import { getInputRequiredNeedsUiDetails } from "./errors.ts";
25
25
  import { createJsonSchemaValidator } from "./json-schema-validator.ts";
@@ -114,8 +114,7 @@ function getEnabledOriginalToolMatches(state: McpExtensionState, toolName: strin
114
114
  }
115
115
 
116
116
  function serverBackoffResult(state: McpExtensionState, mode: string, serverName: string): ProxyToolResult {
117
- const failedAgo = getFailureAgeSeconds(state, serverName) ?? 0;
118
- const message = `Server "${serverName}" not available (last failed ${failedAgo}s ago)`;
117
+ const message = `Server "${serverName}" not available (last ${describeFailure(state, serverName) ?? "failed 0s ago"})`;
119
118
  return {
120
119
  content: [{ type: "text" as const, text: message }],
121
120
  details: { mode, error: "server_backoff", server: serverName },
@@ -583,7 +582,7 @@ export function executeStatus(state: McpExtensionState): ProxyToolResult {
583
582
  continue;
584
583
  }
585
584
  if (server.status === "failed") {
586
- text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
585
+ text += `✗ ${server.name} (${describeFailure(state, server.name) ?? `failed ${server.failedAgo ?? 0}s ago`})\n`;
587
586
  continue;
588
587
  }
589
588
  text += `○ ${server.name} (not listening; disconnected)\n`;
@@ -1286,10 +1285,10 @@ export async function executeCall(
1286
1285
  }
1287
1286
 
1288
1287
  if (!toolMeta) {
1289
- const failedAgo = getFailureAgeSeconds(state, serverName);
1290
- if (failedAgo !== null) {
1288
+ const failure = describeFailure(state, serverName);
1289
+ if (failure !== null) {
1291
1290
  return {
1292
- content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
1291
+ content: [{ type: "text" as const, text: `Server "${serverName}" not available (last ${failure})` }],
1293
1292
  details: { mode: "call", error: "server_backoff", server: serverName, requestedTool: toolName },
1294
1293
  };
1295
1294
  }
@@ -1409,10 +1408,10 @@ export async function executeCall(
1409
1408
  }
1410
1409
  }
1411
1410
  if (!connection || connection.status !== "connected") {
1412
- const failedAgo = getFailureAgeSeconds(state, serverName);
1413
- if (failedAgo !== null) {
1411
+ const failure = describeFailure(state, serverName);
1412
+ if (failure !== null) {
1414
1413
  return {
1415
- content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
1414
+ content: [{ type: "text" as const, text: `Server "${serverName}" not available (last ${failure})` }],
1416
1415
  details: { mode: "call", error: "server_backoff", ...callIdentity },
1417
1416
  };
1418
1417
  }
package/search-ranking.ts CHANGED
@@ -107,7 +107,7 @@ export function tokenize(value: string): string[] {
107
107
  previous = character;
108
108
  }
109
109
  }
110
- return tokens;
110
+ return [...new Set(tokens)];
111
111
  }
112
112
 
113
113
  function matchesAsciiStem(fieldToken: string, queryToken: string): boolean {
@@ -40,7 +40,7 @@ function stableHash(value: string): string {
40
40
  return createHash("sha256").update(value).digest("hex");
41
41
  }
42
42
 
43
- function eligibleMatches(state: McpExtensionState, server: string | undefined, allowed: Set<string>): RankedToolMatch[] {
43
+ function eligibleMatches(state: McpExtensionState, server: string | undefined, allowed: ReadonlySet<string>): RankedToolMatch[] {
44
44
  const matches: RankedToolMatch[] = [];
45
45
  for (const [serverName, metadata] of state.toolMetadata) {
46
46
  if ((server && serverName !== server) || !allowed.has(serverName)
@@ -83,10 +83,10 @@ function selectSemanticCandidates(
83
83
  state: McpExtensionState,
84
84
  query: string,
85
85
  server: string | undefined,
86
- allowedServers: string[],
86
+ allowedServers: ReadonlySet<string>,
87
87
  limit: number,
88
88
  ): Candidate[] {
89
- const eligible = eligibleMatches(state, server, new Set(allowedServers));
89
+ const eligible = eligibleMatches(state, server, allowedServers);
90
90
  let selected = eligible;
91
91
  if (eligible.length > limit) {
92
92
  const eligiblePaths = new Set(eligible.map(match => `${match.server}\0${match.tool.name}`));
@@ -131,7 +131,8 @@ export async function semanticSearch(
131
131
  if (query.trim().length === 0) {
132
132
  return { ok: false, error: { code: "empty_query", message: "Semantic search query cannot be empty." } };
133
133
  }
134
- const candidates = selectSemanticCandidates(state, query, server, settings.allowedServers, settings.semanticCandidateLimit);
134
+ const allowedServers = new Set(settings.allowedServers);
135
+ const candidates = selectSemanticCandidates(state, query, server, allowedServers, settings.semanticCandidateLimit);
135
136
  if (candidates.length === 0) {
136
137
  return { ok: false, error: { code: "no_eligible_tools", message: "Semantic search has no eligible cached tools from the allowed servers. Connect an allowed server or update settings.jev.allowedServers." } };
137
138
  }
@@ -158,7 +159,7 @@ export async function semanticSearch(
158
159
  if (envelope.error.code === "timeout" || envelope.error.code === "rate_limited" || envelope.error.code === "service_unavailable") {
159
160
  return {
160
161
  ok: true,
161
- matches: rankToolMatches(state, query, server),
162
+ matches: rankToolMatches(state, query, server).filter(match => allowedServers.has(match.server)),
162
163
  backend: { requested: "semantic", used: "lexical", degraded: true, reason: envelope.error.code },
163
164
  };
164
165
  }
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);