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 +21 -0
- package/README.md +28 -2
- package/commands.ts +21 -2
- package/index.ts +13 -3
- package/mcp-auth-flow.ts +64 -3
- package/mcp-auth.ts +60 -2
- package/npx-resolver.ts +81 -26
- package/package.json +3 -3
- package/proxy-modes.ts +7 -3
- package/search-ranking.ts +86 -7
- package/server-manager.ts +36 -7
- package/session-recovery.ts +8 -2
- package/types.ts +20 -9
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
|
|
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,
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
|
|
755
|
+
entry = readAuthEntryFromStore(getAuthSecretStore(), serverName, options, behavior);
|
|
709
756
|
} catch (error) {
|
|
710
757
|
if (!shouldAttemptLinuxKeyringRecovery(error)) throw error;
|
|
711
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
438
|
-
const cachePath = getNpxCachePath();
|
|
437
|
+
function readNpxCachePayload(cachePath: string): unknown | null {
|
|
439
438
|
if (!existsSync(cachePath)) return null;
|
|
440
439
|
try {
|
|
441
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/session-recovery.ts
CHANGED
|
@@ -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, "")
|
|
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
|
|
649
|
-
return serverName
|
|
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:
|
|
704
|
-
//
|
|
705
|
-
//
|
|
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
|
|
736
|
+
const serverPart = getServerPrefix(serverName, prefix) || sanitizeServerPrefix(serverName) || "server";
|
|
726
737
|
return `mcp__${serverPart}__${sanitizePromptName(promptName)}`;
|
|
727
738
|
}
|
|
728
739
|
|