pi-mcp-adapter 2.13.0 → 2.15.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 +19 -0
- package/README.md +30 -5
- package/config.ts +13 -1
- package/direct-tools.ts +6 -24
- package/index.ts +29 -5
- package/init.ts +8 -6
- package/json-schema-validator.ts +55 -0
- package/mcp-auth-flow.ts +11 -3
- package/mcp-oauth-provider.ts +8 -1
- package/mcp-trace.ts +3 -2
- package/metadata-cache.ts +34 -3
- package/package.json +3 -1
- package/proxy-modes.ts +3 -3
- package/server-manager.ts +138 -96
- package/types.ts +6 -7
- package/unix-socket-transport.ts +88 -0
- package/utils.ts +65 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.15.0] - 2026-07-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added native `rmcp-mux` Unix-socket connections for explicitly sharing external MCP server processes across Pi sessions. Thanks j0e1 (@pWoLiAn) for issue #76.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
## [2.14.0] - 2026-07-25
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Added the global `settings.showStatusIcon` opt-out for plain `MCP: ...` status and connection text while keeping the plug icon enabled by default. Thanks @vaultboy001 for issue #216.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- Deferred implicit OAuth credential-store access until an HTTP server actually challenges for authentication, so unauthenticated remote Streamable HTTP servers work in headless environments. Thanks @vdom-1 for issue #218.
|
|
27
|
+
- Accepted draft-07 tool output schemas alongside JSON Schema 2020-12 while preserving structured-content validation. Thanks Daniel Marbach (@danielmarbach) for issue #217.
|
|
28
|
+
|
|
10
29
|
## [2.13.0] - 2026-07-25
|
|
11
30
|
|
|
12
31
|
### Added
|
package/README.md
CHANGED
|
@@ -172,21 +172,22 @@ 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) |
|
|
@@ -200,6 +201,24 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
|
|
|
200
201
|
|
|
201
202
|
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
203
|
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
### Shared MCP processes with rmcp-mux
|
|
207
|
+
|
|
208
|
+
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:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"mcpServers": {
|
|
213
|
+
"memory": {
|
|
214
|
+
"socket": "~/.rmcp-servers/rmcp-mux/sockets/memory.sock"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
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.
|
|
221
|
+
|
|
203
222
|
### Remote/headless OAuth
|
|
204
223
|
|
|
205
224
|
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:
|
|
@@ -237,6 +256,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
|
|
|
237
256
|
"toolPrefix": "server",
|
|
238
257
|
"idleTimeout": 10,
|
|
239
258
|
"requestTimeoutMs": 30000,
|
|
259
|
+
"showStatusIcon": true,
|
|
240
260
|
"hostConfigDiscovery": "off",
|
|
241
261
|
"oauthDir": ".pi/mcp-oauth",
|
|
242
262
|
"trace": {
|
|
@@ -255,6 +275,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
|
|
|
255
275
|
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
|
|
256
276
|
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
|
257
277
|
| `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
278
|
+
| `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
|
|
258
279
|
| `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
|
|
259
280
|
| `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. |
|
|
260
281
|
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
|
@@ -522,6 +543,10 @@ If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`,
|
|
|
522
543
|
|
|
523
544
|
In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In remote/headless sessions, use the proxy tool's `auth-start` and `auth-complete` actions to copy the authorization URL locally and paste the redirect URL back into Pi. `/mcp-auth` without a server only opens a picker in the interactive UI.
|
|
524
545
|
|
|
546
|
+
### MCP output schemas
|
|
547
|
+
|
|
548
|
+
Advertised tool `outputSchema` values support JSON Schema draft-07 and 2020-12. Unstamped schemas use the SDK's 2020-12 default. Returned `structuredContent` is validated against the advertised schema for both proxy and direct-tool calls.
|
|
549
|
+
|
|
525
550
|
## How It Works
|
|
526
551
|
|
|
527
552
|
- One `mcp` tool in context (~200 tokens) instead of hundreds
|
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
|
@@ -5,7 +5,7 @@ import type { DirectToolSpec, McpConfig, McpContent, ToolPrefix } from "./types.
|
|
|
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";
|
|
@@ -114,25 +114,7 @@ export function resolveDirectTools(
|
|
|
114
114
|
|
|
115
115
|
const seenNames = new Set<string>();
|
|
116
116
|
|
|
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
|
-
|
|
117
|
+
const envSelection = envOverride ? parseDirectToolSelectors(envOverride) : null;
|
|
136
118
|
const globalDirect = config.settings?.directTools;
|
|
137
119
|
|
|
138
120
|
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
@@ -142,11 +124,11 @@ export function resolveDirectTools(
|
|
|
142
124
|
|
|
143
125
|
let toolFilter: true | string[] | false = false;
|
|
144
126
|
|
|
145
|
-
if (
|
|
146
|
-
if (
|
|
127
|
+
if (envSelection) {
|
|
128
|
+
if (envSelection.servers.has(serverName)) {
|
|
147
129
|
toolFilter = true;
|
|
148
|
-
} else if (
|
|
149
|
-
toolFilter = [...
|
|
130
|
+
} else if (envSelection.tools.has(serverName)) {
|
|
131
|
+
toolFilter = [...envSelection.tools.get(serverName)!];
|
|
150
132
|
}
|
|
151
133
|
} else {
|
|
152
134
|
if (definition.directTools !== undefined) {
|
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
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { McpServerManager } from "./server-manager.ts";
|
|
23
23
|
import { buildToolMetadata, totalToolCount } from "./tool-metadata.ts";
|
|
24
24
|
import { UiResourceHandler } from "./ui-resource-handler.ts";
|
|
25
|
-
import { openUrl, parallelLimit, sanitizeTerminalText } from "./utils.ts";
|
|
25
|
+
import { formatMcpStatus, openUrl, parallelLimit, sanitizeTerminalText } from "./utils.ts";
|
|
26
26
|
import { logger } from "./logger.ts";
|
|
27
27
|
import { throwIfAborted } from "./abort.ts";
|
|
28
28
|
import { getAuthStorageOptions } from "./mcp-auth.ts";
|
|
@@ -261,7 +261,7 @@ export async function initializeMcp(
|
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
if (ui && startupServers.length > 0) {
|
|
264
|
-
ui.setStatus("mcp",
|
|
264
|
+
ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`));
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
@@ -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(
|
|
@@ -523,10 +524,11 @@ export function updateStatusBar(state: McpExtensionState): void {
|
|
|
523
524
|
const definition = state.config.mcpServers[name];
|
|
524
525
|
return connection.status === "connected" && definition !== undefined && !isServerDisabled(definition);
|
|
525
526
|
}).length;
|
|
526
|
-
let status =
|
|
527
|
+
let status = `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
|
|
527
528
|
if (connectedCount > 0) status += ` (${connectedCount} connected)`;
|
|
528
529
|
if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
|
|
529
|
-
|
|
530
|
+
const formattedStatus = formatMcpStatus(state.config, status);
|
|
531
|
+
ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", formattedStatus) : formattedStatus);
|
|
530
532
|
}
|
|
531
533
|
|
|
532
534
|
export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
|
|
@@ -563,7 +565,7 @@ export async function lazyConnect(state: McpExtensionState, serverName: string,
|
|
|
563
565
|
|
|
564
566
|
try {
|
|
565
567
|
if (state.ui) {
|
|
566
|
-
state.ui.setStatus("mcp",
|
|
568
|
+
state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
|
|
567
569
|
}
|
|
568
570
|
const newConnection = await state.manager.connect(serverName, definition, ownedSignal);
|
|
569
571
|
if (newConnection.status === "needs-auth") {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Ajv,
|
|
3
|
+
AjvJsonSchemaValidator,
|
|
4
|
+
addFormats,
|
|
5
|
+
} from "@modelcontextprotocol/client/validators/ajv";
|
|
6
|
+
import type {
|
|
7
|
+
JsonSchemaType,
|
|
8
|
+
JsonSchemaValidator,
|
|
9
|
+
jsonSchemaValidator as JsonSchemaValidatorProvider,
|
|
10
|
+
} from "@modelcontextprotocol/client";
|
|
11
|
+
|
|
12
|
+
type SchemaDialect =
|
|
13
|
+
| { status: "unstamped" }
|
|
14
|
+
| { status: "stamped"; uri: string };
|
|
15
|
+
|
|
16
|
+
const DRAFT_07_SCHEMA_URIS: ReadonlySet<string> = new Set([
|
|
17
|
+
"http://json-schema.org/draft-07/schema",
|
|
18
|
+
"https://json-schema.org/draft-07/schema",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function schemaDialect(schema: JsonSchemaType): SchemaDialect {
|
|
22
|
+
if (!("$schema" in schema) || typeof schema.$schema !== "string") {
|
|
23
|
+
return { status: "unstamped" };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
status: "stamped",
|
|
27
|
+
uri: schema.$schema.endsWith("#") ? schema.$schema.slice(0, -1) : schema.$schema,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createJsonSchemaValidator(): JsonSchemaValidatorProvider {
|
|
32
|
+
const defaultValidator = new AjvJsonSchemaValidator();
|
|
33
|
+
let draft07Validator: AjvJsonSchemaValidator | undefined;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
|
|
37
|
+
const dialect = schemaDialect(schema);
|
|
38
|
+
if (dialect.status !== "stamped" || !DRAFT_07_SCHEMA_URIS.has(dialect.uri)) {
|
|
39
|
+
return defaultValidator.getValidator<T>(schema);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
draft07Validator ??= (() => {
|
|
43
|
+
const ajv = new Ajv({
|
|
44
|
+
strict: false,
|
|
45
|
+
validateFormats: true,
|
|
46
|
+
validateSchema: false,
|
|
47
|
+
allErrors: true,
|
|
48
|
+
});
|
|
49
|
+
addFormats(ajv);
|
|
50
|
+
return new AjvJsonSchemaValidator(ajv);
|
|
51
|
+
})();
|
|
52
|
+
return draft07Validator.getValidator<T>(schema);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/mcp-auth-flow.ts
CHANGED
|
@@ -154,7 +154,10 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
|
|
154
154
|
}
|
|
155
155
|
if (definition.oauth?.clientSecret !== undefined) {
|
|
156
156
|
if (typeof definition.oauth.clientSecret !== "string") throw new Error("OAuth clientSecret must be a string")
|
|
157
|
-
|
|
157
|
+
// Preserve command expressions for the provider; interpolation remains eager for ordinary values.
|
|
158
|
+
config.clientSecret = definition.oauth.clientSecret.startsWith("!")
|
|
159
|
+
? definition.oauth.clientSecret
|
|
160
|
+
: interpolateEnvVars(definition.oauth.clientSecret)
|
|
158
161
|
}
|
|
159
162
|
if (definition.oauth?.scope !== undefined) {
|
|
160
163
|
if (typeof definition.oauth.scope !== "string") throw new Error("OAuth scope must be a string")
|
|
@@ -194,13 +197,18 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
async function probeAuthDiscovery(serverUrl: string, definition?: ServerEntry, signal?: AbortSignal): Promise<AuthDiscovery> {
|
|
200
|
+
// Discovery must not execute config commands or send their source text.
|
|
201
|
+
const discoveryHeaders = definition?.headers
|
|
202
|
+
? Object.fromEntries(Object.entries(definition.headers).filter(([, value]) => !value.startsWith("!") || value.startsWith("!!")))
|
|
203
|
+
: undefined
|
|
204
|
+
const headers = new Headers(interpolateEnvRecord(discoveryHeaders))
|
|
205
|
+
headers.set("content-type", "application/json")
|
|
206
|
+
|
|
197
207
|
const controller = new AbortController()
|
|
198
208
|
const discoverySignal = combineAbortSignals(signal, controller.signal)
|
|
199
209
|
const timer = setTimeout(() => controller.abort(), 5000)
|
|
200
210
|
|
|
201
211
|
try {
|
|
202
|
-
const headers = new Headers(interpolateEnvRecord(definition?.headers))
|
|
203
|
-
headers.set("content-type", "application/json")
|
|
204
212
|
headers.set("accept", "application/json, text/event-stream")
|
|
205
213
|
|
|
206
214
|
const response = await fetch(new URL(serverUrl), {
|
package/mcp-oauth-provider.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type StoredTokens,
|
|
27
27
|
type StoredClientInfo,
|
|
28
28
|
} from "./mcp-auth.ts"
|
|
29
|
+
import { resolveCommandSecret } from "./utils.ts"
|
|
29
30
|
|
|
30
31
|
// Callback server configuration
|
|
31
32
|
const DEFAULT_OAUTH_CALLBACK_PORT = 19876
|
|
@@ -169,9 +170,15 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
169
170
|
// this pre-registered client with one.
|
|
170
171
|
const stored = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions)
|
|
171
172
|
const issuer = stored?.clientInfo?.clientId === this.config.clientId ? stored.clientInfo.issuer : undefined
|
|
173
|
+
const clientSecret = this.config.clientSecret?.startsWith("!")
|
|
174
|
+
? resolveCommandSecret(
|
|
175
|
+
this.config.clientSecret,
|
|
176
|
+
`MCP server "${this.serverName}" OAuth clientSecret`,
|
|
177
|
+
)
|
|
178
|
+
: this.config.clientSecret
|
|
172
179
|
return {
|
|
173
180
|
client_id: this.config.clientId,
|
|
174
|
-
client_secret:
|
|
181
|
+
client_secret: clientSecret,
|
|
175
182
|
...(issuer !== undefined ? { issuer } : {}),
|
|
176
183
|
}
|
|
177
184
|
}
|
package/mcp-trace.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const DEFAULT_MCP_TRACE_MAX_BYTES = 256 * 1024;
|
|
|
9
9
|
export const DEFAULT_MCP_TRACE_MAX_EVENTS = 10_000;
|
|
10
10
|
|
|
11
11
|
export type McpTraceDirection = "outbound" | "inbound";
|
|
12
|
-
export type McpTraceTransport = "stdio" | "sse" | "streamable-http" | "unknown";
|
|
12
|
+
export type McpTraceTransport = "stdio" | "unix-socket" | "sse" | "streamable-http" | "unknown";
|
|
13
13
|
export type McpTraceMessageKind = "request" | "response" | "notification";
|
|
14
14
|
|
|
15
15
|
export interface McpTraceSettings {
|
|
@@ -301,8 +301,9 @@ export function wrapTransportWithMcpTrace<T extends Transport>(
|
|
|
301
301
|
return traced as T;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
export function traceTransportKind(definition: { command?: string; url?: string }, transport: Transport): McpTraceTransport {
|
|
304
|
+
export function traceTransportKind(definition: { command?: string; url?: string; socket?: string }, transport: Transport): McpTraceTransport {
|
|
305
305
|
if (definition.command) return "stdio";
|
|
306
|
+
if (definition.socket) return "unix-socket";
|
|
306
307
|
const constructorName = transport.constructor?.name.toLowerCase() ?? "";
|
|
307
308
|
if (constructorName.includes("sse")) return "sse";
|
|
308
309
|
if (constructorName.includes("streamable")) return "streamable-http";
|
package/metadata-cache.ts
CHANGED
|
@@ -84,6 +84,7 @@ export function computeServerHash(definition: ServerEntry): string {
|
|
|
84
84
|
const identity: Record<string, unknown> = {
|
|
85
85
|
command: definition.command,
|
|
86
86
|
args: definition.args,
|
|
87
|
+
socket: resolveConfigPath(definition.socket),
|
|
87
88
|
env: interpolateEnvRecord(definition.env),
|
|
88
89
|
cwd: resolveConfigPath(definition.cwd),
|
|
89
90
|
url: resolveServerUrl(definition),
|
|
@@ -116,18 +117,48 @@ export function isServerCacheValid(
|
|
|
116
117
|
return true;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
export function parseDirectToolSelectors(selectors: string[]): {
|
|
121
|
+
servers: Set<string>;
|
|
122
|
+
tools: Map<string, Set<string>>;
|
|
123
|
+
} {
|
|
124
|
+
const servers = new Set<string>();
|
|
125
|
+
const tools = new Map<string, Set<string>>();
|
|
126
|
+
|
|
127
|
+
for (let selector of selectors) {
|
|
128
|
+
selector = selector.replace(/\/+$/, "");
|
|
129
|
+
if (selector.includes("/")) {
|
|
130
|
+
const [server, tool] = selector.split("/", 2);
|
|
131
|
+
if (server && tool) {
|
|
132
|
+
const serverTools = tools.get(server) ?? new Set<string>();
|
|
133
|
+
serverTools.add(tool);
|
|
134
|
+
tools.set(server, serverTools);
|
|
135
|
+
} else if (server) {
|
|
136
|
+
servers.add(server);
|
|
137
|
+
}
|
|
138
|
+
} else if (selector) {
|
|
139
|
+
servers.add(selector);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { servers, tools };
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
export function getMissingConfiguredDirectToolServers(
|
|
120
147
|
config: McpConfig,
|
|
121
148
|
cache: MetadataCache | null,
|
|
149
|
+
envOverride?: string[],
|
|
122
150
|
): string[] {
|
|
123
151
|
const missing: string[] = [];
|
|
124
152
|
const globalDirect = config.settings?.directTools;
|
|
153
|
+
const envSelection = envOverride ? parseDirectToolSelectors(envOverride) : null;
|
|
125
154
|
|
|
126
155
|
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
127
156
|
if (isServerDisabled(definition)) continue;
|
|
128
|
-
const hasDirectTools =
|
|
129
|
-
?
|
|
130
|
-
:
|
|
157
|
+
const hasDirectTools = envSelection
|
|
158
|
+
? envSelection.servers.has(serverName) || envSelection.tools.has(serverName)
|
|
159
|
+
: definition.directTools !== undefined
|
|
160
|
+
? !!definition.directTools
|
|
161
|
+
: !!globalDirect;
|
|
131
162
|
|
|
132
163
|
if (!hasDirectTools) continue;
|
|
133
164
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-adapter",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -75,6 +75,8 @@
|
|
|
75
75
|
"ui-stream-types.ts",
|
|
76
76
|
"config.ts",
|
|
77
77
|
"server-manager.ts",
|
|
78
|
+
"unix-socket-transport.ts",
|
|
79
|
+
"json-schema-validator.ts",
|
|
78
80
|
"session-recovery.ts",
|
|
79
81
|
"sampling-handler.ts",
|
|
80
82
|
"elicitation-handler.ts",
|
package/proxy-modes.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { reconstructPromptMetadata } from "./metadata-cache.ts";
|
|
|
12
12
|
import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
|
|
13
13
|
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
14
14
|
import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
|
|
15
|
-
import { formatAuthRequiredMessage, resolveServerUrl, truncateAtWord } from "./utils.ts";
|
|
15
|
+
import { formatAuthRequiredMessage, formatMcpStatus, resolveServerUrl, truncateAtWord } from "./utils.ts";
|
|
16
16
|
import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
|
|
17
17
|
import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.ts";
|
|
18
18
|
|
|
@@ -663,7 +663,7 @@ export async function executeConnect(state: McpExtensionState, serverName: strin
|
|
|
663
663
|
|
|
664
664
|
try {
|
|
665
665
|
if (state.ui) {
|
|
666
|
-
state.ui.setStatus("mcp",
|
|
666
|
+
state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
|
|
667
667
|
}
|
|
668
668
|
const currentConnection = state.manager.getConnection(serverName);
|
|
669
669
|
let connection = currentConnection?.status === "connected"
|
|
@@ -948,7 +948,7 @@ export async function executeCall(
|
|
|
948
948
|
|
|
949
949
|
try {
|
|
950
950
|
if (state.ui) {
|
|
951
|
-
state.ui.setStatus("mcp",
|
|
951
|
+
state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
|
|
952
952
|
}
|
|
953
953
|
connection = await state.manager.connect(serverName, definition, ownedSignal);
|
|
954
954
|
if (connection.status === "needs-auth") {
|
package/server-manager.ts
CHANGED
|
@@ -2,12 +2,14 @@ import {
|
|
|
2
2
|
Client,
|
|
3
3
|
SSEClientTransport,
|
|
4
4
|
StreamableHTTPClientTransport,
|
|
5
|
+
SdkHttpError,
|
|
5
6
|
UnauthorizedError,
|
|
6
7
|
type RequestOptions,
|
|
7
8
|
type ReadResourceResult,
|
|
8
9
|
type UrlElicitationRequiredError,
|
|
9
10
|
} from "@modelcontextprotocol/client";
|
|
10
11
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
12
|
+
import { UnixSocketClientTransport } from "./unix-socket-transport.ts";
|
|
11
13
|
import {
|
|
12
14
|
isServerDisabled,
|
|
13
15
|
type McpTool,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
} from "./types.ts";
|
|
21
23
|
import { SERVER_STREAM_RESULT_PATCH_METHOD, serverStreamResultPatchNotificationSchema } from "./types.ts";
|
|
22
24
|
import { resolveNpxBinary } from "./npx-resolver.ts";
|
|
25
|
+
import { createJsonSchemaValidator } from "./json-schema-validator.ts";
|
|
23
26
|
import { logger } from "./logger.ts";
|
|
24
27
|
import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
|
|
25
28
|
import { extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
|
|
@@ -30,7 +33,13 @@ import {
|
|
|
30
33
|
registerElicitationHandler,
|
|
31
34
|
type ServerElicitationConfig,
|
|
32
35
|
} from "./elicitation-handler.ts";
|
|
33
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
resolveBearerToken,
|
|
38
|
+
resolveCommandSecret,
|
|
39
|
+
resolveCommandSecretsRecord,
|
|
40
|
+
resolveConfigPath,
|
|
41
|
+
resolveServerUrl,
|
|
42
|
+
} from "./utils.ts";
|
|
34
43
|
import { abortable, throwIfAborted } from "./abort.ts";
|
|
35
44
|
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
36
45
|
import {
|
|
@@ -50,6 +59,16 @@ const MCP_CLIENT_OPTIONS = {
|
|
|
50
59
|
};
|
|
51
60
|
const abortCleanupPromises = new WeakMap<object, Promise<void>>();
|
|
52
61
|
|
|
62
|
+
type HttpAuthProviderState =
|
|
63
|
+
| { status: "disabled" }
|
|
64
|
+
| { status: "implicit-deferred" }
|
|
65
|
+
| { status: "explicit"; provider: McpOAuthProvider }
|
|
66
|
+
| { status: "implicit-challenged"; provider: McpOAuthProvider };
|
|
67
|
+
|
|
68
|
+
function isUnauthorizedHttpError(error: unknown): boolean {
|
|
69
|
+
return error instanceof UnauthorizedError || (error instanceof SdkHttpError && error.status === 401);
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
function boundedStderrChunk(chunk: Buffer | string): Buffer {
|
|
54
73
|
if (Buffer.isBuffer(chunk)) {
|
|
55
74
|
const start = Math.max(0, chunk.byteLength - MAX_CAPTURED_STDERR_BYTES);
|
|
@@ -296,6 +315,11 @@ export class McpServerManager {
|
|
|
296
315
|
|
|
297
316
|
let transport: Transport;
|
|
298
317
|
let stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
318
|
+
const configuredTransports = [definition.command, definition.url, definition.socket]
|
|
319
|
+
.filter(value => typeof value === "string" && value.length > 0);
|
|
320
|
+
if (configuredTransports.length !== 1) {
|
|
321
|
+
throw new Error(`Server ${name} must configure exactly one of command, url, or socket`);
|
|
322
|
+
}
|
|
299
323
|
|
|
300
324
|
if (definition.command) {
|
|
301
325
|
let command = definition.command;
|
|
@@ -314,7 +338,7 @@ export class McpServerManager {
|
|
|
314
338
|
const stdioTransport = new StdioClientTransport({
|
|
315
339
|
command,
|
|
316
340
|
args,
|
|
317
|
-
env: resolveEnv(definition.env),
|
|
341
|
+
env: resolveEnv(definition.env, name),
|
|
318
342
|
cwd: resolveConfigPath(definition.cwd) ?? this.defaultCwd,
|
|
319
343
|
stderr: definition.debug ? "inherit" : "pipe",
|
|
320
344
|
});
|
|
@@ -330,7 +354,7 @@ export class McpServerManager {
|
|
|
330
354
|
// HTTP transport with fallback
|
|
331
355
|
transport = await this.createHttpTransport(definition, name, signal, requestSignal, traceObserver);
|
|
332
356
|
} else {
|
|
333
|
-
|
|
357
|
+
transport = new UnixSocketClientTransport(resolveConfigPath(definition.socket!)!);
|
|
334
358
|
}
|
|
335
359
|
|
|
336
360
|
if (traceObserver) {
|
|
@@ -406,7 +430,7 @@ export class McpServerManager {
|
|
|
406
430
|
|
|
407
431
|
// Check for UnauthorizedError - server requires OAuth. A cleanup failure
|
|
408
432
|
// remains a setup failure rather than being hidden behind needs-auth.
|
|
409
|
-
if (error
|
|
433
|
+
if (isUnauthorizedHttpError(error) && supportsOAuth(definition) && cleanupFailures.length === 0) {
|
|
410
434
|
return {
|
|
411
435
|
client,
|
|
412
436
|
transport,
|
|
@@ -484,6 +508,7 @@ export class McpServerManager {
|
|
|
484
508
|
{ name: `pi-mcp-${serverName}`, version: "1.0.0" },
|
|
485
509
|
{
|
|
486
510
|
...MCP_CLIENT_OPTIONS,
|
|
511
|
+
jsonSchemaValidator: createJsonSchemaValidator(),
|
|
487
512
|
...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
|
|
488
513
|
listChanged: {
|
|
489
514
|
tools: {
|
|
@@ -617,101 +642,128 @@ export class McpServerManager {
|
|
|
617
642
|
const serverUrl = resolveServerUrl(definition)!;
|
|
618
643
|
const url = new URL(serverUrl);
|
|
619
644
|
|
|
620
|
-
//
|
|
621
|
-
const
|
|
645
|
+
// Resolve secret commands only for this connection attempt, without mutating config.
|
|
646
|
+
const hasCommandHeader = Object.values(definition.headers ?? {})
|
|
647
|
+
.some(value => value.startsWith("!") && !value.startsWith("!!"));
|
|
648
|
+
const headers = resolveCommandSecretsRecord(
|
|
649
|
+
definition.headers,
|
|
650
|
+
key => `MCP server "${serverName}" HTTP header "${key}"`,
|
|
651
|
+
) ?? {};
|
|
622
652
|
|
|
623
653
|
// For bearer auth, add the token to headers BEFORE creating requestInit
|
|
654
|
+
const commandBearer = definition.bearerToken?.startsWith("!") && !definition.bearerToken.startsWith("!!")
|
|
655
|
+
? definition.bearerToken
|
|
656
|
+
: undefined;
|
|
624
657
|
if (definition.auth === "bearer") {
|
|
625
|
-
const token =
|
|
626
|
-
|
|
627
|
-
|
|
658
|
+
const token = commandBearer
|
|
659
|
+
? resolveCommandSecret(commandBearer, `MCP server "${serverName}" HTTP bearer token`)
|
|
660
|
+
: resolveBearerToken(definition);
|
|
661
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (hasCommandHeader || commandBearer) {
|
|
665
|
+
try {
|
|
666
|
+
new Headers(headers);
|
|
667
|
+
} catch {
|
|
668
|
+
throw new Error(`Failed to resolve MCP server "${serverName}" HTTP command secret: command returned an invalid header value`);
|
|
628
669
|
}
|
|
629
670
|
}
|
|
630
671
|
|
|
631
672
|
// Create request init with headers (Authorization now included for bearer auth)
|
|
632
673
|
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
serverUrl,
|
|
641
|
-
oauthConfig,
|
|
642
|
-
{
|
|
643
|
-
onRedirect: async (_authUrl) => {
|
|
644
|
-
// URL is captured by startAuth, no need to log
|
|
645
|
-
},
|
|
674
|
+
const createAuthProvider = (): McpOAuthProvider => new McpOAuthProvider(
|
|
675
|
+
serverName,
|
|
676
|
+
serverUrl,
|
|
677
|
+
extractOAuthConfig(definition),
|
|
678
|
+
{
|
|
679
|
+
onRedirect: async (_authUrl) => {
|
|
680
|
+
// URL is captured by startAuth, no need to log
|
|
646
681
|
},
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Try StreamableHTTP first (modern MCP servers)
|
|
653
|
-
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
|
654
|
-
requestInit,
|
|
655
|
-
authProvider,
|
|
656
|
-
});
|
|
657
|
-
const probeTransport = traceObserver
|
|
658
|
-
? wrapTransportWithMcpTrace(streamableTransport, serverName, "streamable-http", traceObserver)
|
|
659
|
-
: streamableTransport;
|
|
660
|
-
|
|
661
|
-
const testClient = new Client(
|
|
662
|
-
{ name: "pi-mcp-probe", version: "2.1.2" },
|
|
663
|
-
MCP_CLIENT_OPTIONS,
|
|
682
|
+
},
|
|
683
|
+
this.authStorageOptions,
|
|
684
|
+
this.oauthRuntime?.signal,
|
|
664
685
|
);
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
686
|
+
|
|
687
|
+
// Explicit OAuth must check the secure credential store before connecting.
|
|
688
|
+
let authState: HttpAuthProviderState = supportsOAuth(definition)
|
|
689
|
+
? definition.auth === undefined
|
|
690
|
+
? { status: "implicit-deferred" }
|
|
691
|
+
: { status: "explicit", provider: createAuthProvider() }
|
|
692
|
+
: { status: "disabled" };
|
|
693
|
+
|
|
694
|
+
// Try StreamableHTTP first (modern MCP servers). For implicit OAuth, defer
|
|
695
|
+
// creating the provider until the server proves that authentication is needed.
|
|
696
|
+
for (;;) {
|
|
697
|
+
const authProvider = "provider" in authState ? authState.provider : undefined;
|
|
698
|
+
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
|
699
|
+
requestInit,
|
|
700
|
+
authProvider,
|
|
701
|
+
});
|
|
702
|
+
const probeTransport = traceObserver
|
|
703
|
+
? wrapTransportWithMcpTrace(streamableTransport, serverName, "streamable-http", traceObserver)
|
|
704
|
+
: streamableTransport;
|
|
705
|
+
const testClient = new Client(
|
|
706
|
+
{ name: "pi-mcp-probe", version: "2.1.2" },
|
|
707
|
+
MCP_CLIENT_OPTIONS,
|
|
672
708
|
);
|
|
673
|
-
probeCleanupAttempted =
|
|
709
|
+
let probeCleanupAttempted = false;
|
|
674
710
|
try {
|
|
675
|
-
await
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
|
682
|
-
} catch (error) {
|
|
683
|
-
if (error instanceof AggregateError && (
|
|
684
|
-
error.message === "MCP connection abort cleanup failed" ||
|
|
685
|
-
error.message === "MCP HTTP probe cleanup failed"
|
|
686
|
-
)) {
|
|
687
|
-
throw error;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// StreamableHTTP failed, close through the SDK client and try SSE fallback.
|
|
691
|
-
// If connectClientWithAbort already owned the close, await that operation
|
|
692
|
-
// instead of closing the same transport again.
|
|
693
|
-
if (!probeCleanupAttempted) {
|
|
711
|
+
await this.connectClientWithAbort(
|
|
712
|
+
testClient,
|
|
713
|
+
probeTransport,
|
|
714
|
+
this.buildRequestOptions(definition, requestSignal),
|
|
715
|
+
signal,
|
|
716
|
+
);
|
|
694
717
|
probeCleanupAttempted = true;
|
|
695
718
|
try {
|
|
696
|
-
await
|
|
719
|
+
await testClient.close();
|
|
697
720
|
} catch (cleanupError) {
|
|
698
|
-
throw new AggregateError([
|
|
721
|
+
throw new AggregateError([cleanupError], "MCP HTTP probe cleanup failed");
|
|
699
722
|
}
|
|
700
|
-
}
|
|
701
723
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
724
|
+
// StreamableHTTP works - create fresh transport for actual use
|
|
725
|
+
return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (error instanceof AggregateError && (
|
|
728
|
+
error.message === "MCP connection abort cleanup failed" ||
|
|
729
|
+
error.message === "MCP HTTP probe cleanup failed"
|
|
730
|
+
)) {
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
707
733
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
734
|
+
// StreamableHTTP failed, close through the SDK client and try SSE fallback.
|
|
735
|
+
// If connectClientWithAbort already owned the close, await that operation
|
|
736
|
+
// instead of closing the same transport twice.
|
|
737
|
+
if (!probeCleanupAttempted) {
|
|
738
|
+
probeCleanupAttempted = true;
|
|
739
|
+
try {
|
|
740
|
+
await (abortCleanupPromises.get(probeTransport) ?? testClient.close());
|
|
741
|
+
} catch (cleanupError) {
|
|
742
|
+
throw new AggregateError([error, cleanupError], "MCP HTTP probe cleanup failed");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
712
745
|
|
|
713
|
-
|
|
714
|
-
|
|
746
|
+
// Host cancellation is not transport capability evidence; do not fall
|
|
747
|
+
// through to SSE when the caller is trying to cancel the connect.
|
|
748
|
+
if (signal?.aborted) {
|
|
749
|
+
throwIfAborted(signal);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// An implicit URL-only server gets a provider only after a real auth
|
|
753
|
+
// challenge. This keeps unauthenticated servers independent of storage.
|
|
754
|
+
if (authState.status === "implicit-deferred" && isUnauthorizedHttpError(error)) {
|
|
755
|
+
authState = { status: "implicit-challenged", provider: createAuthProvider() };
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// If this was an UnauthorizedError, don't try SSE - the server needs auth
|
|
760
|
+
if (isUnauthorizedHttpError(error)) {
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// SSE is the legacy transport
|
|
765
|
+
return new SSEClientTransport(url, { requestInit, authProvider });
|
|
766
|
+
}
|
|
715
767
|
}
|
|
716
768
|
}
|
|
717
769
|
|
|
@@ -966,28 +1018,18 @@ export class McpServerManager {
|
|
|
966
1018
|
/**
|
|
967
1019
|
* Resolve environment variables with interpolation.
|
|
968
1020
|
*/
|
|
969
|
-
function resolveEnv(env
|
|
970
|
-
// Copy process.env, filtering out undefined values
|
|
1021
|
+
function resolveEnv(env: Record<string, string> | undefined, serverName: string): Record<string, string> {
|
|
971
1022
|
const resolved: Record<string, string> = {};
|
|
972
1023
|
for (const [key, value] of Object.entries(process.env)) {
|
|
973
|
-
if (value !== undefined)
|
|
974
|
-
resolved[key] = value;
|
|
975
|
-
}
|
|
1024
|
+
if (value !== undefined) resolved[key] = value;
|
|
976
1025
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1026
|
+
const overrides = resolveCommandSecretsRecord(
|
|
1027
|
+
env,
|
|
1028
|
+
key => `MCP server "${serverName}" stdio env "${key}"`,
|
|
1029
|
+
);
|
|
981
1030
|
return overrides ? { ...resolved, ...overrides } : resolved;
|
|
982
1031
|
}
|
|
983
1032
|
|
|
984
|
-
/**
|
|
985
|
-
* Resolve headers with environment variable interpolation.
|
|
986
|
-
*/
|
|
987
|
-
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
|
988
|
-
return interpolateEnvRecord(headers);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
1033
|
function normalizeRequestTimeoutMs(timeoutMs: number | undefined): number | undefined {
|
|
992
1034
|
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
993
1035
|
? timeoutMs
|
package/types.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
// types.ts - Core type definitions
|
|
2
|
-
import type {
|
|
3
|
-
import type { SSEClientTransport, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { Transport as McpTransport } from "@modelcontextprotocol/client";
|
|
4
3
|
import type { TextContent, ImageContent } from "@earendil-works/pi-ai";
|
|
5
4
|
import type { UiStreamMode } from "./ui-stream-types.ts";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
export type Transport =
|
|
9
|
-
| StdioClientTransport
|
|
10
|
-
| SSEClientTransport
|
|
11
|
-
| StreamableHTTPClientTransport;
|
|
6
|
+
export type Transport = McpTransport;
|
|
12
7
|
|
|
13
8
|
/** Versioned shared-event-bus channel for read-only MCP runtime snapshots. */
|
|
14
9
|
export const MCP_STATUS_EVENT = "pi-mcp-adapter/status/v1";
|
|
@@ -324,6 +319,8 @@ export interface OAuthConfig {
|
|
|
324
319
|
export interface ServerEntry {
|
|
325
320
|
command?: string;
|
|
326
321
|
args?: string[];
|
|
322
|
+
/** Explicit rmcp-mux Unix-domain socket path. Mutually exclusive with command and url. */
|
|
323
|
+
socket?: string;
|
|
327
324
|
env?: Record<string, string>;
|
|
328
325
|
cwd?: string;
|
|
329
326
|
// HTTP fields
|
|
@@ -395,6 +392,8 @@ export interface McpTraceSettings {
|
|
|
395
392
|
|
|
396
393
|
export interface McpSettings {
|
|
397
394
|
toolPrefix?: ToolPrefix;
|
|
395
|
+
/** Show the plug prefix in MCP status and connection text (default: true). Set to false to disable it. */
|
|
396
|
+
showStatusIcon?: boolean;
|
|
398
397
|
/** Discover detected host-specific MCP configs only when explicitly enabled. */
|
|
399
398
|
hostConfigDiscovery?: HostConfigDiscovery;
|
|
400
399
|
idleTimeout?: number; // minutes, default 10, 0 to disable
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createConnection, type Socket } from "node:net";
|
|
2
|
+
import {
|
|
3
|
+
ReadBuffer,
|
|
4
|
+
serializeMessage,
|
|
5
|
+
type JSONRPCMessage,
|
|
6
|
+
type Transport,
|
|
7
|
+
} from "@modelcontextprotocol/client";
|
|
8
|
+
|
|
9
|
+
/** MCP JSONL transport for an explicitly configured Unix-domain socket. */
|
|
10
|
+
export class UnixSocketClientTransport implements Transport {
|
|
11
|
+
private socket?: Socket;
|
|
12
|
+
private readonly readBuffer = new ReadBuffer();
|
|
13
|
+
|
|
14
|
+
onclose?: () => void;
|
|
15
|
+
onerror?: (error: Error) => void;
|
|
16
|
+
onmessage?: (message: JSONRPCMessage) => void;
|
|
17
|
+
|
|
18
|
+
constructor(private readonly socketPath: string) {}
|
|
19
|
+
|
|
20
|
+
async start(): Promise<void> {
|
|
21
|
+
if (this.socket) {
|
|
22
|
+
throw new Error("UnixSocketClientTransport already started");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await new Promise<void>((resolve, reject) => {
|
|
26
|
+
const socket = createConnection(this.socketPath);
|
|
27
|
+
this.socket = socket;
|
|
28
|
+
let connected = false;
|
|
29
|
+
|
|
30
|
+
socket.once("connect", () => {
|
|
31
|
+
connected = true;
|
|
32
|
+
resolve();
|
|
33
|
+
});
|
|
34
|
+
socket.on("data", chunk => {
|
|
35
|
+
try {
|
|
36
|
+
this.readBuffer.append(chunk);
|
|
37
|
+
while (true) {
|
|
38
|
+
const message = this.readBuffer.readMessage();
|
|
39
|
+
if (message === null) break;
|
|
40
|
+
this.onmessage?.(message);
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
44
|
+
this.onerror?.(cause);
|
|
45
|
+
void this.close();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
socket.on("error", error => {
|
|
49
|
+
if (!connected) reject(error);
|
|
50
|
+
this.onerror?.(error);
|
|
51
|
+
});
|
|
52
|
+
socket.on("close", () => {
|
|
53
|
+
if (this.socket === socket) this.socket = undefined;
|
|
54
|
+
this.readBuffer.clear();
|
|
55
|
+
this.onclose?.();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async close(): Promise<void> {
|
|
61
|
+
const socket = this.socket;
|
|
62
|
+
this.socket = undefined;
|
|
63
|
+
this.readBuffer.clear();
|
|
64
|
+
if (!socket || socket.destroyed) return;
|
|
65
|
+
|
|
66
|
+
await new Promise<void>(resolve => {
|
|
67
|
+
const timeout = setTimeout(() => socket.destroy(), 2_000);
|
|
68
|
+
timeout.unref();
|
|
69
|
+
socket.once("close", () => {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
socket.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async send(message: JSONRPCMessage): Promise<void> {
|
|
78
|
+
const socket = this.socket;
|
|
79
|
+
if (!socket || socket.destroyed) throw new Error("Unix socket is not connected");
|
|
80
|
+
|
|
81
|
+
await new Promise<void>((resolve, reject) => {
|
|
82
|
+
socket.write(serializeMessage(message), error => {
|
|
83
|
+
if (error) reject(error);
|
|
84
|
+
else resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
package/utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { homedir, platform } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import type { McpConfig, ServerEntry } from "./types.ts";
|
|
@@ -87,18 +88,73 @@ export function toStringRecord(value: unknown): Record<string, string> | undefin
|
|
|
87
88
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function interpolateSecretExpression(value: string): string {
|
|
92
|
+
if (value.startsWith("!!")) return interpolateEnvVars(value.slice(1));
|
|
93
|
+
return value.startsWith("!") ? value : interpolateEnvVars(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
export function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
|
|
91
97
|
if (!values) return undefined;
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [
|
|
100
|
+
key,
|
|
101
|
+
interpolateSecretExpression(value),
|
|
102
|
+
]));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const COMMAND_SECRET_TIMEOUT_MS = 10_000;
|
|
106
|
+
const COMMAND_SECRET_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
107
|
+
|
|
108
|
+
/** Resolve a secret value, executing only a single leading `!` command marker. */
|
|
109
|
+
export function resolveCommandSecret(value: string | undefined, context: string): string | undefined {
|
|
110
|
+
if (value === undefined) return undefined;
|
|
111
|
+
if (value.startsWith("!!")) return interpolateEnvVars(value.slice(1));
|
|
112
|
+
if (!value.startsWith("!")) return interpolateEnvVars(value);
|
|
113
|
+
|
|
114
|
+
const result = spawnSync(value.slice(1), {
|
|
115
|
+
shell: true,
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
timeout: COMMAND_SECRET_TIMEOUT_MS,
|
|
118
|
+
maxBuffer: COMMAND_SECRET_MAX_OUTPUT_BYTES,
|
|
119
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
});
|
|
122
|
+
if (result.error) {
|
|
123
|
+
const code = (result.error as NodeJS.ErrnoException).code;
|
|
124
|
+
const reason = code === "ETIMEDOUT"
|
|
125
|
+
? "command timed out after 10 seconds"
|
|
126
|
+
: code === "ENOBUFS"
|
|
127
|
+
? "command output exceeded 1 MiB"
|
|
128
|
+
: "command failed to start";
|
|
129
|
+
throw new Error(`Failed to resolve ${context}: ${reason}`);
|
|
96
130
|
}
|
|
131
|
+
if (result.status !== 0) {
|
|
132
|
+
throw new Error(`Failed to resolve ${context}: command exited with code ${result.status ?? "unknown"}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const resolved = result.stdout.trim();
|
|
136
|
+
if (!resolved) throw new Error(`Failed to resolve ${context}: command returned empty output`);
|
|
97
137
|
return resolved;
|
|
98
138
|
}
|
|
99
139
|
|
|
140
|
+
/** Resolve command markers in a configured record without mutating the input. */
|
|
141
|
+
export function resolveCommandSecretsRecord(
|
|
142
|
+
values: Record<string, string> | undefined,
|
|
143
|
+
context: (key: string) => string,
|
|
144
|
+
): Record<string, string> | undefined {
|
|
145
|
+
if (!values) return undefined;
|
|
146
|
+
|
|
147
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [
|
|
148
|
+
key,
|
|
149
|
+
resolveCommandSecret(value, context(key)),
|
|
150
|
+
]));
|
|
151
|
+
}
|
|
152
|
+
|
|
100
153
|
export function resolveServerUrl(definition: Pick<ServerEntry, "url">): string | undefined {
|
|
101
|
-
if (definition.url
|
|
154
|
+
if (definition.url == null) return undefined;
|
|
155
|
+
if (typeof definition.url !== "string") {
|
|
156
|
+
throw new Error("MCP server URL must be a string");
|
|
157
|
+
}
|
|
102
158
|
|
|
103
159
|
const missing = getMissingEnvVars(definition.url);
|
|
104
160
|
if (missing.length > 0) {
|
|
@@ -127,7 +183,7 @@ export function resolveConfigPath(value: string | undefined): string | undefined
|
|
|
127
183
|
|
|
128
184
|
export function resolveBearerToken(definition: Pick<ServerEntry, "bearerToken" | "bearerTokenEnv">): string | undefined {
|
|
129
185
|
if (definition.bearerToken !== undefined) {
|
|
130
|
-
return
|
|
186
|
+
return interpolateSecretExpression(definition.bearerToken);
|
|
131
187
|
}
|
|
132
188
|
return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined;
|
|
133
189
|
}
|
|
@@ -221,6 +277,10 @@ export function formatAuthRequiredMessage(
|
|
|
221
277
|
return template ? template.replaceAll("${server}", serverName) : defaultMessage;
|
|
222
278
|
}
|
|
223
279
|
|
|
280
|
+
export function formatMcpStatus(config: Pick<McpConfig, "settings">, message: string): string {
|
|
281
|
+
return `${config.settings?.showStatusIcon === false ? "MCP: " : "🔌 MCP: "}${message}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
224
284
|
/**
|
|
225
285
|
* Extract the adapter-owned UI stream mode from tool metadata.
|
|
226
286
|
*/
|