pi-mcp-adapter 2.15.0 → 2.17.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 +27 -0
- package/README.md +6 -2
- package/commands.ts +44 -10
- package/direct-tools.ts +18 -10
- package/elicitation-handler.ts +11 -10
- package/init.ts +28 -9
- package/json-schema-validator.ts +23 -9
- package/mcp-auth-flow.ts +36 -17
- package/mcp-auth.ts +281 -13
- package/mcp-oauth-provider.ts +117 -35
- package/metadata-cache.ts +10 -7
- package/oauth-handler.ts +1 -1
- package/package.json +4 -4
- package/prompts.ts +2 -2
- package/proxy-modes.ts +4 -4
- package/sampling-handler.ts +11 -10
- package/server-manager.ts +22 -30
- package/session-recovery.ts +12 -18
- package/tool-metadata.ts +6 -5
- package/tool-result-renderer.ts +91 -14
- package/types.ts +15 -1
- package/ui-resource-handler.ts +1 -1
- package/ui-server.ts +3 -3
- package/ui-session.ts +1 -1
- package/unix-socket-transport.ts +3 -6
- package/utils.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.17.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added `settings.mcpFooterStatus` to compact or hide the persistent MCP footer status. Thanks @jwintz for issue #5.
|
|
14
|
+
- Added per-server OAuth `authorizationParams` for provider-specific authorization URL parameters such as Google's `access_type=offline`, while rejecting OAuth flow-owned parameter overrides. Thanks @hank-warren for issue #238.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Added a best-effort absolute-path fallback for loading the `@napi-rs/keyring` native binding when compiled Pi/Bun cannot resolve the package loader. Thanks @sgiath for issue #230.
|
|
18
|
+
- Bound collapsed MCP tool result rendering by character count as well as line count, preventing huge single-line results from slowing long TUI sessions. Thanks @Whisperfall for issue #249.
|
|
19
|
+
- Let configured `oauth.scope` override OAuth discovery scopes during authorization flows. Thanks @viggy28 for issue #225 and @adity982 for PR #226.
|
|
20
|
+
|
|
21
|
+
## [2.16.0] - 2026-07-30
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- 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.
|
|
25
|
+
- Added an advisory warning when resolved direct tools pass the documented 75-tool threshold, with no cap or enforcement. Thanks @JasonLandbridge for issue #240.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- Avoided a double-close race when disconnecting MCP connections by letting the SDK client own transport shutdown. Thanks Szymon Wiszczuk (@golota60) for PR #235.
|
|
32
|
+
- Prevented `/mcp` and `/mcp-auth` from crashing when the OS OAuth credential store is unavailable.
|
|
33
|
+
- 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.
|
|
34
|
+
- Stored OAuth credential payloads as compact JSON, so multiline secrets no longer corrupt gnome-keyring plaintext keyrings. Thanks @hank-warren for issue #239.
|
|
35
|
+
- 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.
|
|
36
|
+
|
|
10
37
|
## [2.15.0] - 2026-07-25
|
|
11
38
|
|
|
12
39
|
### Added
|
package/README.md
CHANGED
|
@@ -193,6 +193,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
|
|
|
193
193
|
| `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
194
194
|
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
|
195
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"`) |
|
|
196
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`) |
|
|
197
198
|
| `excludeTools` | `string[]` of tool names or glob patterns to hide (applied after `includeTools`) |
|
|
198
199
|
| `debug` | Show server stderr (default: false) |
|
|
@@ -257,6 +258,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
|
|
|
257
258
|
"idleTimeout": 10,
|
|
258
259
|
"requestTimeoutMs": 30000,
|
|
259
260
|
"showStatusIcon": true,
|
|
261
|
+
"mcpFooterStatus": "full",
|
|
260
262
|
"hostConfigDiscovery": "off",
|
|
261
263
|
"oauthDir": ".pi/mcp-oauth",
|
|
262
264
|
"trace": {
|
|
@@ -272,12 +274,14 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
|
|
|
272
274
|
|
|
273
275
|
| Setting | Description |
|
|
274
276
|
|---------|-------------|
|
|
275
|
-
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
|
|
277
|
+
| `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. |
|
|
276
278
|
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
|
277
279
|
| `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
278
280
|
| `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
|
|
281
|
+
| `mcpFooterStatus` | MCP footer verbosity: `"full"` (default), `"compact"` for `MCP connected/enabled`, or `"off"` to clear the persistent footer status. `/mcp status` remains available. |
|
|
279
282
|
| `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
|
|
280
283
|
| `oauthDir` | Legacy OAuth `tokens.json` import directory for this MCP config. Relative paths resolve from the active project cwd. `MCP_OAUTH_DIR` still wins when set. Persistent OAuth credentials are stored in the OS credential store, not this directory. |
|
|
284
|
+
| `mcpServers.<name>.oauth.authorizationParams` | Extra authorization URL parameters for provider-specific OAuth extensions. Flow-owned parameters such as `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `response_type`, and `resource` cannot be overridden. |
|
|
281
285
|
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
|
282
286
|
| `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
|
|
283
287
|
| `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
|
|
@@ -407,7 +411,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
|
|
|
407
411
|
|
|
408
412
|
`includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
|
|
409
413
|
|
|
410
|
-
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[]`.
|
|
414
|
+
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.
|
|
411
415
|
|
|
412
416
|
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>`.
|
|
413
417
|
|
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/direct-tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
@@ -11,7 +11,7 @@ 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" }
|
|
@@ -140,10 +141,12 @@ export function resolveDirectTools(
|
|
|
140
141
|
|
|
141
142
|
if (!toolFilter) continue;
|
|
142
143
|
|
|
144
|
+
const effectivePrefix = resolveToolPrefix(definition, prefix);
|
|
145
|
+
|
|
143
146
|
for (const tool of serverCache.tools ?? []) {
|
|
144
147
|
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
|
145
|
-
if (!isToolAllowed(tool.name, serverName,
|
|
146
|
-
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);
|
|
147
150
|
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
148
151
|
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
|
149
152
|
continue;
|
|
@@ -168,8 +171,8 @@ export function resolveDirectTools(
|
|
|
168
171
|
for (const resource of serverCache.resources ?? []) {
|
|
169
172
|
const baseName = `read_${resourceNameToToolName(resource.name)}`;
|
|
170
173
|
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
|
171
|
-
if (!isToolAllowed(baseName, serverName,
|
|
172
|
-
const prefixedName = formatToolName(baseName, serverName,
|
|
174
|
+
if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools)) continue;
|
|
175
|
+
const prefixedName = formatToolName(baseName, serverName, effectivePrefix);
|
|
173
176
|
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
174
177
|
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
|
175
178
|
continue;
|
|
@@ -190,6 +193,10 @@ export function resolveDirectTools(
|
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
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
|
+
|
|
193
200
|
return specs;
|
|
194
201
|
}
|
|
195
202
|
|
|
@@ -217,13 +224,14 @@ export function buildProxyDescription(
|
|
|
217
224
|
const definition = config.mcpServers[serverName];
|
|
218
225
|
if (isServerDisabled(definition)) continue;
|
|
219
226
|
const entry = cache?.servers?.[serverName];
|
|
227
|
+
const effectivePrefix = resolveToolPrefix(definition, prefix);
|
|
220
228
|
const toolCount = (entry?.tools ?? []).filter(
|
|
221
|
-
(tool) => isToolAllowed(tool.name, serverName,
|
|
229
|
+
(tool) => isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools),
|
|
222
230
|
).length;
|
|
223
231
|
const resourceCount = definition?.exposeResources !== false
|
|
224
232
|
? (entry?.resources ?? []).filter((resource) => {
|
|
225
233
|
const baseName = `read_${resourceNameToToolName(resource.name)}`;
|
|
226
|
-
return isToolAllowed(baseName, serverName,
|
|
234
|
+
return isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools);
|
|
227
235
|
}).length
|
|
228
236
|
: 0;
|
|
229
237
|
const totalItems = toolCount + resourceCount;
|
|
@@ -433,9 +441,9 @@ export function createDirectToolExecutor(
|
|
|
433
441
|
name: spec.originalName,
|
|
434
442
|
arguments: params ?? {},
|
|
435
443
|
_meta: uiSession?.requestMeta,
|
|
436
|
-
}, requestOptions), ownedSignal),
|
|
444
|
+
}, undefined, requestOptions), ownedSignal),
|
|
437
445
|
);
|
|
438
|
-
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/
|
|
446
|
+
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
|
439
447
|
|
|
440
448
|
if (result.isError) {
|
|
441
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/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);
|
|
@@ -261,7 +261,8 @@ export async function initializeMcp(
|
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
if (ui && startupServers.length > 0) {
|
|
264
|
-
|
|
264
|
+
const status = formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`);
|
|
265
|
+
ui.setStatus("mcp", status);
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
@@ -301,7 +302,7 @@ export async function initializeMcp(
|
|
|
301
302
|
toolMetadata.set(name, metadata);
|
|
302
303
|
resourceCounts.set(name, connection.resources.length);
|
|
303
304
|
if (!connection.promptDiscoveryFailed) {
|
|
304
|
-
promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix));
|
|
305
|
+
promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix, definition));
|
|
305
306
|
promptMetadataLive.add(name);
|
|
306
307
|
}
|
|
307
308
|
if (connection.instructions) {
|
|
@@ -399,6 +400,9 @@ export async function initializeMcp(
|
|
|
399
400
|
|
|
400
401
|
owner.throwIfInactive();
|
|
401
402
|
lifecycle.startHealthChecks(runtimeSignal);
|
|
403
|
+
if (config.settings?.mcpFooterStatus === "off") {
|
|
404
|
+
ui?.setStatus("mcp", undefined);
|
|
405
|
+
}
|
|
402
406
|
publishMcpStatusSnapshot(state);
|
|
403
407
|
|
|
404
408
|
return state;
|
|
@@ -433,7 +437,7 @@ export function updateServerMetadata(state: McpExtensionState, serverName: strin
|
|
|
433
437
|
state.toolMetadata.set(serverName, metadata);
|
|
434
438
|
state.resourceCounts?.set(serverName, connection.resources.length);
|
|
435
439
|
if (!connection.promptDiscoveryFailed) {
|
|
436
|
-
state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix));
|
|
440
|
+
state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
|
|
437
441
|
state.promptMetadataLive?.add(serverName);
|
|
438
442
|
}
|
|
439
443
|
if (connection.instructions) {
|
|
@@ -524,10 +528,24 @@ export function updateStatusBar(state: McpExtensionState): void {
|
|
|
524
528
|
const definition = state.config.mcpServers[name];
|
|
525
529
|
return connection.status === "connected" && definition !== undefined && !isServerDisabled(definition);
|
|
526
530
|
}).length;
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
+
const footerStatus = state.config.settings?.mcpFooterStatus ?? "full";
|
|
532
|
+
if (footerStatus === "off") {
|
|
533
|
+
ui.setStatus("mcp", undefined);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let status = footerStatus === "compact"
|
|
538
|
+
? `MCP ${connectedCount}/${enabledCount}`
|
|
539
|
+
: `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
|
|
540
|
+
if (footerStatus === "full") {
|
|
541
|
+
if (connectedCount > 0) status += ` (${connectedCount} connected)`;
|
|
542
|
+
if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
|
|
543
|
+
}
|
|
544
|
+
const formattedStatus = footerStatus === "compact" ? status : formatMcpStatus(state.config, status);
|
|
545
|
+
if (formattedStatus === undefined) {
|
|
546
|
+
ui.setStatus("mcp", undefined);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
531
549
|
ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", formattedStatus) : formattedStatus);
|
|
532
550
|
}
|
|
533
551
|
|
|
@@ -565,7 +583,8 @@ export async function lazyConnect(state: McpExtensionState, serverName: string,
|
|
|
565
583
|
|
|
566
584
|
try {
|
|
567
585
|
if (state.ui) {
|
|
568
|
-
|
|
586
|
+
const status = formatMcpStatus(state.config, `connecting to ${serverName}...`);
|
|
587
|
+
state.ui.setStatus("mcp", status);
|
|
569
588
|
}
|
|
570
589
|
const newConnection = await state.manager.connect(serverName, definition, ownedSignal);
|
|
571
590
|
if (newConnection.status === "needs-auth") {
|
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 ??= (() => {
|
package/mcp-auth-flow.ts
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
import {
|
|
8
8
|
auth as runSdkAuth,
|
|
9
9
|
extractWWWAuthenticateParams,
|
|
10
|
-
IssuerMismatchError,
|
|
11
10
|
UnauthorizedError,
|
|
12
|
-
|
|
13
|
-
} from "@modelcontextprotocol/
|
|
11
|
+
} from "@modelcontextprotocol/sdk/client/auth.js"
|
|
12
|
+
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
|
|
14
13
|
import open from "open"
|
|
15
14
|
import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.ts"
|
|
16
15
|
import {
|
|
@@ -58,6 +57,10 @@ type AuthDiscovery = {
|
|
|
58
57
|
scope?: string
|
|
59
58
|
}
|
|
60
59
|
|
|
60
|
+
function applyConfiguredScope(discovery: AuthDiscovery, config: McpOAuthConfig): AuthDiscovery {
|
|
61
|
+
return config.scope !== undefined ? { ...discovery, scope: config.scope } : discovery
|
|
62
|
+
}
|
|
63
|
+
|
|
61
64
|
type PendingAuth = {
|
|
62
65
|
serverName: string
|
|
63
66
|
authProvider: McpOAuthProvider
|
|
@@ -163,6 +166,18 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
|
|
163
166
|
if (typeof definition.oauth.scope !== "string") throw new Error("OAuth scope must be a string")
|
|
164
167
|
config.scope = interpolateEnvVars(definition.oauth.scope)
|
|
165
168
|
}
|
|
169
|
+
if (definition.oauth?.authorizationParams !== undefined) {
|
|
170
|
+
const params = definition.oauth.authorizationParams
|
|
171
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
172
|
+
throw new Error("OAuth authorizationParams must be an object")
|
|
173
|
+
}
|
|
174
|
+
config.authorizationParams = {}
|
|
175
|
+
for (const [key, value] of Object.entries(params)) {
|
|
176
|
+
if (!key) throw new Error("OAuth authorizationParams keys must not be empty")
|
|
177
|
+
if (typeof value !== "string") throw new Error(`OAuth authorizationParams.${key} must be a string`)
|
|
178
|
+
config.authorizationParams[key] = interpolateEnvVars(value)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
166
181
|
if (definition.oauth?.redirectUri !== undefined) {
|
|
167
182
|
if (typeof definition.oauth.redirectUri !== "string") {
|
|
168
183
|
throw new Error("OAuth redirectUri must be a string")
|
|
@@ -305,7 +320,7 @@ export async function startAuth(
|
|
|
305
320
|
},
|
|
306
321
|
}, authStorageOptions, runtime.signal)
|
|
307
322
|
try {
|
|
308
|
-
const discovery = await probeAuthDiscovery(serverUrl, definition, signal)
|
|
323
|
+
const discovery = applyConfiguredScope(await probeAuthDiscovery(serverUrl, definition, signal), config)
|
|
309
324
|
throwIfAborted(signal)
|
|
310
325
|
const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery }), signal)
|
|
311
326
|
throwIfAborted(signal)
|
|
@@ -371,7 +386,7 @@ export async function startAuth(
|
|
|
371
386
|
|
|
372
387
|
throwIfAborted(signal)
|
|
373
388
|
|
|
374
|
-
const discovery = await probeAuthDiscovery(serverUrl, definition, signal)
|
|
389
|
+
const discovery = applyConfiguredScope(await probeAuthDiscovery(serverUrl, definition, signal), config)
|
|
375
390
|
throwIfAborted(signal)
|
|
376
391
|
const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery }), signal)
|
|
377
392
|
throwIfAborted(signal)
|
|
@@ -572,10 +587,25 @@ export async function completeAuth(
|
|
|
572
587
|
let keepPendingForRetry = false
|
|
573
588
|
let caughtError: unknown
|
|
574
589
|
try {
|
|
590
|
+
const discoveryState = await pendingAuth.authProvider.discoveryState()
|
|
591
|
+
const metadata = discoveryState?.authorizationServerMetadata
|
|
592
|
+
const expectedIssuer = metadata?.issuer ?? discoveryState?.authorizationServerUrl
|
|
593
|
+
const requiresIssuer = (metadata as { authorization_response_iss_parameter_supported?: unknown } | undefined)
|
|
594
|
+
?.authorization_response_iss_parameter_supported === true
|
|
595
|
+
if (expectedIssuer !== undefined && iss === undefined && requiresIssuer) {
|
|
596
|
+
keepPendingForRetry = true
|
|
597
|
+
throw new Error(
|
|
598
|
+
`The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` +
|
|
599
|
+
"Paste the full redirect URL from the browser address bar (not just the authorization code).",
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
if (expectedIssuer !== undefined && iss !== undefined && iss !== expectedIssuer) {
|
|
603
|
+
throw new Error(`The OAuth authorization response issuer does not match the discovered issuer for ${serverName}.`)
|
|
604
|
+
}
|
|
605
|
+
|
|
575
606
|
const result = await abortable(runSdkAuth(pendingAuth.authProvider, {
|
|
576
607
|
serverUrl: pendingAuth.serverUrl,
|
|
577
608
|
authorizationCode: code,
|
|
578
|
-
...(iss !== undefined ? { iss } : {}),
|
|
579
609
|
...pendingAuth.discovery,
|
|
580
610
|
}), signal)
|
|
581
611
|
throwIfAborted(signal)
|
|
@@ -585,17 +615,6 @@ export async function completeAuth(
|
|
|
585
615
|
return "authenticated"
|
|
586
616
|
} catch (error) {
|
|
587
617
|
caughtError = error
|
|
588
|
-
// RFC 9207: the AS advertises authorization_response_iss_parameter_supported
|
|
589
|
-
// but no `iss` accompanied the pasted code (e.g. the user pasted only the
|
|
590
|
-
// raw code). Keep the pending flow alive so the user can re-paste the full
|
|
591
|
-
// redirect URL instead of restarting authentication from scratch.
|
|
592
|
-
if (iss === undefined && error instanceof IssuerMismatchError && error.kind === "authorization_response") {
|
|
593
|
-
keepPendingForRetry = true
|
|
594
|
-
throw new Error(
|
|
595
|
-
`The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` +
|
|
596
|
-
"Paste the full redirect URL from the browser address bar (not just the authorization code).",
|
|
597
|
-
)
|
|
598
|
-
}
|
|
599
618
|
throw error
|
|
600
619
|
} finally {
|
|
601
620
|
if (!keepPendingForRetry) {
|