pi-mcp-adapter 2.22.0 → 2.24.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,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.24.0] - 2026-08-13
11
+
12
+ ### Added
13
+ - Added per-server `searchKeywords` so `mcp({ search })` and `mcpScript` `tools.search` understand user-defined synonyms and aliases for tools. Keywords are keyed by tool name or glob and boost ranked and regex search only. They never appear in tool schemas, describe output, or the metadata cache. Thanks @Serisium for PR #336.
14
+
15
+ ### Fixed
16
+ - Interpolated environment placeholders in stdio server arguments. Thanks @vjik for issue #333.
17
+ - Kept remote `/mcp-auth` authorization links reachable before the callback input opens. Thanks @trevorleibert-mixpanel for PR #331.
18
+ - Cached OAuth credentials in memory and refreshed them after OAuth-backed 401 responses. Thanks @daniel-sampliner for PR #335.
19
+ - Sanitized MCP server-name prefixes for provider-safe tool names while preserving server resolution. Thanks @triple-dex for issue #334.
20
+ - Validated persisted npx resolver cache entries before reuse, including malformed and prototype-sensitive persisted keys.
21
+
22
+ ## [2.23.0] - 2026-08-11
23
+
24
+ ### Added
25
+ - Added interactive callback URL pasting to `/mcp-auth` for OAuth flows running on remote or headless machines. Thanks @trevorleibert-mixpanel for PR #330.
26
+
27
+ ### Fixed
28
+ - Stopped load-time MCP initialization from printing a TUI startup error when Pi action methods are not bound yet. Thanks @21307369 for issue #327.
29
+ - Kept interactive OAuth authorization URLs clickable as a single terminal hyperlink. Thanks @rfccg for PR #329.
30
+
10
31
  ## [2.22.0] - 2026-08-11
11
32
 
12
33
  ### Added
package/README.md CHANGED
@@ -227,6 +227,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
227
227
  | `toolPrefix` | Override global `settings.toolPrefix` for this server (`"server"`, `"short"`, `"none"`, or `"mcp"`) |
228
228
  | `includeTools` | `string[]` of tool names or glob patterns to expose (matches original names like `get_screenshot`, generated resource names like `read_figjam`, and prefixed names like `figma_get_screenshot`) |
229
229
  | `excludeTools` | `string[]` of tool names or glob patterns to hide (applied after `includeTools`) |
230
+ | `searchKeywords` | `{ "tool-or-glob": ["keyword", ...] }` — extra keywords that boost `mcp({ search })` ranking for matching tools; never shown to the model |
230
231
  | `debug` | Show server stderr (default: false) |
231
232
  | `trace` | Enable metadata-only JSONL protocol tracing for this server; payloads, prompts, tool arguments/results, authorization data, and URLs are never persisted |
232
233
  | `disabled` | Keep the server visible in config and status, but prevent connections, authentication, tools, and resource calls (only literal `true` disables it) |
@@ -265,7 +266,9 @@ The adapter owns only its client socket and closes that connection when the Pi r
265
266
 
266
267
  ### Remote/headless OAuth
267
268
 
268
- If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool. Persistent OAuth still requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable.
269
+ If Pi is running on a remote server, `/mcp-auth <server>` shows a clickable authorization URL first. Open it in your local browser and approve access, then select **Yes** in Pi to open the callback input. The browser may fail to load the localhost callback page because localhost refers to your workstation; copy the full URL from its address bar and paste it into Pi. The authorization screen closes automatically instead when the browser can reach Pi's callback directly.
270
+
271
+ The same flow is available through the proxy tool for non-interactive clients. Persistent OAuth still requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable.
269
272
 
270
273
  On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter uses a best-effort recovery path through `keyctl session - node <packaged helper>` so explicit re-authentication can write fresh credentials without killing a long-lived tmux server. This path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
271
274
 
@@ -648,10 +651,33 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
648
651
 
649
652
  MCP proxy and direct-tool results render compactly by default: long text shows the first three terminal-wrapped lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model.
650
653
 
651
- Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are ranked by weighted matches across name, server, and description, then returned one page at a time (`limit` defaults to 12). Use `details.nextOffset` for the next page. Regex search is still available with `regex: true`, but regex results are paginated without ranking.
654
+ Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are ranked by weighted matches across name, server, description, and any configured `searchKeywords`, then returned one page at a time (`limit` defaults to 12). Use `details.nextOffset` for the next page. Regex search is still available with `regex: true`, but regex results are paginated without ranking.
652
655
 
