pi-mcp-adapter 2.13.0 → 2.14.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,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.14.0] - 2026-07-25
11
+
12
+ ### Added
13
+ - 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.
14
+
15
+ ### Fixed
16
+ - 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.
17
+ - Accepted draft-07 tool output schemas alongside JSON Schema 2020-12 while preserving structured-content validation. Thanks Daniel Marbach (@danielmarbach) for issue #217.
18
+
10
19
  ## [2.13.0] - 2026-07-25
11
20
 
12
21
  ### Added
package/README.md CHANGED
@@ -237,6 +237,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
237
237
  "toolPrefix": "server",
238
238
  "idleTimeout": 10,
239
239
  "requestTimeoutMs": 30000,
240
+ "showStatusIcon": true,
240
241
  "hostConfigDiscovery": "off",
241
242
  "oauthDir": ".pi/mcp-oauth",
242
243
  "trace": {
@@ -255,6 +256,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
255
256
  | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
256
257
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
257
258
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
259
+ | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
258
260
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
259
261
  | `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
262
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
@@ -522,6 +524,10 @@ If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`,
522
524
 
523
525
  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
526
 
527
+ ### MCP output schemas
528
+
529
+ 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.
530
+
525
531
  ## How It Works
526
532
 
527
533
  - One `mcp` tool in context (~200 tokens) instead of hundreds
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]) => {
@@ -523,10 +523,11 @@ export function updateStatusBar(state: McpExtensionState): void {
523
523
  const definition = state.config.mcpServers[name];
524
524
  return connection.status === "connected" && definition !== undefined && !isServerDisabled(definition);
525
525
  }).length;
526
- let status = `🔌 MCP: ${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
526
+ let status = `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
527
527
  if (connectedCount > 0) status += ` (${connectedCount} connected)`;
528
528
  if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
529
- ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", status) : status);
529
+ const formattedStatus = formatMcpStatus(state.config, status);
530
+ ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", formattedStatus) : formattedStatus);
530
531
  }
531
532
 
532
533
  export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
