pi-mcp-adapter 2.21.2 → 2.23.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.23.0] - 2026-08-11
11
+
12
+ ### Added
13
+ - Added interactive callback URL pasting to `/mcp-auth` for OAuth flows running on remote or headless machines. Thanks @trevorleibert-mixpanel for PR #330.
14
+
15
+ ### Fixed
16
+ - Stopped load-time MCP initialization from printing a TUI startup error when Pi action methods are not bound yet. Thanks @21307369 for issue #327.
17
+ - Kept interactive OAuth authorization URLs clickable as a single terminal hyperlink. Thanks @rfccg for PR #329.
18
+
19
+ ## [2.22.0] - 2026-08-11
20
+
21
+ ### Added
22
+ - Added the `pi-mcp-adapter/oauth` subpath for URL-bound OAuth token reuse by cooperating Pi extensions. Thanks @ThePhoenixCoding for issue #323.
23
+ - Added `oauth.logoUri` for OAuth Dynamic Client Registration, with validation that requires an absolute HTTP(S) URL. Thanks @grinich for PR #321.
24
+
25
+ ### Fixed
26
+ - Materialized binary MCP resources as private temporary files before model-facing output, with bounded per-session cleanup. Thanks @zenworr and @shaworr for PR #324.
27
+ - 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.
28
+
10
29
  ## [2.21.2] - 2026-08-09
11
30
 
12
31
  ### Fixed
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"` |
@@ -253,7 +265,9 @@ The adapter owns only its client socket and closes that connection when the Pi r
253
265
 
254
266
  ### Remote/headless OAuth
255
267
 
256
- 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.
268
+ If Pi is running on a remote server, `/mcp-auth <server>` prints the authorization URL and opens a callback input. Open the URL in your local browser. After approval, the browser may fail to load the localhost callback page because localhost refers to your workstation; copy the full URL from its address bar and paste it into Pi. The input closes automatically instead when the browser can reach Pi's callback directly.
269
+
270
+ The same flow is available through the proxy tool for non-interactive clients. 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.
257
271
 
258
272
  On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter uses a best-effort recovery path through `keyctl session - node <packaged helper>` so explicit re-authentication can write fresh credentials without killing a long-lived tmux server. This path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
259
273
 
@@ -372,9 +386,10 @@ Oversized MCP tool/resource results are guarded by default so a single huge resp
372
386
 
373
387
  - 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
388
  - **Image content blocks pass through unchanged** — only text output is guarded. Images are delivered to the provider as native image content.
389
+ - 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
390
  - 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
391
 
377
- Tune the limits with the object form:
392
+ Tune the text and details limits with the object form:
378
393
 
379
394
  ```json
380
395
  {
@@ -384,7 +399,7 @@ Tune the limits with the object form:
384
399
  }
385
400
  ```
386
401
 
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.
402
+ 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
403
 
389
404
  ### MCP Scripting
390
405
 
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/commands.ts CHANGED
@@ -25,6 +25,10 @@ import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markS
25
25
  import { openPath, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
26
26
  import { isAbortError } from "./runtime-owner.ts";
27
27
 
28
+ function terminalHyperlink(label: string, url: string): string {
29
+ return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
30
+ }
31
+
28
32
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
29
33
  if (!ctx.hasUI) return;
30
34
 
@@ -273,11 +277,17 @@ export async function authenticateServer(
273
277
  ...(authStorageOptions.baseDir ? { authStorageOptions } : {}),
274
278
  onAuthorizationUrl: (authorizationUrl) => {
275
279
  ui.notify(
276
- `Open this URL to authenticate ${serverName}:\n\n${authorizationUrl}\n\n` +
277
- "After approving, return to Pi; the local callback will complete automatically.",
280
+ `Open this URL to authenticate ${serverName}:\n\n${terminalHyperlink(authorizationUrl, authorizationUrl)}\n\n` +
281
+ "After approving, Pi will complete automatically if the browser can reach its localhost callback. " +
282
+ "On a remote machine, copy the full localhost URL from the browser address bar and paste it into Pi.",
278
283
  "info"
279
284
  );
280
285
  },
286
+ onAuthorizationInput: (_authorizationUrl, inputSignal) => ui.input(
287
+ `Complete ${serverName} OAuth`,
288
+ "Paste the full callback URL, or wait for automatic completion",
289
+ { signal: inputSignal },
290
+ ),
281
291
  ...(signal ? { signal } : {}),
282
292
  ...(runtime ? { runtime } : {}),
283
293
  });
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 {
@@ -168,13 +169,23 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
168
169
  return resolveDirectTools(config, cache, prefix, envDirectToolOverride);
169
170
  }
170
171
 
