pi-mcp-adapter 2.21.1 → 2.22.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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.22.0] - 2026-08-11
11
+
12
+ ### Added
13
+ - Added the `pi-mcp-adapter/oauth` subpath for URL-bound OAuth token reuse by cooperating Pi extensions. Thanks @ThePhoenixCoding for issue #323.
14
+ - Added `oauth.logoUri` for OAuth Dynamic Client Registration, with validation that requires an absolute HTTP(S) URL. Thanks @grinich for PR #321.
15
+
16
+ ### Fixed
17
+ - Materialized binary MCP resources as private temporary files before model-facing output, with bounded per-session cleanup. Thanks @zenworr and @shaworr for PR #324.
18
+ - Named OAuth callback pages and dynamic client registrations after rebranded Pi hosts, while preserving stock Pi defaults and avoiding guessed client homepages. Thanks @grinich for PR #320.
19
+
20
+ ## [2.21.2] - 2026-08-09
21
+
22
+ ### Fixed
23
+ - Reported MCP servers still connecting after a zero-result tool search, so agents retry instead of treating the result as definitive. Thanks @Leon69924 for issue #316.
24
+ - Rejected malformed MCP config server entries and persisted OAuth credential records at their trust boundaries, so invalid local state fails before it reaches runtime connection or token code.
25
+ - Sized OAuth credential chunks below the Windows Credential Manager per-value limit, so oversized OAuth records persist on Windows instead of failing at every payload size. The previous 1800-character chunk size exceeded the 1280-character ceiling, which left the chunking added in #246 ineffective on Windows. Thanks @CrazyCoder for PR #318.
26
+
10
27
  ## [2.21.1] - 2026-08-08
11
28
 
12
29
  ### Changed
package/README.md CHANGED
@@ -153,6 +153,17 @@ A supplied `config` is a complete, isolated snapshot. It is not merged with file
153
153
 
154
154
  With `configPath` and no `config`, the adapter keeps normal file merge behavior, and that path takes precedence over argv and `--mcp-config`. The default export keeps the normal file-based behavior. OAuth credentials are stored in the operating system credential store and keyed by the configured server name; URL binding prevents credentials from being accepted for a different server URL. `settings.oauthDir` and `MCP_OAUTH_DIR` are used only as legacy plaintext import locations for older `tokens.json` files, not as credential namespaces. CSRF state and PKCE verifiers are flow-local, so concurrent authorization flows do not share transient secrets.
155
155
 
156
+ Cooperating Pi extensions can use `pi-mcp-adapter/oauth` to reuse URL-bound OAuth tokens without deep-importing private files:
157
+
158
+ ```ts
159
+ import { getMcpOAuthTokensForUrl, updateMcpOAuthTokensForUrl } from "pi-mcp-adapter/oauth";
160
+
161
+ const tokens = await getMcpOAuthTokensForUrl("jira", "https://jira.example.com/mcp");
162
+ updateMcpOAuthTokensForUrl("jira", "https://jira.example.com/mcp", { accessToken: "..." });
163
+ ```
164
+
165
+ The public subpath exposes only token read/update helpers plus a status helper. The async read path uses the adapter's refresh logic before it returns tokens. The helpers keep secure-store storage, URL binding, refresh persistence, chunk handling, legacy import, and fail-closed credential-store errors. They do not expose client registration secrets, PKCE verifiers, or OAuth state.
166
+
156
167
  ### Runtime status snapshots
157
168
 
158
169
  Extensions can subscribe to the adapter's versioned shared event-bus channel instead of parsing `/mcp` or `mcp({})` output:
@@ -203,7 +214,8 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
203
214
  | `oauth.scope` | Requested OAuth scopes |
204
215
  | `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
205
216
  | `oauth.clientName` | Client display name advertised during Dynamic Client Registration fallback |
206
- | `oauth.clientUri` | Client homepage URI advertised during Dynamic Client Registration fallback |
217
+ | `oauth.clientUri` | Client homepage URI advertised during Dynamic Client Registration fallback. Defaults to `piConfig.clientUri` from the host's manifest when set, and is omitted rather than guessed under a rebranded host |
218
+ | `oauth.logoUri` | Client logo URL advertised during Dynamic Client Registration fallback (RFC 7591 `logo_uri`). Must be an absolute `http(s)` URL — consent screens fetch it server-side, so local paths render nothing. Omitted from the registration request when unset |
207
219
  | `oauth.skipIssuerMetadataValidation` | `true` disables the OAuth authorization-server metadata issuer check for this server. This weakens OAuth mix-up protection and should only be used for known-misconfigured internal servers while their metadata is being fixed. |
208
220
  | `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 `!`. |
209
221
  | `lifecycle` | `"lazy"` (default), `"eager"`, `"keep-alive"`, or `"lazy-keep-alive"` |
@@ -372,9 +384,10 @@ Oversized MCP tool/resource results are guarded by default so a single huge resp
372
384
 
