pi-mcp-adapter 2.9.0 → 2.11.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 +33 -0
- package/README.md +57 -7
- package/abort.ts +34 -0
- package/cli.js +2 -1
- package/commands.ts +15 -6
- package/config.ts +13 -2
- package/direct-tools.ts +53 -33
- package/elicitation-handler.ts +252 -258
- package/error-signal.ts +21 -0
- package/index.ts +36 -7
- package/init.ts +17 -11
- package/mcp-auth-flow.ts +142 -32
- package/mcp-oauth-provider.ts +48 -1
- package/mcp-output-guard.ts +403 -0
- package/mcp-panel.ts +86 -34
- package/mcp-setup-panel.ts +13 -9
- package/metadata-cache.ts +1 -1
- package/npx-resolver.ts +4 -2
- package/package.json +6 -2
- package/panel-keys.ts +37 -0
- package/proxy-modes.ts +180 -63
- package/server-manager.ts +165 -56
- package/tool-metadata.ts +98 -34
- package/tool-registrar.ts +23 -0
- package/types.ts +20 -2
- package/ui-resource-handler.ts +2 -1
- package/ui-server.ts +1 -1
- package/ui-session.ts +2 -1
- package/utils.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.11.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Restored the tracked npm lockfile for reproducible installs and downstream packaging. Thanks @fmoda3 for issue #71.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Added default-on MCP output guarding with temp-file spillover for oversized text results, compact summaries for large proxy result details, and `settings.outputGuard` tuning. Thanks @tmustier for PR #160.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Defaulted stdio MCP servers without an explicit `cwd` to the Pi session cwd so relative server output lands in the workspace. Thanks @TimoFreiberg for PR #152.
|
|
20
|
+
- Kept multiline/control MCP panel metadata from corrupting rows and made Keep & Close save dirty changes. Thanks @gpmarques for issue/PR #14, @Vahor for issue #115, and @markokocic for issue #134/PR #135.
|
|
21
|
+
- Preserved `--` separators when resolving `npx` wrapper commands so subcommand flags are not consumed by tools like `dotenv-cli`. Thanks @sherif-fanous for issue #15.
|
|
22
|
+
- Merged partial per-server Pi overrides into imported MCP server definitions instead of replacing the full server entry. Thanks @cfbraun for issue #94.
|
|
23
|
+
- Fixed the `pi-mcp-adapter` bin entrypoint when invoked through installed symlinks, so `init` runs instead of silently exiting. Thanks @cfbraun for issue #95.
|
|
24
|
+
- Normalized direct MCP tool schemas so draft metadata and strict top-level additional properties do not break Pi registration. Thanks @marchellodev for issue #2/PR #3 and @comtihon for PR #144.
|
|
25
|
+
- Routed interactive `/mcp-auth` OAuth URLs through Pi UI notifications so long authorization links remain intact instead of being truncated by raw terminal output. Thanks @feoh for issue #147/PR #148.
|
|
26
|
+
- Respected configured HTTP headers before implicit OAuth auto-detection so API-key/custom-header MCP servers do not trigger OAuth DCR. Thanks @OnlyXianzo for issue #158.
|
|
27
|
+
- Propagated Pi abort signals into MCP connect, resource, and tool requests so cancelled calls settle promptly. Thanks @xz-dev for PR #159.
|
|
28
|
+
- Re-flagged failed MCP tool calls (`tool_error`/`call_failed`) as errors so they are recorded as failures (`isError: true`) instead of successes. Thanks @ishinder for PR #157.
|
|
29
|
+
- Honored configured `requestTimeoutMs` during MCP connection, discovery, tool, resource, and UI proxy requests. Thanks @mizuikki for PR #155.
|
|
30
|
+
- Rendered successful MCP `structuredContent` when servers return it without `content`. Thanks @dovixman for PR #146.
|
|
31
|
+
|
|
32
|
+
## [2.10.0] - 2026-06-13
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Added manual remote/headless OAuth proxy actions for copying authorization URLs and completing pasted redirect URLs or codes. Thanks @Gabrielgvl for PR #120.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- Honored user `tui.select.*` keybindings in MCP management, setup, and auth panels. Thanks @owenniles for PR #138.
|
|
39
|
+
- Included configured OAuth scopes in authorization-code flows while preserving token endpoint authentication method selection. Thanks @carlosdagos for PR #140.
|
|
40
|
+
- Fixed MCP elicitation on stock Pi, including form dialogs with validation and review, consent-based URL handling, URL-required errors, completion notifications, and TUI-only browser navigation. Thanks @dmmulroy for PR #139.
|
|
41
|
+
- Expanded MCP schema formatting for nested `anyOf`/`oneOf` variants, `const` discriminators, nested object properties, and array items.
|
|
42
|
+
|
|
10
43
|
## [2.9.0] - 2026-06-04
|
|
11
44
|
|
|
12
45
|
### Added
|
package/README.md
CHANGED
|
@@ -101,6 +101,8 @@ Use the shared MCP files when you want one setup to work across hosts, and Pi-ow
|
|
|
101
101
|
|
|
102
102
|
Pi-specific files are the write targets for imported or shared global servers when Pi needs to persist adapter-only settings such as `directTools`.
|
|
103
103
|
|
|
104
|
+
In the configuration examples below, `30000` is illustrative only. If `requestTimeoutMs` is omitted or set to `<= 0`, the MCP SDK default timeout is used.
|
|
105
|
+
|
|
104
106
|
### Server Options
|
|
105
107
|
|
|
106
108
|
```json
|
|
@@ -110,7 +112,8 @@ Pi-specific files are the write targets for imported or shared global servers wh
|
|
|
110
112
|
"command": "npx",
|
|
111
113
|
"args": ["-y", "some-mcp-server"],
|
|
112
114
|
"lifecycle": "lazy",
|
|
113
|
-
"idleTimeout": 10
|
|
115
|
+
"idleTimeout": 10,
|
|
116
|
+
"requestTimeoutMs": 30000
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
}
|
|
@@ -135,6 +138,7 @@ Pi-specific files are the write targets for imported or shared global servers wh
|
|
|
135
138
|
| `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation |
|
|
136
139
|
| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
|
|
137
140
|
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
|
141
|
+
| `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
138
142
|
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
|
139
143
|
| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
|
|
140
144
|
| `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) |
|
|
@@ -142,6 +146,26 @@ Pi-specific files are the write targets for imported or shared global servers wh
|
|
|
142
146
|
|
|
143
147
|
For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
|
|
144
148
|
|
|
149
|
+
### Remote/headless OAuth
|
|
150
|
+
|
|
151
|
+
If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
mcp({ action: "auth-start", server: "linear-server" })
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Open the returned authorization URL in your local browser. After approval, your browser redirects to a localhost URL. On a remote server that local page may fail to load; copy the full URL from the browser address bar anyway and complete the flow in the same Pi session:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
mcp({
|
|
161
|
+
action: "auth-complete",
|
|
162
|
+
server: "linear-server",
|
|
163
|
+
args: '{"redirectUrl":"http://localhost:19876/callback?code=...&state=..."}'
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
You can also pass only the `code` query parameter with `args: '{"code":"..."}'`. Treat authorization URLs and codes as sensitive; they can grant access to the MCP server until the flow expires or completes.
|
|
168
|
+
|
|
145
169
|
### Lifecycle Modes
|
|
146
170
|
|
|
147
171
|
- **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
|
|
@@ -154,7 +178,8 @@ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact c
|
|
|
154
178
|
{
|
|
155
179
|
"settings": {
|
|
156
180
|
"toolPrefix": "server",
|
|
157
|
-
"idleTimeout": 10
|
|
181
|
+
"idleTimeout": 10,
|
|
182
|
+
"requestTimeoutMs": 30000
|
|
158
183
|
},
|
|
159
184
|
"mcpServers": { }
|
|
160
185
|
}
|
|
@@ -164,19 +189,42 @@ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact c
|
|
|
164
189
|
|---------|-------------|
|
|
165
190
|
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
|
|
166
191
|
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
|
192
|
+
| `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
|
|
167
193
|
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
|
168
194
|
| `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
|
|
169
195
|
| `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
|
|
170
196
|
| `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
|
|
171
197
|
| `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
|
|
172
|
-
| `elicitation` | Allow MCP servers to request user input through Pi
|
|
173
|
-
| `
|
|
198
|
+
| `elicitation` | Allow MCP servers to request user input through Pi dialogs (default: true when Pi UI is available). |
|
|
199
|
+
| `outputGuard` | Guard oversized MCP output: `true` (default), `false`, or `{ maxBytes, maxLines, detailsMaxBytes }`. See [Output Guard](#output-guard). |
|
|
200
|
+
|
|
201
|
+
Per-server `idleTimeout` and `requestTimeoutMs` override the global settings.
|
|
202
|
+
|
|
203
|
+
### Output Guard
|
|
174
204
|
|
|
175
|
-
|
|
205
|
+
Oversized MCP tool/resource results are guarded by default so a single huge response can't blow up the model context window or the session file:
|
|
206
|
+
|
|
207
|
+
- 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.
|
|
208
|
+
- **Image content blocks pass through unchanged** — only text output is guarded. Images are delivered to the provider as native image content.
|
|
209
|
+
- 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`.
|
|
210
|
+
|
|
211
|
+
Tune the limits with the object form:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"settings": {
|
|
216
|
+
"outputGuard": { "maxBytes": 51200, "maxLines": 2000, "detailsMaxBytes": 16384 }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — to disable the guard and restore raw output behavior. Saved temp files are created with mode `0600` under the system temp directory and are not cleaned up automatically; note that spilled MCP output may contain sensitive data.
|
|
176
222
|
|
|
177
223
|
### MCP Elicitation
|
|
178
224
|
|
|
179
|
-
When Pi exposes UI, the adapter advertises
|
|
225
|
+
When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
|
|
226
|
+
|
|
227
|
+
URL mode is advertised only in TUI mode. The adapter displays the requesting server, target host, and full URL, and always requires consent before opening the browser. It also handles URL-required tool errors (`-32042`) and completion notifications; after completing the browser interaction, retry the original tool call.
|
|
180
228
|
|
|
181
229
|
### Direct Tools
|
|
182
230
|
|
|
@@ -345,6 +393,8 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
|
|
|
345
393
|
| Call | `mcp({ tool: "...", args: '{"key": "value"}' })` |
|
|
346
394
|
| Connect | `mcp({ connect: "server-name" })` |
|
|
347
395
|
| UI messages | `mcp({ action: "ui-messages" })` |
|
|
396
|
+
| Auth start | `mcp({ action: "auth-start", server: "name" })` |
|
|
397
|
+
| Auth complete | `mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' })` |
|
|
348
398
|
|
|
349
399
|
MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model.
|
|
350
400
|
|
|
@@ -367,7 +417,7 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
|
|
|
367
417
|
|
|
368
418
|
If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls automatically run OAuth when needed and retry once.
|
|
369
419
|
|
|
370
|
-
In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In
|
|
420
|
+
In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In remote/headless sessions, use the proxy tool's `auth-start` and `auth-complete` actions to copy the authorization URL locally and paste the redirect URL back into Pi. `/mcp-auth` without a server only opens a picker in the interactive UI.
|
|
371
421
|
|
|
372
422
|
## How It Works
|
|
373
423
|
|
package/abort.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function throwIfAborted(signal?: AbortSignal): void {
|
|
2
|
+
if (!signal?.aborted) return;
|
|
3
|
+
throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "MCP request aborted"));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
7
|
+
if (!signal) return promise;
|
|
8
|
+
throwIfAborted(signal);
|
|
9
|
+
return await new Promise<T>((resolve, reject) => {
|
|
10
|
+
let settled = false;
|
|
11
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
12
|
+
const onAbort = () => {
|
|
13
|
+
if (settled) return;
|
|
14
|
+
settled = true;
|
|
15
|
+
cleanup();
|
|
16
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "MCP request aborted")));
|
|
17
|
+
};
|
|
18
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
19
|
+
promise.then(
|
|
20
|
+
value => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
cleanup();
|
|
24
|
+
resolve(value);
|
|
25
|
+
},
|
|
26
|
+
error => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true;
|
|
29
|
+
cleanup();
|
|
30
|
+
reject(error);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
package/cli.js
CHANGED
|
@@ -172,7 +172,8 @@ export async function main(argv = process.argv.slice(2), log = console.log, erro
|
|
|
172
172
|
return 1;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
const
|
|
175
|
+
const resolvedEntrypoint = process.argv[1] ? fs.realpathSync(process.argv[1]) : undefined;
|
|
176
|
+
const isEntrypoint = resolvedEntrypoint && import.meta.url === pathToFileURL(resolvedEntrypoint).href;
|
|
176
177
|
|
|
177
178
|
if (isEntrypoint) {
|
|
178
179
|
main().then((code) => {
|
package/commands.ts
CHANGED
|
@@ -168,7 +168,15 @@ export async function authenticateServer(
|
|
|
168
168
|
|
|
169
169
|
try {
|
|
170
170
|
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
|
|
171
|
-
const status = await authenticate(serverName, definition.url, definition
|
|
171
|
+
const status = await authenticate(serverName, definition.url, definition, {
|
|
172
|
+
onAuthorizationUrl: (authorizationUrl) => {
|
|
173
|
+
ctx.ui.notify(
|
|
174
|
+
`Open this URL to authenticate ${serverName}:\n\n${authorizationUrl}\n\n` +
|
|
175
|
+
"After approving, return to Pi; the local callback will complete automatically.",
|
|
176
|
+
"info"
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
172
180
|
|
|
173
181
|
if (status === "authenticated") {
|
|
174
182
|
const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
|
|
@@ -286,8 +294,8 @@ export async function openMcpSetup(
|
|
|
286
294
|
|
|
287
295
|
return new Promise<PanelFlowResult>((resolve) => {
|
|
288
296
|
ctx.ui.custom(
|
|
289
|
-
(tui, _theme,
|
|
290
|
-
return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState }, tui, () => {
|
|
297
|
+
(tui, _theme, keybindings, done) => {
|
|
298
|
+
return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState, keybindings }, tui, () => {
|
|
291
299
|
done(undefined);
|
|
292
300
|
resolve({ configChanged });
|
|
293
301
|
});
|
|
@@ -358,7 +366,7 @@ export async function openMcpPanel(
|
|
|
358
366
|
|
|
359
367
|
await new Promise<void>((resolve) => {
|
|
360
368
|
ctx.ui.custom(
|
|
361
|
-
(tui, _theme,
|
|
369
|
+
(tui, _theme, keybindings, done) => {
|
|
362
370
|
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
|
363
371
|
if (!result.cancelled && result.changes.size > 0) {
|
|
364
372
|
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
|
@@ -367,7 +375,7 @@ export async function openMcpPanel(
|
|
|
367
375
|
}
|
|
368
376
|
done(undefined);
|
|
369
377
|
resolve();
|
|
370
|
-
}, { noticeLines });
|
|
378
|
+
}, { noticeLines, keybindings });
|
|
371
379
|
},
|
|
372
380
|
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
|
373
381
|
);
|
|
@@ -403,12 +411,13 @@ export async function openMcpAuthPanel(
|
|
|
403
411
|
|
|
404
412
|
await new Promise<void>((resolve) => {
|
|
405
413
|
ctx.ui.custom(
|
|
406
|
-
(tui, _theme,
|
|
414
|
+
(tui, _theme, keybindings, done) => {
|
|
407
415
|
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, () => {
|
|
408
416
|
done(undefined);
|
|
409
417
|
resolve();
|
|
410
418
|
}, {
|
|
411
419
|
authOnly: true,
|
|
420
|
+
keybindings,
|
|
412
421
|
noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."],
|
|
413
422
|
});
|
|
414
423
|
},
|
package/config.ts
CHANGED
|
@@ -250,12 +250,23 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
|
|
|
250
250
|
|
|
251
251
|
function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
|
|
252
252
|
return {
|
|
253
|
-
mcpServers:
|
|
253
|
+
mcpServers: mergeServerMaps(base.mcpServers, next.mcpServers),
|
|
254
254
|
imports: mergeImports(base.imports, next.imports),
|
|
255
255
|
settings: next.settings ? { ...base.settings, ...next.settings } : base.settings,
|
|
256
256
|
};
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
function mergeServerMaps(
|
|
260
|
+
base: Record<string, ServerEntry>,
|
|
261
|
+
next: Record<string, ServerEntry>,
|
|
262
|
+
): Record<string, ServerEntry> {
|
|
263
|
+
const merged = { ...base };
|
|
264
|
+
for (const [name, definition] of Object.entries(next)) {
|
|
265
|
+
merged[name] = { ...(merged[name] ?? {}), ...definition };
|
|
266
|
+
}
|
|
267
|
+
return merged;
|
|
268
|
+
}
|
|
269
|
+
|
|
259
270
|
function mergeImports(left: ImportKind[] | undefined, right: ImportKind[] | undefined): ImportKind[] | undefined {
|
|
260
271
|
const merged = [...(left ?? []), ...(right ?? [])];
|
|
261
272
|
if (merged.length === 0) return undefined;
|
|
@@ -286,7 +297,7 @@ function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig {
|
|
|
286
297
|
return {
|
|
287
298
|
imports: config.imports,
|
|
288
299
|
settings: config.settings,
|
|
289
|
-
mcpServers:
|
|
300
|
+
mcpServers: mergeServerMaps(importedServers, config.mcpServers),
|
|
290
301
|
};
|
|
291
302
|
}
|
|
292
303
|
|
package/direct-tools.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
|
2
3
|
import type { McpExtensionState } from "./state.ts";
|
|
3
4
|
import type { DirectToolSpec, McpConfig, McpContent } from "./types.ts";
|
|
4
5
|
import type { MetadataCache } from "./metadata-cache.ts";
|
|
5
6
|
import { lazyConnect, getFailureAgeSeconds } from "./init.ts";
|
|
7
|
+
import { abortable, throwIfAborted } from "./abort.ts";
|
|
6
8
|
import { isServerCacheValid } from "./metadata-cache.ts";
|
|
7
9
|
import { formatSchema } from "./tool-metadata.ts";
|
|
8
|
-
import { transformMcpContent } from "./tool-registrar.ts";
|
|
10
|
+
import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
|
|
11
|
+
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
9
12
|
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
|
10
13
|
import { formatToolName, isToolExcluded } from "./types.ts";
|
|
11
14
|
import { resourceNameToToolName } from "./resource-tools.ts";
|
|
@@ -22,7 +25,7 @@ type DirectAutoAuthResult =
|
|
|
22
25
|
function getDirectAuthRequiredMessage(
|
|
23
26
|
state: McpExtensionState,
|
|
24
27
|
serverName: string,
|
|
25
|
-
defaultMessage = `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName}
|
|
28
|
+
defaultMessage = `MCP server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
|
26
29
|
): string {
|
|
27
30
|
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
|
28
31
|
}
|
|
@@ -32,7 +35,7 @@ function getDirectAuthFailedMessage(state: McpExtensionState, serverName: string
|
|
|
32
35
|
if (customGuidance) {
|
|
33
36
|
return `OAuth authentication failed for "${serverName}": ${message}. ${getDirectAuthRequiredMessage(state, serverName)}`;
|
|
34
37
|
}
|
|
35
|
-
return `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName}
|
|
38
|
+
return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
async function attemptDirectAutoAuth(
|
|
@@ -55,7 +58,7 @@ async function attemptDirectAutoAuth(
|
|
|
55
58
|
message: getDirectAuthRequiredMessage(
|
|
56
59
|
state,
|
|
57
60
|
serverName,
|
|
58
|
-
`MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`,
|
|
61
|
+
`MCP server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
|
59
62
|
),
|
|
60
63
|
};
|
|
61
64
|
}
|
|
@@ -255,7 +258,9 @@ export function buildProxyDescription(
|
|
|
255
258
|
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
|
256
259
|
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
|
257
260
|
desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n`;
|
|
258
|
-
desc +=
|
|
261
|
+
desc += ` mcp({ action: "auth-start", server: "name" }) → Start manual OAuth and get a browser URL\n`;
|
|
262
|
+
desc += ` mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' }) → Complete manual OAuth\n`;
|
|
263
|
+
desc += `\nMode: action > tool (call) > connect > describe > search > server (list) > nothing (status)`;
|
|
259
264
|
|
|
260
265
|
return desc;
|
|
261
266
|
}
|
|
@@ -273,7 +278,8 @@ export function createDirectToolExecutor(
|
|
|
273
278
|
getInitPromise: () => Promise<McpExtensionState> | null,
|
|
274
279
|
spec: DirectToolSpec
|
|
275
280
|
): DirectToolExecute {
|
|
276
|
-
return async function execute(_toolCallId, params) {
|
|
281
|
+
return async function execute(_toolCallId, params, signal) {
|
|
282
|
+
throwIfAborted(signal);
|
|
277
283
|
let state = getState();
|
|
278
284
|
const initPromise = getInitPromise();
|
|
279
285
|
|
|
@@ -295,7 +301,7 @@ export function createDirectToolExecutor(
|
|
|
295
301
|
};
|
|
296
302
|
}
|
|
297
303
|
|
|
298
|
-
let connected = await lazyConnect(state, spec.serverName);
|
|
304
|
+
let connected = await lazyConnect(state, spec.serverName, signal);
|
|
299
305
|
let autoAuthAttempted = false;
|
|
300
306
|
|
|
301
307
|
if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
|
|
@@ -310,7 +316,7 @@ export function createDirectToolExecutor(
|
|
|
310
316
|
if (autoAuth.status === "success") {
|
|
311
317
|
await state.manager.close(spec.serverName);
|
|
312
318
|
state.failureTracker.delete(spec.serverName);
|
|
313
|
-
connected = await lazyConnect(state, spec.serverName);
|
|
319
|
+
connected = await lazyConnect(state, spec.serverName, signal);
|
|
314
320
|
}
|
|
315
321
|
}
|
|
316
322
|
|
|
@@ -339,20 +345,24 @@ export function createDirectToolExecutor(
|
|
|
339
345
|
}
|
|
340
346
|
|
|
341
347
|
let uiSession: UiSessionRuntime | null = null;
|
|
348
|
+
const requestOptions = state.manager.getRequestOptions?.(spec.serverName, signal) ?? (signal ? { signal } : undefined);
|
|
349
|
+
|
|
350
|
+
const outputGuardOptions = resolveMcpOutputGuardOptions(state.config.settings);
|
|
342
351
|
|
|
343
352
|
try {
|
|
344
353
|
state.manager.touch(spec.serverName);
|
|
345
354
|
state.manager.incrementInFlight(spec.serverName);
|
|
346
355
|
|
|
347
356
|
if (spec.resourceUri) {
|
|
348
|
-
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
|
357
|
+
const result = await connection.client.readResource({ uri: spec.resourceUri }, requestOptions);
|
|
349
358
|
const content = (result.contents ?? []).map(c => ({
|
|
350
359
|
type: "text" as const,
|
|
351
360
|
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
352
361
|
}));
|
|
362
|
+
const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
|
|
353
363
|
return {
|
|
354
|
-
content:
|
|
355
|
-
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
|
364
|
+
content: guarded.content,
|
|
365
|
+
details: { server: spec.serverName, resourceUri: spec.resourceUri, ...guardedMcpDetails(guarded) },
|
|
356
366
|
};
|
|
357
367
|
}
|
|
358
368
|
|
|
@@ -371,50 +381,60 @@ export function createDirectToolExecutor(
|
|
|
371
381
|
name: spec.originalName,
|
|
372
382
|
arguments: params ?? {},
|
|
373
383
|
_meta: uiSession?.requestMeta,
|
|
374
|
-
});
|
|
384
|
+
}, undefined, requestOptions);
|
|
375
385
|
|
|
376
|
-
const result = await resultPromise;
|
|
386
|
+
const result = await abortable(resultPromise, signal);
|
|
377
387
|
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
|
378
388
|
|
|
379
|
-
const mcpContent = (result.content ?? []) as McpContent[];
|
|
380
|
-
const content = transformMcpContent(mcpContent);
|
|
381
|
-
|
|
382
389
|
if (result.isError) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
390
|
+
const mcpContent = (result.content ?? []) as McpContent[];
|
|
391
|
+
const content = transformMcpContent(mcpContent);
|
|
392
|
+
const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
|
|
393
|
+
const schemaText = spec.inputSchema ? `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` : "";
|
|
394
|
+
const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, prefix: "Error: ", suffix: schemaText, emptyTextFallback: "Tool execution failed" });
|
|
387
395
|
return {
|
|
388
|
-
content:
|
|
389
|
-
details: { error: "tool_error", server: spec.serverName },
|
|
396
|
+
content: guarded.content,
|
|
397
|
+
details: { error: "tool_error", server: spec.serverName, ...guardedMcpDetails(guarded) },
|
|
390
398
|
};
|
|
391
399
|
}
|
|
392
400
|
|
|
393
|
-
const
|
|
401
|
+
const content = resolveMcpResultContent(result as Record<string, unknown>);
|
|
402
|
+
const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
|
|
394
403
|
if (hasUi) {
|
|
395
404
|
const uiMessage = uiSession?.reused
|
|
396
405
|
? "Updated the open UI."
|
|
397
406
|
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
|
407
|
+
const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, suffix: `\n\n${uiMessage}` });
|
|
398
408
|
return {
|
|
399
|
-
content:
|
|
400
|
-
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true },
|
|
409
|
+
content: guarded.content,
|
|
410
|
+
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true, ...guardedMcpDetails(guarded) },
|
|
401
411
|
};
|
|
402
412
|
}
|
|
403
413
|
|
|
414
|
+
const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions });
|
|
404
415
|
return {
|
|
405
|
-
content:
|
|
406
|
-
details: { server: spec.serverName, tool: spec.originalName },
|
|
416
|
+
content: guarded.content,
|
|
417
|
+
details: { server: spec.serverName, tool: spec.originalName, ...guardedMcpDetails(guarded) },
|
|
407
418
|
};
|
|
408
419
|
} catch (error) {
|
|
420
|
+
if (error instanceof UrlElicitationRequiredError) {
|
|
421
|
+
const action = await state.manager.handleUrlElicitationRequired(spec.serverName, error);
|
|
422
|
+
const message = action === "accept"
|
|
423
|
+
? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
|
|
424
|
+
: `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
|
|
425
|
+
uiSession?.sendToolCancelled(message);
|
|
426
|
+
return {
|
|
427
|
+
content: [{ type: "text" as const, text: message }],
|
|
428
|
+
details: { error: "url_elicitation_required", server: spec.serverName, action },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
409
431
|
const message = error instanceof Error ? error.message : String(error);
|
|
410
432
|
uiSession?.sendToolCancelled(message);
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
414
|
-
}
|
|
433
|
+
const schemaText = spec.inputSchema ? `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` : "";
|
|
434
|
+
const guarded = await guardMcpOutput([{ type: "text" as const, text: message }], { ...outputGuardOptions, prefix: "Failed to call tool: ", suffix: schemaText });
|
|
415
435
|
return {
|
|
416
|
-
content:
|
|
417
|
-
details: { error: "call_failed", server: spec.serverName },
|
|
436
|
+
content: guarded.content,
|
|
437
|
+
details: { error: "call_failed", server: spec.serverName, ...guardedMcpDetails(guarded) },
|
|
418
438
|
};
|
|
419
439
|
} finally {
|
|
420
440
|
if (uiSession?.reused) {
|