172
+ function getActiveToolsIfReady(): string[] | undefined {
173
+ try {
174
+ return pi.getActiveTools?.();
175
+ } catch (error) {
176
+ if (error instanceof Error
177
+ && error.message.includes("Action methods cannot be called during extension loading")) return undefined;
178
+ throw error;
179
+ }
180
+ }
181
+
171
182
  function deactivateTools(toolNames: string[]): string[] {
172
183
  if (toolNames.length === 0) return [];
173
184
  const unregisterTool = (pi as ExtensionAPI & { unregisterTool?: (name: string) => boolean }).unregisterTool;
174
185
  const unregistered = toolNames.filter((toolName) => unregisterTool?.(toolName) === true);
175
186
  const fallbackNames = toolNames.filter((toolName) => !unregistered.includes(toolName));
176
187
  const remove = new Set(toolNames);
177
- const activeTools = pi.getActiveTools?.();
188
+ const activeTools = getActiveToolsIfReady();
178
189
  if (!activeTools || activeTools.length === 0) {
179
190
  for (const toolName of fallbackNames) fallbackDeactivatedTools.add(toolName);
180
191
  return unregistered;
@@ -206,7 +217,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
206
217
  registerDirectTool(spec);
207
218
  registeredDirectTools.set(spec.prefixedName, fingerprint);
208
219
  if (fallbackDeactivatedTools.delete(spec.prefixedName)) {
209
- const activeTools = pi.getActiveTools?.();
220
+ const activeTools = getActiveToolsIfReady();
210
221
  if (activeTools && !activeTools.includes(spec.prefixedName)) {
211
222
  pi.setActiveTools([...activeTools, spec.prefixedName]);
212
223
  }
@@ -275,6 +286,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
275
286
  });
276
287
 
277
288
  function startInitialization(ctx: ExtensionContext, owner: McpRuntimeOwner, oauthRuntime: McpOAuthRuntime, generation: number, staleReason: string): Promise<void> {
289
+ owner.addCleanup(() => cleanupMaterializedBinaryResources(owner.signal));
278
290
  const promise = initializeMcp(pi, ctx, owner, {
279
291
  ...(programmaticConfig || options.configPath !== undefined
280
292
  ? {
@@ -847,7 +859,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
847
859
  registerProxyTool(description);
848
860
  return;
849
861
  }
850
- const activeTools = pi.getActiveTools?.();
862
+ const activeTools = getActiveToolsIfReady();
851
863
  if (activeTools && !activeTools.includes("mcp")) {
852
864
  pi.setActiveTools([...activeTools, "mcp"]);
853
865
  }
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"
@@ -48,6 +49,10 @@ export interface McpOAuthRuntime {
48
49
 
49
50
  export interface AuthenticateOptions {
50
51
  onAuthorizationUrl?: (authorizationUrl: string) => void | Promise<void>
52
+ onAuthorizationInput?: (
53
+ authorizationUrl: string,
54
+ signal: AbortSignal,
55
+ ) => Promise<string | undefined>
51
56
  authStorageOptions?: AuthStorageOptions
52
57
  signal?: AbortSignal
53
58
  runtime?: McpOAuthRuntime
@@ -211,6 +216,27 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
211
216
  }
212
217
  config.clientUri = clientUri
213
218
  }
219
+ if (definition.oauth?.logoUri !== undefined) {
220
+ if (typeof definition.oauth.logoUri !== "string") {
221
+ throw new Error("OAuth logoUri must be a string")
222
+ }
223
+ const logoUri = interpolateEnvVars(definition.oauth.logoUri).trim()
224
+ if (!logoUri) {
225
+ throw new Error("OAuth logoUri must not be empty")
226
+ }
227
+ // Consent screens fetch this server-side, so a local path silently renders
228
+ // nothing. Fail here instead, where the message can say why.
229
+ let parsed: URL
230
+ try {
231
+ parsed = new URL(logoUri)
232
+ } catch {
233
+ throw new Error("OAuth logoUri must be an absolute http(s) URL")
234
+ }
235
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
236
+ throw new Error("OAuth logoUri must be an absolute http(s) URL")
237
+ }
238
+ config.logoUri = logoUri
239
+ }
214
240
  if (definition.oauth?.skipIssuerMetadataValidation !== undefined) {
215
241
  if (typeof definition.oauth.skipIssuerMetadataValidation !== "boolean") {
216
242
  throw new Error("OAuth skipIssuerMetadataValidation must be a boolean")
@@ -546,6 +572,53 @@ export function parseAuthorizationCodeInput(input: string, expectedState?: strin
546
572
  return parseAuthorizationRedirectInput(input, expectedState).code
547
573
  }
548
574
 
575
+ type AuthorizationResponse = {
576
+ input: AuthorizationCodeInput
577
+ source: "callback" | "manual"
578
+ }
579
+
580
+ /**
581
+ * Wait for either the localhost callback or a manually pasted redirect URL.
582
+ * The manual input prompt is dismissed as soon as either path finishes.
583
+ */
584
+ export async function waitForAuthorizationResponse(
585
+ callbackPromise: Promise<AuthorizationCodeInput>,
586
+ authorizationUrl: string,
587
+ expectedState: string,
588
+ onAuthorizationInput?: AuthenticateOptions["onAuthorizationInput"],
589
+ signal?: AbortSignal,
590
+ ): Promise<AuthorizationResponse> {
591
+ if (!onAuthorizationInput) {
592
+ return {
593
+ input: await abortable(callbackPromise, signal),
594
+ source: "callback",
595
+ }
596
+ }
597
+
598
+ const inputController = new AbortController()
599
+ try {
600
+ const response = await abortable(Promise.race([
601
+ callbackPromise.then((input) => ({ input, source: "callback" as const })),
602
+ onAuthorizationInput(authorizationUrl, inputController.signal).then((input) => ({
603
+ input,
604
+ source: "manual" as const,
605
+ })),
606
+ ]), signal)
607
+
608
+ if (response.source === "callback") return response
609
+ if (!response.input?.trim()) throw new Error("OAuth authentication cancelled")
610
+ if (!getSearchParamsFromInput(response.input.trim())) {
611
+ throw new Error("Paste the full OAuth callback URL, including its code and state parameters")
612
+ }
613
+ return {
614
+ input: parseAuthorizationRedirectInput(response.input, expectedState),
615
+ source: "manual",
616
+ }
617
+ } finally {
618
+ inputController.abort()
619
+ }
620
+ }
621
+
549
622
  /**
550
623
  * Complete OAuth authentication from manual user input.
551
624
  */
@@ -706,12 +779,22 @@ export async function authenticate(
706
779
  console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
707
780
  }
708
781
 
709
- const callbackResult = await abortable(callbackPromise, signal)
782
+ const authorizationResponse = await waitForAuthorizationResponse(
783
+ callbackPromise,
784
+ authorizationUrl,
785
+ oauthState,
786
+ options.onAuthorizationInput,
787
+ signal,
788
+ )
789
+ if (authorizationResponse.source === "manual") {
790
+ cancelPendingCallback(oauthState)
791
+ }
710
792
 
711
- // The callback server accepted only the flow-local reserved state.
793
+ // The callback server accepted only the flow-local reserved state. Manual
794
+ // input is checked against the same state before token exchange.
712
795
  throwIfAborted(signal)
713
796
 
714
- return await completeAuth(serverName, callbackResult, {
797
+ return await completeAuth(serverName, authorizationResponse.input, {
715
798
  ...options,
716
799
  ...(signal ? { signal } : {}),
717
800
  runtime,
@@ -799,7 +882,7 @@ export async function getValidToken(
799
882
  authProvider.deactivate()
800
883
  }
801
884
  } catch (error) {
802
- if (isAbortError(error, signal)) throw error
885
+ if (isAbortError(error, signal) || error instanceof OAuthCredentialStoreError) throw error
803
886
  console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
804
887
  return null
805
888
  }
@@ -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.2",
3
+ "version": "2.23.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;
@@ -1112,7 +1113,7 @@ export async function executeCall(
1112
1113
  state.manager.incrementInFlight(serverName);
1113
1114
 
1114
1115
  if (toolMeta.resourceUri) {
1115
- const result = await withSessionRecovery(
1116
+ const result = await withSessionRecovery<ClientReadResourceResult>(
1116
1117
  {
1117
1118
  manager: state.manager,
1118
1119
  config: state.config,
@@ -1122,10 +1123,7 @@ export async function executeCall(
1122
1123
  serverName,
1123
1124
  (conn) => conn.client.readResource({ uri: toolMeta.resourceUri! }, requestOptions),
1124
1125
  );
1125
- const content = (result.contents ?? []).map(c => ({
1126
- type: "text" as const,
1127
- text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
1128
- }));
1126
+ const content = transformMcpResourceContents(result.contents ?? [], state.owner?.signal);
1129
1127
  const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
1130
1128
  return {
1131
1129
  content: guarded.content,
@@ -1165,7 +1163,7 @@ export async function executeCall(
1165
1163
 
1166
1164
  if (result.isError) {
1167
1165
  const mcpContent = (result.content ?? []) as McpContent[];
1168
- const content = transformMcpContent(mcpContent);
1166
+ const content = transformMcpContent(mcpContent, state.owner?.signal);
1169
1167
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1170
1168
  const schemaText = toolMeta.inputSchema ? `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` : "";
1171
1169
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed", rawMcpResult: result });
@@ -1175,7 +1173,7 @@ export async function executeCall(
1175
1173
  };
1176
1174
  }
1177
1175
 
1178
- const content = resolveMcpResultContent(result as Record<string, unknown>);
1176
+ const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
1179
1177
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1180
1178
  const uiSummary = summarizeUiSessionResult(uiSession);
1181
1179
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, suffix: `\n\n${uiSummary.message}`, rawMcpResult: result });
@@ -1194,7 +1192,7 @@ export async function executeCall(
1194
1192
 
1195
1193
  if (result.isError) {
1196
1194
  const mcpContent = (result.content ?? []) as McpContent[];
1197
- const content = transformMcpContent(mcpContent);
1195
+ const content = transformMcpContent(mcpContent, state.owner?.signal);
1198
1196
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1199
1197
  const schemaText = toolMeta.inputSchema ? `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` : "";
1200
1198
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed", rawMcpResult: result });
@@ -1204,7 +1202,7 @@ export async function executeCall(
1204
1202
  };
1205
1203
  }
1206
1204
 
1207
- const content = resolveMcpResultContent(result as Record<string, unknown>);
1205
+ const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
1208
1206
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1209
1207
  const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, rawMcpResult: result });
1210
1208
  return {
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
  }