373
385
  - Inline text output is capped at **50 KiB / 2,000 lines** (matching Pi's built-in `bash` guard). Larger output is truncated to a head preview and the full text is saved to a temp file whose path is included in the result, so the agent can `read`/`grep` it.
374
386
  - **Image content blocks pass through unchanged** — only text output is guarded. Images are delivered to the provider as native image content.
387
+ - Binary resource blobs up to **10 MiB** are decoded to private temp files and replaced with file references. Each session is limited to **100 MiB** and **10,000 files**. The files are removed at session teardown.
375
388
  - In proxy mode, `details.mcpResult` is kept raw when its JSON is **≤ 16 KiB**; larger results are replaced with a compact summary (block counts, sizes, key previews) and the raw JSON is saved to a temp file. Direct tools keep their lean details and never carry `mcpResult`.
376
389
 
377
- Tune the limits with the object form:
390
+ Tune the text and details limits with the object form:
378
391
 
379
392
  ```json
380
393
  {
@@ -384,7 +397,7 @@ Tune the limits with the object form:
384
397
  }
385
398
  ```
386
399
 
387
- Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — to disable the guard and restore raw output behavior. Saved temp files are created with mode `0600` under the system temp directory and are not cleaned up automatically; note that spilled MCP output may contain sensitive data.
400
+ Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — to disable text and details guarding. Binary resource materialization and its safety limits remain active. Output-guard spill files are created with mode `0600` under the system temp directory and are not cleaned up automatically; note that spilled MCP output may contain sensitive data.
388
401
 
389
402
  ### MCP Scripting
390
403
 
package/agent-dir.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
 
@@ -18,3 +19,42 @@ export function getAgentDir(): string {
18
19
  export function getAgentPath(...segments: string[]): string {
19
20
  return join(getAgentDir(), ...segments);
20
21
  }
22
+
23
+ /**
24
+ * What the host calls itself.
25
+ *
26
+ * pi supports rebranding through `piConfig.name` in the package.json that its
27
+ * `getPackageDir()` resolves, and distributions built on pi (arc, tau, …) point
28
+ * `PI_PACKAGE_DIR` at their own manifest. Read that manifest directly rather
29
+ * than importing pi: this package deliberately depends on pi-ai and pi-tui
30
+ * only, and `getAgentDir()` above reads its env var the same self-contained way.
31
+ *
32
+ * Falls back to "pi", which is what pi's own APP_NAME resolves to.
33
+ */
34
+ function readPiConfig(): { name?: unknown; clientUri?: unknown } | undefined {
35
+ const dir = process.env.PI_PACKAGE_DIR?.trim()
36
+ if (!dir) return undefined
37
+ try {
38
+ const manifest = JSON.parse(readFileSync(join(resolve(dir), "package.json"), "utf8")) as {
39
+ piConfig?: { name?: unknown; clientUri?: unknown }
40
+ }
41
+ return manifest.piConfig
42
+ } catch {
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ export function getAppName(): string {
48
+ const name = readPiConfig()?.name
49
+ return typeof name === "string" && name.trim() ? name.trim() : "pi"
50
+ }
51
+
52
+ /**
53
+ * Home page the host declares for itself, via `piConfig.clientUri` in the same
54
+ * manifest that carries `piConfig.name`. Only the distribution knows its own
55
+ * URL, so this is the one place it can come from without guessing.
56
+ */
57
+ export function getAppClientUri(): string | undefined {
58
+ const uri = readPiConfig()?.clientUri
59
+ return typeof uri === "string" && uri.trim() ? uri.trim() : undefined
60
+ }
package/config.ts CHANGED
@@ -638,22 +638,32 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
638
638
  }
639
639
 
640
640
  function validateConfig(raw: unknown): McpConfig {
641
- if (!raw || typeof raw !== "object") {
641
+ if (!isRecord(raw)) {
642
642
  return { mcpServers: {} };
643
643
  }
644
644
 
645
- const obj = raw as Record<string, unknown>;
646
- const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
645
+ return {
646
+ mcpServers: toServerEntries(raw.mcpServers ?? raw["mcp-servers"]),
647
+ ...(Array.isArray(raw.imports) ? { imports: raw.imports as ImportKind[] } : {}),
648
+ ...(raw.settings !== undefined ? { settings: raw.settings as McpSettings } : {}),
649
+ };
650
+ }
647
651
 
648
- if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
649
- return { mcpServers: {} };
652
+ function toServerEntries(servers: unknown): Record<string, ServerEntry> {
653
+ if (!isRecord(servers)) return {};
654
+ const entries: Record<string, ServerEntry> = {};
655
+ for (const [name, entry] of Object.entries(servers)) {
656
+ if (isServerEntry(entry)) entries[name] = entry;
650
657
  }
658
+ return entries;
659
+ }
651
660
 
652
- return {
653
- mcpServers: servers as Record<string, ServerEntry>,
654
- ...(Array.isArray(obj.imports) ? { imports: obj.imports as ImportKind[] } : {}),
655
- ...(obj.settings !== undefined ? { settings: obj.settings as McpSettings } : {}),
656
- };
661
+ function isServerEntry(value: unknown): value is ServerEntry {
662
+ return isRecord(value);
663
+ }
664
+
665
+ function isRecord(value: unknown): value is Record<string, unknown> {
666
+ return typeof value === "object" && value !== null && !Array.isArray(value);
657
667
  }
658
668
 
659
669
  function mergeOpenCodeConfigs(base: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {
@@ -787,12 +797,13 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
787
797
  continue;
788
798
  }
789
799
 
790
- if (kind !== "codex" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
791
- mappedServers[name] = entry as ServerEntry;
800
+ if (!isRecord(entry)) continue;
801
+ if (kind !== "codex") {
802
+ mappedServers[name] = entry;
792
803
  continue;
793
804
  }
794
805
 
795
- const mapped = { ...(entry as Record<string, unknown>) };
806
+ const mapped = { ...entry };
796
807
  const bearerTokenEnv = mapped.bearer_token_env_var;
797
808
  const httpHeaders = mapped.http_headers;
798
809
  const envHttpHeaders = mapped.env_http_headers;
package/direct-tools.ts CHANGED
@@ -8,7 +8,7 @@ import { abortable, throwIfAborted } from "./abort.ts";
8
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
- import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
11
+ import { resolveMcpResultContent, transformMcpContent, transformMcpResourceContents } from "./tool-registrar.ts";
12
12
  import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
13
13
  import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
14
14
  import { formatToolName, isServerDisabled, isToolAllowed, resolveToolPrefix } from "./types.ts";
@@ -20,6 +20,7 @@ import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
20
20
  import { ensureToolCallApproved } from "./tool-approval.ts";
21
21
 
22
22
  type ClientCallToolResult = Awaited<ReturnType<Client["callTool"]>>;
23
+ type ClientReadResourceResult = Awaited<ReturnType<Client["readResource"]>>;
23
24
 
24
25
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
25
26
  const INSTRUCTIONS_SNIPPET_LENGTH = 150;
@@ -435,7 +436,7 @@ export function createDirectToolExecutor(
435
436
  state.manager.incrementInFlight(spec.serverName);
436
437
 
437
438
  if (spec.resourceUri) {
438
- const result = await withSessionRecovery(
439
+ const result = await withSessionRecovery<ClientReadResourceResult>(
439
440
  {
440
441
  manager: state.manager,
441
442
  config: state.config,
@@ -445,10 +446,7 @@ export function createDirectToolExecutor(
445
446
  spec.serverName,
446
447
  (conn) => conn.client.readResource({ uri: spec.resourceUri! }, requestOptions),
447
448
  );
448
- const content = (result.contents ?? []).map(c => ({
449
- type: "text" as const,
450
- text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
451
- }));
449
+ const content = transformMcpResourceContents(result.contents ?? [], state.owner?.signal);
452
450
  const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
453
451
  return {
454
452
  content: guarded.content,
@@ -487,7 +485,7 @@ export function createDirectToolExecutor(
487
485
 
488
486
  if (result.isError) {
489
487
  const mcpContent = (result.content ?? []) as McpContent[];
490
- const content = transformMcpContent(mcpContent);
488
+ const content = transformMcpContent(mcpContent, state.owner?.signal);
491
489
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
492
490
  const schemaText = spec.inputSchema ? `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` : "";
493
491
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed" });
@@ -497,7 +495,7 @@ export function createDirectToolExecutor(
497
495
  };
498
496
  }
499
497
 
500
- const content = resolveMcpResultContent(result as Record<string, unknown>);
498
+ const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
501
499
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
502
500
  if (hasUi) {
503
501
  const uiSummary = summarizeUiSessionResult(uiSession);
package/index.ts CHANGED
@@ -19,6 +19,7 @@ import { toolErrorOverride } from "./error-signal.ts";
19
19
  import { createMcpRuntimeOwner, createOwnedUi, isAbortError, type McpRuntimeOwner } from "./runtime-owner.ts";
20
20
  import { publishMcpStatusShutdown } from "./mcp-status.ts";
21
21
  import { runMcpScript } from "./mcp-code.ts";
22
+ import { cleanupMaterializedBinaryResources } from "./tool-registrar.ts";
22
23
 
23
24
  export type { McpAdapterOptions } from "./types.ts";
24
25
  export {
@@ -275,6 +276,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
275
276
  });
276
277
 
277
278
  function startInitialization(ctx: ExtensionContext, owner: McpRuntimeOwner, oauthRuntime: McpOAuthRuntime, generation: number, staleReason: string): Promise<void> {
279
+ owner.addCleanup(() => cleanupMaterializedBinaryResources(owner.signal));
278
280
  const promise = initializeMcp(pi, ctx, owner, {
279
281
  ...(programmaticConfig || options.configPath !== undefined
280
282
  ? {
package/mcp-auth-flow.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  getOAuthState,
32
32
  clearOAuthState,
33
33
  getAuthBaseDir,
34
+ OAuthCredentialStoreError,
34
35
  type AuthStorageOptions,
35
36
  type StoredTokens,
36
37
  } from "./mcp-auth.ts"
@@ -211,6 +212,27 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
211
212
  }
212
213
  config.clientUri = clientUri
213
214
  }
215
+ if (definition.oauth?.logoUri !== undefined) {
216
+ if (typeof definition.oauth.logoUri !== "string") {
217
+ throw new Error("OAuth logoUri must be a string")
218
+ }
219
+ const logoUri = interpolateEnvVars(definition.oauth.logoUri).trim()
220
+ if (!logoUri) {
221
+ throw new Error("OAuth logoUri must not be empty")
222
+ }
223
+ // Consent screens fetch this server-side, so a local path silently renders
224
+ // nothing. Fail here instead, where the message can say why.
225
+ let parsed: URL
226
+ try {
227
+ parsed = new URL(logoUri)
228
+ } catch {
229
+ throw new Error("OAuth logoUri must be an absolute http(s) URL")
230
+ }
231
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
232
+ throw new Error("OAuth logoUri must be an absolute http(s) URL")
233
+ }
234
+ config.logoUri = logoUri
235
+ }
214
236
  if (definition.oauth?.skipIssuerMetadataValidation !== undefined) {
215
237
  if (typeof definition.oauth.skipIssuerMetadataValidation !== "boolean") {
216
238
  throw new Error("OAuth skipIssuerMetadataValidation must be a boolean")
@@ -667,14 +689,12 @@ export async function authenticate(
667
689
  }
668
690
 
669
691
  const operation = (async (): Promise<AuthStatus> => {
670
- // Start auth flow
671
692
  const { authorizationUrl } = await startAuth(serverName, serverUrl, definition, {
672
693
  ...options,
673
694
  ...(signal ? { signal } : {}),
674
695
  runtime,
675
696
  })
676
697
 
677
- // If no auth URL needed, already authenticated
678
698
  if (!authorizationUrl) {
679
699
  return "authenticated"
680
700
  }
@@ -708,13 +728,11 @@ export async function authenticate(
708
728
  console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
709
729
  }
710
730
 
711
- // Wait for callback
712
731
  const callbackResult = await abortable(callbackPromise, signal)
713
732
 
714
733
  // The callback server accepted only the flow-local reserved state.
715
734
  throwIfAborted(signal)
716
735
 
717
- // Complete the auth
718
736
  return await completeAuth(serverName, callbackResult, {
719
737
  ...options,
720
738
  ...(signal ? { signal } : {}),
@@ -758,25 +776,21 @@ export async function getValidToken(
758
776
  const authStorageOptions = options.authStorageOptions ?? {}
759
777
  const signal = combineAbortSignals(runtime.signal, options.signal)
760
778
  throwIfAborted(signal)
761
- // Check if we have valid tokens
762
779
  const entry = await getAuthForUrl(serverName, serverUrl, authStorageOptions)
763
780
  throwIfAborted(signal)
764
781
  if (!entry?.tokens) {
765
782
  return null
766
783
  }
767
784
 
768
- // Check expiration
769
785
  const expired = await isTokenExpired(serverName, authStorageOptions)
770
786
  if (expired === false) {
771
787
  return entry.tokens
772
788
  }
773
789
 
774
790
  if (expired === true && entry.tokens.refreshToken) {
775
- // Token is expired, try to refresh
776
791
  console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
777
792
 
778
793
  try {
779
- // Create auth provider for token refresh
780
794
  const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
781
795
  onRedirect: async () => {},
782
796
  }, authStorageOptions, runtime.signal)
@@ -807,7 +821,7 @@ export async function getValidToken(
807
821
  authProvider.deactivate()
808
822
  }
809
823
  } catch (error) {
810
- if (isAbortError(error, signal)) throw error
824
+ if (isAbortError(error, signal) || error instanceof OAuthCredentialStoreError) throw error
811
825
  console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
812
826
  return null
813
827
  }
package/mcp-auth.ts CHANGED
@@ -22,7 +22,16 @@ import { resolveConfiguredOAuthDir } from './config.ts';
22
22
  const require = createRequire(import.meta.url);
23
23
  const AUTH_SECRET_SERVICE = 'pi-mcp-adapter.oauth';
24
24
  const TEST_AUTH_STORE_ENV = 'PI_MCP_ADAPTER_TEST_AUTH_STORE';
25
- const AUTH_SECRET_CHUNK_SIZE = 1800;
25
+ /**
26
+ * Windows Credential Manager caps one value at CRED_MAX_CREDENTIAL_BLOB_SIZE
27
+ * (2560 bytes) and stores it as UTF-16, so the real ceiling is
28
+ * AUTH_SECRET_VALUE_LIMIT characters. Chunks must stay below that, and so must
29
+ * the threshold that decides whether to chunk at all, or oversized records still
30
+ * fail to persist on Windows.
31
+ */
32
+ const AUTH_SECRET_CHUNK_SIZE = 1000;
33
+ /** Largest single value the strictest supported credential store accepts. */
34
+ const AUTH_SECRET_VALUE_LIMIT = 1280;
26
35
  const KEYRING_RECOVERY_DISABLED_ENV = 'PI_MCP_ADAPTER_DISABLE_KEYRING_RECOVERY';
27
36
  const KEYRING_RECOVERY_KEYCTL_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_KEYCTL';
28
37
  const KEYRING_RECOVERY_NODE_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_NODE';
@@ -163,6 +172,22 @@ const keyringAuthSecretStore: AuthSecretStore = {
163
172
  },
164
173
  };
165
174
 
175
+ /** Mimics the Windows Credential Manager per-value ceiling for tests. */
176
+ const sizeLimitedAuthSecretStore: AuthSecretStore = {
177
+ read(account) {
178
+ return memoryAuthEntries.get(account);
179
+ },
180
+ write(account, payload) {
181
+ if (payload.length > AUTH_SECRET_VALUE_LIMIT) {
182
+ throw new Error(`Value of 'password encoded as UTF-16' is longer than the platform limit of ${AUTH_SECRET_VALUE_LIMIT * 2} chars`);
183
+ }
184
+ memoryAuthEntries.set(account, payload);
185
+ },
186
+ remove(account) {
187
+ memoryAuthEntries.delete(account);
188
+ },
189
+ };
190
+
166
191
  const unavailableAuthSecretStore: AuthSecretStore = {
167
192
  read() {
168
193
  throw new Error('simulated secure credential store unavailable');
@@ -205,6 +230,7 @@ export function removeTestAuthSecretStoreEntry(account: string): void {
205
230
 
206
231
  function getAuthSecretStore(): AuthSecretStore {
207
232
  if (process.env[TEST_AUTH_STORE_ENV] === 'memory') return memoryAuthSecretStore;
233
+ if (process.env[TEST_AUTH_STORE_ENV] === 'sizelimited') return sizeLimitedAuthSecretStore;
208
234
  if (process.env[TEST_AUTH_STORE_ENV] === 'unavailable') return unavailableAuthSecretStore;
209
235
  if (process.env[TEST_AUTH_STORE_ENV] === 'keyrevoked') return keyRevokedAuthSecretStore;
210
236
  return keyringAuthSecretStore;
@@ -401,7 +427,99 @@ function parseJsonPayload(serverName: string, payload: string, source: string):
401
427
  }
402
428
 
403
429
  function parseAuthEntryPayload(serverName: string, payload: string, source: string): AuthEntry {
404
- return parseJsonPayload(serverName, payload, source) as AuthEntry;
430
+ const parsed = parseJsonPayload(serverName, payload, source);
431
+ const entry = toAuthEntry(parsed);
432
+ if (!entry) {
433
+ throw new Error(`Failed to parse OAuth credentials for ${serverName} from ${source}: invalid credential shape`);
434
+ }
435
+ return entry;
436
+ }
437
+
438
+ function toAuthEntry(value: unknown): AuthEntry | undefined {
439
+ const entry = toRecord(value);
440
+ if (!entry) return undefined;
441
+
442
+ const codeVerifier = optionalString(entry.codeVerifier);
443
+ const oauthState = optionalString(entry.oauthState);
444
+ const serverUrl = optionalString(entry.serverUrl);
445
+ if (codeVerifier === null || oauthState === null || serverUrl === null) return undefined;
446
+
447
+ const tokens = entry.tokens === undefined ? undefined : toStoredTokens(entry.tokens);
448
+ const clientInfo = entry.clientInfo === undefined ? undefined : toStoredClientInfo(entry.clientInfo);
449
+ if ((entry.tokens !== undefined && !tokens) || (entry.clientInfo !== undefined && !clientInfo)) return undefined;
450
+
451
+ const authEntry: AuthEntry = {};
452
+ if (tokens) authEntry.tokens = tokens;
453
+ if (clientInfo) authEntry.clientInfo = clientInfo;
454
+ if (codeVerifier !== undefined) authEntry.codeVerifier = codeVerifier;
455
+ if (oauthState !== undefined) authEntry.oauthState = oauthState;
456
+ if (serverUrl !== undefined) authEntry.serverUrl = serverUrl;
457
+ return authEntry;
458
+ }
459
+
460
+ function toStoredTokens(value: unknown): StoredTokens | undefined {
461
+ const tokens = toRecord(value);
462
+ if (!tokens || typeof tokens.accessToken !== 'string') return undefined;
463
+
464
+ const refreshToken = optionalString(tokens.refreshToken);
465
+ const scope = optionalString(tokens.scope);
466
+ const issuer = optionalString(tokens.issuer);
467
+ const expiresAt = optionalNumber(tokens.expiresAt);
468
+ if (refreshToken === null || scope === null || issuer === null || expiresAt === null) return undefined;
469
+
470
+ const storedTokens: StoredTokens = { accessToken: tokens.accessToken };
471
+ if (refreshToken !== undefined) storedTokens.refreshToken = refreshToken;
472
+ if (expiresAt !== undefined) storedTokens.expiresAt = expiresAt;
473
+ if (scope !== undefined) storedTokens.scope = scope;
474
+ if (issuer !== undefined) storedTokens.issuer = issuer;
475
+ return storedTokens;
476
+ }
477
+
478
+ function toStoredClientInfo(value: unknown): StoredClientInfo | undefined {
479
+ const clientInfo = toRecord(value);
480
+ if (!clientInfo || typeof clientInfo.clientId !== 'string') return undefined;
481
+
482
+ const clientSecret = optionalString(clientInfo.clientSecret);
483
+ const issuer = optionalString(clientInfo.issuer);
484
+ const clientIdIssuedAt = optionalNumber(clientInfo.clientIdIssuedAt);
485
+ const clientSecretExpiresAt = optionalNumber(clientInfo.clientSecretExpiresAt);
486
+ const configPreRegistered = optionalBoolean(clientInfo.configPreRegistered);
487
+ if (clientSecret === null || issuer === null || clientIdIssuedAt === null || clientSecretExpiresAt === null || configPreRegistered === null) return undefined;
488
+
489
+ const storedClient: StoredClientInfo = { clientId: clientInfo.clientId };
490
+ const redirectUris = stringArray(clientInfo.redirectUris);
491
+ if (clientSecret !== undefined) storedClient.clientSecret = clientSecret;
492
+ if (clientIdIssuedAt !== undefined) storedClient.clientIdIssuedAt = clientIdIssuedAt;
493
+ if (clientSecretExpiresAt !== undefined) storedClient.clientSecretExpiresAt = clientSecretExpiresAt;
494
+ if (redirectUris !== undefined) storedClient.redirectUris = redirectUris;
495
+ if (issuer !== undefined) storedClient.issuer = issuer;
496
+ if (configPreRegistered !== undefined) storedClient.configPreRegistered = configPreRegistered;
497
+ return storedClient;
498
+ }
499
+
500
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
501
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
502
+ ? value as Record<string, unknown>
503
+ : undefined;
504
+ }
505
+
506
+ function optionalString(value: unknown): string | null | undefined {
507
+ if (value === undefined) return undefined;
508
+ return typeof value === 'string' ? value : null;
509
+ }
510
+
511
+ function optionalNumber(value: unknown): number | null | undefined {
512
+ if (value === undefined) return undefined;
513
+ return typeof value === 'number' ? value : null;
514
+ }
515
+
516
+ function optionalBoolean(value: unknown): boolean | null | undefined {
517
+ if (value === undefined) return undefined;
518
+ return typeof value === 'boolean' ? value : null;
519
+ }
520
+
521
+ function stringArray(value: unknown): string[] | undefined {
522
+ return Array.isArray(value) && value.every(uri => typeof uri === 'string') ? value : undefined;
405
523
  }
406
524
 
407
525
  function isAuthEntryChunkManifest(value: unknown): value is AuthEntryChunkManifest {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
9
+ import { getAppName } from "./agent-dir.ts"
9
10
  import {
10
11
  DEFAULT_OAUTH_CALLBACK_PATH,
11
12
  getConfiguredOAuthCallbackPort,
@@ -15,45 +16,106 @@ import {
15
16
  setOAuthCallbackPort,
16
17
  } from "./mcp-oauth-provider.ts"
17
18
 
18
- // HTML templates for callback responses
19
- const HTML_SUCCESS = `<!DOCTYPE html>
20
- <html>
21
- <head>
22
- <title>Pi - Authorization Successful</title>
23
- <style>
24
- body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
25
- .container { text-align: center; padding: 2rem; }
26
- h1 { color: #4ade80; margin-bottom: 1rem; }
27
- p { color: #aaa; }
28
- </style>
29
- </head>
30
- <body>
31
- <div class="container">
32
- <h1>Authorization Successful</h1>
33
- <p>You can close this window and return to Pi.</p>
34
- </div>
35
- <script>setTimeout(() => window.close(), 2000);</script>
36
- </body>
37
- </html>`
38
-
39
- const HTML_MANUAL_SUCCESS = `<!DOCTYPE html>
40
- <html>
19
+ // HTML templates for callback responses.
20
+ //
21
+ // These pages are served from localhost during OAuth and are the last thing a
22
+ // user sees before returning to their terminal, so they are self-contained: no
23
+ // webfonts, no external assets, nothing that needs the network. They also name
24
+ // the host rather than hardcoding "Pi", so a distribution that rebrands pi
25
+ // (arc, tau, …) does not send its users back to an app they are not running.
26
+
27
+ /** Shared chrome: system fonts, a centred card, and light/dark support. */
28
+ const PAGE_STYLE = `
29
+ :root { color-scheme: light dark; }
30
+ * { box-sizing: border-box; }
31
+ body {
32
+ margin: 0;
33
+ min-height: 100vh;
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ padding: 24px;
38
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
39
+ background: #0f1117;
40
+ color: #e6e8ee;
41
+ }
42
+ .card {
43
+ width: 100%;
44
+ max-width: 26rem;
45
+ padding: 2.5rem 2rem;
46
+ text-align: center;
47
+ background: #161922;
48
+ border: 1px solid #242938;
49
+ border-radius: 14px;
50
+ box-shadow: 0 1px 2px rgba(0,0,0,.3), 0 12px 32px rgba(0,0,0,.25);
51
+ }
52
+ .badge {
53
+ width: 3rem; height: 3rem;
54
+ margin: 0 auto 1.25rem;
55
+ display: grid; place-items: center;
56
+ border-radius: 50%;
57
+ }
58
+ .badge svg { width: 1.5rem; height: 1.5rem; display: block; }
59
+ .ok { background: rgba(74,222,128,.12); color: #4ade80; }
60
+ .bad { background: rgba(248,113,113,.12); color: #f87171; }
61
+ h1 { margin: 0 0 .5rem; font-size: 1.15rem; font-weight: 600; letter-spacing: -0.01em; }
62
+ p { margin: 0; color: #9aa1b1; }
63
+ .app { color: #e6e8ee; font-weight: 500; }
64
+ .hint { margin-top: 1.25rem; font-size: .8125rem; color: #6b7280; }
65
+ code {
66
+ display: block;
67
+ margin-top: 1.25rem;
68
+ padding: .75rem .875rem;
69
+ text-align: left;
70
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
71
+ color: #fca5a5;
72
+ background: rgba(248,113,113,.08);
73
+ border: 1px solid rgba(248,113,113,.2);
74
+ border-radius: 8px;
75
+ overflow-wrap: anywhere;
76
+ }
77
+ @media (prefers-color-scheme: light) {
78
+ body { background: #f6f7f9; color: #121620; }
79
+ .card { background: #fff; border-color: #e4e7ee; }
80
+ p { color: #5b6474; }
81
+ .app { color: #121620; }
82
+ .hint { color: #8b93a3; }
83
+ }`
84
+
85
+ const CHECK_ICON =
86
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>'
87
+
88
+ const CROSS_ICON =
89
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>'
90
+
91
+ function page(options: {
92
+ title: string
93
+ heading: string
94
+ body: string
95
+ icon: string
96
+ tone: "ok" | "bad"
97
+ extra?: string
98
+ autoClose?: boolean
99
+ }): string {
100
+ return `<!DOCTYPE html>
101
+ <html lang="en">
41
102
  <head>
42
- <title>Pi - Authorization Received</title>
43
- <style>
44
- body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
45
- .container { text-align: center; padding: 2rem; }
46
- h1 { color: #4ade80; margin-bottom: 1rem; }
47
- p { color: #aaa; }
103
+ <meta charset="utf-8">
104
+ <meta name="viewport" content="width=device-width, initial-scale=1">
105
+ <title>${options.title}</title>
106
+ <style>${PAGE_STYLE}
48
107
  </style>
49
108
  </head>
50
109
  <body>
51
- <div class="container">
52
- <h1>Authorization Received</h1>
53
- <p>Copy the full callback URL from your browser address bar and paste it back into Pi with auth-complete.</p>
54
- </div>
55
- </body>
110
+ <main class="card">
111
+ <div class="badge ${options.tone}">${options.icon}</div>
112
+ <h1>${options.heading}</h1>
113
+ <p>${options.body}</p>
114
+ ${options.extra ?? ""}
115
+ </main>
116
+ ${options.autoClose ? " <script>setTimeout(() => window.close(), 2000);</script>\n" : ""}</body>
56
117
  </html>`
118
+ }
57
119
 
58
120
  function escapeHtml(value: string): string {
59
121
  return value
@@ -64,26 +126,41 @@ function escapeHtml(value: string): string {
64
126
  .replace(/'/g, "&#39;")
65
127
  }
66
128
 
67
- const HTML_ERROR = (error: string) => `<!DOCTYPE html>
68
- <html>
69
- <head>
70
- <title>Pi - Authorization Failed</title>
71
- <style>
72
- body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
73
- .container { text-align: center; padding: 2rem; }
74
- h1 { color: #f87171; margin-bottom: 1rem; }
75
- p { color: #aaa; }
76
- .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
77
- </style>
78
- </head>
79
- <body>
80
- <div class="container">
81
- <h1>Authorization Failed</h1>
82
- <p>An error occurred during authorization.</p>
83
- <div class="error">${escapeHtml(error)}</div>
84
- </div>
85
- </body>
86
- </html>`
129
+ /** Built per request so a host that sets PI_PACKAGE_DIR late is still named right. */
130
+ function htmlSuccess(): string {
131
+ const app = escapeHtml(getAppName())
132
+ return page({
133
+ title: `${app} — Authorization Successful`,
134
+ heading: "Authorization Successful",
135
+ body: `You can close this window and return to <span class="app">${app}</span>.`,
136
+ icon: CHECK_ICON,
137
+ tone: "ok",
138
+ autoClose: true,
139
+ })
140
+ }
141
+
142
+ function htmlManualSuccess(): string {
143
+ const app = escapeHtml(getAppName())
144
+ return page({
145
+ title: `${app} — Authorization Received`,
146
+ heading: "Authorization Received",
147
+ body: `Copy the full callback URL from your browser address bar and paste it back into <span class="app">${app}</span> with auth-complete.`,
148
+ icon: CHECK_ICON,
149
+ tone: "ok",
150
+ })
151
+ }
152
+
153
+ function htmlError(error: string): string {
154
+ const app = escapeHtml(getAppName())
155
+ return page({
156
+ title: `${app} — Authorization Failed`,
157
+ heading: "Authorization Failed",
158
+ body: `Something went wrong during authorization. You can close this window and try again from <span class="app">${app}</span>.`,
159
+ icon: CROSS_ICON,
160
+ tone: "bad",
161
+ extra: `<code>${escapeHtml(error)}</code>`,
162
+ })
163
+ }
87
164
 
88
165
  /** Result of a successful OAuth callback */
89
166
  export interface OAuthCallbackResult {
@@ -145,7 +222,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
145
222
  if (!state) {
146
223
  const errorMsg = "Missing required state parameter - potential CSRF attack"
147
224
  res.writeHead(400, { "Content-Type": "text/html" })
148
- res.end(HTML_ERROR(errorMsg))
225
+ res.end(htmlError(errorMsg))
149
226
  return
150
227
  }
151
228
 
@@ -157,14 +234,14 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
157
234
  if (!pending && !isReserved) {
158
235
  const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
159
236
  res.writeHead(400, { "Content-Type": "text/html" })
160
- res.end(HTML_ERROR(errorMsg))
237
+ res.end(htmlError(errorMsg))
161
238
  return
162
239
  }
163
240
 
164
241
  const errorMsg = errorDescription || error
165
242
  // Send HTTP response first before rejecting promise
166
243
  res.writeHead(200, { "Content-Type": "text/html" })
167
- res.end(HTML_ERROR(errorMsg))
244
+ res.end(htmlError(errorMsg))
168
245
  // Reject promise after response is sent (defer to allow test to attach handler)
169
246
  if (pending) {
170
247
  reservedAuthStates.delete(state)
@@ -179,20 +256,20 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
179
256
  if (!pending && !isReserved) {
180
257
  const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
181
258
  res.writeHead(400, { "Content-Type": "text/html" })
182
- res.end(HTML_ERROR(errorMsg))
259
+ res.end(htmlError(errorMsg))
183
260
  return
184
261
  }
185
262
 
186
263
  // Require authorization code
187
264
  if (!code) {
188
265
  res.writeHead(400, { "Content-Type": "text/html" })
189
- res.end(HTML_ERROR("No authorization code provided"))
266
+ res.end(htmlError("No authorization code provided"))
190
267
  return
191
268
  }
192
269
 
193
270
  if (!pending) {
194
271
  res.writeHead(200, { "Content-Type": "text/html" })
195
- res.end(HTML_MANUAL_SUCCESS)
272
+ res.end(htmlManualSuccess())
196
273
  return
197
274
  }
198
275
 
@@ -202,7 +279,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
202
279
  pending.resolve({ code, ...(iss !== null ? { iss } : {}) })
203
280
 
204
281
  res.writeHead(200, { "Content-Type": "text/html" })
205
- res.end(HTML_SUCCESS)
282
+ res.end(htmlSuccess())
206
283
  }
207
284
 
208
285
  /**
@@ -30,6 +30,37 @@ import {
30
30
  type StoredClientInfo,
31
31
  } from "./mcp-auth.ts"
32
32
  import { resolveCommandSecret } from "./utils.ts"
33
+ import { getAppClientUri, getAppName } from "./agent-dir.ts"
34
+
35
+ /**
36
+ * Client name advertised during Dynamic Client Registration.
37
+ *
38
+ * A distribution that rebrands pi (arc, tau, …) should register under its own
39
+ * name — otherwise every consent screen its users see asks them to authorize
40
+ * an app they have never run. Stock pi keeps the long-standing
41
+ * "Pi Coding Agent" so existing registrations are unaffected.
42
+ */
43
+ function defaultClientName(): string {
44
+ const app = getAppName()
45
+ return app === "pi" ? "Pi Coding Agent" : app
46
+ }
47
+
48
+ /**
49
+ * Client homepage advertised during Dynamic Client Registration.
50
+ *
51
+ * RFC 7591 defines client_uri as the home page *of the client*. Under a
52
+ * rebranded pi the client is that distribution, not this adapter, so pointing
53
+ * at the adapter's repository misidentifies it on the consent screen. There is
54
+ * no way to guess the right URL and a wrong one is worse than none, so the
55
+ * field is omitted unless a server config supplies oauth.clientUri.
56
+ *
57
+ * Stock pi keeps the historical value.
58
+ */
59
+ function defaultClientUri(): string | undefined {
60
+ const declared = getAppClientUri()
61
+ if (declared) return declared
62
+ return getAppName() === "pi" ? "https://github.com/nicobailon/pi-mcp-adapter" : undefined
63
+ }
33
64
 
34
65
  type IssuerBoundClientInformation = OAuthClientInformationMixed & { issuer?: string }
35
66
  type IssuerBoundTokens = OAuthTokens & { issuer?: string }
@@ -86,6 +117,7 @@ export interface McpOAuthConfig {
86
117
  redirectUri?: string
87
118
  clientName?: string
88
119
  clientUri?: string
120
+ logoUri?: string
89
121
  skipIssuerMetadataValidation?: boolean
90
122
  }
91
123
 
@@ -189,6 +221,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
189
221
  return this.redirectUrlSnapshot
190
222
  }
191
223
 
224
+ /** Configured homepage, else the historical default on stock pi, else nothing. */
225
+ private get clientUri(): string | undefined {
226
+ return this.config.clientUri ?? defaultClientUri()
227
+ }
228
+
192
229
  /**
193
230
  * Client metadata for dynamic registration.
194
231
  * Describes this client to the OAuth authorization server.
@@ -196,8 +233,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
196
233
  get clientMetadata(): OAuthClientMetadata {
197
234
  if (this.usesClientCredentials) {
198
235
  return {
199
- client_name: this.config.clientName ?? "Pi Coding Agent",
200
- client_uri: this.config.clientUri ?? "https://github.com/nicobailon/pi-mcp-adapter",
236
+ client_name: this.config.clientName ?? defaultClientName(),
237
+ ...(this.clientUri !== undefined ? { client_uri: this.clientUri } : {}),
238
+ ...(this.config.logoUri !== undefined ? { logo_uri: this.config.logoUri } : {}),
201
239
  redirect_uris: [],
202
240
  grant_types: ["client_credentials"],
203
241
  token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
@@ -211,8 +249,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
211
249
 
212
250
  return {
213
251
  redirect_uris: [redirectUrl],
214
- client_name: this.config.clientName ?? "Pi Coding Agent",
215
- client_uri: this.config.clientUri ?? "https://github.com/nicobailon/pi-mcp-adapter",
252
+ client_name: this.config.clientName ?? defaultClientName(),
253
+ ...(this.clientUri !== undefined ? { client_uri: this.clientUri } : {}),
254
+ ...(this.config.logoUri !== undefined ? { logo_uri: this.config.logoUri } : {}),
216
255
  grant_types: ["authorization_code", "refresh_token"],
217
256
  response_types: ["code"],
218
257
  token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
package/oauth.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { getValidToken } from "./mcp-auth-flow.ts";
2
+ import {
3
+ inspectAuthForUrl,
4
+ updateTokens,
5
+ type AuthStorageOptions,
6
+ type StoredTokens,
7
+ } from "./mcp-auth.ts";
8
+
9
+ export type McpOAuthTokens = StoredTokens;
10
+ export type McpOAuthStorageOptions = AuthStorageOptions;
11
+ export interface McpOAuthTokenOptions {
12
+ authStorageOptions?: McpOAuthStorageOptions;
13
+ signal?: AbortSignal;
14
+ skipIssuerMetadataValidation?: boolean;
15
+ }
16
+ export type McpOAuthTokenStatus =
17
+ | { status: "present"; tokens: McpOAuthTokens }
18
+ | { status: "absent" }
19
+ | { status: "unavailable"; message: string };
20
+
21
+ export async function getMcpOAuthTokensForUrl(
22
+ serverName: string,
23
+ serverUrl: string,
24
+ options: McpOAuthTokenOptions = {},
25
+ ): Promise<McpOAuthTokens | undefined> {
26
+ return (await getValidToken(serverName, serverUrl, options)) ?? undefined;
27
+ }
28
+
29
+ export function inspectMcpOAuthTokensForUrl(
30
+ serverName: string,
31
+ serverUrl: string,
32
+ options?: McpOAuthStorageOptions,
33
+ ): McpOAuthTokenStatus {
34
+ const status = inspectAuthForUrl(serverName, serverUrl, options);
35
+ if (status.status !== "present") return status;
36
+ return status.entry.tokens
37
+ ? { status: "present", tokens: status.entry.tokens }
38
+ : { status: "absent" };
39
+ }
40
+
41
+ export function updateMcpOAuthTokensForUrl(
42
+ serverName: string,
43
+ serverUrl: string,
44
+ tokens: McpOAuthTokens,
45
+ options?: McpOAuthStorageOptions,
46
+ ): void {
47
+ updateTokens(serverName, tokens, serverUrl, options);
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.21.1",
3
+ "version": "2.22.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -14,6 +14,11 @@
14
14
  "types": "./types.ts",
15
15
  "import": "./types.ts",
16
16
  "default": "./types.ts"
17
+ },
18
+ "./oauth": {
19
+ "types": "./oauth.ts",
20
+ "import": "./oauth.ts",
21
+ "default": "./oauth.ts"
17
22
  }
18
23
  },
19
24
  "license": "MIT",
@@ -29,7 +34,7 @@
29
34
  "test": "vitest run",
30
35
  "test:watch": "vitest",
31
36
  "test:coverage": "vitest run --coverage",
32
- "test:oauth": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory node --import tsx --test --test-concurrency=1 mcp-auth.test.ts mcp-auth-flow.test.ts mcp-callback-server.test.ts mcp-oauth-provider.test.ts",
37
+ "test:oauth": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory node --import tsx --test --test-concurrency=1 oauth-public-api.test.ts mcp-auth.test.ts mcp-auth-flow.test.ts mcp-callback-server.test.ts mcp-oauth-provider.test.ts",
33
38
  "test:oauth-provider": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory node --import tsx --test mcp-oauth-provider.test.ts",
34
39
  "test:conformance": "bash conformance/run.sh"
35
40
  },
@@ -107,6 +112,7 @@
107
112
  "glimpse-ui.ts",
108
113
  "npx-resolver.ts",
109
114
  "oauth-handler.ts",
115
+ "oauth.ts",
110
116
  "mcp-auth.ts",
111
117
  "mcp-keyring-helper.cjs",
112
118
  "mcp-oauth-provider.ts",
package/proxy-modes.ts CHANGED
@@ -10,7 +10,7 @@ import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
10
10
  import { buildToolMetadata, getToolNames, findToolByName, formatSchema } from "./tool-metadata.ts";
11
11
  import { renderTsShape } from "./ts-shape.ts";
12
12
  import { reconstructPromptMetadata } from "./metadata-cache.ts";
13
- import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
13
+ import { resolveMcpResultContent, transformMcpContent, transformMcpResourceContents } from "./tool-registrar.ts";
14
14
  import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
15
15
  import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
16
16
  import { formatAuthRequiredMessage, formatMcpStatus, resolveServerUrl, truncateAtWord } from "./utils.ts";
@@ -21,6 +21,7 @@ import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-appro
21
21
 
22
22
  type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
23
23
  type ClientCallToolResult = Awaited<ReturnType<Client["callTool"]>>;
24
+ type ClientReadResourceResult = Awaited<ReturnType<Client["readResource"]>>;
24
25
 
25
26
  const require = createRequire(import.meta.url);
26
27
  const MAX_REGEX_SEARCH_QUERY_LENGTH = 256;
@@ -525,10 +526,28 @@ export function executeSearch(
525
526
 
526
527
  const page = paginate(matches, offset, limit);
527
528
  if (page.total === 0) {
529
+ const connectingServers = server
530
+ ? state.config.mcpServers[server] && state.manager.isConnecting(server) ? [server] : []
531
+ : Object.keys(state.config.mcpServers)
532
+ .filter(name => !isServerDisabled(state.config.mcpServers[name]) && state.manager.isConnecting(name))
533
+ .sort((a, b) => a.localeCompare(b));
528
534
  const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"`;
535
+ const connectingMessage = connectingServers.length === 1
536
+ ? ` Server "${connectingServers[0]}" is still connecting; retry in a moment.`
537
+ : connectingServers.length > 1
538
+ ? ` Servers ${connectingServers.map(name => `"${name}"`).join(", ")} are still connecting; retry in a moment.`
539
+ : "";
529
540
  return {
530
- content: [{ type: "text" as const, text: msg }],
531
- details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query },
541
+ content: [{ type: "text" as const, text: `${msg}${connectingMessage}` }],
542
+ details: {
543
+ mode: "search",
544
+ matches: [],
545
+ count: 0,
546
+ hasMore: false,
547
+ nextOffset: null,
548
+ query,
549
+ ...(connectingServers.length > 0 ? { connectingServers } : {}),
550
+ },
532
551
  };
533
552
  }
534
553
 
@@ -1094,7 +1113,7 @@ export async function executeCall(
1094
1113
  state.manager.incrementInFlight(serverName);
1095
1114
 
1096
1115
  if (toolMeta.resourceUri) {
1097
- const result = await withSessionRecovery(
1116
+ const result = await withSessionRecovery<ClientReadResourceResult>(
1098
1117
  {
1099
1118
  manager: state.manager,
1100
1119
  config: state.config,
@@ -1104,10 +1123,7 @@ export async function executeCall(
1104
1123
  serverName,
1105
1124
  (conn) => conn.client.readResource({ uri: toolMeta.resourceUri! }, requestOptions),
1106
1125
  );
1107
- const content = (result.contents ?? []).map(c => ({
1108
- type: "text" as const,
1109
- text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
1110
- }));
1126
+ const content = transformMcpResourceContents(result.contents ?? [], state.owner?.signal);
1111
1127
  const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
1112
1128
  return {
1113
1129
  content: guarded.content,
@@ -1147,7 +1163,7 @@ export async function executeCall(
1147
1163
 
1148
1164
  if (result.isError) {
1149
1165
  const mcpContent = (result.content ?? []) as McpContent[];
1150
- const content = transformMcpContent(mcpContent);
1166
+ const content = transformMcpContent(mcpContent, state.owner?.signal);
1151
1167
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1152
1168
  const schemaText = toolMeta.inputSchema ? `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` : "";
1153
1169
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed", rawMcpResult: result });
@@ -1157,7 +1173,7 @@ export async function executeCall(
1157
1173
  };
1158
1174
  }
1159
1175
 
1160
- const content = resolveMcpResultContent(result as Record<string, unknown>);
1176
+ const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
1161
1177
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1162
1178
  const uiSummary = summarizeUiSessionResult(uiSession);
1163
1179
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, suffix: `\n\n${uiSummary.message}`, rawMcpResult: result });
@@ -1176,7 +1192,7 @@ export async function executeCall(
1176
1192
 
1177
1193
  if (result.isError) {
1178
1194
  const mcpContent = (result.content ?? []) as McpContent[];
1179
- const content = transformMcpContent(mcpContent);
1195
+ const content = transformMcpContent(mcpContent, state.owner?.signal);
1180
1196
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1181
1197
  const schemaText = toolMeta.inputSchema ? `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` : "";
1182
1198
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed", rawMcpResult: result });
@@ -1186,7 +1202,7 @@ export async function executeCall(
1186
1202
  };
1187
1203
  }
1188
1204
 
1189
- const content = resolveMcpResultContent(result as Record<string, unknown>);
1205
+ const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
1190
1206
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1191
1207
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, rawMcpResult: result });
1192
1208
  return {
package/server-manager.ts CHANGED
@@ -1030,6 +1030,10 @@ export class McpServerManager {
1030
1030
  return false;
1031
1031
  }
1032
1032
 
1033
+ isConnecting(name: string): boolean {
1034
+ return this.connectPromises.has(name);
1035
+ }
1036
+
1033
1037
  getConnection(name: string): ServerConnection | undefined {
1034
1038
  return this.connections.get(name);
1035
1039
  }
package/tool-registrar.ts CHANGED
@@ -2,12 +2,189 @@
2
2
  // NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered.
3
3
  // This keeps the LLM context small (1 tool instead of 100s).
4
4
 
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
5
8
  import type { McpContent, ContentBlock } from "./types.ts";
6
9
 
10
+ const MAX_BINARY_RESOURCE_BYTES = 10 * 1024 * 1024;
11
+ const MAX_SESSION_RESOURCE_BYTES = 100 * 1024 * 1024;
12
+ const MAX_SESSION_RESOURCE_FILES = 10_000; // Bounds metadata from empty or tiny resources.
13
+ const CLEANUP_RETRY_DELAY_MS = 30_000;
14
+ const MAX_CLEANUP_RETRY_ATTEMPTS = 3;
15
+ type MaterializedResourceSession = {
16
+ directory: string | undefined;
17
+ bytes: number;
18
+ files: number;
19
+ sequence: number;
20
+ };
21
+
22
+ function createMaterializedResourceSession(): MaterializedResourceSession {
23
+ return {
24
+ directory: undefined,
25
+ bytes: 0,
26
+ files: 0,
27
+ sequence: 0,
28
+ };
29
+ }
30
+
31
+ const defaultMaterializedResourceSession = createMaterializedResourceSession();
32
+ const scopedMaterializedResourceSessions = new WeakMap<object, MaterializedResourceSession>();
33
+ const pendingCleanupDirectories = new Set<string>();
34
+ const cleanupRetryAttempts = new Map<string, number>();
35
+ let pendingCleanupRetry: ReturnType<typeof setTimeout> | undefined;
36
+
37
+ function isAbortedScope(scope: object | undefined): boolean {
38
+ return !!scope && "aborted" in scope && (scope as { aborted?: unknown }).aborted === true;
39
+ }
40
+
41
+ function getMaterializedResourceSession(scope?: object): MaterializedResourceSession | undefined {
42
+ if (isAbortedScope(scope)) return undefined;
43
+ if (!scope) return defaultMaterializedResourceSession;
44
+ let session = scopedMaterializedResourceSessions.get(scope);
45
+ if (!session) {
46
+ session = createMaterializedResourceSession();
47
+ scopedMaterializedResourceSessions.set(scope, session);
48
+ }
49
+ return session;
50
+ }
51
+
52
+ type BinaryResource = { uri?: string | undefined; text?: string | undefined; mimeType?: string | undefined; blob: string };
53
+ type McpResourceContent = {
54
+ uri: string;
55
+ text?: string | undefined;
56
+ blob?: string | undefined;
57
+ mimeType?: string | undefined;
58
+ [key: string]: unknown;
59
+ };
60
+
61
+ function hasRetryableCleanupDirectory(): boolean {
62
+ for (const directory of pendingCleanupDirectories) {
63
+ if ((cleanupRetryAttempts.get(directory) ?? 0) < MAX_CLEANUP_RETRY_ATTEMPTS) return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function schedulePendingCleanupRetry(): void {
69
+ if (pendingCleanupRetry || !hasRetryableCleanupDirectory()) return;
70
+ for (const directory of pendingCleanupDirectories) {
71
+ const attempts = cleanupRetryAttempts.get(directory) ?? 0;
72
+ if (attempts < MAX_CLEANUP_RETRY_ATTEMPTS) cleanupRetryAttempts.set(directory, attempts + 1);
73
+ }
74
+ pendingCleanupRetry = setTimeout(() => {
75
+ pendingCleanupRetry = undefined;
76
+ try {
77
+ drainPendingCleanupDirectories();
78
+ } catch {
79
+ // drainPendingCleanupDirectories already retained the paths and rescheduled another retry.
80
+ }
81
+ }, CLEANUP_RETRY_DELAY_MS);
82
+ }
83
+
84
+ function drainPendingCleanupDirectories(): void {
85
+ const failures: unknown[] = [];
86
+ for (const directory of Array.from(pendingCleanupDirectories)) {
87
+ try {
88
+ rmSync(directory, { recursive: true, force: true });
89
+ pendingCleanupDirectories.delete(directory);
90
+ cleanupRetryAttempts.delete(directory);
91
+ } catch (error) {
92
+ failures.push(error);
93
+ }
94
+ }
95
+ if (pendingCleanupDirectories.size === 0 && pendingCleanupRetry) {
96
+ clearTimeout(pendingCleanupRetry);
97
+ pendingCleanupRetry = undefined;
98
+ }
99
+ if (failures.length > 0) {
100
+ schedulePendingCleanupRetry();
101
+ throw new AggregateError(failures, "Failed to clean materialized MCP resources");
102
+ }
103
+ }
104
+
105
+ export function cleanupMaterializedBinaryResources(scope?: object): void {
106
+ const session = scope ? scopedMaterializedResourceSessions.get(scope) : defaultMaterializedResourceSession;
107
+ if (session?.directory) pendingCleanupDirectories.add(session.directory);
108
+ if (session) {
109
+ session.directory = undefined;
110
+ session.bytes = 0;
111
+ session.files = 0;
112
+ session.sequence = 0;
113
+ if (scope) scopedMaterializedResourceSessions.delete(scope);
114
+ }
115
+
116
+ drainPendingCleanupDirectories();
117
+ }
118
+
119
+ function replaceBlob(resource: BinaryResource, text: string): string {
120
+ delete (resource as Partial<BinaryResource>).blob;
121
+ resource.text = text;
122
+ return text;
123
+ }
124
+
125
+ function omitBinaryResource(resource: BinaryResource, reason: string): string {
126
+ return replaceBlob(resource, [
127
+ `[Resource: ${resource.uri ?? "(no URI)"}]`,
128
+ `Binary content omitted: ${reason}`,
129
+ `MIME type: ${resource.mimeType ?? "application/octet-stream"}`,
130
+ ].join("\n"));
131
+ }
132
+
133
+ function materializeBinaryResource(resource: BinaryResource, scope?: object): string {
134
+ const session = getMaterializedResourceSession(scope);
135
+ if (!session) return omitBinaryResource(resource, "runtime stopped");
136
+ const decodedBytes = Buffer.byteLength(resource.blob, "base64");
137
+ if (decodedBytes > MAX_BINARY_RESOURCE_BYTES) {
138
+ return omitBinaryResource(resource, "decoded size exceeds 10 MiB");
139
+ }
140
+ if (
141
+ session.bytes + decodedBytes > MAX_SESSION_RESOURCE_BYTES ||
142
+ session.files >= MAX_SESSION_RESOURCE_FILES
143
+ ) {
144
+ return omitBinaryResource(resource, "session resource limit reached");
145
+ }
146
+
147
+ try {
148
+ session.directory ??= mkdtempSync(join(tmpdir(), "pi-mcp-resource-"));
149
+ } catch {
150
+ return omitBinaryResource(resource, "could not be saved");
151
+ }
152
+
153
+ const filePath = join(session.directory, `resource-${++session.sequence}.bin`);
154
+ session.bytes += decodedBytes;
155
+ session.files += 1;
156
+ try {
157
+ writeFileSync(filePath, Buffer.from(resource.blob, "base64"), { flag: "wx", mode: 0o600 });
158
+ } catch {
159
+ try {
160
+ rmSync(filePath, { force: true });
161
+ session.bytes -= decodedBytes;
162
+ session.files -= 1;
163
+ } catch {
164
+ // Keep the reservation when a partial file cannot be removed.
165
+ }
166
+ return omitBinaryResource(resource, "could not be saved");
167
+ }
168
+
169
+ return replaceBlob(resource, [
170
+ `[Resource: ${resource.uri ?? "(no URI)"}]`,
171
+ `Binary content saved to ${filePath}`,
172
+ `MIME type: ${resource.mimeType ?? "application/octet-stream"}`,
173
+ ].join("\n"));
174
+ }
175
+
7
176
  /**
8
177
  * Transform MCP content types to Pi content blocks.
9
178
  */
10
- export function transformMcpContent(content: McpContent[]): ContentBlock[] {
179
+ export function transformMcpResourceContents(contents: McpResourceContent[], scope?: object): ContentBlock[] {
180
+ return contents.map(resource => {
181
+ if (typeof resource.text === "string") return { type: "text" as const, text: resource.text };
182
+ if (typeof resource.blob === "string") return { type: "text" as const, text: materializeBinaryResource(resource as BinaryResource, scope) };
183
+ return { type: "text" as const, text: JSON.stringify(resource) };
184
+ });
185
+ }
186
+
187
+ export function transformMcpContent(content: McpContent[], scope?: object): ContentBlock[] {
11
188
  return content.map(c => {
12
189
  if (c.type === "text") {
13
190
  return { type: "text" as const, text: c.text ?? "" };
@@ -21,6 +198,13 @@ export function transformMcpContent(content: McpContent[]): ContentBlock[] {
21
198
  }
22
199
  if (c.type === "resource") {
23
200
  const resourceUri = c.resource?.uri ?? "(no URI)";
201
+ if (c.resource && "blob" in c.resource && typeof c.resource.blob === "string") {
202
+ const binaryResource = c.resource as typeof c.resource & { mimeType?: string; blob: string };
203
+ return {
204
+ type: "text" as const,
205
+ text: materializeBinaryResource(binaryResource, scope),
206
+ };
207
+ }
24
208
  const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
25
209
  return {
26
210
  type: "text" as const,
@@ -49,8 +233,8 @@ export function transformMcpContent(content: McpContent[]): ContentBlock[] {
49
233
  * Resolve a tool result's content blocks, falling back to structuredContent
50
234
  * when content is empty.
51
235
  */
52
- export function resolveMcpResultContent(result: Record<string, unknown>): ContentBlock[] {
53
- const blocks = transformMcpContent((Array.isArray(result.content) ? result.content : []) as McpContent[]);
236
+ export function resolveMcpResultContent(result: Record<string, unknown>, scope?: object): ContentBlock[] {
237
+ const blocks = transformMcpContent((Array.isArray(result.content) ? result.content : []) as McpContent[], scope);
54
238
  if (blocks.length > 0) return blocks;
55
239
 
56
240
  if (result.structuredContent !== undefined && result.structuredContent !== null) {
package/types.ts CHANGED
@@ -350,6 +350,8 @@ export interface OAuthConfig {
350
350
  clientName?: string;
351
351
  /** Client homepage URI for dynamic registration */
352
352
  clientUri?: string;
353
+ /** Client logo URL for dynamic registration; shown on consent screens */
354
+ logoUri?: string;
353
355
  /** Security-weakening escape hatch for known-misconfigured authorization servers. */
354
356
  skipIssuerMetadataValidation?: boolean;
355
357
  }
@@ -179,10 +179,10 @@ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMet
179
179
 
180
180
  const ui = isRecord(meta.ui) ? meta.ui : undefined;
181
181
  const out: UiResourceMeta = {};
182
- const openAiCsp = hasOwnProperty(meta, "openai/widgetCSP")
182
+ const openAiCsp = Object.hasOwn(meta, "openai/widgetCSP")
183
183
  ? normalizeOpenAiWidgetCsp(meta["openai/widgetCSP"])
184
184
  : undefined;
185
- const hasStandardCsp = !!ui && hasOwnProperty(ui, "csp");
185
+ const hasStandardCsp = !!ui && Object.hasOwn(ui, "csp");
186
186
  const standardCspValue = hasStandardCsp ? ui.csp : undefined;
187
187
 
188
188
  if (hasStandardCsp && !isRecord(standardCspValue)) {
@@ -196,7 +196,7 @@ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMet
196
196
  out.csp = { ...openAiCsp, ...standardCsp };
197
197
  if (isRecord(standardCspValue)) {
198
198
  for (const [, standardField] of OPENAI_CSP_FIELD_MAPPINGS) {
199
- if (hasOwnProperty(standardCspValue, standardField) && !copyStringArray(standardCspValue[standardField])) {
199
+ if (Object.hasOwn(standardCspValue, standardField) && !copyStringArray(standardCspValue[standardField])) {
200
200
  delete out.csp[standardField];
201
201
  }
202
202
  }
@@ -245,10 +245,6 @@ function copyStringArray(value: unknown): string[] | undefined {
245
245
  : undefined;
246
246
  }
247
247
 
248
- function hasOwnProperty(record: Record<string, unknown>, property: string): boolean {
249
- return Object.prototype.hasOwnProperty.call(record, property);
250
- }
251
-
252
248
  function isRecord(value: unknown): value is Record<string, unknown> {
253
249
  return typeof value === "object" && value !== null && !Array.isArray(value);
254
250
  }