pi-mcp-adapter 2.14.0 → 2.16.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 +26 -0
- package/README.md +27 -7
- package/commands.ts +44 -10
- package/config.ts +13 -1
- package/direct-tools.ts +24 -34
- package/elicitation-handler.ts +11 -10
- package/index.ts +29 -5
- package/init.ts +5 -4
- package/json-schema-validator.ts +23 -9
- package/mcp-auth-flow.ts +29 -18
- package/mcp-auth.ts +209 -11
- package/mcp-oauth-provider.ts +100 -35
- package/mcp-trace.ts +3 -2
- package/metadata-cache.ts +44 -10
- package/oauth-handler.ts +1 -1
- package/package.json +5 -4
- package/prompts.ts +2 -2
- package/proxy-modes.ts +4 -4
- package/sampling-handler.ts +11 -10
- package/server-manager.ts +64 -54
- package/session-recovery.ts +12 -18
- package/tool-metadata.ts +6 -5
- package/tool-result-renderer.ts +30 -5
- package/types.ts +13 -7
- package/ui-resource-handler.ts +1 -1
- package/ui-server.ts +3 -3
- package/ui-session.ts +1 -1
- package/unix-socket-transport.ts +85 -0
- package/utils.ts +61 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.16.0] - 2026-07-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added an optional per-server `toolPrefix` override for direct MCP tools, prompts, and proxy summaries, falling back to the global prefix when unset. Thanks @FurryWolfX for issue #229.
|
|
14
|
+
- Added an advisory warning when resolved direct tools pass the documented 75-tool threshold, with no cap or enforcement. Thanks @JasonLandbridge for issue #240.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Restored the MCP SDK v1 client for compatibility with deployed MCP servers and OAuth providers. This rollback temporarily removes SDK v2-only protocol negotiation while retaining OAuth issuer binding and callback issuer validation. Thanks @hyknerf for PR #237 and issue #236, @leonfox28 for issue #227, and @JorelLatraille for issue #241.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Avoided a double-close race when disconnecting MCP connections by letting the SDK client own transport shutdown. Thanks Szymon Wiszczuk (@golota60) for PR #235.
|
|
21
|
+
- Prevented `/mcp` and `/mcp-auth` from crashing when the OS OAuth credential store is unavailable.
|
|
22
|
+
- Stored oversized OAuth credential payloads as secure-store manifests plus chunks, so Windows Credential Manager value limits no longer block OAuth completion for large token records. Thanks @LysanderdeJong for issue #223.
|
|
23
|
+
- Stored OAuth credential payloads as compact JSON, so multiline secrets no longer corrupt gnome-keyring plaintext keyrings. Thanks @hank-warren for issue #239.
|
|
24
|
+
- Kept collapsed MCP tool results from re-wrapping full multi-10KB payloads on repeated TUI renders, avoiding composer keystroke lag after large MCP dumps. Thanks @hyknerf for PR #233 and @chapmanb for the held-Text follow-up fix.
|
|
25
|
+
|
|
26
|
+
## [2.15.0] - 2026-07-25
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Added native `rmcp-mux` Unix-socket connections for explicitly sharing external MCP server processes across Pi sessions. Thanks j0e1 (@pWoLiAn) for issue #76.
|
|
30
|
+
- Added connection-time command resolution for HTTP bearer tokens and headers, OAuth client secrets, and stdio environment values, with `!!` escaping and fail-closed execution. Thanks @estrizhok for issue #221.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- Treated null optional server URLs as absent and cleaned unpublished runtimes after initialization failures, preventing `not_initialized` sessions with surviving MCP children. Thanks @autopeasant for the diagnosis in issue #222.
|
|
34
|
+
- Registered env-selected direct MCP tools before child `agent_start` when their metadata cache must be populated first. Thanks @peedrr for the original report in pi-subagents issue #638 and issue #219.
|
|
35
|
+
|
|
10
36
|
## [2.14.0] - 2026-07-25
|
|
11
37
|
|
|
12
38
|
### Added
|
package/README.md
CHANGED
|
@@ -172,26 +172,28 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
|
|
|
172
172
|
|
|
173
173
|
| Field | Description |
|
|
174
174
|
|-------|-------------|
|
|
175
|
-
| `command` | Executable for stdio transport |
|
|
175
|
+
| `command` | Executable for stdio transport; mutually exclusive with `url` and `socket` |
|
|
176
176
|
| `args` | Command arguments |
|
|
177
|
-
| `
|
|
177
|
+
| `socket` | Explicit `rmcp-mux` Unix-domain socket path; supports `${VAR}`, `$env:VAR`, and `~` expansion and is mutually exclusive with `command` and `url` |
|
|
178
|
+
| `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation. A value beginning with `!` runs a command when the stdio server connects; use `!!` for a literal leading `!`. |
|
|
178
179
|
| `cwd` | Working directory; supports `${VAR}`, `$env:VAR`, and `~` expansion |
|
|
179
180
|
| `url` | HTTP endpoint (StreamableHTTP with SSE fallback); supports raw `${VAR}` and `$env:VAR` interpolation, and missing URL variables fail before any request is sent |
|
|
180
|
-
| `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation |
|
|
181
|
+
| `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation. A value beginning with `!` runs a command when the HTTP server connects or OAuth authenticates; use `!!` for a literal leading `!`. |
|
|
181
182
|
| `auth` | `"bearer"` or `"oauth"` |
|
|
182
183
|
| `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
|
|
183
184
|
| `oauth.clientId` | Pre-registered OAuth client ID; dynamic registration is used when omitted |
|
|
184
|
-
| `oauth.clientSecret` | OAuth client secret for confidential clients |
|
|
185
|
+
| `oauth.clientSecret` | OAuth client secret for confidential clients; a value beginning with `!` runs a command when OAuth authenticates, while `!!` escapes a literal leading `!` |
|
|
185
186
|
| `oauth.scope` | Requested OAuth scopes |
|
|
186
187
|
| `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
|
|
187
188
|
| `oauth.clientName` | Client display name advertised during dynamic registration |
|
|
188
189
|
| `oauth.clientUri` | Client homepage URI advertised during dynamic registration |
|
|
189
|
-
| `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation |
|
|
190
|
+
| `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation. A leading `!` in `bearerToken` runs a command when the HTTP server connects; use `!!` for a literal leading `!`. |
|
|
190
191
|
| `lifecycle` | `"lazy"` (default), `"eager"`, `"keep-alive"`, or `"lazy-keep-alive"` |
|
|
191
192
|
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
|
192
193
|
| `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
193
194
|
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
|
194
195
|
| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
|
|
196
|
+
| `toolPrefix` | Override global `settings.toolPrefix` for this server (`"server"`, `"short"`, `"none"`, or `"mcp"`) |
|
|
195
197
|
| `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`) |
|
|
196
198
|
| `excludeTools` | `string[]` of tool names or glob patterns to hide (applied after `includeTools`) |
|
|
197
199
|
| `debug` | Show server stderr (default: false) |
|
|
@@ -200,6 +202,24 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
|
|
|
200
202
|
|
|
201
203
|
For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
|
|
202
204
|
|
|
205
|
+
Secret values in `headers`, `bearerToken`, `oauth.clientSecret`, and stdio `env` may use a leading `!command` to obtain their value at connection or authentication time. The command runs with stdin and stderr suppressed, stdout is limited to 1 MiB and trimmed, and it must finish within 10 seconds with non-empty output; failures stop the connection or authentication flow. Commands are not run during OAuth discovery or while reading, merging, previewing, hashing, or rendering configuration. Use `!!` to escape a literal leading `!`; ordinary and escaped values retain environment interpolation.
|
|
206
|
+
|
|
207
|
+
### Shared MCP processes with rmcp-mux
|
|
208
|
+
|
|
209
|
+
To share one stdio MCP server across Pi sessions, run it under [`rmcp-mux`](https://github.com/VetCoders/rmcp-mux) and point each session at the service socket:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"mcpServers": {
|
|
214
|
+
"memory": {
|
|
215
|
+
"socket": "~/.rmcp-servers/rmcp-mux/sockets/memory.sock"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The adapter owns only its client socket and closes that connection when the Pi runtime stops. `rmcp-mux` owns the upstream process, request routing, initialization cache, restart policy, client limits, and socket permissions. Start and configure the mux separately; the adapter never discovers, starts, adopts, or stops its daemon. A socket is an explicit trusted local endpoint, so do not point unrelated projects or users at a mux service unless its tools, state, credentials, and filesystem access are intended to be shared.
|
|
222
|
+
|
|
203
223
|
### Remote/headless OAuth
|
|
204
224
|
|
|
205
225
|
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:
|
|
@@ -253,7 +273,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
|
|
|
253
273
|
|
|
254
274
|
| Setting | Description |
|
|
255
275
|
|---------|-------------|
|
|
256
|
-
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
|
|
276
|
+
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization). Per-server `toolPrefix` overrides this for that server. |
|
|
257
277
|
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
|
258
278
|
| `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
259
279
|
| `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
|
|
@@ -388,7 +408,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
|
|
|
388
408
|
|
|
389
409
|
`includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
|
|
390
410
|
|
|
391
|
-
Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`.
|
|
411
|
+
Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`. If 75+ direct tools resolve, the adapter prints a warning but still registers the tools you configured.
|
|
392
412
|
|
|
393
413
|
Direct tools register from the metadata cache in the Pi agent dir (`~/.pi/agent/mcp-cache.json` by default, or `$PI_CODING_AGENT_DIR/mcp-cache.json` when set), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only while the cache populates, then the extension hot-loads the refreshed direct tools into the current session. Servers that advertise MCP list-change notifications refresh the current session when their tool or resource list changes. On Pi versions that expose `pi.unregisterTool()`, stale direct tools are removed from the registry during refresh; older Pi versions still deactivate them from the active tool set. To force a refresh: `/mcp reconnect <server>`.
|
|
394
414
|
|
package/commands.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateMetadataCac
|
|
|
17
17
|
import { loadMetadataCache, reconstructPromptMetadata } from "./metadata-cache.ts";
|
|
18
18
|
import { buildToolMetadata } from "./tool-metadata.ts";
|
|
19
19
|
import { supportsOAuth, authenticate, removeAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { getAuthStorageOptions, inspectAuthForUrl } from "./mcp-auth.ts";
|
|
21
21
|
import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
|
|
22
22
|
import { openPath, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
|
|
23
23
|
import { isAbortError } from "./runtime-owner.ts";
|
|
@@ -166,7 +166,7 @@ export async function reconnectServer(
|
|
|
166
166
|
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
167
167
|
state.toolMetadata.set(name, metadata);
|
|
168
168
|
if (!connection.promptDiscoveryFailed) {
|
|
169
|
-
state.promptMetadata?.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix));
|
|
169
|
+
state.promptMetadata?.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix, definition));
|
|
170
170
|
state.promptMetadataLive?.add(name);
|
|
171
171
|
}
|
|
172
172
|
if (connection.instructions) {
|
|
@@ -312,9 +312,33 @@ export async function logoutServer(
|
|
|
312
312
|
return { ok: false, message };
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
|
|
315
|
+
const signal = state.owner?.signal;
|
|
316
|
+
try {
|
|
317
|
+
await removeAuth(serverName, { authStorageOptions: state.authStorageOptions, signal, runtime: state.oauthRuntime });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (isAbortError(error, signal)) throw error;
|
|
320
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
321
|
+
if (ui) {
|
|
322
|
+
ui.notify(`Failed to clear OAuth credentials for "${serverName}": ${sanitizeTerminalText(message)}`, "error");
|
|
323
|
+
}
|
|
324
|
+
return { ok: false, message };
|
|
325
|
+
}
|
|
326
|
+
|
|
316
327
|
state.owner?.throwIfInactive();
|
|
317
|
-
|
|
328
|
+
try {
|
|
329
|
+
await state.manager.close(serverName);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (isAbortError(error, signal)) throw error;
|
|
332
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
333
|
+
if (ui) {
|
|
334
|
+
ui.notify(
|
|
335
|
+
`OAuth credentials were cleared for "${serverName}", but its connection could not be closed: ${sanitizeTerminalText(message)}`,
|
|
336
|
+
"error",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return { ok: false, message };
|
|
340
|
+
}
|
|
341
|
+
|
|
318
342
|
state.owner?.throwIfInactive();
|
|
319
343
|
updateStatusBar(state);
|
|
320
344
|
|
|
@@ -416,6 +440,11 @@ function buildMcpPanelCallbacks(
|
|
|
416
440
|
config: McpConfig,
|
|
417
441
|
ctx: ExtensionContext,
|
|
418
442
|
): McpPanelCallbacks {
|
|
443
|
+
// Panel-only diagnostics keep status inspection from mutating connection
|
|
444
|
+
// failure state while allowing the existing panel failure UI to show why the
|
|
445
|
+
// credential store could not be inspected.
|
|
446
|
+
const authStatusFailures = new Map<string, string>();
|
|
447
|
+
|
|
419
448
|
return {
|
|
420
449
|
reconnect: (serverName: string) => reconnectServer(state, ctx, serverName),
|
|
421
450
|
canAuthenticate: (serverName: string) => {
|
|
@@ -424,12 +453,10 @@ function buildMcpPanelCallbacks(
|
|
|
424
453
|
},
|
|
425
454
|
authenticate: (serverName: string) => authenticateServer(serverName, config, ctx, state.owner?.signal, state.oauthRuntime),
|
|
426
455
|
getConnectionStatus: (serverName: string) => {
|
|
456
|
+
authStatusFailures.delete(serverName);
|
|
427
457
|
const definition = config.mcpServers[serverName];
|
|
428
458
|
if (isServerDisabled(definition)) return "disabled";
|
|
429
459
|
const connection = state.manager.getConnection(serverName);
|
|
430
|
-
if (connection?.status === "needs-auth") {
|
|
431
|
-
return "needs-auth";
|
|
432
|
-
}
|
|
433
460
|
let serverUrl: string | undefined;
|
|
434
461
|
try {
|
|
435
462
|
serverUrl = definition ? resolveServerUrl(definition) : undefined;
|
|
@@ -441,15 +468,22 @@ function buildMcpPanelCallbacks(
|
|
|
441
468
|
&& serverUrl
|
|
442
469
|
&& definition.oauth !== false
|
|
443
470
|
&& definition.oauth?.grantType !== "client_credentials"
|
|
444
|
-
&& !getAuthForUrl(serverName, serverUrl, state.authStorageOptions)?.tokens
|
|
445
471
|
) {
|
|
446
|
-
|
|
472
|
+
const authStatus = inspectAuthForUrl(serverName, serverUrl, state.authStorageOptions);
|
|
473
|
+
if (authStatus.status === "unavailable") {
|
|
474
|
+
authStatusFailures.set(serverName, authStatus.message);
|
|
475
|
+
return "failed";
|
|
476
|
+
}
|
|
477
|
+
if (authStatus.status === "absent" || !authStatus.entry.tokens) {
|
|
478
|
+
return "needs-auth";
|
|
479
|
+
}
|
|
447
480
|
}
|
|
481
|
+
if (connection?.status === "needs-auth") return "needs-auth";
|
|
448
482
|
if (connection?.status === "connected") return "connected";
|
|
449
483
|
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
|
450
484
|
return "idle";
|
|
451
485
|
},
|
|
452
|
-
getFailureMessage: (serverName: string) => getFailureMessage(state, serverName),
|
|
486
|
+
getFailureMessage: (serverName: string) => authStatusFailures.get(serverName) ?? getFailureMessage(state, serverName),
|
|
453
487
|
refreshCacheAfterReconnect: (serverName: string) => {
|
|
454
488
|
const freshCache = loadMetadataCache();
|
|
455
489
|
return freshCache?.servers?.[serverName] ?? null;
|
package/config.ts
CHANGED
|
@@ -409,8 +409,20 @@ function mergeServerMaps(
|
|
|
409
409
|
// applies (it is spread last). Behaviour is unchanged when the url is
|
|
410
410
|
// identical or the override omits `url` (partial overrides still inherit).
|
|
411
411
|
let baseEntry: ServerEntry = existing ?? {};
|
|
412
|
-
if (existing && typeof definition.
|
|
412
|
+
if (existing && typeof definition.socket === "string") {
|
|
413
|
+
baseEntry = { ...existing };
|
|
414
|
+
for (const field of [
|
|
415
|
+
"command", "args", "env", "cwd", "url", "headers", "auth",
|
|
416
|
+
"bearerToken", "bearerTokenEnv", "oauth",
|
|
417
|
+
] as const) {
|
|
418
|
+
delete baseEntry[field];
|
|
419
|
+
}
|
|
420
|
+
} else if (existing?.socket && (typeof definition.command === "string" || typeof definition.url === "string")) {
|
|
413
421
|
baseEntry = { ...existing };
|
|
422
|
+
delete baseEntry.socket;
|
|
423
|
+
}
|
|
424
|
+
if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
|
|
425
|
+
if (baseEntry === existing) baseEntry = { ...existing };
|
|
414
426
|
for (const field of URL_BOUND_AUTH_FIELDS) {
|
|
415
427
|
delete baseEntry[field];
|
|
416
428
|
}
|
package/direct-tools.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { UrlElicitationRequiredError } from "@modelcontextprotocol/
|
|
2
|
+
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import type { McpExtensionState } from "./state.ts";
|
|
4
4
|
import type { DirectToolSpec, McpConfig, McpContent, ToolPrefix } from "./types.ts";
|
|
5
5
|
import type { MetadataCache } from "./metadata-cache.ts";
|
|
6
6
|
import { lazyConnect, getFailureAgeSeconds, clearFailure } from "./init.ts";
|
|
7
7
|
import { abortable, throwIfAborted } from "./abort.ts";
|
|
8
|
-
import { isServerCacheValid } from "./metadata-cache.ts";
|
|
8
|
+
import { isServerCacheValid, parseDirectToolSelectors } from "./metadata-cache.ts";
|
|
9
9
|
export { getMissingConfiguredDirectToolServers } from "./metadata-cache.ts";
|
|
10
10
|
import { formatSchema } from "./tool-metadata.ts";
|
|
11
11
|
import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
|
|
12
12
|
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
13
13
|
import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
|
|
14
|
-
import { formatToolName, isServerDisabled, isToolAllowed } from "./types.ts";
|
|
14
|
+
import { formatToolName, isServerDisabled, isToolAllowed, resolveToolPrefix } from "./types.ts";
|
|
15
15
|
import { resourceNameToToolName } from "./resource-tools.ts";
|
|
16
16
|
import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
|
|
17
17
|
import { formatAuthRequiredMessage, resolveServerUrl, truncateAtWord } from "./utils.ts";
|
|
@@ -20,6 +20,7 @@ import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
|
|
|
20
20
|
|
|
21
21
|
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
|
22
22
|
const INSTRUCTIONS_SNIPPET_LENGTH = 150;
|
|
23
|
+
export const DIRECT_TOOLS_ADVISORY_THRESHOLD = 75;
|
|
23
24
|
|
|
24
25
|
type DirectAutoAuthResult =
|
|
25
26
|
| { status: "skipped" }
|
|
@@ -114,25 +115,7 @@ export function resolveDirectTools(
|
|
|
114
115
|
|
|
115
116
|
const seenNames = new Set<string>();
|
|
116
117
|
|
|
117
|
-
const
|
|
118
|
-
const envTools = new Map<string, Set<string>>();
|
|
119
|
-
if (envOverride) {
|
|
120
|
-
for (let item of envOverride) {
|
|
121
|
-
item = item.replace(/\/+$/, "");
|
|
122
|
-
if (item.includes("/")) {
|
|
123
|
-
const [server, tool] = item.split("/", 2);
|
|
124
|
-
if (server && tool) {
|
|
125
|
-
if (!envTools.has(server)) envTools.set(server, new Set());
|
|
126
|
-
envTools.get(server)!.add(tool);
|
|
127
|
-
} else if (server) {
|
|
128
|
-
envServers.add(server);
|
|
129
|
-
}
|
|
130
|
-
} else if (item) {
|
|
131
|
-
envServers.add(item);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
118
|
+
const envSelection = envOverride ? parseDirectToolSelectors(envOverride) : null;
|
|
136
119
|
const globalDirect = config.settings?.directTools;
|
|
137
120
|
|
|
138
121
|
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
@@ -142,11 +125,11 @@ export function resolveDirectTools(
|
|
|
142
125
|
|
|
143
126
|
let toolFilter: true | string[] | false = false;
|
|
144
127
|
|
|
145
|
-
if (
|
|
146
|
-
if (
|
|
128
|
+
if (envSelection) {
|
|
129
|
+
if (envSelection.servers.has(serverName)) {
|
|
147
130
|
toolFilter = true;
|
|
148
|
-
} else if (
|
|
149
|
-
toolFilter = [...
|
|
131
|
+
} else if (envSelection.tools.has(serverName)) {
|
|
132
|
+
toolFilter = [...envSelection.tools.get(serverName)!];
|
|
150
133
|
}
|
|
151
134
|
} else {
|
|
152
135
|
if (definition.directTools !== undefined) {
|
|
@@ -158,10 +141,12 @@ export function resolveDirectTools(
|
|
|
158
141
|
|
|
159
142
|
if (!toolFilter) continue;
|
|
160
143
|
|
|
144
|
+
const effectivePrefix = resolveToolPrefix(definition, prefix);
|
|
145
|
+
|
|
161
146
|
for (const tool of serverCache.tools ?? []) {
|
|
162
147
|
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
|
163
|
-
if (!isToolAllowed(tool.name, serverName,
|
|
164
|
-
const prefixedName = formatToolName(tool.name, serverName,
|
|
148
|
+
if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools)) continue;
|
|
149
|
+
const prefixedName = formatToolName(tool.name, serverName, effectivePrefix);
|
|
165
150
|
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
166
151
|
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
|
167
152
|
continue;
|
|
@@ -186,8 +171,8 @@ export function resolveDirectTools(
|
|
|
186
171
|
for (const resource of serverCache.resources ?? []) {
|
|
187
172
|
const baseName = `read_${resourceNameToToolName(resource.name)}`;
|
|
188
173
|
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
|
189
|
-
if (!isToolAllowed(baseName, serverName,
|
|
190
|
-
const prefixedName = formatToolName(baseName, serverName,
|
|
174
|
+
if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools)) continue;
|
|
175
|
+
const prefixedName = formatToolName(baseName, serverName, effectivePrefix);
|
|
191
176
|
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
192
177
|
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
|
193
178
|
continue;
|
|
@@ -208,6 +193,10 @@ export function resolveDirectTools(
|
|
|
208
193
|
}
|
|
209
194
|
}
|
|
210
195
|
|
|
196
|
+
if (specs.length >= DIRECT_TOOLS_ADVISORY_THRESHOLD) {
|
|
197
|
+
console.warn(`MCP: ${specs.length} direct tools resolved. Each direct tool adds prompt context; README guidance recommends targeted sets of 5-20 tools and using the proxy or an explicit string[] when 75+ direct tools would be registered.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
211
200
|
return specs;
|
|
212
201
|
}
|
|
213
202
|
|
|
@@ -235,13 +224,14 @@ export function buildProxyDescription(
|
|
|
235
224
|
const definition = config.mcpServers[serverName];
|
|
236
225
|
if (isServerDisabled(definition)) continue;
|
|
237
226
|
const entry = cache?.servers?.[serverName];
|
|
227
|
+
const effectivePrefix = resolveToolPrefix(definition, prefix);
|
|
238
228
|
const toolCount = (entry?.tools ?? []).filter(
|
|
239
|
-
(tool) => isToolAllowed(tool.name, serverName,
|
|
229
|
+
(tool) => isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools),
|
|
240
230
|
).length;
|
|
241
231
|
const resourceCount = definition?.exposeResources !== false
|
|
242
232
|
? (entry?.resources ?? []).filter((resource) => {
|
|
243
233
|
const baseName = `read_${resourceNameToToolName(resource.name)}`;
|
|
244
|
-
return isToolAllowed(baseName, serverName,
|
|
234
|
+
return isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools);
|
|
245
235
|
}).length
|
|
246
236
|
: 0;
|
|
247
237
|
const totalItems = toolCount + resourceCount;
|
|
@@ -451,9 +441,9 @@ export function createDirectToolExecutor(
|
|
|
451
441
|
name: spec.originalName,
|
|
452
442
|
arguments: params ?? {},
|
|
453
443
|
_meta: uiSession?.requestMeta,
|
|
454
|
-
}, requestOptions), ownedSignal),
|
|
444
|
+
}, undefined, requestOptions), ownedSignal),
|
|
455
445
|
);
|
|
456
|
-
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/
|
|
446
|
+
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
|
457
447
|
|
|
458
448
|
if (result.isError) {
|
|
459
449
|
const mcpContent = (result.content ?? []) as McpContent[];
|
package/elicitation-handler.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
ElicitRequestSchema,
|
|
5
|
+
ErrorCode,
|
|
6
|
+
McpError,
|
|
6
7
|
type ElicitRequest,
|
|
7
8
|
type ElicitRequestFormParams,
|
|
8
9
|
type ElicitRequestURLParams,
|
|
9
10
|
type ElicitResult,
|
|
10
|
-
|
|
11
|
-
} from "@modelcontextprotocol/
|
|
12
|
-
import {
|
|
11
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
13
|
+
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
|
|
13
14
|
import open from "open";
|
|
14
15
|
|
|
15
16
|
export type ElicitationValue = string | number | boolean | string[] | undefined;
|
|
@@ -27,7 +28,7 @@ export interface ElicitationHandlerOptions {
|
|
|
27
28
|
export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
|
|
28
29
|
|
|
29
30
|
export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
|
|
30
|
-
client.setRequestHandler(
|
|
31
|
+
client.setRequestHandler(ElicitRequestSchema, request =>
|
|
31
32
|
handleElicitationRequest(options, request));
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -304,16 +305,16 @@ export async function handleUrlElicitation(
|
|
|
304
305
|
options: ElicitationHandlerOptions,
|
|
305
306
|
params: ElicitRequestURLParams,
|
|
306
307
|
): Promise<ElicitResult> {
|
|
307
|
-
if (!options.allowUrl) throw new
|
|
308
|
+
if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
|
|
308
309
|
|
|
309
310
|
let parsed: URL;
|
|
310
311
|
try {
|
|
311
312
|
parsed = new URL(params.url);
|
|
312
313
|
} catch {
|
|
313
|
-
throw new
|
|
314
|
+
throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
|
|
314
315
|
}
|
|
315
316
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
316
|
-
throw new
|
|
317
|
+
throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
const decision = await options.ui.select([
|
package/index.ts
CHANGED
|
@@ -254,7 +254,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
254
254
|
type: "string",
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
-
function startInitialization(ctx: ExtensionContext, owner: McpRuntimeOwner, oauthRuntime: McpOAuthRuntime, generation: number, staleReason: string): void {
|
|
257
|
+
function startInitialization(ctx: ExtensionContext, owner: McpRuntimeOwner, oauthRuntime: McpOAuthRuntime, generation: number, staleReason: string): Promise<void> {
|
|
258
258
|
const promise = initializeMcp(pi, ctx, owner, {
|
|
259
259
|
...(programmaticConfig || options.configPath !== undefined
|
|
260
260
|
? { configPath: earlyConfigPath, config: sessionConfig }
|
|
@@ -264,7 +264,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
264
264
|
});
|
|
265
265
|
initPromise = promise;
|
|
266
266
|
|
|
267
|
-
promise.then(async (nextState) => {
|
|
267
|
+
return promise.then(async (nextState) => {
|
|
268
268
|
if (!owner.isActive() || generation !== lifecycleGeneration || initPromise !== promise) {
|
|
269
269
|
try {
|
|
270
270
|
await shutdownState(nextState, staleReason);
|
|
@@ -284,7 +284,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
284
284
|
syncToolSurface(ctx);
|
|
285
285
|
updateStatusBar(nextState);
|
|
286
286
|
initPromise = null;
|
|
287
|
-
}).catch(err => {
|
|
287
|
+
}).catch(async err => {
|
|
288
288
|
if (!owner.isActive() || generation !== lifecycleGeneration) {
|
|
289
289
|
return;
|
|
290
290
|
}
|
|
@@ -293,6 +293,16 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
293
293
|
}
|
|
294
294
|
console.error(`MCP initialization failed: ${formatTerminalError(err)}`);
|
|
295
295
|
initPromise = null;
|
|
296
|
+
if (state) return;
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
await Promise.all([
|
|
300
|
+
owner.stop("MCP initialization failed"),
|
|
301
|
+
shutdownOAuth(oauthRuntime),
|
|
302
|
+
]);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error(`MCP: failed to clean rejected initialization: ${formatTerminalError(error)}`);
|
|
305
|
+
}
|
|
296
306
|
});
|
|
297
307
|
}
|
|
298
308
|
|
|
@@ -347,7 +357,17 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
347
357
|
|
|
348
358
|
if (generation !== lifecycleGeneration || !owner.isActive()) return;
|
|
349
359
|
|
|
350
|
-
startInitialization(ctx, owner, oauthRuntime, generation, "stale_session_start");
|
|
360
|
+
const initialization = startInitialization(ctx, owner, oauthRuntime, generation, "stale_session_start");
|
|
361
|
+
if (envRaw !== undefined && envRaw !== "__none__") {
|
|
362
|
+
const missingEnvDirectTools = getMissingConfiguredDirectToolServers(
|
|
363
|
+
earlyConfig,
|
|
364
|
+
loadMetadataCache(),
|
|
365
|
+
envDirectToolOverride,
|
|
366
|
+
);
|
|
367
|
+
if (missingEnvDirectTools.length > 0) {
|
|
368
|
+
await initialization;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
351
371
|
});
|
|
352
372
|
|
|
353
373
|
pi.on("session_shutdown", async () => {
|
|
@@ -726,7 +746,11 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
726
746
|
}
|
|
727
747
|
|
|
728
748
|
function syncProxyTool(config: McpConfig, cache: MetadataCache | null, directSpecs: DirectToolSpec[]): void {
|
|
729
|
-
const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(
|
|
749
|
+
const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(
|
|
750
|
+
config,
|
|
751
|
+
cache,
|
|
752
|
+
envRaw === undefined || envRaw === "__none__" ? undefined : envDirectToolOverride,
|
|
753
|
+
);
|
|
730
754
|
const shouldRegisterProxyTool =
|
|
731
755
|
config.settings?.disableProxyTool !== true
|
|
732
756
|
|| directSpecs.length === 0
|
package/init.ts
CHANGED
|
@@ -245,7 +245,7 @@ export async function initializeMcp(
|
|
|
245
245
|
resourceCounts.set(name, cachedEntry.resources.length);
|
|
246
246
|
}
|
|
247
247
|
if (cachedEntry.prompts?.length) {
|
|
248
|
-
promptMetadata.set(name, reconstructPromptMetadata(name, cachedEntry.prompts ?? [], prefix));
|
|
248
|
+
promptMetadata.set(name, reconstructPromptMetadata(name, cachedEntry.prompts ?? [], prefix, definition));
|
|
249
249
|
}
|
|
250
250
|
if (cachedEntry.instructions) {
|
|
251
251
|
serverInstructions.set(name, cachedEntry.instructions);
|
|
@@ -301,7 +301,7 @@ export async function initializeMcp(
|
|
|
301
301
|
toolMetadata.set(name, metadata);
|
|
302
302
|
resourceCounts.set(name, connection.resources.length);
|
|
303
303
|
if (!connection.promptDiscoveryFailed) {
|
|
304
|
-
promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix));
|
|
304
|
+
promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix, definition));
|
|
305
305
|
promptMetadataLive.add(name);
|
|
306
306
|
}
|
|
307
307
|
if (connection.instructions) {
|
|
@@ -334,7 +334,8 @@ export async function initializeMcp(
|
|
|
334
334
|
const envDirect = process.env.MCP_DIRECT_TOOLS;
|
|
335
335
|
if (envDirect !== "__none__") {
|
|
336
336
|
const currentCache = loadMetadataCache();
|
|
337
|
-
const
|
|
337
|
+
const envDirectToolOverride = envDirect?.split(",").map(selector => selector.trim()).filter(Boolean);
|
|
338
|
+
const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache, envDirectToolOverride);
|
|
338
339
|
|
|
339
340
|
if (missingCacheServers.length > 0) {
|
|
340
341
|
const bootstrapResults = await parallelLimit(
|
|
@@ -432,7 +433,7 @@ export function updateServerMetadata(state: McpExtensionState, serverName: strin
|
|
|
432
433
|
state.toolMetadata.set(serverName, metadata);
|
|
433
434
|
state.resourceCounts?.set(serverName, connection.resources.length);
|
|
434
435
|
if (!connection.promptDiscoveryFailed) {
|
|
435
|
-
state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix));
|
|
436
|
+
state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
|
|
436
437
|
state.promptMetadataLive?.add(serverName);
|
|
437
438
|
}
|
|
438
439
|
if (connection.instructions) {
|
package/json-schema-validator.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "@modelcontextprotocol/client/validators/ajv";
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import Ajv2020Import from "ajv/dist/2020.js";
|
|
3
|
+
import addFormatsImport from "ajv-formats";
|
|
4
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
6
5
|
import type {
|
|
7
6
|
JsonSchemaType,
|
|
8
7
|
JsonSchemaValidator,
|
|
9
8
|
jsonSchemaValidator as JsonSchemaValidatorProvider,
|
|
10
|
-
} from "@modelcontextprotocol/
|
|
9
|
+
} from "@modelcontextprotocol/sdk/validation/types.js";
|
|
10
|
+
|
|
11
|
+
// ajv-formats types target its bundled ajv; the runtime accepts both instances.
|
|
12
|
+
const addFormats = addFormatsImport as unknown as (instance: Ajv) => void;
|
|
11
13
|
|
|
12
14
|
type SchemaDialect =
|
|
13
15
|
| { status: "unstamped" }
|
|
@@ -17,6 +19,9 @@ const DRAFT_07_SCHEMA_URIS: ReadonlySet<string> = new Set([
|
|
|
17
19
|
"http://json-schema.org/draft-07/schema",
|
|
18
20
|
"https://json-schema.org/draft-07/schema",
|
|
19
21
|
]);
|
|
22
|
+
const DRAFT_2020_12_SCHEMA_URIS: ReadonlySet<string> = new Set([
|
|
23
|
+
"https://json-schema.org/draft/2020-12/schema",
|
|
24
|
+
]);
|
|
20
25
|
|
|
21
26
|
function schemaDialect(schema: JsonSchemaType): SchemaDialect {
|
|
22
27
|
if (!("$schema" in schema) || typeof schema.$schema !== "string") {
|
|
@@ -29,14 +34,23 @@ function schemaDialect(schema: JsonSchemaType): SchemaDialect {
|
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export function createJsonSchemaValidator(): JsonSchemaValidatorProvider {
|
|
32
|
-
const defaultValidator = new AjvJsonSchemaValidator();
|
|
33
37
|
let draft07Validator: AjvJsonSchemaValidator | undefined;
|
|
38
|
+
let draft2020Validator: AjvJsonSchemaValidator | undefined;
|
|
34
39
|
|
|
35
40
|
return {
|
|
36
41
|
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
|
|
37
42
|
const dialect = schemaDialect(schema);
|
|
38
|
-
if (dialect.status
|
|
39
|
-
|
|
43
|
+
if (dialect.status === "unstamped" || DRAFT_2020_12_SCHEMA_URIS.has(dialect.uri)) {
|
|
44
|
+
draft2020Validator ??= (() => {
|
|
45
|
+
const Ajv2020 = Ajv2020Import as unknown as typeof Ajv;
|
|
46
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
47
|
+
addFormats(ajv);
|
|
48
|
+
return new AjvJsonSchemaValidator(ajv);
|
|
49
|
+
})();
|
|
50
|
+
return draft2020Validator.getValidator<T>(schema);
|
|
51
|
+
}
|
|
52
|
+
if (!DRAFT_07_SCHEMA_URIS.has(dialect.uri)) {
|
|
53
|
+
throw new Error(`Unsupported JSON Schema dialect: ${dialect.uri}`);
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
draft07Validator ??= (() => {
|