bestony-pi-preset 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/node_modules/pi-mcp-adapter/CHANGELOG.md +21 -0
  2. package/node_modules/pi-mcp-adapter/README.md +28 -2
  3. package/node_modules/pi-mcp-adapter/commands.ts +21 -2
  4. package/node_modules/pi-mcp-adapter/index.ts +13 -3
  5. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +64 -3
  6. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  7. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  8. package/node_modules/pi-mcp-adapter/package.json +3 -3
  9. package/node_modules/pi-mcp-adapter/proxy-modes.ts +7 -3
  10. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  11. package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
  12. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  13. package/node_modules/pi-mcp-adapter/types.ts +20 -9
  14. package/node_modules/pi-web-access/CHANGELOG.md +9 -0
  15. package/node_modules/pi-web-access/README.md +12 -10
  16. package/node_modules/pi-web-access/bocha.ts +221 -0
  17. package/node_modules/pi-web-access/curator-page.ts +5 -3
  18. package/node_modules/pi-web-access/curator-server.ts +2 -1
  19. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  20. package/node_modules/pi-web-access/index.ts +33 -16
  21. package/node_modules/pi-web-access/package.json +1 -1
  22. package/node_modules/pi-web-access/storage.ts +252 -29
  23. package/node_modules/pi-xai/CHANGELOG.md +6 -0
  24. package/node_modules/pi-xai/README.md +4 -3
  25. package/node_modules/pi-xai/index.ts +14 -6
  26. package/node_modules/pi-xai/package.json +1 -1
  27. package/node_modules/pi-xai/xai-config.ts +1 -0
  28. package/node_modules/pi-xai/xai-provider.ts +19 -0
  29. package/node_modules/pi-xai/xai-vision.ts +1 -1
  30. package/package.json +4 -4
@@ -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
@@ -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.
@@ -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
  });
@@ -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
  }
@@ -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,
@@ -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
  */
@@ -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 {
@@ -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": {
@@ -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) {