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 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
- | `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation |
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.url === "string" && definition.url !== existing.url) {
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 envServers = new Set<string>();
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 (envOverride) {
146
- if (envServers.has(serverName)) {
127
+ if (envSelection) {
128
+ if (envSelection.servers.has(serverName)) {
147
129
  toolFilter = true;
148
- } else if (envTools.has(serverName)) {
149
- toolFilter = [...envTools.get(serverName)!];
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(config, cache);
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 missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
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
- config.clientSecret = interpolateEnvVars(definition.oauth.clientSecret)
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), {
@@ -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: this.config.clientSecret,
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 = definition.directTools !== undefined
129
- ? !!definition.directTools
130
- : !!globalDirect;
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.14.0",
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 { interpolateEnvRecord, resolveBearerToken, resolveConfigPath, resolveServerUrl } from "./utils.ts";
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
- throw new Error(`Server ${name} has no command or url`);
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
- // Build headers first (including any bearer token)
634
- const headers = resolveHeaders(definition.headers) ?? {};
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 = resolveBearerToken(definition);
639
- if (token) {
640
- headers["Authorization"] = `Bearer ${token}`;
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?: Record<string, string>): Record<string, string> {
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
- if (!env) return resolved;
1003
-
1004
- const overrides = interpolateEnvRecord(env);
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 { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
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
- // Transport type (stdio + HTTP)
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
- const resolved: Record<string, string> = {};
94
- for (const [key, value] of Object.entries(values)) {
95
- resolved[key] = interpolateEnvVars(value);
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 === undefined) return undefined;
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 interpolateEnvVars(definition.bearerToken);
186
+ return interpolateSecretExpression(definition.bearerToken);
131
187
  }
132
188
  return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined;
133
189
  }