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