pi-mcp-adapter 2.14.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 +10 -0
- package/README.md +24 -5
- package/config.ts +13 -1
- package/direct-tools.ts +6 -24
- package/index.ts +29 -5
- package/init.ts +2 -1
- 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 +2 -1
- package/server-manager.ts +42 -24
- package/types.ts +4 -7
- package/unix-socket-transport.ts +88 -0
- package/utils.ts +61 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ 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
|
+
|
|
10
20
|
## [2.14.0] - 2026-07-25
|
|
11
21
|
|
|
12
22
|
### 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:
|
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
|
@@ -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(
|
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,7 @@
|
|
|
75
75
|
"ui-stream-types.ts",
|
|
76
76
|
"config.ts",
|
|
77
77
|
"server-manager.ts",
|
|
78
|
+
"unix-socket-transport.ts",
|
|
78
79
|
"json-schema-validator.ts",
|
|
79
80
|
"session-recovery.ts",
|
|
80
81
|
"sampling-handler.ts",
|
package/server-manager.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type UrlElicitationRequiredError,
|
|
10
10
|
} from "@modelcontextprotocol/client";
|
|
11
11
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
12
|
+
import { UnixSocketClientTransport } from "./unix-socket-transport.ts";
|
|
12
13
|
import {
|
|
13
14
|
isServerDisabled,
|
|
14
15
|
type McpTool,
|
|
@@ -32,7 +33,13 @@ import {
|
|
|
32
33
|
registerElicitationHandler,
|
|
33
34
|
type ServerElicitationConfig,
|
|
34
35
|
} from "./elicitation-handler.ts";
|
|
35
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
resolveBearerToken,
|
|
38
|
+
resolveCommandSecret,
|
|
39
|
+
resolveCommandSecretsRecord,
|
|
40
|
+
resolveConfigPath,
|
|
41
|
+
resolveServerUrl,
|
|
42
|
+
} from "./utils.ts";
|
|
36
43
|
import { abortable, throwIfAborted } from "./abort.ts";
|
|
37
44
|
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
38
45
|
import {
|
|
@@ -308,6 +315,11 @@ export class McpServerManager {
|
|
|
308
315
|
|
|
309
316
|
let transport: Transport;
|
|
310
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
|
+
}
|
|
311
323
|
|
|
312
324
|
if (definition.command) {
|
|
313
325
|
let command = definition.command;
|
|
@@ -326,7 +338,7 @@ export class McpServerManager {
|
|
|
326
338
|
const stdioTransport = new StdioClientTransport({
|
|
327
339
|
command,
|
|
328
340
|
args,
|
|
329
|
-
env: resolveEnv(definition.env),
|
|
341
|
+
env: resolveEnv(definition.env, name),
|
|
330
342
|
cwd: resolveConfigPath(definition.cwd) ?? this.defaultCwd,
|
|
331
343
|
stderr: definition.debug ? "inherit" : "pipe",
|
|
332
344
|
});
|
|
@@ -342,7 +354,7 @@ export class McpServerManager {
|
|
|
342
354
|
// HTTP transport with fallback
|
|
343
355
|
transport = await this.createHttpTransport(definition, name, signal, requestSignal, traceObserver);
|
|
344
356
|
} else {
|
|
345
|
-
|
|
357
|
+
transport = new UnixSocketClientTransport(resolveConfigPath(definition.socket!)!);
|
|
346
358
|
}
|
|
347
359
|
|
|
348
360
|
if (traceObserver) {
|
|
@@ -630,14 +642,30 @@ export class McpServerManager {
|
|
|
630
642
|
const serverUrl = resolveServerUrl(definition)!;
|
|
631
643
|
const url = new URL(serverUrl);
|
|
632
644
|
|
|
633
|
-
//
|
|
634
|
-
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
|
+
) ?? {};
|
|
635
652
|
|
|
636
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;
|
|
637
657
|
if (definition.auth === "bearer") {
|
|
638
|
-
const token =
|
|
639
|
-
|
|
640
|
-
|
|
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`);
|
|
641
669
|
}
|
|
642
670
|
}
|
|
643
671
|
|
|
@@ -990,28 +1018,18 @@ export class McpServerManager {
|
|
|
990
1018
|
/**
|
|
991
1019
|
* Resolve environment variables with interpolation.
|
|
992
1020
|
*/
|
|
993
|
-
function resolveEnv(env
|
|
994
|
-
// Copy process.env, filtering out undefined values
|
|
1021
|
+
function resolveEnv(env: Record<string, string> | undefined, serverName: string): Record<string, string> {
|
|
995
1022
|
const resolved: Record<string, string> = {};
|
|
996
1023
|
for (const [key, value] of Object.entries(process.env)) {
|
|
997
|
-
if (value !== undefined)
|
|
998
|
-
resolved[key] = value;
|
|
999
|
-
}
|
|
1024
|
+
if (value !== undefined) resolved[key] = value;
|
|
1000
1025
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1026
|
+
const overrides = resolveCommandSecretsRecord(
|
|
1027
|
+
env,
|
|
1028
|
+
key => `MCP server "${serverName}" stdio env "${key}"`,
|
|
1029
|
+
);
|
|
1005
1030
|
return overrides ? { ...resolved, ...overrides } : resolved;
|
|
1006
1031
|
}
|
|
1007
1032
|
|
|
1008
|
-
/**
|
|
1009
|
-
* Resolve headers with environment variable interpolation.
|
|
1010
|
-
*/
|
|
1011
|
-
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
|
1012
|
-
return interpolateEnvRecord(headers);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
1033
|
function normalizeRequestTimeoutMs(timeoutMs: number | undefined): number | undefined {
|
|
1016
1034
|
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
1017
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
|
|
@@ -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}`);
|
|
130
|
+
}
|
|
131
|
+
if (result.status !== 0) {
|
|
132
|
+
throw new Error(`Failed to resolve ${context}: command exited with code ${result.status ?? "unknown"}`);
|
|
96
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
|
}
|