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 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
- | `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:
@@ -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.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
@@ -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", `🔌 MCP: connecting to ${startupServers.length} servers...`);
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 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(
@@ -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 = `🔌 MCP: ${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
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
- ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", status) : status);
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", `🔌 MCP: connecting to ${serverName}...`);
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
- 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.13.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,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", `🔌 MCP: connecting to ${serverName}...`);
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", `🔌 MCP: connecting to ${serverName}...`);
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 { interpolateEnvRecord, resolveBearerToken, resolveConfigPath, resolveServerUrl } from "./utils.ts";
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
- throw new Error(`Server ${name} has no command or url`);
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 instanceof UnauthorizedError && supportsOAuth(definition) && cleanupFailures.length === 0) {
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
- // Build headers first (including any bearer token)
621
- 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
+ ) ?? {};
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 = resolveBearerToken(definition);
626
- if (token) {
627
- 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`);
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
- // For OAuth servers, create an auth provider
635
- let authProvider: McpOAuthProvider | undefined;
636
- if (supportsOAuth(definition)) {
637
- const oauthConfig = extractOAuthConfig(definition);
638
- authProvider = new McpOAuthProvider(
639
- serverName,
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
- this.authStorageOptions,
648
- this.oauthRuntime?.signal,
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
- let probeCleanupAttempted = false;
666
- try {
667
- await this.connectClientWithAbort(
668
- testClient,
669
- probeTransport,
670
- this.buildRequestOptions(definition, requestSignal),
671
- signal,
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 = true;
709
+ let probeCleanupAttempted = false;
674
710
  try {
675
- await testClient.close();
676
- } catch (cleanupError) {
677
- throw new AggregateError([cleanupError], "MCP HTTP probe cleanup failed");
678
- }
679
-
680
- // StreamableHTTP works - create fresh transport for actual use
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 (abortCleanupPromises.get(probeTransport) ?? testClient.close());
719
+ await testClient.close();
697
720
  } catch (cleanupError) {
698
- throw new AggregateError([error, cleanupError], "MCP HTTP probe cleanup failed");
721
+ throw new AggregateError([cleanupError], "MCP HTTP probe cleanup failed");
699
722
  }
700
- }
701
723
 
702
- // Host cancellation is not transport capability evidence; do not fall
703
- // through to SSE when the caller is trying to cancel the connect.
704
- if (signal?.aborted) {
705
- throwIfAborted(signal);
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
- // If this was an UnauthorizedError, don't try SSE - the server needs auth
709
- if (error instanceof UnauthorizedError) {
710
- throw error;
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
- // SSE is the legacy transport
714
- return new SSEClientTransport(url, { requestInit, authProvider });
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?: Record<string, string>): Record<string, string> {
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
- if (!env) return resolved;
979
-
980
- const overrides = interpolateEnvRecord(env);
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 { 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
@@ -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
- 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}`);
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 === 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
  }
@@ -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
  */