@@ -563,7 +564,7 @@ export async function lazyConnect(state: McpExtensionState, serverName: string,
563
564
 
564
565
  try {
565
566
  if (state.ui) {
566
- state.ui.setStatus("mcp", `🔌 MCP: connecting to ${serverName}...`);
567
+ state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
567
568
  }
568
569
  const newConnection = await state.manager.connect(serverName, definition, ownedSignal);
569
570
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.13.0",
3
+ "version": "2.14.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
+ "json-schema-validator.ts",
78
79
  "session-recovery.ts",
79
80
  "sampling-handler.ts",
80
81
  "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,6 +2,7 @@ import {
2
2
  Client,
3
3
  SSEClientTransport,
4
4
  StreamableHTTPClientTransport,
5
+ SdkHttpError,
5
6
  UnauthorizedError,
6
7
  type RequestOptions,
7
8
  type ReadResourceResult,
@@ -20,6 +21,7 @@ import {
20
21
  } from "./types.ts";
21
22
  import { SERVER_STREAM_RESULT_PATCH_METHOD, serverStreamResultPatchNotificationSchema } from "./types.ts";
22
23
  import { resolveNpxBinary } from "./npx-resolver.ts";
24
+ import { createJsonSchemaValidator } from "./json-schema-validator.ts";
23
25
  import { logger } from "./logger.ts";
24
26
  import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
25
27
  import { extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
@@ -50,6 +52,16 @@ const MCP_CLIENT_OPTIONS = {
50
52
  };
51
53
  const abortCleanupPromises = new WeakMap<object, Promise<void>>();
52
54
 
55
+ type HttpAuthProviderState =
56
+ | { status: "disabled" }
57
+ | { status: "implicit-deferred" }
58
+ | { status: "explicit"; provider: McpOAuthProvider }
59
+ | { status: "implicit-challenged"; provider: McpOAuthProvider };
60
+
61
+ function isUnauthorizedHttpError(error: unknown): boolean {
62
+ return error instanceof UnauthorizedError || (error instanceof SdkHttpError && error.status === 401);
63
+ }
64
+
53
65
  function boundedStderrChunk(chunk: Buffer | string): Buffer {
54
66
  if (Buffer.isBuffer(chunk)) {
55
67
  const start = Math.max(0, chunk.byteLength - MAX_CAPTURED_STDERR_BYTES);
@@ -406,7 +418,7 @@ export class McpServerManager {
406
418
 
407
419
  // Check for UnauthorizedError - server requires OAuth. A cleanup failure
408
420
  // remains a setup failure rather than being hidden behind needs-auth.
409
- if (error instanceof UnauthorizedError && supportsOAuth(definition) && cleanupFailures.length === 0) {
421
+ if (isUnauthorizedHttpError(error) && supportsOAuth(definition) && cleanupFailures.length === 0) {
410
422
  return {
411
423
  client,
412
424
  transport,
@@ -484,6 +496,7 @@ export class McpServerManager {
484
496
  { name: `pi-mcp-${serverName}`, version: "1.0.0" },
485
497
  {
486
498
  ...MCP_CLIENT_OPTIONS,
499
+ jsonSchemaValidator: createJsonSchemaValidator(),
487
500
  ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
488
501
  listChanged: {
489
502
  tools: {
@@ -630,88 +643,99 @@ export class McpServerManager {
630
643
 
631
644
  // Create request init with headers (Authorization now included for bearer auth)
632
645
  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
- },
646
+ const createAuthProvider = (): McpOAuthProvider => new McpOAuthProvider(
647
+ serverName,
648
+ serverUrl,
649
+ extractOAuthConfig(definition),
650
+ {
651
+ onRedirect: async (_authUrl) => {
652
+ // URL is captured by startAuth, no need to log
646
653
  },
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,
654
+ },
655
+ this.authStorageOptions,
656
+ this.oauthRuntime?.signal,
664
657
  );
665
- let probeCleanupAttempted = false;
666
- try {
667
- await this.connectClientWithAbort(
668
- testClient,
669
- probeTransport,
670
- this.buildRequestOptions(definition, requestSignal),
671
- signal,
658
+
659
+ // Explicit OAuth must check the secure credential store before connecting.
660
+ let authState: HttpAuthProviderState = supportsOAuth(definition)
661
+ ? definition.auth === undefined
662
+ ? { status: "implicit-deferred" }
663
+ : { status: "explicit", provider: createAuthProvider() }
664
+ : { status: "disabled" };
665
+
666
+ // Try StreamableHTTP first (modern MCP servers). For implicit OAuth, defer
667
+ // creating the provider until the server proves that authentication is needed.
668
+ for (;;) {
669
+ const authProvider = "provider" in authState ? authState.provider : undefined;
670
+ const streamableTransport = new StreamableHTTPClientTransport(url, {
671
+ requestInit,
672
+ authProvider,
673
+ });
674
+ const probeTransport = traceObserver
675
+ ? wrapTransportWithMcpTrace(streamableTransport, serverName, "streamable-http", traceObserver)
676
+ : streamableTransport;
677
+ const testClient = new Client(
678
+ { name: "pi-mcp-probe", version: "2.1.2" },
679
+ MCP_CLIENT_OPTIONS,
672
680
  );
673
- probeCleanupAttempted = true;
681
+ let probeCleanupAttempted = false;
674
682
  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) {
683
+ await this.connectClientWithAbort(
684
+ testClient,
685
+ probeTransport,
686
+ this.buildRequestOptions(definition, requestSignal),
687
+ signal,
688
+ );
694
689
  probeCleanupAttempted = true;
695
690
  try {
696
- await (abortCleanupPromises.get(probeTransport) ?? testClient.close());
691
+ await testClient.close();
697
692
  } catch (cleanupError) {
698
- throw new AggregateError([error, cleanupError], "MCP HTTP probe cleanup failed");
693
+ throw new AggregateError([cleanupError], "MCP HTTP probe cleanup failed");
699
694
  }
700
- }
701
695
 
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
- }
696
+ // StreamableHTTP works - create fresh transport for actual use
697
+ return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
698
+ } catch (error) {
699
+ if (error instanceof AggregateError && (
700
+ error.message === "MCP connection abort cleanup failed" ||
701
+ error.message === "MCP HTTP probe cleanup failed"
702
+ )) {
703
+ throw error;
704
+ }
707
705
 
708
- // If this was an UnauthorizedError, don't try SSE - the server needs auth
709
- if (error instanceof UnauthorizedError) {
710
- throw error;
711
- }
706
+ // StreamableHTTP failed, close through the SDK client and try SSE fallback.
707
+ // If connectClientWithAbort already owned the close, await that operation
708
+ // instead of closing the same transport twice.
709
+ if (!probeCleanupAttempted) {
710
+ probeCleanupAttempted = true;
711
+ try {
712
+ await (abortCleanupPromises.get(probeTransport) ?? testClient.close());
713
+ } catch (cleanupError) {
714
+ throw new AggregateError([error, cleanupError], "MCP HTTP probe cleanup failed");
715
+ }
716
+ }
717
+
718
+ // Host cancellation is not transport capability evidence; do not fall
719
+ // through to SSE when the caller is trying to cancel the connect.
720
+ if (signal?.aborted) {
721
+ throwIfAborted(signal);
722
+ }
723
+
724
+ // An implicit URL-only server gets a provider only after a real auth
725
+ // challenge. This keeps unauthenticated servers independent of storage.
726
+ if (authState.status === "implicit-deferred" && isUnauthorizedHttpError(error)) {
727
+ authState = { status: "implicit-challenged", provider: createAuthProvider() };
728
+ continue;
729
+ }
730
+
731
+ // If this was an UnauthorizedError, don't try SSE - the server needs auth
732
+ if (isUnauthorizedHttpError(error)) {
733
+ throw error;
734
+ }
712
735
 
713
- // SSE is the legacy transport
714
- return new SSEClientTransport(url, { requestInit, authProvider });
736
+ // SSE is the legacy transport
737
+ return new SSEClientTransport(url, { requestInit, authProvider });
738
+ }
715
739
  }
716
740
  }
717
741
 
package/types.ts CHANGED
@@ -395,6 +395,8 @@ export interface McpTraceSettings {
395
395
 
396
396
  export interface McpSettings {
397
397
  toolPrefix?: ToolPrefix;
398
+ /** Show the plug prefix in MCP status and connection text (default: true). Set to false to disable it. */
399
+ showStatusIcon?: boolean;
398
400
  /** Discover detected host-specific MCP configs only when explicitly enabled. */
399
401
  hostConfigDiscovery?: HostConfigDiscovery;
400
402
  idleTimeout?: number; // minutes, default 10, 0 to disable
package/utils.ts CHANGED
@@ -221,6 +221,10 @@ export function formatAuthRequiredMessage(
221
221
  return template ? template.replaceAll("${server}", serverName) : defaultMessage;
222
222
  }
223
223
 
224
+ export function formatMcpStatus(config: Pick<McpConfig, "settings">, message: string): string {
225
+ return `${config.settings?.showStatusIcon === false ? "MCP: " : "🔌 MCP: "}${message}`;
226
+ }
227
+
224
228
  /**
225
229
  * Extract the adapter-owned UI stream mode from tool metadata.
226
230
  */