pi-mcp-adapter 2.21.2 → 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 +10 -0
- package/README.md +16 -3
- package/agent-dir.ts +40 -0
- package/direct-tools.ts +6 -8
- package/index.ts +2 -0
- package/mcp-auth-flow.ts +23 -1
- 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,16 @@ 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
|
+
|
|
10
20
|
## [2.21.2] - 2026-08-09
|
|
11
21
|
|
|
12
22
|
### 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"` |
|
|
@@ -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/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")
|
|
@@ -799,7 +821,7 @@ export async function getValidToken(
|
|
|
799
821
|
authProvider.deactivate()
|
|
800
822
|
}
|
|
801
823
|
} catch (error) {
|
|
802
|
-
if (isAbortError(error, signal)) throw error
|
|
824
|
+
if (isAbortError(error, signal) || error instanceof OAuthCredentialStoreError) throw error
|
|
803
825
|
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
|
|
804
826
|
return null
|
|
805
827
|
}
|
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;
|
|
@@ -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
|
}
|