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 +19 -0
- package/README.md +19 -4
- package/agent-dir.ts +40 -0
- package/commands.ts +12 -2
- package/direct-tools.ts +6 -8
- package/index.ts +15 -3
- package/mcp-auth-flow.ts +87 -4
- package/mcp-callback-server.ts +138 -61
- package/mcp-oauth-provider.ts +43 -4
- package/oauth.ts +48 -0
- package/package.json +8 -2
- package/proxy-modes.ts +8 -10
- package/tool-registrar.ts +187 -3
- package/types.ts +2 -0
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
|
|
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
|
|
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,
|
|
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 ?? [])
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
}
|
package/mcp-callback-server.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
<
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
<
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
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, "'")
|
|
65
127
|
}
|
|
66
128
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
</
|
|
86
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
282
|
+
res.end(htmlSuccess())
|
|
206
283
|
}
|
|
207
284
|
|
|
208
285
|
/**
|
package/mcp-oauth-provider.ts
CHANGED
|
@@ -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 ??
|
|
200
|
-
client_uri: this.
|
|
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 ??
|
|
215
|
-
client_uri: this.
|
|
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.
|
|
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 ?? [])
|
|
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
|
|
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
|
|
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
|
}
|