653
656
  Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`. When `describe` or `tool` cannot resolve a name, the result includes top suggestions so the agent can correct a typo or missing prefix in the same turn.
654
657
 
658
+ ### Search keywords
659
+
660
+ Search uses literal matching so a tool whose name and description use different vocabulary than the query won't be found. Per-server `searchKeywords` adds extra vocabulary for matching tools:
661
+
662
+ ```json
663
+ {
664
+ "mcpServers": {
665
+ "github": {
666
+ "command": "npx",
667
+ "args": ["-y", "@modelcontextprotocol/server-github"],
668
+ "searchKeywords": {
669
+ "search_code": ["grep"],
670
+ "*": ["gh"]
671
+ }
672
+ }
673
+ }
674
+ }
675
+ ```
676
+
677
+ With this config, `mcp({ search: "grep" })` finds `github_search_code` even though neither its name nor description contains that word. Similarly, `mcp({ search: "gh" })` finds all tools provided by the github server.
678
+
679
+ Keys match a tool's original name, prefixed name, or a glob (`*` applies to every tool on the server) and all matching entries combine. Keywords are weighted like description text, with an extra boost when the query exactly matches a configured phrase. They affect ranked and regex search only (including `tools.search` in `mcpScript`): they never appear in tool schemas, `describe` output, direct-tool registration, or the metadata cache, and search with keywords works offline from cached metadata.
680
+
655
681
  When `includeSchemas` is enabled, search and describe render common JSON Schema parameters as compact TypeScript shapes like `{ query: string; limit?: number; }`, with the older schema formatter retained as a fallback for unsupported schemas.
656
682
 
657
683
  For HTTP servers, failed connects run a one-request shape probe that can turn opaque transport errors into setup hints such as `endpoint returned HTML (200) — this URL does not appear to speak MCP`. Healthy connections are not probed.
package/commands.ts CHANGED
@@ -25,6 +25,10 @@ import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markS
25
25
  import { openPath, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
26
26
  import { isAbortError } from "./runtime-owner.ts";
27
27
 
28
+ function terminalHyperlink(label: string, url: string): string {
29
+ return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
30
+ }
31
+
28
32
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
29
33
  if (!ctx.hasUI) return;
30
34
 
@@ -273,11 +277,26 @@ export async function authenticateServer(
273
277
  ...(authStorageOptions.baseDir ? { authStorageOptions } : {}),
274
278
  onAuthorizationUrl: (authorizationUrl) => {
275
279
  ui.notify(
276
- `Open this URL to authenticate ${serverName}:\n\n${authorizationUrl}\n\n` +
277
- "After approving, return to Pi; the local callback will complete automatically.",
280
+ `Open this URL to authenticate ${serverName}:\n\n${terminalHyperlink(authorizationUrl, authorizationUrl)}\n\n` +
281
+ "After approving, Pi will complete automatically if the browser can reach its localhost callback. " +
282
+ "On a remote machine, copy the full localhost URL from the browser address bar and paste it into Pi.",
278
283
  "info"
279
284
  );
280
285
  },
286
+ onAuthorizationInput: async (authorizationUrl, inputSignal) => {
287
+ const readyToPaste = await ui.confirm(
288
+ `Authorize ${serverName}`,
289
+ `Open this link in your browser:\n${terminalHyperlink(authorizationUrl, authorizationUrl)}\n\n` +
290
+ "After approving access, select Yes to paste the callback URL.",
291
+ { signal: inputSignal },
292
+ );
293
+ if (!readyToPaste || inputSignal.aborted) return undefined;
294
+ return ui.input(
295
+ `Complete ${serverName} OAuth`,
296
+ "Paste the full callback URL",
297
+ { signal: inputSignal },
298
+ );
299
+ },
281
300
  ...(signal ? { signal } : {}),
282
301
  ...(runtime ? { runtime } : {}),
283
302
  });
package/index.ts CHANGED
@@ -169,13 +169,23 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
169
169
  return resolveDirectTools(config, cache, prefix, envDirectToolOverride);
170
170
  }
171
171
 
172
+ function getActiveToolsIfReady(): string[] | undefined {
173
+ try {
174
+ return pi.getActiveTools?.();
175
+ } catch (error) {
176
+ if (error instanceof Error
177
+ && error.message.includes("Action methods cannot be called during extension loading")) return undefined;
178
+ throw error;
179
+ }
180
+ }
181
+
172
182
  function deactivateTools(toolNames: string[]): string[] {
173
183
  if (toolNames.length === 0) return [];
174
184
  const unregisterTool = (pi as ExtensionAPI & { unregisterTool?: (name: string) => boolean }).unregisterTool;
175
185
  const unregistered = toolNames.filter((toolName) => unregisterTool?.(toolName) === true);
176
186
  const fallbackNames = toolNames.filter((toolName) => !unregistered.includes(toolName));
177
187
  const remove = new Set(toolNames);
178
- const activeTools = pi.getActiveTools?.();
188
+ const activeTools = getActiveToolsIfReady();
179
189
  if (!activeTools || activeTools.length === 0) {
180
190
  for (const toolName of fallbackNames) fallbackDeactivatedTools.add(toolName);
181
191
  return unregistered;
@@ -207,7 +217,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
207
217
  registerDirectTool(spec);
208
218
  registeredDirectTools.set(spec.prefixedName, fingerprint);
209
219
  if (fallbackDeactivatedTools.delete(spec.prefixedName)) {
210
- const activeTools = pi.getActiveTools?.();
220
+ const activeTools = getActiveToolsIfReady();
211
221
  if (activeTools && !activeTools.includes(spec.prefixedName)) {
212
222
  pi.setActiveTools([...activeTools, spec.prefixedName]);
213
223
  }
@@ -849,7 +859,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
849
859
  registerProxyTool(description);
850
860
  return;
851
861
  }
852
- const activeTools = pi.getActiveTools?.();
862
+ const activeTools = getActiveToolsIfReady();
853
863
  if (activeTools && !activeTools.includes("mcp")) {
854
864
  pi.setActiveTools([...activeTools, "mcp"]);
855
865
  }
package/mcp-auth-flow.ts CHANGED
@@ -49,6 +49,10 @@ export interface McpOAuthRuntime {
49
49
 
50
50
  export interface AuthenticateOptions {
51
51
  onAuthorizationUrl?: (authorizationUrl: string) => void | Promise<void>
52
+ onAuthorizationInput?: (
53
+ authorizationUrl: string,
54
+ signal: AbortSignal,
55
+ ) => Promise<string | undefined>
52
56
  authStorageOptions?: AuthStorageOptions
53
57
  signal?: AbortSignal
54
58
  runtime?: McpOAuthRuntime
@@ -568,6 +572,53 @@ export function parseAuthorizationCodeInput(input: string, expectedState?: strin
568
572
  return parseAuthorizationRedirectInput(input, expectedState).code
569
573
  }
570
574
 
575
+ type AuthorizationResponse = {
576
+ input: AuthorizationCodeInput
577
+ source: "callback" | "manual"
578
+ }
579
+
580
+ /**
581
+ * Wait for either the localhost callback or a manually pasted redirect URL.
582
+ * The manual input prompt is dismissed as soon as either path finishes.
583
+ */
584
+ export async function waitForAuthorizationResponse(
585
+ callbackPromise: Promise<AuthorizationCodeInput>,
586
+ authorizationUrl: string,
587
+ expectedState: string,
588
+ onAuthorizationInput?: AuthenticateOptions["onAuthorizationInput"],
589
+ signal?: AbortSignal,
590
+ ): Promise<AuthorizationResponse> {
591
+ if (!onAuthorizationInput) {
592
+ return {
593
+ input: await abortable(callbackPromise, signal),
594
+ source: "callback",
595
+ }
596
+ }
597
+
598
+ const inputController = new AbortController()
599
+ try {
600
+ const response = await abortable(Promise.race([
601
+ callbackPromise.then((input) => ({ input, source: "callback" as const })),
602
+ onAuthorizationInput(authorizationUrl, inputController.signal).then((input) => ({
603
+ input,
604
+ source: "manual" as const,
605
+ })),
606
+ ]), signal)
607
+
608
+ if (response.source === "callback") return response
609
+ if (!response.input?.trim()) throw new Error("OAuth authentication cancelled")
610
+ if (!getSearchParamsFromInput(response.input.trim())) {
611
+ throw new Error("Paste the full OAuth callback URL, including its code and state parameters")
612
+ }
613
+ return {
614
+ input: parseAuthorizationRedirectInput(response.input, expectedState),
615
+ source: "manual",
616
+ }
617
+ } finally {
618
+ inputController.abort()
619
+ }
620
+ }
621
+
571
622
  /**
572
623
  * Complete OAuth authentication from manual user input.
573
624
  */
@@ -728,12 +779,22 @@ export async function authenticate(
728
779
  console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
729
780
  }
730
781
 
731
- const callbackResult = await abortable(callbackPromise, signal)
782
+ const authorizationResponse = await waitForAuthorizationResponse(
783
+ callbackPromise,
784
+ authorizationUrl,
785
+ oauthState,
786
+ options.onAuthorizationInput,
787
+ signal,
788
+ )
789
+ if (authorizationResponse.source === "manual") {
790
+ cancelPendingCallback(oauthState)
791
+ }
732
792
 
733
- // The callback server accepted only the flow-local reserved state.
793
+ // The callback server accepted only the flow-local reserved state. Manual
794
+ // input is checked against the same state before token exchange.
734
795
  throwIfAborted(signal)
735
796
 
736
- return await completeAuth(serverName, callbackResult, {
797
+ return await completeAuth(serverName, authorizationResponse.input, {
737
798
  ...options,
738
799
  ...(signal ? { signal } : {}),
739
800
  runtime,
package/mcp-auth.ts CHANGED
@@ -37,6 +37,7 @@ const KEYRING_RECOVERY_KEYCTL_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_KEYCTL';
37
37
  const KEYRING_RECOVERY_NODE_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_NODE';
38
38
  const KEYRING_RECOVERY_HELPER_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_HELPER';
39
39
  const TEST_LINUX_KEYRING_RECOVERY_ENV = 'PI_MCP_ADAPTER_TEST_LINUX_KEYRING_RECOVERY';
40
+ const AUTH_CACHE_DISABLED_ENV = 'PI_MCP_ADAPTER_DISABLE_AUTH_CACHE';
40
41
  const KEYRING_RECOVERY_TIMEOUT_MS = 10_000;
41
42
  const AUTH_CHUNK_MANIFEST_KEY = '__piMcpAdapterOAuthChunked';
42
43
 
@@ -148,8 +149,20 @@ interface AuthEntryChunkManifest {
148
149
  let KeyringEntryClass: KeyringEntryConstructor | undefined;
149
150
  const memoryAuthEntries = new Map<string, string>();
150
151
 
152
+ let testAuthSecretStoreReadCount = 0;
153
+ const authEntryCache = new Map<string, AuthEntry | undefined>();
154
+
155
+ function isAuthEntryCacheEnabled(): boolean {
156
+ return process.env[AUTH_CACHE_DISABLED_ENV] !== '1';
157
+ }
158
+
159
+ function cloneAuthEntry(entry: AuthEntry | undefined): AuthEntry | undefined {
160
+ return entry === undefined ? undefined : structuredClone(entry);
161
+ }
162
+
151
163
  const memoryAuthSecretStore: AuthSecretStore = {
152
164
  read(account) {
165
+ testAuthSecretStoreReadCount++;
153
166
  return memoryAuthEntries.get(account);
154
167
  },
155
168
  write(account, payload) {
@@ -175,6 +188,7 @@ const keyringAuthSecretStore: AuthSecretStore = {
175
188
  /** Mimics the Windows Credential Manager per-value ceiling for tests. */
176
189
  const sizeLimitedAuthSecretStore: AuthSecretStore = {
177
190
  read(account) {
191
+ testAuthSecretStoreReadCount++;
178
192
  return memoryAuthEntries.get(account);
179
193
  },
180
194
  write(account, payload) {
@@ -190,6 +204,7 @@ const sizeLimitedAuthSecretStore: AuthSecretStore = {
190
204
 
191
205
  const unavailableAuthSecretStore: AuthSecretStore = {
192
206
  read() {
207
+ testAuthSecretStoreReadCount++;
193
208
  throw new Error('simulated secure credential store unavailable');
194
209
  },
195
210
  write() {
@@ -206,6 +221,7 @@ function createKeyRevokedTestError(): Error {
206
221
 
207
222
  const keyRevokedAuthSecretStore: AuthSecretStore = {
208
223
  read() {
224
+ testAuthSecretStoreReadCount++;
209
225
  throw createKeyRevokedTestError();
210
226
  },
211
227
  write() {
@@ -218,6 +234,16 @@ const keyRevokedAuthSecretStore: AuthSecretStore = {
218
234
 
219
235
  export function resetTestAuthSecretStore(): void {
220
236
  memoryAuthEntries.clear();
237
+ authEntryCache.clear();
238
+ testAuthSecretStoreReadCount = 0;
239
+ }
240
+
241
+ export function resetAuthEntryCache(): void {
242
+ authEntryCache.clear();
243
+ }
244
+
245
+ export function getTestAuthSecretStoreReadCount(): number {
246
+ return testAuthSecretStoreReadCount;
221
247
  }
222
248
 
223
249
  export function getTestAuthSecretStoreEntries(): [string, string][] {
@@ -649,6 +675,19 @@ function writeSecureAuthEntryToStore(store: AuthSecretStore, serverName: string,
649
675
  error,
650
676
  );
651
677
  }
678
+
679
+ publishAuthEntryToCache(serverName, payload);
680
+ }
681
+
682
+ function publishAuthEntryToCache(serverName: string, payload: string): void {
683
+ if (!isAuthEntryCacheEnabled()) return;
684
+ // Cache the same normalized shape a fresh persistent-store read returns.
685
+ const normalized = toAuthEntry(JSON.parse(payload) as unknown);
686
+ if (!normalized) {
687
+ authEntryCache.delete(serverName);
688
+ return;
689
+ }
690
+ authEntryCache.set(serverName, cloneAuthEntry(normalized));
652
691
  }
653
692
 
654
693
  function writeSecureAuthEntry(serverName: string, entry: AuthEntry): void {
@@ -704,12 +743,23 @@ function readAuthEntry(
704
743
  options?: AuthStorageOptions,
705
744
  behavior: { migrateLegacy?: boolean } = {},
706
745
  ): AuthEntry | undefined {
746
+ // Status-only reads deliberately bypass the cache because they do not
747
+ // migrate legacy entries.
748
+ const cacheable = behavior.migrateLegacy !== false && isAuthEntryCacheEnabled();
749
+ if (cacheable && authEntryCache.has(serverName)) {
750
+ return cloneAuthEntry(authEntryCache.get(serverName));
751
+ }
752
+
753
+ let entry: AuthEntry | undefined;
707
754
  try {
708
- return readAuthEntryFromStore(getAuthSecretStore(), serverName, options, behavior);
755
+ entry = readAuthEntryFromStore(getAuthSecretStore(), serverName, options, behavior);
709
756
  } catch (error) {
710
757
  if (!shouldAttemptLinuxKeyringRecovery(error)) throw error;
711
- return readAuthEntryFromStore(linuxKeyringRecoveryAuthSecretStore, serverName, options, behavior);
758
+ entry = readAuthEntryFromStore(linuxKeyringRecoveryAuthSecretStore, serverName, options, behavior);
712
759
  }
760
+
761
+ if (cacheable) authEntryCache.set(serverName, cloneAuthEntry(entry));
762
+ return entry;
713
763
  }
714
764
 
715
765
  /**
@@ -794,9 +844,17 @@ export function removeAuthEntry(serverName: string, options?: AuthStorageOptions
794
844
  if (!shouldAttemptLinuxKeyringRecovery(error)) throw error;
795
845
  removeAuthEntryFromStore(linuxKeyringRecoveryAuthSecretStore, serverName);
796
846
  }
847
+ authEntryCache.delete(serverName);
797
848
  removeLegacyAuthEntry(serverName, options);
798
849
  }
799
850
 
851
+ /**
852
+ * Forget a cached entry so the next ordinary read reloads secure storage.
853
+ */
854
+ export function invalidateAuthEntryCache(serverName: string): void {
855
+ authEntryCache.delete(serverName);
856
+ }
857
+
800
858
  /**
801
859
  * Update tokens for a server.
802
860
  */
package/npx-resolver.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes
2
- import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
2
+ import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync, unlinkSync } from "node:fs";
3
3
  import { join, dirname, extname, resolve, sep } from "node:path";
4
4
  import { getAgentPath } from "./agent-dir.ts";
5
5
  import { throwIfAborted } from "./abort.ts";
6
6
  import crossSpawn from "cross-spawn";
7
7
 
8
- const CACHE_VERSION = 1;
8
+ const CACHE_VERSION = 2;
9
9
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
10
10
  const EXACT_PACKAGE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
11
11
 
@@ -53,7 +53,7 @@ export async function resolveNpxBinary(
53
53
  if (!parsed) return null;
54
54
 
55
55
  const packageSpec = parsePackageSpec(parsed.packageSpec);
56
- const cacheKey = JSON.stringify([command, ...args]);
56
+ const cacheKey = JSON.stringify([command, parsed.packageSpec, parsed.binName ?? ""]);
57
57
  const cache = loadCache();
58
58
  const cached = cache?.entries?.[cacheKey];
59
59
 
@@ -434,41 +434,96 @@ function getNpxCachePath(): string {
434
434
  return getAgentPath("mcp-npx-cache.json");
435
435
  }
436
436
 
437
- function loadCache(): NpxCache | null {
438
- const cachePath = getNpxCachePath();
437
+ function readNpxCachePayload(cachePath: string): unknown | null {
439
438
  if (!existsSync(cachePath)) return null;
440
439
  try {
441
- const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
442
- if (!raw || typeof raw !== "object") return null;
443
- if (raw.version !== CACHE_VERSION) return null;
444
- if (!raw.entries || typeof raw.entries !== "object") return null;
445
- return raw as NpxCache;
440
+ return JSON.parse(readFileSync(cachePath, "utf-8")) as unknown;
446
441
  } catch {
447
442
  return null;
448
443
  }
449
444
  }
450
445
 
451
- function saveCacheEntry(key: string, entry: NpxCacheEntry): void {
452
- const cachePath = getNpxCachePath();
453
- const dir = dirname(cachePath);
454
- mkdirSync(dir, { recursive: true });
446
+ function asRecord(value: unknown): Record<string, unknown> | null {
447
+ return typeof value === "object" && value !== null && !Array.isArray(value)
448
+ ? value as Record<string, unknown>
449
+ : null;
450
+ }
451
+
452
+ function createCacheEntries(): Record<string, NpxCacheEntry> {
453
+ return Object.create(null) as Record<string, NpxCacheEntry>;
454
+ }
455
+
456
+ function toNpxCacheEntry(value: unknown): NpxCacheEntry | null {
457
+ const raw = asRecord(value);
458
+ if (!raw) return null;
459
+ if (typeof raw.resolvedBin !== "string") return null;
460
+ if (typeof raw.resolvedAt !== "number" || !Number.isFinite(raw.resolvedAt)) return null;
461
+ if (typeof raw.isJs !== "boolean") return null;
462
+ if (raw.packageVersion !== undefined && typeof raw.packageVersion !== "string") return null;
463
+ return {
464
+ resolvedBin: raw.resolvedBin,
465
+ resolvedAt: raw.resolvedAt,
466
+ ...(raw.packageVersion !== undefined ? { packageVersion: raw.packageVersion } : {}),
467
+ isJs: raw.isJs,
468
+ };
469
+ }
470
+
471
+ function toNpxCache(value: unknown): NpxCache | null {
472
+ const raw = asRecord(value);
473
+ if (!raw || raw.version !== CACHE_VERSION) return null;
474
+ const rawEntries = asRecord(raw.entries);
475
+ if (!rawEntries) return null;
476
+
477
+ const entries = createCacheEntries();
478
+ for (const [key, rawEntry] of Object.entries(rawEntries)) {
479
+ const entry = toNpxCacheEntry(rawEntry);
480
+ if (entry) entries[key] = entry;
481
+ }
482
+ return { version: CACHE_VERSION, entries };
483
+ }
455
484
 
456
- let merged: NpxCache = { version: CACHE_VERSION, entries: {} };
485
+ function clearLegacyCache(): boolean {
486
+ const cachePath = getNpxCachePath();
487
+ const raw = asRecord(readNpxCachePayload(cachePath));
488
+ if (raw?.version !== 1) return false;
457
489
  try {
458
- if (existsSync(cachePath)) {
459
- const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as NpxCache;
460
- if (existing && existing.version === CACHE_VERSION && existing.entries) {
461
- merged.entries = { ...existing.entries };
462
- }
463
- }
490
+ unlinkSync(cachePath);
464
491
  } catch {
465
- // Ignore parse errors
492
+ try {
493
+ writeFileSync(cachePath, "", "utf-8");
494
+ } catch {
495
+ // Cache cleanup is best effort; resolution must still proceed.
496
+ }
466
497
  }
498
+ return true;
499
+ }
467
500
 
468
- merged.entries[key] = entry;
469
- const tmpPath = `${cachePath}.${process.pid}.tmp`;
470
- writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
471
- renameSync(tmpPath, cachePath);
501
+ clearLegacyCache();
502
+
503
+ function loadCache(): NpxCache | null {
504
+ if (clearLegacyCache()) return null;
505
+
506
+ return toNpxCache(readNpxCachePayload(getNpxCachePath()));
507
+ }
508
+
509
+ function saveCacheEntry(key: string, entry: NpxCacheEntry): void {
510
+ try {
511
+ const cachePath = getNpxCachePath();
512
+ const dir = dirname(cachePath);
513
+ mkdirSync(dir, { recursive: true });
514
+
515
+ const existing = toNpxCache(readNpxCachePayload(cachePath));
516
+ const entries = createCacheEntries();
517
+ if (existing) Object.assign(entries, existing.entries);
518
+ const merged: NpxCache = { version: CACHE_VERSION, entries };
519
+
520
+ merged.entries[key] = entry;
521
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
522
+ writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
523
+ renameSync(tmpPath, cachePath);
524
+ } catch {
525
+ // Cache writes are best effort; resolution must still proceed.
526
+ }
472
527
  }
473
528
 
474
529
  function safeRealpath(path: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.22.0",
3
+ "version": "2.24.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -34,8 +34,8 @@
34
34
  "test": "vitest run",
35
35
  "test:watch": "vitest",
36
36
  "test:coverage": "vitest run --coverage",
37
- "test:oauth": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory node --import tsx --test --test-concurrency=1 oauth-public-api.test.ts mcp-auth.test.ts mcp-auth-flow.test.ts mcp-callback-server.test.ts mcp-oauth-provider.test.ts",
38
- "test:oauth-provider": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory node --import tsx --test mcp-oauth-provider.test.ts",
37
+ "test:oauth": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory PI_MCP_ADAPTER_DISABLE_AUTH_CACHE=1 node --import tsx --test --test-concurrency=1 oauth-public-api.test.ts mcp-auth.test.ts mcp-auth-flow.test.ts mcp-callback-server.test.ts mcp-oauth-provider.test.ts",
38
+ "test:oauth-provider": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory PI_MCP_ADAPTER_DISABLE_AUTH_CACHE=1 node --import tsx --test mcp-oauth-provider.test.ts",
39
39
  "test:conformance": "bash conformance/run.sh"
40
40
  },
41
41
  "repository": {
package/proxy-modes.ts CHANGED
@@ -16,7 +16,7 @@ import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime }
16
16
  import { formatAuthRequiredMessage, formatMcpStatus, resolveServerUrl, truncateAtWord } from "./utils.ts";
17
17
  import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
18
18
  import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.ts";
19
- import { paginate, rankSuggestions, rankToolMatches } from "./search-ranking.ts";
19
+ import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords } from "./search-ranking.ts";
20
20
  import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
21
21
 
22
22
  type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
@@ -503,11 +503,15 @@ export function executeSearch(
503
503
  }
504
504
 
505
505
  matches = [];
506
+ const globalPrefix = state.config.settings?.toolPrefix ?? "server";
506
507
  for (const [serverName, metadata] of state.toolMetadata.entries()) {
507
- if (isServerDisabled(state.config.mcpServers[serverName])) continue;
508
+ const definition = state.config.mcpServers[serverName];
509
+ if (isServerDisabled(definition)) continue;
508
510
  if (server && serverName !== server) continue;
509
511
  for (const tool of metadata) {
510
- if (pattern.test(tool.name) || pattern.test(tool.description)) matches.push({ server: serverName, tool, score: 0 });
512
+ const matched = pattern.test(tool.name) || pattern.test(tool.description)
513
+ || resolveSearchKeywords(definition, tool.originalName, serverName, globalPrefix).some(keyword => pattern.test(keyword));
514
+ if (matched) matches.push({ server: serverName, tool, score: 0 });
511
515
  }
512
516
  }
513
517
  } else if (query.trim().length === 0) {
package/search-ranking.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { McpExtensionState } from "./state.ts";
2
- import type { ToolMetadata } from "./types.ts";
3
- import { getServerPrefix, isServerDisabled } from "./types.ts";
2
+ import type { ServerEntry, ToolMetadata, ToolPrefix } from "./types.ts";
3
+ import { getServerPrefix, getToolNameCandidates, isServerDisabled, matchesToolPattern, resolveToolPrefix } from "./types.ts";
4
4
 
5
5
  /**
6
6
  * Shortest field token allowed to stem-match a longer query token.
@@ -14,6 +14,7 @@ const FIELD_WEIGHTS = {
14
14
  originalName: 10,
15
15
  server: 8,
16
16
  description: 5,
17
+ keywords: 5,
17
18
  } as const;
18
19
 
19
20
  export interface RankedToolMatch {
@@ -22,6 +23,36 @@ export interface RankedToolMatch {
22
23
  score: number;
23
24
  }
24
25
 
26
+ /**
27
+ * Resolve the configured searchKeywords entries that apply to a tool.
28
+ * Keys match by original name, prefixed name, or glob — the same candidate
29
+ * set includeTools/excludeTools use — and all matching entries are unioned.
30
+ */
31
+ export function resolveSearchKeywords(
32
+ definition: ServerEntry | undefined,
33
+ toolOriginalName: string,
34
+ serverName: string,
35
+ globalPrefix: ToolPrefix,
36
+ ): string[] {
37
+ const map = definition?.searchKeywords;
38
+ if (!map || typeof map !== "object" || Array.isArray(map)) return [];
39
+ const candidates = getToolNameCandidates(toolOriginalName, serverName, resolveToolPrefix(definition, globalPrefix));
40
+ const keywords: string[] = [];
41
+ const seen = new Set<string>();
42
+ for (const [pattern, values] of Object.entries(map)) {
43
+ if (!Array.isArray(values)) continue;
44
+ if (!matchesToolPattern(candidates, [pattern])) continue;
45
+ for (const value of values) {
46
+ if (typeof value !== "string") continue;
47
+ const trimmed = value.trim();
48
+ if (!trimmed || seen.has(trimmed)) continue;
49
+ seen.add(trimmed);
50
+ keywords.push(trimmed);
51
+ }
52
+ }
53
+ return keywords;
54
+ }
55
+
25
56
  export function normalizeSearchText(value: string): string {
26
57
  return value
27
58
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
@@ -33,7 +64,7 @@ export function tokenize(value: string): string[] {
33
64
  return normalizeSearchText(value).split(/[^a-z0-9]+/).filter(Boolean);
34
65
  }
35
66
 
36
- export function scoreToolMatch(tool: ToolMetadata, server: string, query: string): number | null {
67
+ export function scoreToolMatch(tool: ToolMetadata, server: string, query: string, keywords?: string[]): number | null {
37
68
  const normalizedQuery = normalizeSearchText(query).trim();
38
69
  const queryTokens = tokenize(query);
39
70
  if (queryTokens.length === 0) return null;
@@ -78,6 +109,43 @@ export function scoreToolMatch(tool: ToolMetadata, server: string, query: string
78
109
  }
79
110
  }
80
111
 
112
+ // Configured keywords are discrete phrases, so the phrase-level bonus is
113
+ // computed per phrase (best match wins) rather than on a joined string,
114
+ // which would phrase-match queries spanning two unrelated keywords.
115
+ if (keywords !== undefined && keywords.length > 0) {
116
+ const weight = FIELD_WEIGHTS.keywords;
117
+ const phrases = keywords.map(keyword => normalizeSearchText(keyword).trim()).filter(Boolean);
118
+ let phraseScore = 0;
119
+ for (const phrase of phrases) {
120
+ if (phrase === normalizedQuery) {
121
+ phraseScore = Math.max(phraseScore, weight * 14);
122
+ phraseMatched = true;
123
+ wholeFieldExact = true;
124
+ } else if (phrase.startsWith(normalizedQuery)) {
125
+ phraseScore = Math.max(phraseScore, weight * 9);
126
+ phraseMatched = true;
127
+ } else if (phrase.includes(normalizedQuery)) {
128
+ phraseScore = Math.max(phraseScore, weight * 6);
129
+ phraseMatched = true;
130
+ }
131
+ }
132
+ score += phraseScore;
133
+
134
+ const keywordTokens = phrases.flatMap(tokenize);
135
+ for (const token of queryTokens) {
136
+ if (keywordTokens.includes(token)) {
137
+ score += weight * 4;
138
+ matchedTokens.add(token);
139
+ } else if (keywordTokens.some(keywordToken => keywordToken.startsWith(token) || (keywordToken.length >= MIN_STEM_LENGTH && token.startsWith(keywordToken)))) {
140
+ score += weight * 2;
141
+ matchedTokens.add(token);
142
+ } else if (phrases.some(phrase => phrase.includes(token))) {
143
+ score += weight;
144
+ matchedTokens.add(token);
145
+ }
146
+ }
147
+ }
148
+
81
149
  const coverage = matchedTokens.size / queryTokens.length;
82
150
  if (!phraseMatched && (queryTokens.length <= 2 ? coverage !== 1 : coverage < 0.6)) return null;
83
151
 
@@ -88,13 +156,24 @@ export function scoreToolMatch(tool: ToolMetadata, server: string, query: string
88
156
  return score;
89
157
  }
90
158
 
91
- export function rankToolMatches(state: McpExtensionState, query: string, server?: string): RankedToolMatch[] {
159
+ export function rankToolMatches(
160
+ state: McpExtensionState,
161
+ query: string,
162
+ server?: string,
163
+ includeKeywords = true,
164
+ ): RankedToolMatch[] {
92
165
  const matches: RankedToolMatch[] = [];
166
+ const globalPrefix = state.config.settings?.toolPrefix ?? "server";
93
167
  for (const [serverName, metadata] of state.toolMetadata.entries()) {
94
168
  if (server && serverName !== server) continue;
95
- if (isServerDisabled(state.config.mcpServers[serverName])) continue;
169
+ const definition = state.config.mcpServers[serverName];
170
+ if (isServerDisabled(definition)) continue;
171
+ const hasKeywords = includeKeywords && definition?.searchKeywords !== undefined;
96
172
  for (const tool of metadata) {
97
- const score = scoreToolMatch(tool, serverName, query);
173
+ const keywords = hasKeywords
174
+ ? resolveSearchKeywords(definition, tool.originalName, serverName, globalPrefix)
175
+ : undefined;
176
+ const score = scoreToolMatch(tool, serverName, query, keywords);
98
177
  if (score !== null) matches.push({ server: serverName, tool, score });
99
178
  }
100
179
  }
@@ -123,5 +202,5 @@ export function rankSuggestions(state: McpExtensionState, name: string, limit: n
123
202
  .sort((a, b) => b.length - a.length)
124
203
  .map(candidate => name.slice(candidate.length + 1));
125
204
  const query = stripped[0] ?? name;
126
- return rankToolMatches(state, query).slice(0, limit).map(match => match.tool.name);
205
+ return rankToolMatches(state, query, undefined, false).slice(0, limit).map(match => match.tool.name);
127
206
  }
package/server-manager.ts CHANGED
@@ -31,7 +31,7 @@ import { createJsonSchemaValidator } from "./json-schema-validator.ts";
31
31
  import { logger } from "./logger.ts";
32
32
  import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
33
33
  import { extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
34
- import type { AuthStorageOptions } from "./mcp-auth.ts";
34
+ import { invalidateAuthEntryCache, type AuthStorageOptions } from "./mcp-auth.ts";
35
35
  import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
36
36
  import {
37
37
  handleUrlElicitation,
@@ -39,6 +39,7 @@ import {
39
39
  type ServerElicitationConfig,
40
40
  } from "./elicitation-handler.ts";
41
41
  import {
42
+ interpolateEnvVars,
42
43
  resolveBearerToken,
43
44
  resolveCommandSecret,
44
45
  resolveCommandSecretsRecord,
@@ -129,8 +130,11 @@ export interface ServerConnection {
129
130
  lastUsedAt: number;
130
131
  inFlight: number;
131
132
  status: "connected" | "closed" | "needs-auth";
133
+ /** True once this needs-auth episode discarded the cached credential. */
134
+ credentialsInvalidated?: boolean;
132
135
  }
133
136
 
137
+
134
138
  type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
135
139
  type MetadataListChangedListener = (serverName: string, reason: string) => void;
136
140
 
@@ -238,10 +242,12 @@ export class McpServerManager {
238
242
  return existing;
239
243
  }
240
244
 
245
+ const credentialsInvalidated = existing?.status === "needs-auth"
246
+ && existing.credentialsInvalidated === true;
241
247
  const generation = this.closeGenerations.get(name) ?? 0;
242
248
  const attemptController = new AbortController();
243
249
  const attemptSignal = combineAbortSignals(ownedSignal, attemptController.signal);
244
- const connectionAttempt = this.createConnection(name, definition, attemptSignal, ownedSignal);
250
+ const connectionAttempt = this.createConnection(name, definition, attemptSignal, ownedSignal, credentialsInvalidated);
245
251
  const promise = definition.url
246
252
  ? connectionAttempt.catch(async error => { throw await this.enrichHttpConnectionError(definition, error); })
247
253
  : connectionAttempt;
@@ -324,6 +330,7 @@ export class McpServerManager {
324
330
  definition: ServerDefinition,
325
331
  signal?: AbortSignal,
326
332
  requestSignal?: AbortSignal,
333
+ credentialsInvalidated = false,
327
334
  ): Promise<ServerConnection> {
328
335
  throwIfAborted(signal);
329
336
 
@@ -338,6 +345,7 @@ export class McpServerManager {
338
345
  let client: Client;
339
346
  let transport: Transport;
340
347
  let clientConnected = false;
348
+ let invalidated = credentialsInvalidated;
341
349
  let transportAlreadyTraced = false;
342
350
  let stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
343
351
  const configuredTransports = [definition.command, definition.url, definition.socket]
@@ -351,7 +359,7 @@ export class McpServerManager {
351
359
  if (definition.command) {
352
360
  client = this.createClient(name, definition);
353
361
  let command = definition.command;
354
- let args = definition.args ?? [];
362
+ let args = (definition.args ?? []).map(interpolateEnvVars);
355
363
 
356
364
  if (command === "npx" || command === "npm") {
357
365
  const resolved = await resolveNpxBinary(command, args, signal);
@@ -387,9 +395,11 @@ export class McpServerManager {
387
395
  requestOptions,
388
396
  signal,
389
397
  traceObserver,
398
+ invalidated,
390
399
  );
391
400
  client = httpConnection.client;
392
401
  transport = httpConnection.transport;
402
+ invalidated = httpConnection.credentialsInvalidated;
393
403
  if (httpConnection.status === "needs-auth") {
394
404
  return {
395
405
  client,
@@ -401,6 +411,7 @@ export class McpServerManager {
401
411
  lastUsedAt: Date.now(),
402
412
  inFlight: 0,
403
413
  status: "needs-auth",
414
+ credentialsInvalidated: invalidated,
404
415
  };
405
416
  }
406
417
  clientConnected = true;
@@ -477,6 +488,10 @@ export class McpServerManager {
477
488
  // A cleanup failure remains a setup failure rather than being hidden
478
489
  // behind needs-auth.
479
490
  if (isUnauthorizedHttpError(error) && supportsOAuth(definition) && cleanupFailures.length === 0) {
491
+ if (!invalidated) {
492
+ invalidateAuthEntryCache(name);
493
+ invalidated = true;
494
+ }
480
495
  return {
481
496
  client,
482
497
  transport,
@@ -487,6 +502,7 @@ export class McpServerManager {
487
502
  lastUsedAt: Date.now(),
488
503
  inFlight: 0,
489
504
  status: "needs-auth",
505
+ credentialsInvalidated: invalidated,
490
506
  };
491
507
  }
492
508
 
@@ -694,7 +710,8 @@ export class McpServerManager {
694
710
  requestOptions: RequestOptions | undefined,
695
711
  signal?: AbortSignal,
696
712
  traceObserver?: McpTraceObserver,
697
- ): Promise<{ client: Client; transport: Transport; status: "connected" | "needs-auth" }> {
713
+ credentialsInvalidated = false,
714
+ ): Promise<{ client: Client; transport: Transport; status: "connected" | "needs-auth"; credentialsInvalidated: boolean }> {
698
715
  throwIfAborted(signal);
699
716
  const serverUrl = resolveServerUrl(definition)!;
700
717
  const url = new URL(serverUrl);
@@ -791,9 +808,10 @@ export class McpServerManager {
791
808
  // OAuth challenge; use SSE only for definitive endpoint incompatibility.
792
809
  // Agent Plugins set httpTransport, and their declared transport is used without fallback.
793
810
  let kind: "streamable-http" | "sse" = definition.httpTransport ?? "streamable-http";
811
+ let invalidated = credentialsInvalidated;
794
812
  for (;;) {
795
813
  const result = await attempt(kind);
796
- if (result.status === "connected") return result;
814
+ if (result.status === "connected") return { ...result, credentialsInvalidated: invalidated };
797
815
  if (result.error instanceof AggregateError
798
816
  && result.error.message === "MCP connection abort cleanup failed") {
799
817
  throw result.error;
@@ -806,7 +824,16 @@ export class McpServerManager {
806
824
  }
807
825
  if (isUnauthorizedHttpError(result.error)) {
808
826
  if (supportsOAuth(definition)) {
809
- return { client: result.client, transport: result.transport, status: "needs-auth" };
827
+ if (!invalidated) {
828
+ invalidateAuthEntryCache(serverName);
829
+ invalidated = true;
830
+ }
831
+ return {
832
+ client: result.client,
833
+ transport: result.transport,
834
+ status: "needs-auth",
835
+ credentialsInvalidated: invalidated,
836
+ };
810
837
  }
811
838
  throw result.error;
812
839
  }
@@ -850,6 +877,7 @@ export class McpServerManager {
850
877
  return { prompts, failed: false };
851
878
  } catch (error) {
852
879
  if (requestOptions?.signal?.aborted) throwIfAborted(requestOptions.signal);
880
+ if (isUnauthorizedHttpError(error)) throw error;
853
881
  const message = error instanceof Error ? error.message : String(error);
854
882
  logger.debug(`MCP: prompts/list failed: ${message}`);
855
883
  return { prompts: [], failed: true };
@@ -871,10 +899,11 @@ export class McpServerManager {
871
899
  } while (cursor);
872
900
 
873
901
  return allResources;
874
- } catch {
902
+ } catch (error) {
875
903
  if (requestOptions?.signal?.aborted) {
876
904
  throwIfAborted(requestOptions.signal);
877
905
  }
906
+ if (isUnauthorizedHttpError(error)) throw error;
878
907
  // The server advertises resources but the listing failed
879
908
  return [];
880
909
  }
@@ -20,10 +20,12 @@
20
20
  // many things other than "your session is gone"
21
21
  // - treat generic -32000/ConnectionClosed errors as session expiry
22
22
  // - treat AbortError/cancellation as a session failure
23
- import { ProtocolError, SdkHttpError } from "@modelcontextprotocol/client";
23
+ import { ProtocolError, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
24
24
  import { logger } from "./logger.ts";
25
25
  import { throwIfAborted } from "./abort.ts";
26
26
  import { isServerDisabled, type McpConfig } from "./types.ts";
27
+ import { supportsOAuth } from "./mcp-auth-flow.ts";
28
+ import { invalidateAuthEntryCache } from "./mcp-auth.ts";
27
29
  import type { McpServerManager, ServerConnection } from "./server-manager.ts";
28
30
 
29
31
  /**
@@ -106,6 +108,11 @@ export async function withSessionRecovery<T>(
106
108
  try {
107
109
  return await fn(connection);
108
110
  } catch (err) {
111
+ const definition = deps.config.mcpServers[serverName];
112
+ if (definition && supportsOAuth(definition)
113
+ && (err instanceof UnauthorizedError || (err instanceof SdkHttpError && err.status === 401))) {
114
+ invalidateAuthEntryCache(serverName);
115
+ }
109
116
  if (!isTerminatedSession(err, hadSessionId)) {
110
117
  throw err;
111
118
  }
@@ -114,7 +121,6 @@ export async function withSessionRecovery<T>(
114
121
  // connection's definition, in case config changed since connect. If the
115
122
  // server was removed from config in the meantime there is nothing to
116
123
  // reconnect to, so surface the original error.
117
- const definition = deps.config.mcpServers[serverName];
118
124
  if (!definition) {
119
125
  throw err;
120
126
  }
package/types.ts CHANGED
@@ -395,6 +395,13 @@ export interface ServerEntry {
395
395
  // Include/exclude specific MCP tools/resources by original or prefixed name
396
396
  includeTools?: string[];
397
397
  excludeTools?: string[];
398
+ /**
399
+ * Extra search keywords per tool, keyed by original name, prefixed name, or
400
+ * glob (same matching rules as includeTools/excludeTools). Keywords boost
401
+ * mcp({ search }) ranking only — they never appear in tool schemas,
402
+ * describe output, or the metadata cache.
403
+ */
404
+ searchKeywords?: Record<string, string[]>;
398
405
  // Require interactive approval before calling matching MCP tools/resources.
399
406
  approveTools?: boolean | string[];
400
407
  // Debug
@@ -635,18 +642,24 @@ export interface McpPanelResult {
635
642
  /**
636
643
  * Get server prefix based on tool prefix mode.
637
644
  */
645
+ function sanitizeServerPrefix(serverName: string): string {
646
+ return Array.from(serverName, char =>
647
+ /^[A-Za-z0-9]$/.test(char) ? char : `_${char.codePointAt(0)!.toString(16)}_`,
648
+ ).join("");
649
+ }
650
+
638
651
  export function getServerPrefix(
639
652
  serverName: string,
640
653
  mode: ToolPrefix
641
654
  ): string {
642
655
  if (mode === "none") return "";
643
656
  if (mode === "short") {
644
- let short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
657
+ let short = sanitizeServerPrefix(serverName.replace(/-?mcp$/i, ""));
645
658
  if (!short) short = "mcp";
646
659
  return short;
647
660
  }
648
- if (mode === "mcp") return `mcp__${serverName.replace(/-/g, "_")}`;
649
- return serverName.replace(/-/g, "_");
661
+ if (mode === "mcp") return `mcp__${sanitizeServerPrefix(serverName)}`;
662
+ return sanitizeServerPrefix(serverName);
650
663
  }
651
664
 
652
665
  /**
@@ -700,11 +713,9 @@ export function resolveServerFromToolName(
700
713
  if (candidates.length === 0) return undefined;
701
714
  candidates.sort((a, b) => b.prefix.length - a.prefix.length);
702
715
  const best = candidates[0];
703
- // Fail safe: two distinct server names can normalize to the same prefix
704
- // (e.g. my-server and my_server both -> my_server under "server" mode).
705
- // When that happens the owning server is ambiguous; return undefined so a
706
- // downstream permission gate falls back to its existing wildcard path rather
707
- // than enforcing a rule against the wrong server.
716
+ // Fail safe: short mode can intentionally map names such as foo and foo-mcp
717
+ // to the same prefix. Return undefined so a downstream permission gate uses
718
+ // its existing wildcard path rather than enforcing a rule against the wrong server.
708
719
  if (candidates.some((c) => c.prefix === best!.prefix && c.name !== best!.name)) {
709
720
  return undefined;
710
721
  }
@@ -722,7 +733,7 @@ export function formatPromptCommandName(
722
733
  serverName: string,
723
734
  prefix: ToolPrefix,
724
735
  ): string {
725
- const serverPart = getServerPrefix(serverName, prefix) || serverName.replace(/-/g, "_") || "server";
736
+ const serverPart = getServerPrefix(serverName, prefix) || sanitizeServerPrefix(serverName) || "server";
726
737
  return `mcp__${serverPart}__${sanitizePromptName(promptName)}`;
727
738
  }
728
739