feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
12
agent/extensions/pi-mcp-adapter/.gitignore
vendored
Normal file
12
agent/extensions/pi-mcp-adapter/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.pi/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
package-lock.json
|
||||
progress.md
|
||||
worker-summary.md
|
||||
worker-*.md
|
||||
plan.md
|
||||
triage-*.md
|
||||
review-*.md
|
||||
5
agent/extensions/pi-mcp-adapter/.npmignore
Normal file
5
agent/extensions/pi-mcp-adapter/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
tsconfig.json
|
||||
.git
|
||||
.gitignore
|
||||
384
agent/extensions/pi-mcp-adapter/CHANGELOG.md
Normal file
384
agent/extensions/pi-mcp-adapter/CHANGELOG.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.10.0] - 2026-06-13
|
||||
|
||||
### Added
|
||||
- Added manual remote/headless OAuth proxy actions for copying authorization URLs and completing pasted redirect URLs or codes. Thanks @Gabrielgvl for PR #120.
|
||||
|
||||
### Fixed
|
||||
- Honored user `tui.select.*` keybindings in MCP management, setup, and auth panels. Thanks @owenniles for PR #138.
|
||||
- Included configured OAuth scopes in authorization-code flows while preserving token endpoint authentication method selection. Thanks @carlosdagos for PR #140.
|
||||
- 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.
|
||||
- Expanded MCP schema formatting for nested `anyOf`/`oneOf` variants, `const` discriminators, nested object properties, and array items.
|
||||
|
||||
## [2.9.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- Added MCP elicitation support with Pi form prompts and browser-opening URL requests.
|
||||
|
||||
### Fixed
|
||||
- Rejected non-http/https MCP URL elicitations before prompting or opening a browser.
|
||||
- Preserved empty string form values for MCP string elicitations unless schema constraints reject them.
|
||||
|
||||
## [2.8.0] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- Added per-server OAuth `redirectUri`, `clientName`, and `clientUri` overrides for pre-registered callbacks and dynamic client metadata.
|
||||
|
||||
### Fixed
|
||||
- Avoided OAuth callback port exhaustion by starting the callback server lazily and using OS-assigned ports for dynamic OAuth flows.
|
||||
- Re-register dynamic OAuth clients before browser auth when cached redirect URI metadata is missing or no longer matches the active callback URI.
|
||||
|
||||
## [2.7.0] - 2026-05-22
|
||||
|
||||
### Added
|
||||
- Added TUI call rendering for MCP proxy and direct tool inputs. Thanks @dmmulroy for PR #102.
|
||||
|
||||
### Fixed
|
||||
- Hardened OAuth credential storage paths against server-name path traversal without rejecting valid configured server names.
|
||||
- Rejected unsafe regex-mode MCP search patterns before executing them.
|
||||
|
||||
## [2.6.1] - 2026-05-13
|
||||
|
||||
### Added
|
||||
- Added `/mcp logout <server>` to clear stored OAuth credentials and disconnect the server. Thanks @mattzcarey for PR #96.
|
||||
|
||||
### Fixed
|
||||
- Cancel pending OAuth callbacks when logging out of an MCP server.
|
||||
|
||||
## [2.6.0] - 2026-05-10
|
||||
|
||||
### Added
|
||||
- Added a no-argument `/mcp-auth` OAuth picker and in-panel auth shortcut for OAuth-capable MCP servers.
|
||||
- Added compact collapsed rendering for MCP proxy and direct-tool result rows while keeping full tool results available when expanded.
|
||||
|
||||
### Changed
|
||||
- Migrated Pi runtime dependencies and imports from deprecated `@mariozechner/*` packages to `@earendil-works/*` packages.
|
||||
|
||||
### Fixed
|
||||
- Re-register dynamic OAuth clients during fresh auth when cached DCR client info exists without tokens, avoiding dead authorization URLs after server-side client invalidation.
|
||||
- Kept OAuth tokens, dynamic client info, PKCE verifiers, and OAuth state bound to the server URL so stale credentials cannot be reused after a server URL changes.
|
||||
- Kept the `/mcp-auth` OAuth picker search focused on OAuth server rows and prevented hidden panel shortcuts from unexpectedly launching auth.
|
||||
- Kept long MCP error results expanded in compact tool result rendering.
|
||||
|
||||
## [2.5.4] - 2026-05-04
|
||||
|
||||
### Changed
|
||||
- Ignored npm lockfiles and removed checked-in `package-lock.json` files.
|
||||
|
||||
### Fixed
|
||||
- Resolved `${VAR}` and `$env:VAR` placeholders in HTTP bearer tokens.
|
||||
- Honored MCP sampling `modelPreferences.hints` before falling back to the current/default model.
|
||||
|
||||
## [2.5.3] - 2026-05-01
|
||||
|
||||
### Added
|
||||
- Added environment variable and `~` expansion for stdio server `cwd` values.
|
||||
|
||||
## [2.5.2] - 2026-04-29
|
||||
|
||||
### Fixed
|
||||
- Respected `PI_CODING_AGENT_DIR` for Pi-owned MCP config and state files, including metadata cache, npx cache, onboarding state, OAuth credentials, and `pi-mcp-adapter init` writes.
|
||||
|
||||
## [2.5.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Changed OAuth browser callbacks to `http://localhost:<port>/callback` for pre-registered clients such as Slack MCP. Thanks @shenal for PR #53.
|
||||
|
||||
## [2.5.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- Added MCP `sampling/createMessage` support with conservative human approval by default and opt-in `settings.samplingAutoApprove` for non-interactive flows.
|
||||
- Added configured Vitest coverage for OAuth provider authorization fallback behavior.
|
||||
- Added `test:oauth-provider` for running the root OAuth provider node test with the required TypeScript loader.
|
||||
|
||||
### Fixed
|
||||
- Applied `settings.authRequiredMessage` to proxy and direct-tool auth-required paths, including non-UI `autoAuth` failures.
|
||||
- Fixed `/mcp-auth <server>` reporting success for expired stored OAuth tokens without forcing the SDK refresh/re-auth flow.
|
||||
- Kept `mcp` search focused on MCP tools and added a direct-call hint when native Pi tools are accidentally routed through the proxy.
|
||||
|
||||
## [2.4.2] - 2026-04-22
|
||||
|
||||
### Fixed
|
||||
- Migrated extension tool schemas from `@sinclair/typebox` to `typebox` 1.x so packaged installs follow Pi's current extension runtime contract.
|
||||
|
||||
### Changed
|
||||
- Replaced the legacy `@sinclair/typebox` runtime dependency with `typebox`.
|
||||
|
||||
## [2.4.1] - 2026-04-22
|
||||
|
||||
### Added
|
||||
- Added standard-MCP-first config discovery: `~/.config/mcp/mcp.json` and project `.mcp.json` now load automatically, with Pi-owned files preserved as override layers.
|
||||
- Added `pi-mcp-adapter init` as a native post-install helper that detects host-specific MCP configs and scaffolds Pi compatibility imports without using the old raw GitHub downloader flow.
|
||||
- Added first-run onboarding inside the extension: `/mcp` now shows shared-config hints or actionable empty states, and `/mcp setup` opens a guided setup flow for compatibility imports, minimal `.mcp.json` scaffolding, detected config paths, RepoPrompt quick-add, and exact before/after write previews.
|
||||
- Added automatic Pi-core reload after setup or direct-tool config changes, using the same flow as `/reload` so freshly configured direct tools can appear without a manual restart.
|
||||
- Added a dedicated Pi-owned onboarding state file so shared-config hints behave as one-time guidance instead of repeating every session.
|
||||
|
||||
### Changed
|
||||
- Updated config precedence to prefer shared MCP files first, then Pi overrides, with `.pi/mcp.json` acting as the final Pi-specific project override.
|
||||
- Updated Claude Code compatibility probing to prefer modern Claude MCP config locations before legacy paths.
|
||||
- Updated project scaffolding so generated `.mcp.json` files are safe minimal shells instead of fake placeholder servers that fail on first reload.
|
||||
- Updated the setup panel and README for clearer first-run guidance, improved spacing, and a more digestible shared-MCP-first setup story.
|
||||
|
||||
## [2.4.0] - 2026-04-13
|
||||
|
||||
### Added
|
||||
- `settings.disableProxyTool` to hide the `mcp` proxy tool once configured direct tools are fully available from cache. Thanks @tanavamsikrishna for PR #41.
|
||||
- Per-server `excludeTools` to hide specific MCP tools/resources by original or prefixed name across direct tools, proxy discovery, and the `/mcp` panel. Thanks @ahmadaccino for issue #36.
|
||||
- `settings.autoAuth` to optionally trigger OAuth automatically from proxy/direct tool usage, then rerun the original blocked connect/tool operation once after authentication succeeds. Thanks @unimonkiez for issue #34.
|
||||
|
||||
### Fixed
|
||||
- Regenerated `package-lock.json` so the root lockfile metadata matches `package.json` again, including the declared `open`, `@types/bun`, `@types/open`, and `tsx` entries.
|
||||
- Kept the `mcp` proxy tool available as a first-session fallback when configured direct tools are still missing cache metadata, avoiding no-tool startup gaps.
|
||||
|
||||
## [2.3.5] - 2026-04-13
|
||||
|
||||
### Fixed
|
||||
- Session lifecycle now always tears down OAuth callback state on restart and shutdown, preventing callback-server leaks across session transitions.
|
||||
- OAuth callback server now calls `unref()` after successful bind so it no longer keeps sub-agent processes alive by itself.
|
||||
- Strict OAuth port mode now rebinds to the configured callback port when safe, while refusing to switch ports when authorizations are still pending.
|
||||
- Added focused lifecycle/callback-server regression coverage for teardown, `unref()`, strict rebinding, and pending-auth guardrails.
|
||||
- Thanks @blai for the investigation and PR #43 that surfaced the sub-agent hang/root lifecycle issues.
|
||||
|
||||
## [2.3.4] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- OAuth callback handling now allows dynamic-registration flows to fall back to a free local port when the preferred callback port is busy, while keeping pre-registered clients on their exact configured redirect port.
|
||||
- Documented the new callback-port behavior and added focused auth-flow regression coverage.
|
||||
|
||||
## [2.3.3] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Remove the blank footer status line when no MCP servers are configured by clearing the MCP status entry instead of setting it to an empty string. Thanks @HazAT for PR #27.
|
||||
|
||||
## [2.3.2] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- Optional `oauth.grantType: "client_credentials"` for non-interactive machine-to-machine OAuth on HTTP MCP servers.
|
||||
|
||||
### Fixed
|
||||
- `/mcp-auth <server>` now handles `client_credentials` without browser/callback flow.
|
||||
- MCP panel status no longer marks `client_credentials` servers as auth-blocked solely because no stored user tokens exist yet.
|
||||
- OAuth auth flow now closes temporary transports consistently on success, refresh, and auth removal paths.
|
||||
- Init paths now preserve debug-level context for previously silent direct-tool bootstrap and lazy-connect failures.
|
||||
|
||||
## [2.3.1] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
- Removed `/mcp-auth-callback`. OAuth auth now hard-cuts to `/mcp-auth <server>` only.
|
||||
|
||||
## [2.3.0] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- OAuth callback server initialization on session start and a deprecated `/mcp-auth-callback` command that now points users to `/mcp-auth <server>`.
|
||||
|
||||
### Fixed
|
||||
- OAuth `needs-auth` handling across `/mcp` status/panel, `mcp({ connect })`, `mcp({ tool })`, reconnect flow, lazy/direct tool execution, and startup bootstrap.
|
||||
- OAuth callback cleanup now cancels by stored OAuth state and closes pending transports on failure/cancel paths.
|
||||
- Callback server now fails fast when the OAuth callback port is occupied by another process.
|
||||
- Package manifest test now ignores root `*.test.ts` files.
|
||||
|
||||
## [2.2.2] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Session lifecycle teardown now handles repeated `session_start` transitions safely and prevents stale async init results from replacing newer state.
|
||||
- Shutdown now still runs `gracefulShutdown()` even if metadata cache flushing throws, avoiding leaked MCP processes.
|
||||
- Proxy/direct tool init error paths now preserve and surface underlying error messages instead of returning generic failures.
|
||||
- Invalid `mcp` tool `args` now fail by throwing with parse/type context instead of returning non-failing tool payloads.
|
||||
- Added focused lifecycle regressions tests for stale init cleanup and init-error visibility.
|
||||
|
||||
## [2.2.1] - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
- Added `promptSnippet` to MCP proxy tool and direct MCP tools so they appear in the system prompt's Available tools section (required since pi 0.59.0)
|
||||
|
||||
## [2.2.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
- **MCP UI Integration** - Support for the [MCP UI](https://github.com/MCP-UI-Org/mcp-ui) standard. Tools with `_meta.ui.resourceUri` open interactive UIs:
|
||||
- Bidirectional AppBridge communication (tool calls, messages, context updates)
|
||||
- Works with both stdio and HTTP MCP servers
|
||||
- User consent management for tool calls from UI (configurable: never/once-per-server/always)
|
||||
- Keyboard shortcuts: Cmd/Ctrl+Enter to complete, Escape to cancel
|
||||
- UI prompts/intents trigger agent turns via `pi.sendMessage({ triggerTurn: true })`
|
||||
- `mcp({ action: "ui-messages" })` retrieves accumulated messages from UI sessions
|
||||
|
||||
- **Session reuse** - When the agent calls the same tool while its UI is already open, results push to the existing window instead of replacing it. Per-call stream IDs with independent sequences. Error results scoped to the individual call.
|
||||
|
||||
- **Glimpse integration** - MCP UI opens in a native macOS WKWebView window instead of a browser tab when [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`). Falls back to browser on non-macOS or when unavailable. Override with `MCP_UI_VIEWER=browser` or `MCP_UI_VIEWER=glimpse`.
|
||||
|
||||
- **Logger module** (`logger.ts`) - Centralized logging with levels (debug/info/warn/error), contextual child loggers, and `MCP_UI_DEBUG=1` env var.
|
||||
|
||||
- **Error types** (`errors.ts`) - Structured errors with recovery hints: `ResourceFetchError`, `ResourceParseError`, `BridgeConnectionError`, `ConsentError`, `SessionError`, `ServerError`, and `wrapError()` helper.
|
||||
|
||||
- **Test suite** - 178 tests covering consent manager, UI resource handler, host HTML template, logger, and error types.
|
||||
|
||||
- **Interactive visualizer example** (`examples/interactive-visualizer`) - Minimal MCP server demonstrating charts (bar/line/pie/doughnut via Chart.js), bidirectional messaging, and streaming.
|
||||
|
||||
### Fixed
|
||||
- Host-iframe timing: bridge now connects before loading iframe, fixing `ui/initialize` timeout on first load
|
||||
- All internal `log.info` calls demoted to `log.debug` to eliminate stdout noise during normal use
|
||||
|
||||
### Technical Notes
|
||||
- Uses local minified AppBridge bundle (408KB) to avoid CDN Zod bundling issues
|
||||
- Serves app HTML from `/ui-app` endpoint instead of blob URLs to avoid iframe issues
|
||||
- SSE for real-time tool result streaming to browser
|
||||
|
||||
## [2.1.2] - 2026-02-03
|
||||
|
||||
### Changed
|
||||
- Added demo video and `pi.video` field to package.json for pi package browser.
|
||||
|
||||
## [2.1.0] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- **Direct tool registration** - Promote specific MCP tools to first-class Pi tools via `directTools` config (per-server or global). Direct tools appear in the agent's tool list alongside builtins, so the LLM uses them without needing to search through the proxy first. Registers from cached metadata at startup — no server connections needed.
|
||||
- **`/mcp` interactive panel** - New TUI overlay replacing the text-based status dump. Shows server connection status, tool lists with direct/proxy toggles, token cost estimates, inline reconnect, and auth notices. Changes written to config on save.
|
||||
- **Auto-enriched proxy description** - The `mcp` proxy tool description now includes server names and tool counts from the metadata cache, so the LLM knows what's available without a search call (~30 extra tokens).
|
||||
- **`MCP_DIRECT_TOOLS` env var** - Subagent processes receive their direct tool configuration via environment variable, keeping subagents lean by default.
|
||||
- **First-run bootstrap** - Servers with `directTools` configured but no cache entry are connected during `session_start` to populate the cache. Direct tools become available after restart.
|
||||
- Config provenance tracking for correct write-back to user/project/import sources
|
||||
- Builtin name collision guard (skips direct tools that would shadow `read`, `write`, etc.)
|
||||
- Cross-server name deduplication for `prefix: "none"` and `prefix: "short"` modes
|
||||
|
||||
## [2.0.1] - 2026-02-01
|
||||
|
||||
### Fixed
|
||||
- Adapt execute signature to pi v0.51.0: add signal, onUpdate, ctx parameters
|
||||
|
||||
## [2.0.0] - 2026-01-29
|
||||
|
||||
### Changed
|
||||
- **BREAKING: Lazy startup by default** - All servers now default to `lifecycle: "lazy"` and only connect when a tool call needs them. Previously all servers connected eagerly on session start. Set `lifecycle: "keep-alive"` or `lifecycle: "eager"` to restore the old behavior per-server.
|
||||
- **Idle timeout** - Connected servers are automatically disconnected after 10 minutes of inactivity (configurable via `settings.idleTimeout` or per-server `idleTimeout`). Cached metadata keeps search/list working after disconnect. Set `idleTimeout: 0` to disable.
|
||||
- `/mcp reconnect` accepts an optional server name to connect or reconnect a single server
|
||||
|
||||
### Added
|
||||
- **Metadata cache** - Tool and resource metadata persisted to `~/.pi/agent/mcp-cache.json`. Enables search/list/describe without live connections. Per-server config hashing with 7-day staleness. Multi-session safe via read-merge-write with per-process tmp files.
|
||||
- **npx binary resolution** - Resolves npx package binaries to direct paths, eliminating the ~143 MB npm parent process per server. Persistent cache at `~/.pi/agent/mcp-npx-cache.json` with 24h TTL.
|
||||
- **`mcp({ connect: "server-name" })` mode** - Explicitly trigger connection and metadata refresh for a named server
|
||||
- **Failure backoff** - Servers that fail to connect are skipped for 60 seconds to avoid repeated connection storms
|
||||
- **In-flight tracking** - Active tool calls prevent idle timeout from shutting down a server mid-request
|
||||
- **Prefix-match fallback** - Tool calls with unrecognized names try to match a server prefix and lazy-connect the matching server
|
||||
- Lifecycle options: `lazy` (default), `eager` (connect at startup, no auto-reconnect), `keep-alive` (unchanged)
|
||||
- Per-server `idleTimeout` override and global `settings.idleTimeout`
|
||||
- First-run bootstrap: connects all servers on first session to populate the cache
|
||||
|
||||
### Fixed
|
||||
- Connection close race condition: concurrent close + connect no longer orphans server processes
|
||||
- **Fuzzy tool name matching** - Hyphens and underscores are treated as equivalent during tool lookup. MCP tools like `resolve-library-id` are now found when called as `resolve_library_id`, which LLMs naturally guess since the prefix separator is `_`.
|
||||
- **Better "tool not found" errors** - When a server is identified (via prefix match or override) but the tool isn't found, the error now lists that server's available tools so the LLM can self-correct immediately instead of needing a separate list call
|
||||
|
||||
## [1.6.0] - 2026-01-29
|
||||
|
||||
### Added
|
||||
- **Unified pi tool search** - `mcp({ search: "..." })` now searches both MCP tools and Pi tools (from installed extensions)
|
||||
- Pi tools appear first in results with `[pi tool]` prefix
|
||||
- Details object includes `server: "pi"` for pi tools
|
||||
- Banner image for README
|
||||
|
||||
## [1.5.1] - 2026-01-26
|
||||
|
||||
### Changed
|
||||
- Added `pi-package` keyword for npm discoverability (pi v0.50.0 package system)
|
||||
|
||||
## [1.5.0] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
- **BREAKING: `args` parameter is now a JSON string** - The `args` parameter which previously accepted an object now accepts a JSON string. This change was required for compatibility with Claude's Vertex AI API (`google-antigravity` provider) which rejects `patternProperties` in JSON schemas (generated by `Type.Record()`).
|
||||
|
||||
### Added
|
||||
- **Type validation for args** - Parsed args are now validated to ensure they're a JSON object (not null, array, or primitive). Clear error messages for invalid input.
|
||||
- **`isError: true` on error responses** - JSON parse errors and type validation errors now properly set `isError: true` to indicate failure to the LLM.
|
||||
|
||||
### Migration
|
||||
```typescript
|
||||
// Before (1.4.x)
|
||||
mcp({ tool: "my_tool", args: { key: "value" } })
|
||||
|
||||
// After (1.5.0)
|
||||
mcp({ tool: "my_tool", args: '{"key": "value"}' })
|
||||
```
|
||||
|
||||
## [1.4.1] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Status bar shows server count instead of tool count ("MCP: 5 servers")
|
||||
|
||||
## [1.4.0] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Non-blocking startup** - Pi starts immediately, MCP servers connect in background. First MCP call waits only if init isn't done yet.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tool metadata now includes `inputSchema` after `/mcp reconnect` (was missing, breaking describe and error hints)
|
||||
|
||||
## [1.3.0] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Parallel server connections** - All MCP servers now connect in parallel on startup instead of sequentially, significantly faster with many servers
|
||||
|
||||
## [1.2.2] - 2026-01-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Installer now downloads from `main` branch (renamed from `master`)
|
||||
|
||||
## [1.2.1] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **npx installer** - Run `npx pi-mcp-adapter` to install (downloads files, installs deps, configures settings.json)
|
||||
|
||||
## [1.1.0] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Search includes schemas by default** - Search results now include parameter schemas, reducing tool calls needed (search + call instead of search + describe + call)
|
||||
- **Space-separated search terms match as OR** - `"navigate screenshot"` finds tools matching either word (like most search engines)
|
||||
- **Suppress server stderr by default** - MCP server logs no longer clutter terminal on startup
|
||||
- Use `includeSchemas: false` for compact output without schemas
|
||||
- Use `debug: true` per-server to show stderr when troubleshooting
|
||||
|
||||
## [1.0.0] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Single unified `mcp` tool** with token-efficient architecture (~200 tokens vs ~15,000 for individual tools)
|
||||
- **Five operation modes:**
|
||||
- `mcp({})` - Show server status
|
||||
- `mcp({ server: "name" })` - List tools from a server
|
||||
- `mcp({ search: "query" })` - Search tools by name/description
|
||||
- `mcp({ describe: "tool_name" })` - Show tool details and parameter schema
|
||||
- `mcp({ tool: "name", args: {...} })` - Call a tool
|
||||
- **Stdio transport** for local MCP servers (command + args)
|
||||
- **HTTP transport** with automatic fallback (StreamableHTTP → SSE)
|
||||
- **Config imports** from Cursor, Claude Code, Claude Desktop, VS Code, Windsurf, Codex
|
||||
- **Resource tools** - MCP resources exposed as callable tools
|
||||
- **OAuth support** - Token file-based authentication
|
||||
- **Bearer token auth** - Static or environment variable tokens
|
||||
- **Keep-alive connections** with automatic health checks and reconnection
|
||||
- **Schema on-demand** - Parameter schemas shown in `describe` mode and error responses
|
||||
- **Commands:**
|
||||
- `/mcp` or `/mcp status` - Show server status
|
||||
- `/mcp tools` - List all tools
|
||||
- `/mcp reconnect` - Force reconnect all servers
|
||||
- `/mcp-auth <server>` - Show OAuth setup instructions
|
||||
|
||||
### Architecture
|
||||
|
||||
- Tools stored in metadata map, not registered individually with Pi
|
||||
- MCP server validates arguments (no client-side schema conversion)
|
||||
- Reconnect callback updates metadata after auto-reconnect
|
||||
- Human-readable schema formatting for LLM consumption
|
||||
21
agent/extensions/pi-mcp-adapter/LICENSE
Normal file
21
agent/extensions/pi-mcp-adapter/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Nico Bailon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
355
agent/extensions/pi-mcp-adapter/OAUTH.md
Normal file
355
agent/extensions/pi-mcp-adapter/OAUTH.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# OAuth 2.1 Authentication for MCP
|
||||
|
||||
This document describes the OAuth 2.1 + PKCE authentication implementation for the Pi MCP Adapter using the official MCP SDK.
|
||||
|
||||
## Overview
|
||||
|
||||
The Pi MCP Adapter uses the official MCP SDK's built-in OAuth implementation, which provides:
|
||||
|
||||
- **Automatic OAuth endpoint discovery** (RFC 9728) - No manual configuration needed
|
||||
- **Dynamic client registration** (RFC 7591) - No clientId needed for most servers
|
||||
- **Automatic callback handling** - Built-in HTTP server handles callbacks automatically
|
||||
- **Automatic token refresh** - SDK handles token refresh transparently
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **PKCE (S256)** - Mandatory code challenge method for OAuth 2.1
|
||||
- ✅ **Automatic Callback Server** - Local browser redirects automatically when available
|
||||
- ✅ **Manual Remote Flow** - Copy auth URLs and pasted redirect URLs/codes for headless SSH sessions
|
||||
- ✅ **Dynamic Client Registration** - Automatically registers with OAuth servers
|
||||
- ✅ **Auto-Discovery** - Discovers OAuth endpoints from server metadata
|
||||
- ✅ **Automatic Token Refresh** - SDK handles expired tokens automatically
|
||||
- ✅ **State Parameter Validation** - CSRF protection
|
||||
- ✅ **Secure Token Storage** - Stored in `~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimal Configuration (Recommended)
|
||||
|
||||
For most MCP servers, you only need the URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-oauth-server": {
|
||||
"url": "https://api.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OAuth is automatically enabled for HTTP servers. The SDK will:
|
||||
- Auto-detect if the server requires OAuth
|
||||
- Discover OAuth endpoints from the server
|
||||
- Register a dynamic client (if supported by the server)
|
||||
- Handle the entire OAuth flow including callback
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
You can optionally provide a pre-registered client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-oauth-server": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"scope": "read write",
|
||||
"redirectUri": "http://localhost:3118/callback"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `url` - The MCP server URL (required)
|
||||
- `auth` - Set to `"oauth"` to force OAuth, `false` to disable, or omit to auto-detect
|
||||
- `oauth.grantType` - `"authorization_code"` (default, browser flow) or `"client_credentials"` (non-interactive)
|
||||
- `oauth.clientId` - Pre-registered client ID (optional, SDK tries dynamic registration if not provided)
|
||||
- `oauth.clientSecret` - Client secret for confidential clients (optional)
|
||||
- `oauth.scope` - Requested OAuth scopes (optional)
|
||||
- `oauth.redirectUri` - Exact browser callback URI to advertise and bind, such as `http://localhost:3118/callback` (optional)
|
||||
- `oauth.clientName` - Client display name used for dynamic registration (optional, defaults to `Pi Coding Agent`)
|
||||
- `oauth.clientUri` - Client homepage URI used for dynamic registration (optional)
|
||||
|
||||
Dynamic clients normally omit `oauth.redirectUri`; the adapter starts the callback server lazily on the default loopback host (`localhost`) and asks the OS for an available local port when auth begins. Use `oauth.redirectUri` when the provider requires a pre-registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`. The URI must use `http://` with `localhost`, `127.0.0.1`, or `[::1]`, include an explicit port, and its host/path become the bound callback endpoint.
|
||||
|
||||
### Non-Interactive `client_credentials`
|
||||
|
||||
For machine-to-machine OAuth, configure `grantType: "client_credentials"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-service": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauth": {
|
||||
"grantType": "client_credentials",
|
||||
"clientId": "service-client-id",
|
||||
"clientSecret": "service-client-secret",
|
||||
"scope": "read write"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This flow does not open a browser or use callback handling. `oauth.redirectUri` is ignored for `client_credentials`; `oauth.clientName` and `oauth.clientUri` still apply to dynamic client registration metadata.
|
||||
|
||||
## Usage
|
||||
|
||||
### Step 1: Authenticate
|
||||
|
||||
Run the `/mcp-auth` command with the server name:
|
||||
|
||||
```
|
||||
/mcp-auth my-oauth-server
|
||||
```
|
||||
|
||||
Manual `/mcp-auth` is the default flow. If you set `settings.autoAuth: true`, proxy/direct tool execution will trigger OAuth automatically when a server returns `needs-auth`, then retry the original operation once.
|
||||
|
||||
This will:
|
||||
1. Start the callback server lazily on an OS-assigned local port, or on the exact `oauth.redirectUri` port for pre-registered callbacks
|
||||
2. Discover OAuth endpoints automatically
|
||||
3. Register a dynamic client (if no clientId provided)
|
||||
4. Open your browser for authentication
|
||||
5. Wait for the automatic callback
|
||||
6. Complete the OAuth flow
|
||||
7. Store tokens securely
|
||||
|
||||
### Remote/headless authentication
|
||||
|
||||
When Pi runs over SSH or in a headless environment, use the proxy tool to retrieve the authorization URL instead of relying on OS browser launch:
|
||||
|
||||
```
|
||||
mcp({ action: "auth-start", server: "my-oauth-server" })
|
||||
```
|
||||
|
||||
Open the returned URL in your local browser. After approval, copy the full redirected localhost URL from the browser address bar (the page may fail to load locally) and complete the same pending auth flow:
|
||||
|
||||
```
|
||||
mcp({
|
||||
action: "auth-complete",
|
||||
server: "my-oauth-server",
|
||||
args: '{"redirectUrl":"http://localhost:19876/callback?code=...&state=..."}'
|
||||
})
|
||||
```
|
||||
|
||||
You can also pass only the `code` query parameter with `args: '{"code":"..."}'`. Redirect URL completion validates the saved OAuth state; raw code completion is available for providers that display a code directly.
|
||||
|
||||
### Step 2: Use the Server
|
||||
|
||||
Once authenticated, use the server normally:
|
||||
|
||||
```
|
||||
mcp({ server: "my-oauth-server" })
|
||||
mcp({ tool: "my-tool", args: '{"key": "value"}' })
|
||||
```
|
||||
|
||||
The SDK automatically:
|
||||
- Adds the access token to requests
|
||||
- Refreshes expired tokens automatically
|
||||
- Re-authenticates if tokens are invalid
|
||||
|
||||
To clear stored OAuth credentials and force a fresh authorization:
|
||||
|
||||
```
|
||||
/mcp logout my-oauth-server
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Pi │────▶│ MCP Server │────▶│ OAuth Server │
|
||||
│ │ │ │ │ │
|
||||
│ 1. Init │ │ 2. Discovery │ │ 3. Register │
|
||||
│ │ │ │ │ │
|
||||
│ │◀────│ │◀────│ 4. Auth URL │
|
||||
│ │ │ │ │ │
|
||||
│ │────▶│ Callback │◀────│ 5. Browser │
|
||||
│ │ │ Server │ │ Redirect │
|
||||
│ │ │ │ │ │
|
||||
│ │◀────│ │◀────│ 6. Code │
|
||||
│ │ │ │ │ │
|
||||
│ │────▶│ │────▶│ 7. Exchange │
|
||||
│ │ │ │ │ │
|
||||
│ │◀────│ │◀────│ 8. Tokens │
|
||||
└─────────┘ └──────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Auto-Discovery
|
||||
|
||||
The SDK attempts to discover OAuth endpoints using:
|
||||
|
||||
1. **RFC 9728 Metadata** - Fetches `/.well-known/oauth-protected-resource`
|
||||
2. **WWW-Authenticate Header** - Parses `resource_metadata` from 401 responses
|
||||
|
||||
### Dynamic Client Registration
|
||||
|
||||
If no `clientId` is provided, the SDK:
|
||||
|
||||
1. Discovers the registration endpoint from OAuth metadata
|
||||
2. Registers a new client with:
|
||||
- `client_name`: configured `oauth.clientName` or "Pi Coding Agent"
|
||||
- `client_uri`: configured `oauth.clientUri` or the adapter repository URL
|
||||
- `redirect_uris`: `["http://localhost:<active-callback-port>/callback"]`, or the configured `oauth.redirectUri`
|
||||
- `grant_types`: `["authorization_code", "refresh_token"]`
|
||||
3. Stores the registered client credentials and the redirect URIs returned by the authorization server
|
||||
|
||||
When a fresh browser auth starts, cached dynamic client info with tokens is re-registered if its stored redirect URIs are missing or do not include the current redirect URI. Token refresh does not perform this redirect check, so existing refresh-token grants keep working even after a callback setting changes.
|
||||
|
||||
### Callback Server
|
||||
|
||||
A Node.js HTTP server runs on a loopback callback endpoint and handles the active callback path:
|
||||
|
||||
- Dynamic registration starts the callback server only when auth begins, binds the default host `localhost`, and asks the OS for an available local port
|
||||
- Pre-registered clients (`oauth.clientId`) without `oauth.redirectUri` require the exact configured callback port from `MCP_OAUTH_CALLBACK_PORT` or the default `19876` on `localhost`
|
||||
- `oauth.redirectUri` binds the exact loopback host, port, and path from that URI and advertises the same URI to the provider
|
||||
|
||||
- Handles `code`, `state`, and `error` parameters
|
||||
- Displays success/error HTML pages
|
||||
- Validates state parameter for CSRF protection
|
||||
- Has a 5-minute timeout for pending authorizations
|
||||
|
||||
## Token Storage
|
||||
|
||||
Tokens are stored per-server in `~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json`. The hash is derived from the configured MCP server name, so any valid config key can be used without becoming a filesystem path component:
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": {
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
|
||||
"expiresAt": 1709769600,
|
||||
"scope": "read write"
|
||||
},
|
||||
"clientInfo": {
|
||||
"clientId": "auto-registered-client-id",
|
||||
"clientSecret": "auto-generated-secret",
|
||||
"redirectUris": ["http://localhost:49152/callback"]
|
||||
},
|
||||
"serverUrl": "https://api.example.com/mcp"
|
||||
}
|
||||
```
|
||||
|
||||
Example directory structure:
|
||||
```
|
||||
~/.pi/agent/mcp-oauth/
|
||||
├── sha256-<linear-server-name-hash>/
|
||||
│ └── tokens.json
|
||||
├── sha256-<github-server-name-hash>/
|
||||
│ └── tokens.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
The `serverUrl` field ensures credentials are invalidated if the server URL changes.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### PKCE
|
||||
|
||||
All OAuth flows use PKCE with the S256 method, preventing authorization code interception attacks.
|
||||
|
||||
### State Parameter
|
||||
|
||||
A cryptographically secure random state parameter is generated for each flow and validated on callback.
|
||||
|
||||
### File Permissions
|
||||
|
||||
Token files (`tokens.json`) are created with `0o600` permissions and stored in hashed per-server directories with `0o700` permissions (readable only by owner).
|
||||
|
||||
### URL Validation
|
||||
|
||||
Credentials are tied to a specific server URL. If the URL changes, the credentials are invalidated and re-authentication is required.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No OAuth tokens found"
|
||||
|
||||
Run `/mcp-auth <server>` to authenticate.
|
||||
|
||||
### "Failed to discover OAuth endpoints"
|
||||
|
||||
The SDK automatically discovers OAuth endpoints from the MCP server. If discovery fails, the server may require a pre-registered client ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"scope": "read"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### "Dynamic client registration not supported"
|
||||
|
||||
Some servers require pre-registered clients. Obtain a client ID from your OAuth provider and add it to the config.
|
||||
|
||||
### Callback server already in use
|
||||
|
||||
Dynamic browser OAuth uses a lazy OS-assigned port on the default loopback host (`localhost`), so the configured default port being busy should not block dynamic registration.
|
||||
|
||||
For pre-registered OAuth clients (`oauth.clientId`), the callback redirect URI must match exactly. Set `oauth.redirectUri` to the full registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`, or free/set `MCP_OAUTH_CALLBACK_PORT` when you rely on the default `/callback` path without an explicit redirect URI.
|
||||
|
||||
### Browser doesn't open
|
||||
|
||||
If the browser fails to open (e.g., in SSH sessions), the authorization URL will be displayed. Copy it manually to your browser.
|
||||
|
||||
## Architecture
|
||||
|
||||
The OAuth implementation uses the following modules:
|
||||
|
||||
- `mcp-auth.ts` - Auth storage and retrieval (hashed per-server `tokens.json` files)
|
||||
- `mcp-oauth-provider.ts` - SDK OAuthClientProvider implementation
|
||||
- `mcp-callback-server.ts` - Node.js HTTP callback server
|
||||
- `mcp-auth-flow.ts` - High-level auth flow using SDK transport
|
||||
|
||||
## SDK Integration
|
||||
|
||||
The implementation uses these SDK exports:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
auth,
|
||||
UnauthorizedError,
|
||||
OAuthClientProvider,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
```
|
||||
|
||||
The `McpOAuthProvider` class implements `OAuthClientProvider` and is passed to `StreamableHTTPClientTransport`:
|
||||
|
||||
```typescript
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
authProvider: new McpOAuthProvider(serverName, serverUrl, config, callbacks),
|
||||
})
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [MCP SDK Documentation](https://github.com/modelcontextprotocol/typescript-sdk)
|
||||
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
|
||||
- [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11)
|
||||
- [PKCE (RFC 7636)](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
- [Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591)
|
||||
- [OAuth Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728)
|
||||
410
agent/extensions/pi-mcp-adapter/README.md
Normal file
410
agent/extensions/pi-mcp-adapter/README.md
Normal file
@@ -0,0 +1,410 @@
|
||||
<p>
|
||||
<img src="banner.png" alt="pi-mcp-adapter" width="1100">
|
||||
</p>
|
||||
|
||||
# Pi MCP Adapter
|
||||
|
||||
Use MCP servers with [Pi](https://github.com/badlogic/pi-mono/) without burning your context window.
|
||||
|
||||
https://github.com/user-attachments/assets/4b7c66ff-e27e-4639-b195-22c3db406a5a
|
||||
|
||||
## Why This Exists
|
||||
|
||||
Mario wrote about [why you might not need MCP](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/). The problem: tool definitions are verbose. A single MCP server can burn 10k+ tokens, and you're paying that cost whether you use those tools or not. Connect a few servers and you've burned half your context window before the conversation starts.
|
||||
|
||||
His take: skip MCP entirely, write simple CLI tools instead.
|
||||
|
||||
But the MCP ecosystem has useful stuff - databases, browsers, APIs. This adapter gives you access without the bloat. One proxy tool (~200 tokens) instead of hundreds. The agent discovers what it needs on-demand. Servers only start when you actually use them.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pi install npm:pi-mcp-adapter
|
||||
```
|
||||
|
||||
Restart Pi after installation.
|
||||
|
||||
## What happens on first run
|
||||
|
||||
The adapter reads standard MCP files automatically. No extra setup needed if you already have them.
|
||||
|
||||
| You already have... | What happens |
|
||||
|---------------------|--------------|
|
||||
| `.mcp.json` or `~/.config/mcp/mcp.json` | Pi uses it immediately. The first time you open `/mcp`, you'll see a short heads-up explaining which file Pi detected and that Pi only writes adapter-specific overrides to its own files. |
|
||||
| Host-specific configs (Cursor, Claude Code, Codex, etc.) but no standard MCP files | Run `/mcp setup` to adopt those host configs into Pi. The setup flow shows exactly what it found, lets you pick which ones to import, and previews the exact file changes before writing. |
|
||||
| Nothing configured yet | Run `/mcp setup` to scaffold a minimal `.mcp.json`, quick-add RepoPrompt, or inspect what the adapter discovered on your machine. |
|
||||
|
||||
If you prefer the terminal, you can also run `pi-mcp-adapter init` after install to scan for host-specific configs and add missing compatibility imports to the Pi agent dir (`~/.pi/agent/mcp.json` by default, or `$PI_CODING_AGENT_DIR/mcp.json` when set).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Preferred project config: `.mcp.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Preferred user-global shared config: `~/.config/mcp/mcp.json`
|
||||
|
||||
Pi also reads Pi-owned override files for settings and host-specific compatibility:
|
||||
|
||||
- `<Pi agent dir>/mcp.json` — Pi global override (`~/.pi/agent/mcp.json` by default)
|
||||
- `.pi/mcp.json` — Pi project override
|
||||
|
||||
Precedence is:
|
||||
|
||||
1. `~/.config/mcp/mcp.json`
|
||||
2. `<Pi agent dir>/mcp.json`
|
||||
3. `.mcp.json`
|
||||
4. `.pi/mcp.json`
|
||||
|
||||
Servers are **lazy by default** — they won't connect until you actually call one of their tools. The adapter caches tool metadata so search and describe work without live connections.
|
||||
|
||||
```
|
||||
mcp({ search: "screenshot" })
|
||||
```
|
||||
```
|
||||
chrome_devtools_take_screenshot
|
||||
Take a screenshot of the page or element.
|
||||
|
||||
Parameters:
|
||||
format (enum: "png", "jpeg", "webp") [default: "png"]
|
||||
fullPage (boolean) - Full page instead of viewport
|
||||
```
|
||||
```
|
||||
mcp({ tool: "chrome_devtools_take_screenshot", args: '{"format": "png"}' })
|
||||
```
|
||||
|
||||
Note: `args` is a JSON string, not an object.
|
||||
|
||||
Two calls instead of 26 tools cluttering the context.
|
||||
|
||||
## Config
|
||||
|
||||
### File Layout
|
||||
|
||||
Use the shared MCP files when you want one setup to work across hosts, and Pi-owned files when you need Pi-specific overrides or settings.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.config/mcp/mcp.json` | User-global shared MCP config |
|
||||
| `.mcp.json` | Project-local shared MCP config |
|
||||
| `<Pi agent dir>/mcp.json` | Pi global override and compatibility imports (`~/.pi/agent/mcp.json` by default) |
|
||||
| `.pi/mcp.json` | Pi project override |
|
||||
|
||||
Pi-specific files are the write targets for imported or shared global servers when Pi needs to persist adapter-only settings such as `directTools`.
|
||||
|
||||
### Server Options
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "some-mcp-server"],
|
||||
"lifecycle": "lazy",
|
||||
"idleTimeout": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `command` | Executable for stdio transport |
|
||||
| `args` | Command arguments |
|
||||
| `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation |
|
||||
| `cwd` | Working directory; supports `${VAR}`, `$env:VAR`, and `~` expansion |
|
||||
| `url` | HTTP endpoint (StreamableHTTP with SSE fallback) |
|
||||
| `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation |
|
||||
| `auth` | `"bearer"` or `"oauth"` |
|
||||
| `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
|
||||
| `oauth.clientId` | Pre-registered OAuth client ID; dynamic registration is used when omitted |
|
||||
| `oauth.clientSecret` | OAuth client secret for confidential clients |
|
||||
| `oauth.scope` | Requested OAuth scopes |
|
||||
| `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
|
||||
| `oauth.clientName` | Client display name advertised during dynamic registration |
|
||||
| `oauth.clientUri` | Client homepage URI advertised during dynamic registration |
|
||||
| `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation |
|
||||
| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
|
||||
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
||||
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
||||
| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
|
||||
| `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) |
|
||||
| `debug` | Show server stderr (default: false) |
|
||||
|
||||
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.
|
||||
|
||||
### Remote/headless OAuth
|
||||
|
||||
If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool:
|
||||
|
||||
```js
|
||||
mcp({ action: "auth-start", server: "linear-server" })
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```js
|
||||
mcp({
|
||||
action: "auth-complete",
|
||||
server: "linear-server",
|
||||
args: '{"redirectUrl":"http://localhost:19876/callback?code=...&state=..."}'
|
||||
})
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Lifecycle Modes
|
||||
|
||||
- **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
|
||||
- **`eager`** — Connect at startup but don't auto-reconnect if the connection drops. No idle timeout by default (set `idleTimeout` explicitly to enable).
|
||||
- **`keep-alive`** — Connect at startup. Auto-reconnect via health checks. No idle timeout. Use for servers you always need available.
|
||||
|
||||
### Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"toolPrefix": "server",
|
||||
"idleTimeout": 10
|
||||
},
|
||||
"mcpServers": { }
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
|
||||
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
||||
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
||||
| `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
|
||||
| `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
|
||||
| `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
|
||||
| `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
|
||||
| `elicitation` | Allow MCP servers to request user input through Pi dialogs (default: true when Pi UI is available). |
|
||||
|
||||
Per-server `idleTimeout` overrides the global setting.
|
||||
|
||||
### MCP Elicitation
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
### Direct Tools
|
||||
|
||||
By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover MCP tools via proxy search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config.
|
||||
|
||||
Per-server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
||||
"directTools": true
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"directTools": ["search_repositories", "get_file_contents"]
|
||||
},
|
||||
"huge-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mega-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `true` | Register all tools from this server as individual Pi tools |
|
||||
| `["tool_a", "tool_b"]` | Register only these tools (use original MCP names) |
|
||||
| Omitted or `false` | Proxy only (default) |
|
||||
|
||||
To set a global default for all servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"directTools": true
|
||||
},
|
||||
"mcpServers": {
|
||||
"huge-server": {
|
||||
"directTools": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`.
|
||||
|
||||
To exclude specific tools while still using `directTools: true`, add `excludeTools` on the server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"figma": {
|
||||
"url": "http://localhost:3845/mcp",
|
||||
"directTools": true,
|
||||
"excludeTools": ["get_figjam", "figma_get_code_connect_map"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`excludeTools` filters direct tools, proxy search/list/describe, and the `/mcp` panel view.
|
||||
|
||||
Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`.
|
||||
|
||||
Direct tools register from the metadata cache in the Pi agent dir (`~/.pi/agent/mcp-cache.json` by default, or `$PI_CODING_AGENT_DIR/mcp-cache.json` when set), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only and the cache populates in the background. To force it: `/mcp reconnect <server>`.
|
||||
|
||||
When you change direct-tool toggles in `/mcp` or write new config through `/mcp setup`, the extension triggers Pi's normal reload flow automatically. That refreshes extensions, prompts, skills, and MCP tool registration in one shot, so newly configured direct tools can appear without a manual restart.
|
||||
|
||||
**Interactive configuration:** Run `/mcp` to open an interactive panel showing all servers with connection status, tools, and direct/proxy toggles. You can reconnect servers and toggle tools between direct and proxy from the same overlay. For OAuth, press Enter on a server that needs auth or `ctrl+a` on any OAuth server.
|
||||
|
||||
**Guided first-run setup:** Run `/mcp setup` to inspect detected shared MCP files, adopt compatibility imports from other hosts, open discovered config paths, preview exact before/after file diffs for writes, scaffold a minimal project `.mcp.json`, or quick-add RepoPrompt into a standard/shared MCP file.
|
||||
|
||||
**Subagent integration:** If you use the subagent extension, agents can request direct MCP tools in their frontmatter with `mcp:server-name` syntax. See the subagent README for details.
|
||||
|
||||
### MCP UI Integration
|
||||
|
||||
MCP servers can ship interactive UIs via the [MCP UI](https://github.com/MCP-UI-Org/mcp-ui) standard. When you call a tool that has a UI resource, the adapter opens it in a native macOS window via [Glimpse](https://github.com/hazat/glimpse) if available, otherwise falls back to the browser.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Agent calls a tool like `launch_dashboard`
|
||||
2. The tool's metadata includes `_meta.ui.resourceUri` pointing to a UI resource
|
||||
3. pi-mcp-adapter fetches the UI HTML and opens it in an iframe
|
||||
4. The UI can call MCP tools and send messages back to the agent
|
||||
|
||||
**Native rendering:** On macOS, if [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`), UIs open in a native WKWebView window instead of a browser tab. Set `MCP_UI_VIEWER=browser` to force the browser, or `MCP_UI_VIEWER=glimpse` to require native rendering.
|
||||
|
||||
**Bidirectional communication:** The UI talks back. When it sends a prompt or intent, the message is stored and `triggerTurn()` wakes the agent. The agent retrieves messages via `mcp({ action: "ui-messages" })` and responds, enabling conversational UIs where the app and agent collaborate in real-time.
|
||||
|
||||
**Session reuse:** When the agent calls the same tool again while its UI is already open, the adapter pushes the new result to the existing window instead of replacing it. This enables live updates — the agent can refine a chart, add data, or respond to user input without losing the current view. Different tools still replace the session as before.
|
||||
|
||||
**Message types from UI:**
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `prompt` | User message that triggers an agent response |
|
||||
| `intent` | Structured action with name + params |
|
||||
| `notify` | Fire-and-forget notification |
|
||||
| `message` | Generic message payload |
|
||||
| (custom) | Any other type forwarded as intent |
|
||||
|
||||
**Retrieving UI messages:**
|
||||
|
||||
```
|
||||
mcp({ action: "ui-messages" })
|
||||
```
|
||||
|
||||
Returns accumulated messages from UI sessions. Each message includes `type`, `sessionId`, `serverName`, `toolName`, and `timestamp`. Prompt messages include `prompt`, intent messages include `intent` and `params`.
|
||||
|
||||
**Browser controls:**
|
||||
|
||||
- **Cmd/Ctrl+Enter** — Complete and close
|
||||
- **Escape** — Cancel and close
|
||||
- **Done/Cancel buttons** — Same as keyboard shortcuts
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Tool consent gates whether UIs can call MCP tools (never/once-per-server/always)
|
||||
- Works with both stdio and HTTP MCP servers
|
||||
- Uses a local 408KB AppBridge bundle (MCP SDK + Zod) for browser↔server communication
|
||||
|
||||
### Local Example: Interactive Visualizer
|
||||
|
||||
A minimal MCP UI example at `examples/interactive-visualizer` demonstrating charts, bidirectional messaging, and streaming. From that directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run install-local
|
||||
```
|
||||
|
||||
Restart pi, then ask the agent to show a chart — it calls `show_chart` and opens the UI in Glimpse (macOS) or the browser. Use `npm run uninstall-local` to remove the MCP entry.
|
||||
|
||||
### Import Existing Configs
|
||||
|
||||
Shared MCP files are loaded automatically. Use `imports` only for host-specific config formats that are not already covered by `.mcp.json` or `~/.config/mcp/mcp.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"imports": ["cursor", "claude-code", "claude-desktop"],
|
||||
"mcpServers": { }
|
||||
}
|
||||
```
|
||||
|
||||
Supported compatibility imports: `cursor`, `claude-code`, `claude-desktop`, `vscode`, `windsurf`, `codex`
|
||||
|
||||
`pi-mcp-adapter init` detects these host-specific configs and adds missing imports to the Pi agent dir config for you.
|
||||
|
||||
### Project Config
|
||||
|
||||
Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only when you need a Pi-specific project override. Project files override both user-global shared MCP config and Pi global overrides.
|
||||
|
||||
## Usage
|
||||
|
||||
| Mode | Example |
|
||||
|------|---------|
|
||||
| Status | `mcp({ })` |
|
||||
| List server | `mcp({ server: "name" })` |
|
||||
| Search | `mcp({ search: "screenshot navigate" })` |
|
||||
| Describe | `mcp({ describe: "tool_name" })` |
|
||||
| Call | `mcp({ tool: "...", args: '{"key": "value"}' })` |
|
||||
| Connect | `mcp({ connect: "server-name" })` |
|
||||
| UI messages | `mcp({ action: "ui-messages" })` |
|
||||
| Auth start | `mcp({ action: "auth-start", server: "name" })` |
|
||||
| Auth complete | `mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' })` |
|
||||
|
||||
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.
|
||||
|
||||
Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd.
|
||||
|
||||
Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/mcp` | Interactive panel and first-run onboarding surface |
|
||||
| `/mcp setup` | Guided setup for imports, a minimal `.mcp.json`, RepoPrompt quick-add, and config-path inspection |
|
||||
| `/mcp tools` | List all tools |
|
||||
| `/mcp reconnect` | Reconnect all servers |
|
||||
| `/mcp reconnect <server>` | Connect or reconnect a single server |
|
||||
| `/mcp logout <server>` | Clear stored OAuth credentials for a server and disconnect it |
|
||||
| `/mcp-auth` | Open an OAuth server picker in interactive UI sessions |
|
||||
| `/mcp-auth <server>` | OAuth setup for a specific server |
|
||||
|
||||
If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls automatically run OAuth when needed and retry once.
|
||||
|
||||
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.
|
||||
|
||||
## How It Works
|
||||
|
||||
- One `mcp` tool in context (~200 tokens) instead of hundreds
|
||||
- Servers are lazy by default — they connect on first tool call, not at startup
|
||||
- Tool metadata is cached to disk so search/list/describe work without live connections
|
||||
- Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use
|
||||
- npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
|
||||
- MCP server validates arguments, not the adapter
|
||||
- Keep-alive servers get health checks and auto-reconnect
|
||||
- Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cross-session server sharing not yet implemented (each Pi session runs its own server processes)
|
||||
- Compact MCP result rendering summarizes text, but inline images are still controlled by Pi's image display settings and may render below the compact text summary.
|
||||
- MCP sampling support is text-only; context inclusion, tools, stop sequences, audio, and image content are rejected with explicit errors.
|
||||
22
agent/extensions/pi-mcp-adapter/agent-dir.ts
Normal file
22
agent/extensions/pi-mcp-adapter/agent-dir.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export function getAgentDir(): string {
|
||||
const configured =
|
||||
process.env.SPROUTCLAW_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (!configured) {
|
||||
return join(homedir(), CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
if (configured === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (configured.startsWith("~/")) {
|
||||
return resolve(homedir(), configured.slice(2));
|
||||
}
|
||||
return resolve(configured);
|
||||
}
|
||||
|
||||
export function getAgentPath(...segments: string[]): string {
|
||||
return join(getAgentDir(), ...segments);
|
||||
}
|
||||
67
agent/extensions/pi-mcp-adapter/app-bridge.bundle.js
Normal file
67
agent/extensions/pi-mcp-adapter/app-bridge.bundle.js
Normal file
File diff suppressed because one or more lines are too long
BIN
agent/extensions/pi-mcp-adapter/banner.png
Normal file
BIN
agent/extensions/pi-mcp-adapter/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
185
agent/extensions/pi-mcp-adapter/cli.js
Normal file
185
agent/extensions/pi-mcp-adapter/cli.js
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const HOME = os.homedir();
|
||||
|
||||
function expandHome(input) {
|
||||
if (input === "~") return HOME;
|
||||
if (input.startsWith("~/")) return path.resolve(HOME, input.slice(2));
|
||||
return path.resolve(input);
|
||||
}
|
||||
|
||||
const CONFIG_DIR_NAME = process.env.SPROUTCLAW_CONFIG_DIR?.trim() || ".sproutclaw";
|
||||
const AGENT_DIR = (process.env.SPROUTCLAW_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim())
|
||||
? expandHome((process.env.SPROUTCLAW_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR).trim())
|
||||
: path.join(HOME, CONFIG_DIR_NAME, "agent");
|
||||
const PI_CONFIG_PATH = path.join(AGENT_DIR, "mcp.json");
|
||||
const GENERIC_GLOBAL_CONFIG_PATH = path.join(HOME, ".config", "mcp", "mcp.json");
|
||||
const PROJECT_CONFIG_PATH = path.resolve(process.cwd(), ".mcp.json");
|
||||
const PROJECT_PI_CONFIG_PATH = path.resolve(process.cwd(), CONFIG_DIR_NAME, "mcp.json");
|
||||
|
||||
const IMPORT_PATHS = {
|
||||
cursor: [path.join(HOME, ".cursor", "mcp.json")],
|
||||
"claude-code": [
|
||||
path.join(HOME, ".claude", "mcp.json"),
|
||||
path.join(HOME, ".claude.json"),
|
||||
path.join(HOME, ".claude", "claude_desktop_config.json"),
|
||||
],
|
||||
"claude-desktop": [path.join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json")],
|
||||
codex: [path.join(HOME, ".codex", "config.json")],
|
||||
windsurf: [path.join(HOME, ".windsurf", "mcp.json")],
|
||||
vscode: [path.resolve(process.cwd(), ".vscode", "mcp.json")],
|
||||
};
|
||||
|
||||
function printHelp(log = console.log) {
|
||||
log("pi-mcp-adapter helper\n");
|
||||
log("Install the package with:");
|
||||
log(" pi install npm:pi-mcp-adapter\n");
|
||||
log("Then optionally run:");
|
||||
log(" pi-mcp-adapter init Detect host configs and scaffold Pi imports");
|
||||
log(" pi-mcp-adapter init --dry-run");
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
function loadPiConfig() {
|
||||
if (!fs.existsSync(PI_CONFIG_PATH)) {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
const raw = readJsonFile(PI_CONFIG_PATH);
|
||||
const mcpServers = raw.mcpServers ?? raw["mcp-servers"] ?? {};
|
||||
if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
|
||||
throw new Error(`Invalid MCP config at ${PI_CONFIG_PATH}: expected \"mcpServers\" to be an object`);
|
||||
}
|
||||
|
||||
const normalized = { ...raw };
|
||||
delete normalized["mcp-servers"];
|
||||
|
||||
const imports = Array.isArray(raw.imports) ? raw.imports.filter((value) => typeof value === "string") : undefined;
|
||||
return {
|
||||
...normalized,
|
||||
mcpServers,
|
||||
imports,
|
||||
};
|
||||
}
|
||||
|
||||
function findAvailableImports() {
|
||||
const found = [];
|
||||
|
||||
for (const [kind, candidates] of Object.entries(IMPORT_PATHS)) {
|
||||
const existing = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (existing) {
|
||||
found.push({ kind, path: existing });
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function printDiscovery(log, imports) {
|
||||
log("Config discovery:\n");
|
||||
|
||||
const paths = [
|
||||
["User-global standard MCP", GENERIC_GLOBAL_CONFIG_PATH],
|
||||
["Pi global override", PI_CONFIG_PATH],
|
||||
["Project standard MCP", PROJECT_CONFIG_PATH],
|
||||
["Project Pi override", PROJECT_PI_CONFIG_PATH],
|
||||
];
|
||||
|
||||
for (const [label, filePath] of paths) {
|
||||
const prefix = fs.existsSync(filePath) ? "✓" : "-";
|
||||
log(`${prefix} ${label}: ${filePath}`);
|
||||
}
|
||||
|
||||
log("\nCompatibility imports:\n");
|
||||
if (imports.length === 0) {
|
||||
log("- No host-specific MCP configs detected");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of imports) {
|
||||
log(`✓ ${entry.kind}: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writePiConfig(config) {
|
||||
fs.mkdirSync(path.dirname(PI_CONFIG_PATH), { recursive: true });
|
||||
fs.writeFileSync(PI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function runInit(argv, log = console.log) {
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
const foundImports = findAvailableImports();
|
||||
const existingConfig = loadPiConfig();
|
||||
const existingImports = new Set(existingConfig.imports ?? []);
|
||||
const importsToAdd = foundImports
|
||||
.map((entry) => entry.kind)
|
||||
.filter((kind) => !existingImports.has(kind));
|
||||
|
||||
printDiscovery(log, foundImports);
|
||||
|
||||
if (importsToAdd.length === 0) {
|
||||
log("\nNo Pi config changes needed.");
|
||||
log("Standard MCP configs are discovered automatically, and host-specific imports are already configured or unavailable.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
...existingConfig,
|
||||
imports: [...existingImports, ...importsToAdd],
|
||||
mcpServers: existingConfig.mcpServers ?? {},
|
||||
};
|
||||
|
||||
log(`\nDetected host configs to import into Pi: ${importsToAdd.join(", ")}`);
|
||||
|
||||
if (dryRun) {
|
||||
log(`Dry run: would update ${PI_CONFIG_PATH}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
writePiConfig(nextConfig);
|
||||
log(`Updated ${PI_CONFIG_PATH}`);
|
||||
log("Pi will now keep reading standard MCP configs automatically, while these imports cover host-specific config formats.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), log = console.log, error = console.error) {
|
||||
const [command, ...rest] = argv;
|
||||
|
||||
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||
printHelp(log);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === "install") {
|
||||
error("The custom downloader has been retired.");
|
||||
error("Use `pi install npm:pi-mcp-adapter` instead, then optionally run `pi-mcp-adapter init`.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (command === "init") {
|
||||
return runInit(rest, log);
|
||||
}
|
||||
|
||||
error(`Unknown command: ${command}`);
|
||||
printHelp(log);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const isEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isEntrypoint) {
|
||||
main().then((code) => {
|
||||
process.exitCode = code;
|
||||
}).catch((err) => {
|
||||
console.error(`\nHelper failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
421
agent/extensions/pi-mcp-adapter/commands.ts
Normal file
421
agent/extensions/pi-mcp-adapter/commands.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { McpAuthResult, McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult, ImportKind } from "./types.ts";
|
||||
import {
|
||||
ensureCompatibilityImports,
|
||||
getMcpDiscoverySummary,
|
||||
getServerProvenance,
|
||||
previewCompatibilityImports,
|
||||
previewSharedServerEntry,
|
||||
previewStarterProjectConfig,
|
||||
writeDirectToolsConfig,
|
||||
writeSharedServerEntry,
|
||||
writeStarterProjectConfig,
|
||||
} from "./config.ts";
|
||||
import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.ts";
|
||||
import { loadMetadataCache } from "./metadata-cache.ts";
|
||||
import { buildToolMetadata } from "./tool-metadata.ts";
|
||||
import { supportsOAuth, authenticate, removeAuth } from "./mcp-auth-flow.ts";
|
||||
import { getAuthForUrl } from "./mcp-auth.ts";
|
||||
import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
|
||||
import { openPath } from "./utils.ts";
|
||||
|
||||
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const lines: string[] = ["MCP Server Status:", ""];
|
||||
|
||||
for (const name of Object.keys(state.config.mcpServers)) {
|
||||
const connection = state.manager.getConnection(name);
|
||||
const metadata = state.toolMetadata.get(name);
|
||||
const toolCount = metadata?.length ?? 0;
|
||||
const failedAgo = getFailureAgeSeconds(state, name);
|
||||
let status = "not connected";
|
||||
let statusIcon = "○";
|
||||
let failed = false;
|
||||
|
||||
if (connection?.status === "connected") {
|
||||
status = "connected";
|
||||
statusIcon = "✓";
|
||||
} else if (connection?.status === "needs-auth") {
|
||||
status = "needs auth";
|
||||
statusIcon = "⚠";
|
||||
} else if (failedAgo !== null) {
|
||||
status = `failed ${failedAgo}s ago`;
|
||||
statusIcon = "✗";
|
||||
failed = true;
|
||||
} else if (metadata !== undefined) {
|
||||
status = "cached";
|
||||
}
|
||||
|
||||
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
|
||||
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
|
||||
}
|
||||
|
||||
if (Object.keys(state.config.mcpServers).length === 0) {
|
||||
lines.push("No MCP servers configured");
|
||||
lines.push("Run /mcp setup to adopt imports or scaffold a starter .mcp.json");
|
||||
}
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
|
||||
|
||||
if (allTools.length === 0) {
|
||||
ctx.ui.notify("No MCP tools available", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"MCP Tools:",
|
||||
"",
|
||||
...allTools.map(t => ` ${t}`),
|
||||
"",
|
||||
`Total: ${allTools.length} tools`,
|
||||
];
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
export async function reconnectServers(
|
||||
state: McpExtensionState,
|
||||
ctx: ExtensionContext,
|
||||
targetServer?: string
|
||||
): Promise<void> {
|
||||
if (targetServer && !state.config.mcpServers[targetServer]) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = targetServer
|
||||
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
|
||||
: Object.entries(state.config.mcpServers);
|
||||
|
||||
for (const [name, definition] of entries) {
|
||||
try {
|
||||
await state.manager.close(name);
|
||||
|
||||
const connection = await state.manager.connect(name, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prefix = state.config.settings?.toolPrefix ?? "server";
|
||||
|
||||
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
||||
state.toolMetadata.set(name, metadata);
|
||||
updateMetadataCache(state, name);
|
||||
state.failureTracker.delete(name);
|
||||
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
||||
"info"
|
||||
);
|
||||
if (failedTools.length > 0) {
|
||||
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
state.failureTracker.set(name, Date.now());
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusBar(state);
|
||||
}
|
||||
|
||||
export async function authenticateServer(
|
||||
serverName: string,
|
||||
config: McpConfig,
|
||||
ctx: ExtensionContext
|
||||
): Promise<McpAuthResult> {
|
||||
if (!ctx.hasUI) return { ok: false, message: "OAuth authentication requires an interactive session." };
|
||||
|
||||
const definition = config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
const message = `Server "${serverName}" not found in config`;
|
||||
ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
if (!supportsOAuth(definition)) {
|
||||
const message = `Server "${serverName}" does not use OAuth authentication. Set "auth": "oauth" or omit auth for auto-detection.`;
|
||||
ctx.ui.notify(
|
||||
`Server "${serverName}" does not use OAuth authentication.\n` +
|
||||
`Set "auth": "oauth" or omit auth for auto-detection.`,
|
||||
"error"
|
||||
);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
if (!definition.url) {
|
||||
const message = `Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`;
|
||||
ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
|
||||
const status = await authenticate(serverName, definition.url, definition);
|
||||
|
||||
if (status === "authenticated") {
|
||||
const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
|
||||
ctx.ui.notify(
|
||||
`OAuth authentication successful for "${serverName}"!\n` +
|
||||
`Run /mcp reconnect ${serverName} to connect with the new token.`,
|
||||
"info"
|
||||
);
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
const message = `OAuth authentication failed for "${serverName}".`;
|
||||
ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
|
||||
return { ok: false, message };
|
||||
} finally {
|
||||
ctx.ui.setStatus("mcp-auth", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutServer(
|
||||
serverName: string,
|
||||
state: McpExtensionState,
|
||||
ctx: ExtensionContext
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
const message = `Server "${serverName}" not found in config`;
|
||||
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
await removeAuth(serverName);
|
||||
await state.manager.close(serverName);
|
||||
updateStatusBar(state);
|
||||
|
||||
const message = `OAuth credentials cleared for "${serverName}". Run /mcp-auth ${serverName} to authenticate again.`;
|
||||
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
export interface PanelFlowResult {
|
||||
configChanged: boolean;
|
||||
}
|
||||
|
||||
function buildSharedConfigNoticeLines(configOverridePath: string | undefined, cwd: string): { lines: string[]; fingerprint: string | null } {
|
||||
const discovery = getMcpDiscoverySummary(configOverridePath, cwd);
|
||||
const onboardingState = loadOnboardingState();
|
||||
if (!discovery.hasSharedServers || onboardingState.sharedConfigHintShown) {
|
||||
return { lines: [], fingerprint: null };
|
||||
}
|
||||
|
||||
const sharedSources = discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0);
|
||||
const sourceList = sharedSources.map((source) => source.path).join(", ");
|
||||
return {
|
||||
lines: [
|
||||
`Using standard MCP config from ${sourceList}.`,
|
||||
"Pi only writes compatibility imports and adapter-specific overrides into Pi-owned files when needed.",
|
||||
],
|
||||
fingerprint: discovery.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openMcpSetup(
|
||||
_state: McpExtensionState,
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
configOverridePath?: string,
|
||||
mode: "empty" | "setup" = "setup",
|
||||
): Promise<PanelFlowResult> {
|
||||
if (!ctx.hasUI) return { configChanged: false };
|
||||
|
||||
const discovery = getMcpDiscoverySummary(configOverridePath, ctx.cwd);
|
||||
const onboardingState = loadOnboardingState();
|
||||
const { createMcpSetupPanel } = await import("./mcp-setup-panel.ts");
|
||||
let configChanged = false;
|
||||
|
||||
const callbacks = {
|
||||
previewImports: (imports: ImportKind[]) => previewCompatibilityImports(imports, configOverridePath),
|
||||
previewStarterProject: () => previewStarterProjectConfig(ctx.cwd),
|
||||
previewRepoPrompt: () => {
|
||||
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
|
||||
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) return null;
|
||||
return previewSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
|
||||
},
|
||||
adoptImports: async (imports: ImportKind[]) => {
|
||||
const result = ensureCompatibilityImports(imports, configOverridePath);
|
||||
if (result.added.length > 0) configChanged = true;
|
||||
return result;
|
||||
},
|
||||
scaffoldProjectConfig: async () => {
|
||||
const path = writeStarterProjectConfig(ctx.cwd);
|
||||
configChanged = true;
|
||||
return { path };
|
||||
},
|
||||
addRepoPrompt: async () => {
|
||||
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
|
||||
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) {
|
||||
throw new Error("RepoPrompt is not available to add from this setup screen.");
|
||||
}
|
||||
const path = writeSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
|
||||
configChanged = true;
|
||||
return { path, serverName: repoPrompt.serverName };
|
||||
},
|
||||
openPath: async (targetPath: string) => {
|
||||
await openPath(pi, targetPath);
|
||||
},
|
||||
markSetupCompleted: () => {
|
||||
persistSetupCompleted(discovery.fingerprint);
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<PanelFlowResult>((resolve) => {
|
||||
ctx.ui.custom(
|
||||
(tui, _theme, keybindings, done) => {
|
||||
return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState, keybindings }, tui, () => {
|
||||
done(undefined);
|
||||
resolve({ configChanged });
|
||||
});
|
||||
},
|
||||
{ overlay: true, overlayOptions: { anchor: "center", width: 92 } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildMcpPanelCallbacks(
|
||||
state: McpExtensionState,
|
||||
config: McpConfig,
|
||||
ctx: ExtensionContext,
|
||||
): McpPanelCallbacks {
|
||||
return {
|
||||
reconnect: (serverName: string) => lazyConnect(state, serverName),
|
||||
canAuthenticate: (serverName: string) => {
|
||||
const definition = config.mcpServers[serverName];
|
||||
return definition ? supportsOAuth(definition) : false;
|
||||
},
|
||||
authenticate: (serverName: string) => authenticateServer(serverName, config, ctx),
|
||||
getConnectionStatus: (serverName: string) => {
|
||||
const definition = config.mcpServers[serverName];
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (connection?.status === "needs-auth") {
|
||||
return "needs-auth";
|
||||
}
|
||||
if (
|
||||
definition?.auth === "oauth"
|
||||
&& definition.url
|
||||
&& definition.oauth !== false
|
||||
&& definition.oauth?.grantType !== "client_credentials"
|
||||
&& !getAuthForUrl(serverName, definition.url)?.tokens
|
||||
) {
|
||||
return "needs-auth";
|
||||
}
|
||||
if (connection?.status === "connected") return "connected";
|
||||
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
||||
return "idle";
|
||||
},
|
||||
refreshCacheAfterReconnect: (serverName: string) => {
|
||||
const freshCache = loadMetadataCache();
|
||||
return freshCache?.servers?.[serverName] ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function openMcpPanel(
|
||||
state: McpExtensionState,
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
configOverridePath?: string,
|
||||
): Promise<PanelFlowResult> {
|
||||
if (Object.keys(state.config.mcpServers).length === 0) {
|
||||
return openMcpSetup(state, pi, ctx, configOverridePath, "empty");
|
||||
}
|
||||
|
||||
const config = state.config;
|
||||
const cache = loadMetadataCache();
|
||||
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
|
||||
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
|
||||
const { lines: noticeLines, fingerprint } = buildSharedConfigNoticeLines(configPath, ctx.cwd);
|
||||
|
||||
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
|
||||
|
||||
const { createMcpPanel } = await import("./mcp-panel.ts");
|
||||
let configChanged = false;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx.ui.custom(
|
||||
(tui, _theme, keybindings, done) => {
|
||||
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
||||
if (!result.cancelled && result.changes.size > 0) {
|
||||
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
||||
configChanged = true;
|
||||
ctx.ui.notify("Direct tools updated. Pi will reload after this panel closes.", "info");
|
||||
}
|
||||
done(undefined);
|
||||
resolve();
|
||||
}, { noticeLines, keybindings });
|
||||
},
|
||||
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
||||
);
|
||||
});
|
||||
|
||||
if (noticeLines.length > 0 && fingerprint) {
|
||||
markSharedConfigHintShown(fingerprint);
|
||||
}
|
||||
|
||||
return { configChanged };
|
||||
}
|
||||
|
||||
export async function openMcpAuthPanel(
|
||||
state: McpExtensionState,
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
configOverridePath?: string,
|
||||
): Promise<PanelFlowResult> {
|
||||
if (!ctx.hasUI) return { configChanged: false };
|
||||
|
||||
const config = state.config;
|
||||
const oauthServers = Object.entries(config.mcpServers).filter(([, definition]) => supportsOAuth(definition));
|
||||
if (oauthServers.length === 0) {
|
||||
ctx.ui.notify("No OAuth-capable MCP servers are configured.", "warning");
|
||||
return { configChanged: false };
|
||||
}
|
||||
|
||||
const cache = loadMetadataCache();
|
||||
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
|
||||
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
|
||||
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
|
||||
const { createMcpPanel } = await import("./mcp-panel.ts");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx.ui.custom(
|
||||
(tui, _theme, keybindings, done) => {
|
||||
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, () => {
|
||||
done(undefined);
|
||||
resolve();
|
||||
}, {
|
||||
authOnly: true,
|
||||
keybindings,
|
||||
noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."],
|
||||
});
|
||||
},
|
||||
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
||||
);
|
||||
});
|
||||
|
||||
return { configChanged: false };
|
||||
}
|
||||
668
agent/extensions/pi-mcp-adapter/config.ts
Normal file
668
agent/extensions/pi-mcp-adapter/config.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
// config.ts - Config loading with import support
|
||||
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.ts";
|
||||
|
||||
const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
|
||||
const PROJECT_CONFIG_NAME = ".mcp.json";
|
||||
const PROJECT_PI_CONFIG_NAME = `${CONFIG_DIR_NAME}/mcp.json`;
|
||||
const REPOPROMPT_BINARY_CANDIDATES = [
|
||||
join(homedir(), "RepoPrompt", "repoprompt_cli"),
|
||||
"/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp",
|
||||
];
|
||||
|
||||
const IMPORT_PATHS: Record<ImportKind, string[]> = {
|
||||
cursor: [join(homedir(), ".cursor", "mcp.json")],
|
||||
"claude-code": [
|
||||
join(homedir(), ".claude", "mcp.json"),
|
||||
join(homedir(), ".claude.json"),
|
||||
join(homedir(), ".claude", "claude_desktop_config.json"),
|
||||
],
|
||||
"claude-desktop": [join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")],
|
||||
codex: [join(homedir(), ".codex", "config.json")],
|
||||
windsurf: [join(homedir(), ".windsurf", "mcp.json")],
|
||||
vscode: [".vscode/mcp.json"],
|
||||
};
|
||||
|
||||
interface ConfigSourceSpec {
|
||||
id: "shared-global" | "pi-global" | "shared-project" | "pi-project";
|
||||
label: string;
|
||||
readPath: string;
|
||||
writePath: string;
|
||||
kind: "user" | "project" | "import";
|
||||
importKind?: string;
|
||||
shared: boolean;
|
||||
scope: "global" | "project";
|
||||
}
|
||||
|
||||
export interface ConfigDiscoveryPath {
|
||||
label: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoveredImportConfig {
|
||||
kind: ImportKind;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ConfigDiscoverySource extends ConfigDiscoveryPath {
|
||||
id: ConfigSourceSpec["id"];
|
||||
scope: ConfigSourceSpec["scope"];
|
||||
kind: "shared" | "pi";
|
||||
serverCount: number;
|
||||
}
|
||||
|
||||
export interface ImportConfigSummary extends DiscoveredImportConfig {
|
||||
serverCount: number;
|
||||
}
|
||||
|
||||
export interface RepoPromptDiscovery {
|
||||
configured: boolean;
|
||||
configuredPath?: string;
|
||||
executablePath?: string;
|
||||
targetPath?: string;
|
||||
serverName?: string;
|
||||
entry?: ServerEntry;
|
||||
}
|
||||
|
||||
export interface McpDiscoverySummary {
|
||||
sources: ConfigDiscoverySource[];
|
||||
imports: ImportConfigSummary[];
|
||||
hasAnyConfig: boolean;
|
||||
hasAnyDetectedPaths: boolean;
|
||||
hasSharedServers: boolean;
|
||||
hasPiOwnedServers: boolean;
|
||||
totalServerCount: number;
|
||||
fingerprint: string;
|
||||
repoPrompt: RepoPromptDiscovery;
|
||||
}
|
||||
|
||||
export interface ConfigWritePreview {
|
||||
path: string;
|
||||
existed: boolean;
|
||||
changed: boolean;
|
||||
beforeText: string;
|
||||
afterText: string;
|
||||
diffText: string;
|
||||
}
|
||||
|
||||
export function getPiGlobalConfigPath(overridePath?: string): string {
|
||||
return overridePath ? resolve(overridePath) : getAgentPath("mcp.json");
|
||||
}
|
||||
|
||||
export function getGenericGlobalConfigPath(): string {
|
||||
return GENERIC_GLOBAL_CONFIG_PATH;
|
||||
}
|
||||
|
||||
export function getProjectConfigPath(cwd = process.cwd()): string {
|
||||
return resolve(cwd, PROJECT_CONFIG_NAME);
|
||||
}
|
||||
|
||||
export function getProjectPiConfigPath(cwd = process.cwd()): string {
|
||||
return resolve(cwd, PROJECT_PI_CONFIG_NAME);
|
||||
}
|
||||
|
||||
export function getConfigDiscoveryPaths(overridePath?: string, cwd = process.cwd()): ConfigDiscoveryPath[] {
|
||||
return getConfigSources(overridePath, cwd).map((source) => ({
|
||||
label: source.label,
|
||||
path: source.readPath,
|
||||
exists: existsSync(source.readPath),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findAvailableImportConfigs(cwd = process.cwd()): DiscoveredImportConfig[] {
|
||||
const discovered: DiscoveredImportConfig[] = [];
|
||||
|
||||
for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) {
|
||||
const importPath = resolveImportPath(importKind, cwd);
|
||||
if (importPath) {
|
||||
discovered.push({ kind: importKind, path: importPath });
|
||||
}
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd()): McpDiscoverySummary {
|
||||
const sources = getConfigSources(overridePath, cwd).map((source) => {
|
||||
const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
path: source.readPath,
|
||||
exists: existsSync(source.readPath),
|
||||
scope: source.scope,
|
||||
kind: source.shared ? "shared" : "pi",
|
||||
serverCount: loaded ? Object.keys(loaded.mcpServers).length : 0,
|
||||
} satisfies ConfigDiscoverySource;
|
||||
});
|
||||
|
||||
const imports = (Object.keys(IMPORT_PATHS) as ImportKind[])
|
||||
.map((kind) => {
|
||||
const path = resolveImportPath(kind, cwd);
|
||||
if (!path) return null;
|
||||
return {
|
||||
kind,
|
||||
path,
|
||||
serverCount: getImportServerCount(kind, path),
|
||||
} satisfies ImportConfigSummary;
|
||||
})
|
||||
.filter((value): value is ImportConfigSummary => value !== null);
|
||||
|
||||
const totalServerCount = sources.reduce((sum, source) => sum + source.serverCount, 0);
|
||||
const hasSharedServers = sources.some((source) => source.kind === "shared" && source.serverCount > 0);
|
||||
const hasPiOwnedServers = sources.some((source) => source.kind === "pi" && source.serverCount > 0);
|
||||
const hasAnyDetectedPaths = sources.some((source) => source.exists) || imports.length > 0;
|
||||
const hasAnyConfig = totalServerCount > 0 || imports.some((entry) => entry.serverCount > 0) || hasAnyDetectedPaths;
|
||||
|
||||
const summaryWithoutRepoPrompt = {
|
||||
sources,
|
||||
imports,
|
||||
hasAnyConfig,
|
||||
hasAnyDetectedPaths,
|
||||
hasSharedServers,
|
||||
hasPiOwnedServers,
|
||||
totalServerCount,
|
||||
};
|
||||
|
||||
const fingerprint = JSON.stringify({
|
||||
sources: sources.map((source) => [source.id, source.exists, source.serverCount]),
|
||||
imports: imports.map((entry) => [entry.kind, entry.path, entry.serverCount]),
|
||||
});
|
||||
|
||||
return {
|
||||
...summaryWithoutRepoPrompt,
|
||||
fingerprint,
|
||||
repoPrompt: detectRepoPrompt(summaryWithoutRepoPrompt, cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadMcpConfig(overridePath?: string, cwd = process.cwd()): McpConfig {
|
||||
let config: McpConfig = { mcpServers: {} };
|
||||
|
||||
for (const source of getConfigSources(overridePath, cwd)) {
|
||||
const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
|
||||
if (!loaded) continue;
|
||||
config = mergeConfigs(config, expandImports(loaded, cwd));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSourceSpec[] {
|
||||
const userPath = getPiGlobalConfigPath(overridePath);
|
||||
const projectPath = getProjectConfigPath(cwd);
|
||||
const projectPiPath = getProjectPiConfigPath(cwd);
|
||||
const sources: ConfigSourceSpec[] = [];
|
||||
|
||||
if (GENERIC_GLOBAL_CONFIG_PATH !== userPath) {
|
||||
sources.push({
|
||||
id: "shared-global",
|
||||
label: "user-global standard MCP",
|
||||
readPath: GENERIC_GLOBAL_CONFIG_PATH,
|
||||
writePath: userPath,
|
||||
kind: "import",
|
||||
importKind: "global MCP config",
|
||||
shared: true,
|
||||
scope: "global",
|
||||
});
|
||||
}
|
||||
|
||||
sources.push({
|
||||
id: "pi-global",
|
||||
label: "Pi global override",
|
||||
readPath: userPath,
|
||||
writePath: userPath,
|
||||
kind: "user",
|
||||
shared: false,
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
if (projectPath !== userPath) {
|
||||
sources.push({
|
||||
id: "shared-project",
|
||||
label: "project standard MCP",
|
||||
readPath: projectPath,
|
||||
writePath: projectPath,
|
||||
kind: "project",
|
||||
shared: true,
|
||||
scope: "project",
|
||||
});
|
||||
}
|
||||
|
||||
if (projectPiPath !== userPath && projectPiPath !== projectPath) {
|
||||
sources.push({
|
||||
id: "pi-project",
|
||||
label: "project Pi override",
|
||||
readPath: projectPiPath,
|
||||
writePath: projectPiPath,
|
||||
kind: "project",
|
||||
shared: false,
|
||||
scope: "project",
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
|
||||
return {
|
||||
mcpServers: { ...base.mcpServers, ...next.mcpServers },
|
||||
imports: mergeImports(base.imports, next.imports),
|
||||
settings: next.settings ? { ...base.settings, ...next.settings } : base.settings,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeImports(left: ImportKind[] | undefined, right: ImportKind[] | undefined): ImportKind[] | undefined {
|
||||
const merged = [...(left ?? []), ...(right ?? [])];
|
||||
if (merged.length === 0) return undefined;
|
||||
return [...new Set(merged)];
|
||||
}
|
||||
|
||||
function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig {
|
||||
if (!config.imports?.length) return config;
|
||||
|
||||
const importedServers: Record<string, ServerEntry> = {};
|
||||
for (const importKind of config.imports) {
|
||||
const importPath = resolveImportPath(importKind, cwd);
|
||||
if (!importPath) continue;
|
||||
|
||||
try {
|
||||
const imported = JSON.parse(readFileSync(importPath, "utf-8"));
|
||||
const servers = extractServers(imported, importKind);
|
||||
for (const [name, definition] of Object.entries(servers)) {
|
||||
if (!importedServers[name]) {
|
||||
importedServers[name] = definition;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to import MCP config from ${importKind}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imports: config.imports,
|
||||
settings: config.settings,
|
||||
mcpServers: { ...importedServers, ...config.mcpServers },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImportPath(importKind: ImportKind, cwd = process.cwd()): string | null {
|
||||
const candidates = IMPORT_PATHS[importKind] ?? [];
|
||||
for (const candidate of candidates) {
|
||||
const fullPath = candidate.startsWith(".") ? resolve(cwd, candidate) : candidate;
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getImportServerCount(importKind: ImportKind, path: string): number {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return Object.keys(extractServers(raw, importKind)).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function readValidatedConfig(path: string, label: string): McpConfig | null {
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
try {
|
||||
return validateConfig(JSON.parse(readFileSync(path, "utf-8")));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load ${label}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfig(raw: unknown): McpConfig {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
|
||||
|
||||
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
mcpServers: servers as Record<string, ServerEntry>,
|
||||
imports: Array.isArray(obj.imports) ? (obj.imports as ImportKind[]) : undefined,
|
||||
settings: obj.settings as McpSettings | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
|
||||
if (!config || typeof config !== "object") return {};
|
||||
|
||||
const obj = config as Record<string, unknown>;
|
||||
|
||||
let servers: unknown;
|
||||
switch (kind) {
|
||||
case "claude-desktop":
|
||||
case "claude-code":
|
||||
case "codex":
|
||||
servers = obj.mcpServers;
|
||||
break;
|
||||
case "cursor":
|
||||
case "windsurf":
|
||||
case "vscode":
|
||||
servers = obj.mcpServers ?? obj["mcp-servers"];
|
||||
break;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return servers as Record<string, ServerEntry>;
|
||||
}
|
||||
|
||||
function serializeRawConfig(raw: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(raw, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function buildUnifiedDiff(beforeText: string, afterText: string): string {
|
||||
if (beforeText === afterText) return "(no changes)";
|
||||
|
||||
const before = beforeText.split("\n");
|
||||
const after = afterText.split("\n");
|
||||
const rows = before.length;
|
||||
const cols = after.length;
|
||||
const lcs = Array.from({ length: rows + 1 }, () => Array<number>(cols + 1).fill(0));
|
||||
|
||||
for (let i = rows - 1; i >= 0; i--) {
|
||||
for (let j = cols - 1; j >= 0; j--) {
|
||||
lcs[i][j] = before[i] === after[j]
|
||||
? lcs[i + 1][j + 1] + 1
|
||||
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = ["--- before", "+++ after"];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < rows || j < cols) {
|
||||
if (i < rows && j < cols && before[i] === after[j]) {
|
||||
lines.push(` ${before[i]}`);
|
||||
i++;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (j < cols && (i === rows || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
||||
lines.push(`+ ${after[j]}`);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (i < rows) {
|
||||
lines.push(`- ${before[i]}`);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildConfigWritePreview(filePath: string, nextRaw: Record<string, unknown>): ConfigWritePreview {
|
||||
const existed = existsSync(filePath);
|
||||
const beforeRaw = readRawConfigObject(filePath);
|
||||
const beforeText = existed ? serializeRawConfig(beforeRaw) : "";
|
||||
const afterText = serializeRawConfig(nextRaw);
|
||||
return {
|
||||
path: filePath,
|
||||
existed,
|
||||
changed: beforeText !== afterText,
|
||||
beforeText,
|
||||
afterText,
|
||||
diffText: buildUnifiedDiff(beforeText, afterText),
|
||||
};
|
||||
}
|
||||
|
||||
function readRawConfigObject(filePath: string): Record<string, unknown> {
|
||||
if (!existsSync(filePath)) return {};
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRawConfigObject(filePath: string, raw: Record<string, unknown>): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
|
||||
renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
function getServersObject(raw: Record<string, unknown>): Record<string, ServerEntry> {
|
||||
const existing = raw.mcpServers ?? raw["mcp-servers"] ?? {};
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
return {};
|
||||
}
|
||||
return existing as Record<string, ServerEntry>;
|
||||
}
|
||||
|
||||
function setServersObject(raw: Record<string, unknown>, servers: Record<string, ServerEntry>): void {
|
||||
delete raw["mcp-servers"];
|
||||
raw.mcpServers = servers;
|
||||
}
|
||||
|
||||
function isRepoPromptServer(name: string, entry: ServerEntry): boolean {
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (normalizedName.includes("repoprompt") || normalizedName === "rp") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const command = entry.command?.toLowerCase() ?? "";
|
||||
if (command.includes("repoprompt") || command.includes("rp-mcp") || command.endsWith("repoprompt_cli")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (entry.args ?? []).some((arg) => typeof arg === "string" && arg.toLowerCase().includes("repoprompt"));
|
||||
}
|
||||
|
||||
function findProjectRoot(cwd = process.cwd()): string | null {
|
||||
let current = resolve(cwd);
|
||||
while (true) {
|
||||
if (
|
||||
existsSync(join(current, ".git"))
|
||||
|| existsSync(join(current, "package.json"))
|
||||
|| existsSync(join(current, PROJECT_CONFIG_NAME))
|
||||
|| existsSync(join(current, CONFIG_DIR_NAME))
|
||||
|| existsSync(join(current, ".pi"))
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRepoPromptEntry(executablePath: string): ServerEntry {
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
lifecycle: "lazy",
|
||||
};
|
||||
}
|
||||
|
||||
function detectRepoPrompt(summary: Omit<McpDiscoverySummary, "fingerprint" | "repoPrompt">, cwd = process.cwd()): RepoPromptDiscovery {
|
||||
for (const source of summary.sources) {
|
||||
if (source.kind !== "shared" || source.serverCount === 0) continue;
|
||||
const config = readValidatedConfig(source.path, `MCP config from ${source.path}`);
|
||||
if (!config) continue;
|
||||
for (const [name, entry] of Object.entries(config.mcpServers)) {
|
||||
if (isRepoPromptServer(name, entry)) {
|
||||
return { configured: true, configuredPath: source.path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executablePath = REPOPROMPT_BINARY_CANDIDATES.find((candidate) => existsSync(candidate));
|
||||
if (!executablePath) {
|
||||
return { configured: false };
|
||||
}
|
||||
|
||||
const projectRoot = findProjectRoot(cwd);
|
||||
const targetPath = projectRoot ? join(projectRoot, PROJECT_CONFIG_NAME) : GENERIC_GLOBAL_CONFIG_PATH;
|
||||
return {
|
||||
configured: false,
|
||||
executablePath,
|
||||
targetPath,
|
||||
serverName: "repoprompt",
|
||||
entry: buildRepoPromptEntry(executablePath),
|
||||
};
|
||||
}
|
||||
|
||||
export function previewCompatibilityImports(importKinds: ImportKind[], overridePath?: string): ConfigWritePreview {
|
||||
const targetPath = getPiGlobalConfigPath(overridePath);
|
||||
const raw = readRawConfigObject(targetPath);
|
||||
const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
|
||||
const merged = [...new Set([...currentImports, ...importKinds])];
|
||||
const nextRaw = { ...raw, imports: merged };
|
||||
setServersObject(nextRaw, getServersObject(nextRaw));
|
||||
return buildConfigWritePreview(targetPath, nextRaw);
|
||||
}
|
||||
|
||||
export function ensureCompatibilityImports(importKinds: ImportKind[], overridePath?: string): { path: string; added: ImportKind[] } {
|
||||
const targetPath = getPiGlobalConfigPath(overridePath);
|
||||
const raw = readRawConfigObject(targetPath);
|
||||
const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
|
||||
const merged = [...new Set([...currentImports, ...importKinds])];
|
||||
const added = merged.filter((kind) => !currentImports.includes(kind));
|
||||
if (added.length === 0) {
|
||||
return { path: targetPath, added: [] };
|
||||
}
|
||||
|
||||
raw.imports = merged;
|
||||
const servers = getServersObject(raw);
|
||||
setServersObject(raw, servers);
|
||||
writeRawConfigObject(targetPath, raw);
|
||||
return { path: targetPath, added };
|
||||
}
|
||||
|
||||
export function buildStarterProjectConfig(): McpConfig {
|
||||
return {
|
||||
mcpServers: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function previewStarterProjectConfig(cwd = process.cwd()): ConfigWritePreview {
|
||||
const targetPath = getProjectConfigPath(cwd);
|
||||
const nextRaw = { mcpServers: buildStarterProjectConfig().mcpServers };
|
||||
return buildConfigWritePreview(targetPath, nextRaw);
|
||||
}
|
||||
|
||||
export function writeStarterProjectConfig(cwd = process.cwd()): string {
|
||||
const targetPath = getProjectConfigPath(cwd);
|
||||
const raw = { mcpServers: buildStarterProjectConfig().mcpServers };
|
||||
writeRawConfigObject(targetPath, raw);
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function previewSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): ConfigWritePreview {
|
||||
const raw = readRawConfigObject(filePath);
|
||||
const nextRaw = { ...raw };
|
||||
const servers = getServersObject(nextRaw);
|
||||
servers[serverName] = entry;
|
||||
setServersObject(nextRaw, servers);
|
||||
return buildConfigWritePreview(filePath, nextRaw);
|
||||
}
|
||||
|
||||
export function writeSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): string {
|
||||
const raw = readRawConfigObject(filePath);
|
||||
const servers = getServersObject(raw);
|
||||
servers[serverName] = entry;
|
||||
setServersObject(raw, servers);
|
||||
writeRawConfigObject(filePath, raw);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function getServerProvenance(overridePath?: string, cwd = process.cwd()): Map<string, ServerProvenance> {
|
||||
const provenance = new Map<string, ServerProvenance>();
|
||||
const userPath = getPiGlobalConfigPath(overridePath);
|
||||
|
||||
for (const source of getConfigSources(overridePath, cwd)) {
|
||||
const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
|
||||
if (!loaded) continue;
|
||||
|
||||
if (loaded.imports?.length) {
|
||||
for (const importKind of loaded.imports) {
|
||||
const importPath = resolveImportPath(importKind, cwd);
|
||||
if (!importPath) continue;
|
||||
|
||||
try {
|
||||
const imported = JSON.parse(readFileSync(importPath, "utf-8"));
|
||||
const servers = extractServers(imported, importKind);
|
||||
for (const name of Object.keys(servers)) {
|
||||
if (!provenance.has(name)) {
|
||||
provenance.set(name, { path: userPath, kind: "import", importKind });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of Object.keys(loaded.mcpServers)) {
|
||||
provenance.set(name, {
|
||||
path: source.writePath,
|
||||
kind: source.kind,
|
||||
importKind: source.importKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return provenance;
|
||||
}
|
||||
|
||||
export function writeDirectToolsConfig(
|
||||
changes: Map<string, true | string[] | false>,
|
||||
provenance: Map<string, ServerProvenance>,
|
||||
fullConfig: McpConfig,
|
||||
): void {
|
||||
const byPath = new Map<string, { name: string; value: true | string[] | false; prov: ServerProvenance }[]>();
|
||||
|
||||
for (const [serverName, value] of changes) {
|
||||
const prov = provenance.get(serverName);
|
||||
if (!prov) continue;
|
||||
|
||||
const targetPath = prov.path;
|
||||
|
||||
if (!byPath.has(targetPath)) byPath.set(targetPath, []);
|
||||
byPath.get(targetPath)!.push({ name: serverName, value, prov });
|
||||
}
|
||||
|
||||
for (const [filePath, entries] of byPath) {
|
||||
const raw = readRawConfigObject(filePath);
|
||||
const servers = getServersObject(raw);
|
||||
|
||||
for (const { name, value, prov } of entries) {
|
||||
if (prov.kind === "import") {
|
||||
const fullDef = fullConfig.mcpServers[name];
|
||||
if (fullDef) {
|
||||
servers[name] = { ...fullDef, directTools: value };
|
||||
}
|
||||
} else if (servers[name]) {
|
||||
servers[name] = { ...servers[name], directTools: value };
|
||||
}
|
||||
}
|
||||
|
||||
setServersObject(raw, servers);
|
||||
writeRawConfigObject(filePath, raw);
|
||||
}
|
||||
}
|
||||
64
agent/extensions/pi-mcp-adapter/consent-manager.ts
Normal file
64
agent/extensions/pi-mcp-adapter/consent-manager.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ConsentError } from "./errors.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
|
||||
export type ToolConsentMode = "never" | "once-per-server" | "always";
|
||||
|
||||
export class ConsentManager {
|
||||
private approvedServers = new Set<string>();
|
||||
private deniedServers = new Set<string>();
|
||||
private log = logger.child({ component: "ConsentManager" });
|
||||
|
||||
constructor(private mode: ToolConsentMode = "once-per-server") {
|
||||
this.log.debug("Initialized", { mode });
|
||||
}
|
||||
|
||||
requiresPrompt(serverName: string): boolean {
|
||||
if (this.mode === "never") return false;
|
||||
if (this.deniedServers.has(serverName)) return true;
|
||||
if (this.mode === "always") return true;
|
||||
return !this.approvedServers.has(serverName);
|
||||
}
|
||||
|
||||
shouldCacheConsent(): boolean {
|
||||
return this.mode !== "always";
|
||||
}
|
||||
|
||||
registerDecision(serverName: string, approved: boolean): void {
|
||||
this.deniedServers.delete(serverName);
|
||||
this.approvedServers.delete(serverName);
|
||||
|
||||
if (approved) {
|
||||
this.approvedServers.add(serverName);
|
||||
this.log.debug("Consent granted", { server: serverName });
|
||||
return;
|
||||
}
|
||||
|
||||
this.deniedServers.add(serverName);
|
||||
this.log.debug("Consent denied", { server: serverName });
|
||||
}
|
||||
|
||||
ensureApproved(serverName: string): void {
|
||||
if (this.mode === "never") return;
|
||||
if (this.deniedServers.has(serverName)) {
|
||||
throw new ConsentError(serverName, { denied: true });
|
||||
}
|
||||
if (!this.approvedServers.has(serverName)) {
|
||||
throw new ConsentError(serverName, { requiresApproval: true });
|
||||
}
|
||||
if (this.mode === "always") {
|
||||
this.approvedServers.delete(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
clear(serverName?: string): void {
|
||||
if (serverName) {
|
||||
this.approvedServers.delete(serverName);
|
||||
this.deniedServers.delete(serverName);
|
||||
this.log.debug("Cleared consent for server", { server: serverName });
|
||||
return;
|
||||
}
|
||||
this.approvedServers.clear();
|
||||
this.deniedServers.clear();
|
||||
this.log.debug("Cleared all consent records");
|
||||
}
|
||||
}
|
||||
441
agent/extensions/pi-mcp-adapter/direct-tools.ts
Normal file
441
agent/extensions/pi-mcp-adapter/direct-tools.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { DirectToolSpec, McpConfig, McpContent } from "./types.ts";
|
||||
import type { MetadataCache } from "./metadata-cache.ts";
|
||||
import { lazyConnect, getFailureAgeSeconds } from "./init.ts";
|
||||
import { isServerCacheValid } from "./metadata-cache.ts";
|
||||
import { formatSchema } from "./tool-metadata.ts";
|
||||
import { transformMcpContent } from "./tool-registrar.ts";
|
||||
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
import { formatAuthRequiredMessage } from "./utils.ts";
|
||||
|
||||
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
||||
|
||||
type DirectAutoAuthResult =
|
||||
| { status: "skipped" }
|
||||
| { status: "success" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
function getDirectAuthRequiredMessage(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
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.`,
|
||||
): string {
|
||||
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
||||
}
|
||||
|
||||
function getDirectAuthFailedMessage(state: McpExtensionState, serverName: string, message: string): string {
|
||||
const customGuidance = state.config.settings?.authRequiredMessage;
|
||||
if (customGuidance) {
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. ${getDirectAuthRequiredMessage(state, serverName)}`;
|
||||
}
|
||||
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.`;
|
||||
}
|
||||
|
||||
async function attemptDirectAutoAuth(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
): Promise<DirectAutoAuthResult> {
|
||||
if (state.config.settings?.autoAuth !== true) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition || !supportsOAuth(definition) || !definition.url) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const grantType = definition.oauth ? definition.oauth.grantType ?? "authorization_code" : "authorization_code";
|
||||
if (!state.ui && grantType !== "client_credentials") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: getDirectAuthRequiredMessage(
|
||||
state,
|
||||
serverName,
|
||||
`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.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await authenticate(serverName, definition.url, definition);
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "failed",
|
||||
message: getDirectAuthFailedMessage(state, serverName, message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDirectTools(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
prefix: "server" | "none" | "short",
|
||||
envOverride?: string[],
|
||||
): DirectToolSpec[] {
|
||||
const specs: DirectToolSpec[] = [];
|
||||
if (!cache) return specs;
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
const envServers = new Set<string>();
|
||||
const envTools = new Map<string, Set<string>>();
|
||||
if (envOverride) {
|
||||
for (let item of envOverride) {
|
||||
item = item.replace(/\/+$/, "");
|
||||
if (item.includes("/")) {
|
||||
const [server, tool] = item.split("/", 2);
|
||||
if (server && tool) {
|
||||
if (!envTools.has(server)) envTools.set(server, new Set());
|
||||
envTools.get(server)!.add(tool);
|
||||
} else if (server) {
|
||||
envServers.add(server);
|
||||
}
|
||||
} else if (item) {
|
||||
envServers.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globalDirect = config.settings?.directTools;
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
const serverCache = cache.servers[serverName];
|
||||
if (!serverCache || !isServerCacheValid(serverCache, definition)) continue;
|
||||
|
||||
let toolFilter: true | string[] | false = false;
|
||||
|
||||
if (envOverride) {
|
||||
if (envServers.has(serverName)) {
|
||||
toolFilter = true;
|
||||
} else if (envTools.has(serverName)) {
|
||||
toolFilter = [...envTools.get(serverName)!];
|
||||
}
|
||||
} else {
|
||||
if (definition.directTools !== undefined) {
|
||||
toolFilter = definition.directTools;
|
||||
} else if (globalDirect) {
|
||||
toolFilter = globalDirect;
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolFilter) continue;
|
||||
|
||||
for (const tool of serverCache.tools ?? []) {
|
||||
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
|
||||
const prefixedName = formatToolName(tool.name, serverName, prefix);
|
||||
if (BUILTIN_NAMES.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(prefixedName);
|
||||
specs.push({
|
||||
serverName,
|
||||
originalName: tool.name,
|
||||
prefixedName,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri: tool.uiResourceUri,
|
||||
uiStreamMode: tool.uiStreamMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of serverCache.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
|
||||
const prefixedName = formatToolName(baseName, serverName, prefix);
|
||||
if (BUILTIN_NAMES.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(prefixedName);
|
||||
specs.push({
|
||||
serverName,
|
||||
originalName: baseName,
|
||||
prefixedName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function getMissingConfiguredDirectToolServers(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
const globalDirect = config.settings?.directTools;
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
const hasDirectTools = definition.directTools !== undefined
|
||||
? !!definition.directTools
|
||||
: !!globalDirect;
|
||||
|
||||
if (!hasDirectTools) continue;
|
||||
|
||||
const serverCache = cache?.servers?.[serverName];
|
||||
if (!serverCache || !isServerCacheValid(serverCache, definition)) {
|
||||
missing.push(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function buildProxyDescription(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
directSpecs: DirectToolSpec[],
|
||||
): string {
|
||||
const prefix = config.settings?.toolPrefix ?? "server";
|
||||
let desc = `MCP gateway - connect to MCP servers and call their tools. Non-MCP Pi tools should be called directly, not through mcp.\n`;
|
||||
|
||||
const directByServer = new Map<string, number>();
|
||||
for (const spec of directSpecs) {
|
||||
directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
|
||||
}
|
||||
if (directByServer.size > 0) {
|
||||
const parts = [...directByServer.entries()].map(
|
||||
([server, count]) => `${server} (${count})`,
|
||||
);
|
||||
desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
|
||||
}
|
||||
|
||||
const serverSummaries: string[] = [];
|
||||
for (const serverName of Object.keys(config.mcpServers)) {
|
||||
const entry = cache?.servers?.[serverName];
|
||||
const definition = config.mcpServers[serverName];
|
||||
const toolCount = (entry?.tools ?? []).filter(
|
||||
(tool) => !isToolExcluded(tool.name, serverName, prefix, definition.excludeTools),
|
||||
).length;
|
||||
const resourceCount = definition?.exposeResources !== false
|
||||
? (entry?.resources ?? []).filter((resource) => {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
return !isToolExcluded(baseName, serverName, prefix, definition.excludeTools);
|
||||
}).length
|
||||
: 0;
|
||||
const totalItems = toolCount + resourceCount;
|
||||
if (totalItems === 0) continue;
|
||||
const directCount = directByServer.get(serverName) ?? 0;
|
||||
const proxyCount = totalItems - directCount;
|
||||
if (proxyCount > 0) {
|
||||
serverSummaries.push(`${serverName} (${proxyCount} tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (serverSummaries.length > 0) {
|
||||
desc += `\nServers: ${serverSummaries.join(", ")}\n`;
|
||||
}
|
||||
|
||||
desc += `\nUsage:\n`;
|
||||
desc += ` mcp({ }) → Show server status\n`;
|
||||
desc += ` mcp({ server: "name" }) → List tools from server\n`;
|
||||
desc += ` mcp({ search: "query" }) → Search MCP tools by name/description\n`;
|
||||
desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
|
||||
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
||||
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
||||
desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n`;
|
||||
desc += ` mcp({ action: "auth-start", server: "name" }) → Start manual OAuth and get a browser URL\n`;
|
||||
desc += ` mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' }) → Complete manual OAuth\n`;
|
||||
desc += `\nMode: action > tool (call) > connect > describe > search > server (list) > nothing (status)`;
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
type DirectToolExecute = (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<AgentToolResult<Record<string, unknown>>>;
|
||||
|
||||
export function createDirectToolExecutor(
|
||||
getState: () => McpExtensionState | null,
|
||||
getInitPromise: () => Promise<McpExtensionState> | null,
|
||||
spec: DirectToolSpec
|
||||
): DirectToolExecute {
|
||||
return async function execute(_toolCallId, params) {
|
||||
let state = getState();
|
||||
const initPromise = getInitPromise();
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
|
||||
details: { error: "init_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
||||
details: { error: "not_initialized" },
|
||||
};
|
||||
}
|
||||
|
||||
let connected = await lazyConnect(state, spec.serverName);
|
||||
let autoAuthAttempted = false;
|
||||
|
||||
if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptDirectAutoAuth(state, spec.serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { error: "auth_required", server: spec.serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(spec.serverName);
|
||||
state.failureTracker.delete(spec.serverName);
|
||||
connected = await lazyConnect(state, spec.serverName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
const authConnection = state.manager.getConnection(spec.serverName);
|
||||
if (authConnection?.status === "needs-auth") {
|
||||
const message = getDirectAuthRequiredMessage(state, spec.serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted },
|
||||
};
|
||||
}
|
||||
const failedAgo = getFailureAgeSeconds(state, spec.serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
||||
details: { error: "server_unavailable", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const connection = state.manager.getConnection(spec.serverName);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }],
|
||||
details: { error: "not_connected", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
let uiSession: UiSessionRuntime | null = null;
|
||||
|
||||
try {
|
||||
state.manager.touch(spec.serverName);
|
||||
state.manager.incrementInFlight(spec.serverName);
|
||||
|
||||
if (spec.resourceUri) {
|
||||
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
||||
const content = (result.contents ?? []).map(c => ({
|
||||
type: "text" as const,
|
||||
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
||||
}));
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
||||
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
||||
};
|
||||
}
|
||||
|
||||
const hasUi = !!spec.uiResourceUri;
|
||||
uiSession = hasUi
|
||||
? await maybeStartUiSession(state, {
|
||||
serverName: spec.serverName,
|
||||
toolName: spec.originalName,
|
||||
toolArgs: params ?? {},
|
||||
uiResourceUri: spec.uiResourceUri!,
|
||||
streamMode: spec.uiStreamMode,
|
||||
})
|
||||
: null;
|
||||
|
||||
const resultPromise = connection.client.callTool({
|
||||
name: spec.originalName,
|
||||
arguments: params ?? {},
|
||||
_meta: uiSession?.requestMeta,
|
||||
});
|
||||
|
||||
const result = await resultPromise;
|
||||
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
||||
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
if (result.isError) {
|
||||
let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
|
||||
if (spec.inputSchema) {
|
||||
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${errorText}` }],
|
||||
details: { error: "tool_error", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const resultText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "(empty result)";
|
||||
if (hasUi) {
|
||||
const uiMessage = uiSession?.reused
|
||||
? "Updated the open UI."
|
||||
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
|
||||
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
||||
details: { server: spec.serverName, tool: spec.originalName },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) {
|
||||
const action = await state.manager.handleUrlElicitationRequired(spec.serverName, error);
|
||||
const message = action === "accept"
|
||||
? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
|
||||
: `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
|
||||
uiSession?.sendToolCancelled(message);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { error: "url_elicitation_required", server: spec.serverName, action },
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
uiSession?.sendToolCancelled(message);
|
||||
let errorText = `Failed to call tool: ${message}`;
|
||||
if (spec.inputSchema) {
|
||||
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorText }],
|
||||
details: { error: "call_failed", server: spec.serverName },
|
||||
};
|
||||
} finally {
|
||||
if (uiSession?.reused) {
|
||||
uiSession.close();
|
||||
}
|
||||
state.manager.decrementInFlight(spec.serverName);
|
||||
state.manager.touch(spec.serverName);
|
||||
}
|
||||
};
|
||||
}
|
||||
347
agent/extensions/pi-mcp-adapter/elicitation-handler.ts
Normal file
347
agent/extensions/pi-mcp-adapter/elicitation-handler.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import {
|
||||
ElicitRequestSchema,
|
||||
ErrorCode,
|
||||
McpError,
|
||||
type ElicitRequest,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
||||
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
|
||||
import open from "open";
|
||||
|
||||
export type ElicitationValue = string | number | boolean | string[] | undefined;
|
||||
type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
|
||||
|
||||
export type ElicitationUIContext = ExtensionUIContext;
|
||||
|
||||
export interface ElicitationHandlerOptions {
|
||||
serverName: string;
|
||||
ui: ElicitationUIContext;
|
||||
allowUrl: boolean;
|
||||
onUrlAccepted?: (elicitationId: string) => void;
|
||||
}
|
||||
|
||||
export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
|
||||
|
||||
export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) =>
|
||||
handleElicitationRequest(options, request));
|
||||
}
|
||||
|
||||
export async function handleElicitationRequest(
|
||||
options: ElicitationHandlerOptions,
|
||||
request: ElicitRequest,
|
||||
): Promise<ElicitResult> {
|
||||
return request.params.mode === "url"
|
||||
? handleUrlElicitation(options, request.params)
|
||||
: handleFormElicitation(options, request.params);
|
||||
}
|
||||
|
||||
export async function handleFormElicitation(
|
||||
options: ElicitationHandlerOptions,
|
||||
params: ElicitRequestFormParams,
|
||||
): Promise<ElicitResult> {
|
||||
const decision = await options.ui.select(
|
||||
`MCP Input Request\nServer: ${options.serverName}\n\n${params.message}`,
|
||||
["Continue", "Decline"],
|
||||
);
|
||||
if (decision === undefined) return { action: "cancel" };
|
||||
if (decision === "Decline") return { action: "decline" };
|
||||
|
||||
const values: Record<string, ElicitationValue> = {};
|
||||
const properties = Object.entries(params.requestedSchema.properties);
|
||||
for (const [name, schema] of properties) {
|
||||
const value = await collectValidField(options.ui, params, name, schema);
|
||||
if (!("value" in value)) return { action: "cancel" };
|
||||
values[name] = value.value;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const content = coerceAndValidateFormValues(params, values);
|
||||
const action = await options.ui.select(
|
||||
formatReview(options.serverName, properties, content),
|
||||
properties.length > 0 ? ["Submit", "Edit", "Decline"] : ["Submit", "Decline"],
|
||||
);
|
||||
if (action === undefined) return { action: "cancel" };
|
||||
if (action === "Decline") return { action: "decline" };
|
||||
if (action === "Submit") return { action: "accept", content };
|
||||
|
||||
const labels = properties.map(([name, schema]) => `${schema.title ?? humanizeName(name)} (${name})`);
|
||||
const selected = await options.ui.select("Choose a field to edit", labels);
|
||||
if (selected === undefined) return { action: "cancel" };
|
||||
const property = properties[labels.indexOf(selected)];
|
||||
if (!property) continue;
|
||||
const [name, schema] = property;
|
||||
const value = await collectValidField(options.ui, params, name, schema, values[name]);
|
||||
if (!("value" in value)) return { action: "cancel" };
|
||||
values[name] = value.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectValidField(
|
||||
ui: ElicitationUIContext,
|
||||
params: ElicitRequestFormParams,
|
||||
name: string,
|
||||
schema: FormProperty,
|
||||
current?: ElicitationValue,
|
||||
): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
|
||||
const required = params.requestedSchema.required?.includes(name) === true;
|
||||
while (true) {
|
||||
const result = await collectField(ui, params, name, schema, current);
|
||||
if (!("value" in result)) return result;
|
||||
try {
|
||||
coerceAndValidateFormValues({
|
||||
...params,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { [name]: schema },
|
||||
...(required ? { required: [name] } : {}),
|
||||
},
|
||||
}, { [name]: result.value });
|
||||
return result;
|
||||
} catch (error) {
|
||||
ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||
current = result.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectField(
|
||||
ui: ElicitationUIContext,
|
||||
params: ElicitRequestFormParams,
|
||||
name: string,
|
||||
schema: FormProperty,
|
||||
current?: ElicitationValue,
|
||||
): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
|
||||
const required = params.requestedSchema.required?.includes(name) === true;
|
||||
const title = [schema.title ?? humanizeName(name), required ? "(required)" : "", schema.description]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (schema.type === "string" && ("enum" in schema || "oneOf" in schema)) {
|
||||
const choices = "oneOf" in schema
|
||||
? schema.oneOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
|
||||
: schema.enum.map((value, index) => ({
|
||||
value,
|
||||
display: formatChoice(value, "enumNames" in schema ? schema.enumNames?.[index] : undefined),
|
||||
}));
|
||||
const displays = uniqueLabels(choices.map(choice => choice.display));
|
||||
const actions = [...displays];
|
||||
const useDefault = schema.default === undefined ? undefined : uniqueAction("Use default", actions);
|
||||
if (useDefault) actions.push(useDefault);
|
||||
const omit = required ? undefined : uniqueAction("Omit", actions);
|
||||
if (omit) actions.push(omit);
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === useDefault) return { cancelled: false, value: schema.default };
|
||||
if (action === omit) return { cancelled: false, value: undefined };
|
||||
return { cancelled: false, value: choices[displays.indexOf(action)]?.value };
|
||||
}
|
||||
|
||||
if (schema.type === "boolean") {
|
||||
const actions = ["Yes", "No"];
|
||||
if (schema.default !== undefined) actions.push("Use default");
|
||||
if (!required) actions.push("Omit");
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === "Use default") return { cancelled: false, value: schema.default };
|
||||
if (action === "Omit") return { cancelled: false, value: undefined };
|
||||
return { cancelled: false, value: action === "Yes" };
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
const actions = ["Choose values"];
|
||||
if (schema.default !== undefined) actions.push("Use default");
|
||||
if (!required) actions.push("Omit");
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === "Use default") return { cancelled: false, value: schema.default };
|
||||
if (action === "Omit") return { cancelled: false, value: undefined };
|
||||
|
||||
const choices = extractMultiSelectOptions(schema);
|
||||
const selected = new Set(Array.isArray(current) ? current : []);
|
||||
while (true) {
|
||||
const displays = uniqueLabels(choices.map(choice => selected.has(choice.value) ? `✓ ${choice.display}` : choice.display));
|
||||
const done = uniqueAction("Done", displays);
|
||||
const picked = await ui.select(title, [...displays, done]);
|
||||
if (picked === undefined) return { cancelled: true };
|
||||
if (picked === done) return { cancelled: false, value: [...selected] };
|
||||
const choice = choices[displays.indexOf(picked)];
|
||||
if (!choice) continue;
|
||||
if (selected.has(choice.value)) selected.delete(choice.value);
|
||||
else selected.add(choice.value);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = ["Enter value"];
|
||||
if (schema.default !== undefined) actions.push("Use default");
|
||||
if (!required) actions.push("Omit");
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === "Use default") return { cancelled: false, value: schema.default };
|
||||
if (action === "Omit") return { cancelled: false, value: undefined };
|
||||
const entered = await ui.input(title, current === undefined ? undefined : String(current));
|
||||
return entered === undefined ? { cancelled: true } : { cancelled: false, value: entered };
|
||||
}
|
||||
|
||||
export function coerceAndValidateFormValues(
|
||||
params: ElicitRequestFormParams,
|
||||
values: Record<string, ElicitationValue>,
|
||||
): Record<string, string | number | boolean | string[]> {
|
||||
const output: Record<string, string | number | boolean | string[]> = {};
|
||||
const required = new Set(params.requestedSchema.required ?? []);
|
||||
for (const [name, schema] of Object.entries(params.requestedSchema.properties)) {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
if (required.has(name)) throw new Error(`Missing required elicitation field: ${name}`);
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "string") {
|
||||
const stringValue = String(value);
|
||||
const limits = schema as typeof schema & { minLength?: number; maxLength?: number };
|
||||
if (limits.minLength !== undefined && stringValue.length < limits.minLength) {
|
||||
throw new Error(`Elicitation field ${name} is shorter than minimum length ${limits.minLength}`);
|
||||
}
|
||||
if (limits.maxLength !== undefined && stringValue.length > limits.maxLength) {
|
||||
throw new Error(`Elicitation field ${name} is longer than maximum length ${limits.maxLength}`);
|
||||
}
|
||||
if ("enum" in schema && !schema.enum.includes(stringValue)) {
|
||||
throw new Error(`Elicitation field ${name} is not an allowed value`);
|
||||
}
|
||||
if ("oneOf" in schema && !schema.oneOf.some(option => option.const === stringValue)) {
|
||||
throw new Error(`Elicitation field ${name} is not an allowed value`);
|
||||
}
|
||||
output[name] = stringValue;
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
throw new Error(`Elicitation field ${name} must be a number`);
|
||||
}
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue)) throw new Error(`Elicitation field ${name} must be a number`);
|
||||
if (schema.type === "integer" && !Number.isInteger(numberValue)) {
|
||||
throw new Error(`Elicitation field ${name} must be an integer`);
|
||||
}
|
||||
if (schema.minimum !== undefined && numberValue < schema.minimum) {
|
||||
throw new Error(`Elicitation field ${name} is below minimum ${schema.minimum}`);
|
||||
}
|
||||
if (schema.maximum !== undefined && numberValue > schema.maximum) {
|
||||
throw new Error(`Elicitation field ${name} is above maximum ${schema.maximum}`);
|
||||
}
|
||||
output[name] = numberValue;
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "boolean") {
|
||||
output[name] = typeof value === "boolean" ? value : value === "true";
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(value)) throw new Error(`Elicitation field ${name} must be a list`);
|
||||
const allowed = new Set(extractMultiSelectOptions(schema).map(option => option.value));
|
||||
const arrayValue = value.map(String);
|
||||
if (schema.minItems !== undefined && arrayValue.length < schema.minItems) {
|
||||
throw new Error(`Elicitation field ${name} has fewer than ${schema.minItems} selections`);
|
||||
}
|
||||
if (schema.maxItems !== undefined && arrayValue.length > schema.maxItems) {
|
||||
throw new Error(`Elicitation field ${name} has more than ${schema.maxItems} selections`);
|
||||
}
|
||||
if (arrayValue.some(item => !allowed.has(item))) {
|
||||
throw new Error(`Elicitation field ${name} contains an invalid selection`);
|
||||
}
|
||||
output[name] = arrayValue;
|
||||
}
|
||||
}
|
||||
const validation = new AjvJsonSchemaValidator()
|
||||
.getValidator(params.requestedSchema as JsonSchemaType)(output);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid elicitation response: ${validation.errorMessage}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatChoice(value: string, title?: string): string {
|
||||
return title && title !== value ? `${title} (${value})` : value;
|
||||
}
|
||||
|
||||
function uniqueLabels(labels: string[]): string[] {
|
||||
const used = new Set<string>();
|
||||
return labels.map(label => {
|
||||
let unique = label;
|
||||
while (used.has(unique)) unique += "…";
|
||||
used.add(unique);
|
||||
return unique;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueAction(label: string, choices: string[]): string {
|
||||
let unique = label;
|
||||
while (choices.includes(unique)) unique += "…";
|
||||
return unique;
|
||||
}
|
||||
|
||||
function extractMultiSelectOptions(schema: Extract<FormProperty, { type: "array" }>): Array<{ value: string; display: string }> {
|
||||
const items = schema.items as { enum?: string[]; anyOf?: Array<{ const: string; title: string }> };
|
||||
return items.anyOf
|
||||
? items.anyOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
|
||||
: (items.enum ?? []).map(value => ({ value, display: value }));
|
||||
}
|
||||
|
||||
function formatReview(
|
||||
serverName: string,
|
||||
properties: Array<[string, FormProperty]>,
|
||||
content: Record<string, string | number | boolean | string[]>,
|
||||
): string {
|
||||
const rows = properties.map(([name, schema]) =>
|
||||
`${schema.title ?? humanizeName(name)}: ${content[name] === undefined ? "(omitted)" : String(content[name])}`);
|
||||
return [`Review input for ${serverName}`, "", ...rows].join("\n");
|
||||
}
|
||||
|
||||
export async function handleUrlElicitation(
|
||||
options: ElicitationHandlerOptions,
|
||||
params: ElicitRequestURLParams,
|
||||
): Promise<ElicitResult> {
|
||||
if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(params.url);
|
||||
} catch {
|
||||
throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
|
||||
}
|
||||
|
||||
const decision = await options.ui.select([
|
||||
"MCP Browser Request",
|
||||
`Server: ${options.serverName}`,
|
||||
"",
|
||||
params.message,
|
||||
"",
|
||||
`Host: ${parsed.host}`,
|
||||
`Full URL: ${params.url}`,
|
||||
"",
|
||||
"Open this URL in your browser?",
|
||||
].join("\n"), ["Open", "Decline"]);
|
||||
if (decision === undefined) return { action: "cancel" };
|
||||
if (decision === "Decline") return { action: "decline" };
|
||||
|
||||
try {
|
||||
await open(params.url);
|
||||
} catch (error) {
|
||||
options.ui.notify(`Could not open MCP elicitation URL: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
return { action: "cancel" };
|
||||
}
|
||||
options.onUrlAccepted?.(params.elicitationId);
|
||||
options.ui.notify("Opened browser for MCP elicitation.", "info");
|
||||
return { action: "accept" };
|
||||
}
|
||||
|
||||
function humanizeName(name: string): string {
|
||||
return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, char => char.toUpperCase());
|
||||
}
|
||||
219
agent/extensions/pi-mcp-adapter/errors.ts
Normal file
219
agent/extensions/pi-mcp-adapter/errors.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Custom error types for MCP UI operations.
|
||||
* Provides structured errors with context and recovery hints.
|
||||
*/
|
||||
|
||||
export interface McpUiErrorContext {
|
||||
server?: string;
|
||||
tool?: string;
|
||||
uri?: string;
|
||||
session?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base error class for MCP UI errors.
|
||||
*/
|
||||
export class McpUiError extends Error {
|
||||
readonly code: string;
|
||||
readonly context: McpUiErrorContext;
|
||||
readonly recoveryHint?: string;
|
||||
readonly cause?: Error;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
code: string;
|
||||
context?: McpUiErrorContext;
|
||||
recoveryHint?: string;
|
||||
cause?: Error;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "McpUiError";
|
||||
this.code = options.code;
|
||||
this.context = options.context ?? {};
|
||||
this.recoveryHint = options.recoveryHint;
|
||||
this.cause = options.cause;
|
||||
|
||||
// Maintain proper stack trace
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
context: this.context,
|
||||
recoveryHint: this.recoveryHint,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error fetching a UI resource from the MCP server.
|
||||
*/
|
||||
export class ResourceFetchError extends McpUiError {
|
||||
constructor(
|
||||
uri: string,
|
||||
reason: string,
|
||||
options?: { server?: string; cause?: Error }
|
||||
) {
|
||||
super(`Failed to fetch UI resource "${uri}": ${reason}`, {
|
||||
code: "RESOURCE_FETCH_ERROR",
|
||||
context: { uri, server: options?.server },
|
||||
recoveryHint: "Check that the MCP server is connected and the resource URI is valid.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "ResourceFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error parsing or validating UI resource content.
|
||||
*/
|
||||
export class ResourceParseError extends McpUiError {
|
||||
constructor(
|
||||
uri: string,
|
||||
reason: string,
|
||||
options?: { server?: string; mimeType?: string }
|
||||
) {
|
||||
super(`Invalid UI resource "${uri}": ${reason}`, {
|
||||
code: "RESOURCE_PARSE_ERROR",
|
||||
context: { uri, server: options?.server, mimeType: options?.mimeType },
|
||||
recoveryHint: "Ensure the resource returns valid HTML with the correct MIME type.",
|
||||
});
|
||||
this.name = "ResourceParseError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error connecting to the AppBridge.
|
||||
*/
|
||||
export class BridgeConnectionError extends McpUiError {
|
||||
constructor(reason: string, options?: { session?: string; cause?: Error }) {
|
||||
super(`AppBridge connection failed: ${reason}`, {
|
||||
code: "BRIDGE_CONNECTION_ERROR",
|
||||
context: { session: options?.session },
|
||||
recoveryHint: "Check browser console for detailed errors. The iframe may have failed to load.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "BridgeConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to user consent for tool calls.
|
||||
*/
|
||||
export class ConsentError extends McpUiError {
|
||||
readonly denied: boolean;
|
||||
|
||||
constructor(
|
||||
server: string,
|
||||
options: { denied?: boolean; requiresApproval?: boolean }
|
||||
) {
|
||||
const message = options.denied
|
||||
? `Tool calls for "${server}" were denied for this session`
|
||||
: `Tool call approval required for "${server}"`;
|
||||
|
||||
super(message, {
|
||||
code: options.denied ? "CONSENT_DENIED" : "CONSENT_REQUIRED",
|
||||
context: { server },
|
||||
recoveryHint: options.denied
|
||||
? "The user denied tool access. Start a new session to try again."
|
||||
: "Prompt the user for consent before calling tools.",
|
||||
});
|
||||
this.name = "ConsentError";
|
||||
this.denied = options.denied ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error with UI server session management.
|
||||
*/
|
||||
export class SessionError extends McpUiError {
|
||||
constructor(
|
||||
reason: string,
|
||||
options?: { session?: string; cause?: Error }
|
||||
) {
|
||||
super(`Session error: ${reason}`, {
|
||||
code: "SESSION_ERROR",
|
||||
context: { session: options?.session },
|
||||
recoveryHint: "The session may have expired or been closed. Try opening the UI again.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "SessionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error starting or operating the UI server.
|
||||
*/
|
||||
export class ServerError extends McpUiError {
|
||||
constructor(
|
||||
reason: string,
|
||||
options?: { port?: number; cause?: Error }
|
||||
) {
|
||||
super(`UI server error: ${reason}`, {
|
||||
code: "SERVER_ERROR",
|
||||
context: { port: options?.port },
|
||||
recoveryHint: "Check if the port is available. Another process may be using it.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "ServerError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error communicating with the MCP server.
|
||||
*/
|
||||
export class McpServerError extends McpUiError {
|
||||
constructor(
|
||||
server: string,
|
||||
reason: string,
|
||||
options?: { tool?: string; cause?: Error }
|
||||
) {
|
||||
super(`MCP server "${server}" error: ${reason}`, {
|
||||
code: "MCP_SERVER_ERROR",
|
||||
context: { server, tool: options?.tool },
|
||||
recoveryHint: "Check that the MCP server is running and responsive.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "McpServerError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an unknown error into an McpUiError.
|
||||
*/
|
||||
export function wrapError(error: unknown, context?: McpUiErrorContext): McpUiError {
|
||||
if (error instanceof McpUiError) {
|
||||
// Merge contexts
|
||||
return new McpUiError(error.message, {
|
||||
code: error.code,
|
||||
context: { ...error.context, ...context },
|
||||
recoveryHint: error.recoveryHint,
|
||||
cause: error.cause,
|
||||
});
|
||||
}
|
||||
|
||||
const cause = error instanceof Error ? error : undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return new McpUiError(message, {
|
||||
code: "UNKNOWN_ERROR",
|
||||
context,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a specific MCP UI error type.
|
||||
*/
|
||||
export function isErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof McpUiError && error.code === code;
|
||||
}
|
||||
80
agent/extensions/pi-mcp-adapter/glimpse-ui.ts
Normal file
80
agent/extensions/pi-mcp-adapter/glimpse-ui.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { platform } from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
let glimpseAvailable: boolean | null = null;
|
||||
let resolvedBinaryPath: string | null = null;
|
||||
|
||||
export function isGlimpseAvailable(): boolean {
|
||||
if (glimpseAvailable !== null) return glimpseAvailable;
|
||||
|
||||
if (platform() !== "darwin") {
|
||||
glimpseAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
resolvedBinaryPath = getGlimpseBinaryPath();
|
||||
glimpseAvailable = resolvedBinaryPath !== null;
|
||||
return glimpseAvailable;
|
||||
}
|
||||
|
||||
function getGlimpseBinaryPath(): string | null {
|
||||
if (process.env.GLIMPSE_BINARY && existsSync(process.env.GLIMPSE_BINARY)) {
|
||||
return process.env.GLIMPSE_BINARY;
|
||||
}
|
||||
|
||||
// Local node_modules
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const glimpseuiPath = require.resolve("glimpseui");
|
||||
const binaryPath = join(dirname(glimpseuiPath), "glimpse");
|
||||
if (existsSync(binaryPath)) return binaryPath;
|
||||
} catch {}
|
||||
|
||||
// Global npm install
|
||||
try {
|
||||
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim();
|
||||
const binaryPath = join(globalRoot, "glimpseui", "src", "glimpse");
|
||||
if (existsSync(binaryPath)) return binaryPath;
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function openGlimpseWindow(
|
||||
html: string,
|
||||
options: {
|
||||
title: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onClosed: () => void;
|
||||
},
|
||||
) {
|
||||
const modulePath = resolvedBinaryPath
|
||||
? join(dirname(resolvedBinaryPath), "glimpse.mjs")
|
||||
: "glimpseui";
|
||||
const glimpse = await import(modulePath);
|
||||
|
||||
let active = true;
|
||||
const win = glimpse.open(html, {
|
||||
width: options.width ?? 900,
|
||||
height: options.height ?? 700,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
win.on("closed", () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
options.onClosed();
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
win.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
427
agent/extensions/pi-mcp-adapter/host-html-template.ts
Normal file
427
agent/extensions/pi-mcp-adapter/host-html-template.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import type { UiHostContext, UiResourceContent, UiResourceCsp } from "./types.ts";
|
||||
|
||||
// Use locally bundled AppBridge to avoid CDN Zod bundling issues
|
||||
const DEFAULT_APP_BRIDGE_MODULE_URL = "/app-bridge.bundle.js";
|
||||
|
||||
export interface HostHtmlTemplateInput {
|
||||
sessionToken: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
resource: UiResourceContent;
|
||||
allowAttribute: string;
|
||||
requireToolConsent: boolean;
|
||||
cacheToolConsent: boolean;
|
||||
hostContext?: UiHostContext;
|
||||
appBridgeModuleUrl?: string;
|
||||
}
|
||||
|
||||
export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
|
||||
const cspContent = buildCspMetaContent(input.resource.meta.csp);
|
||||
const resourceHtml = applyCspMeta(input.resource.html, cspContent);
|
||||
const hostContext = input.hostContext ?? {};
|
||||
|
||||
const sessionToken = safeInlineJSON(input.sessionToken);
|
||||
const toolArgs = safeInlineJSON(input.toolArgs);
|
||||
const uiHtml = safeInlineJSON(resourceHtml);
|
||||
const serverName = safeInlineJSON(input.serverName);
|
||||
const toolName = safeInlineJSON(input.toolName);
|
||||
const hostContextJson = safeInlineJSON(hostContext);
|
||||
const allowAttribute = safeInlineJSON(input.allowAttribute);
|
||||
const requireToolConsent = safeInlineJSON(input.requireToolConsent);
|
||||
const cacheToolConsent = safeInlineJSON(input.cacheToolConsent);
|
||||
const moduleUrl = safeInlineJSON(input.appBridgeModuleUrl ?? DEFAULT_APP_BRIDGE_MODULE_URL);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MCP UI - ${escapeHtml(input.serverName)} / ${escapeHtml(input.toolName)}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0f1115;
|
||||
--surface: #181c22;
|
||||
--text: #ecf0f5;
|
||||
--muted: #a9b2bf;
|
||||
--accent: #43c0ff;
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--good: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--bad: #f87171;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--text: #1d2939;
|
||||
--muted: #667085;
|
||||
--accent: #0ea5e9;
|
||||
--border: rgba(15, 23, 42, 0.14);
|
||||
--good: #059669;
|
||||
--warn: #b45309;
|
||||
--bad: #b91c1c;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
|
||||
body { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.title { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
|
||||
.server { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; }
|
||||
.tool { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.badge { border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||
.controls { display: flex; gap: 8px; align-items: center; }
|
||||
.status { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
button { border: 1px solid var(--border); background: transparent; color: var(--text); border-radius: 8px; padding: 6px 10px; cursor: pointer; font-size: 12px; }
|
||||
button.primary { border-color: color-mix(in srgb, var(--good) 40%, var(--border) 60%); color: var(--good); }
|
||||
button.danger { border-color: color-mix(in srgb, var(--bad) 40%, var(--border) 60%); color: var(--bad); }
|
||||
button:hover { background: color-mix(in srgb, var(--surface) 75%, var(--accent) 25%); }
|
||||
main { flex: 1; min-height: 0; padding: 10px; display: flex; }
|
||||
iframe { width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 10px; background: white; }
|
||||
.overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--bg) 90%, black 10%); display: none; align-items: center; justify-content: center; z-index: 2; }
|
||||
.overlay.visible { display: flex; }
|
||||
.panel { width: min(680px, calc(100vw - 40px)); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
|
||||
.panel h2 { margin: 0 0 8px; font-size: 16px; }
|
||||
.panel p { margin: 0; color: var(--muted); line-height: 1.4; font-size: 14px; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title">
|
||||
<span class="server">MCP · <span id="server-name"></span></span>
|
||||
<span class="tool" id="tool-name"></span>
|
||||
<span class="badge">Sandboxed</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span class="status" id="status">Loading UI...</span>
|
||||
<button class="primary" id="done-btn" title="Cmd/Ctrl+Enter">Done</button>
|
||||
<button class="danger" id="cancel-btn" title="Escape">Cancel</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<iframe id="mcp-app" referrerpolicy="no-referrer"></iframe>
|
||||
</main>
|
||||
<div class="overlay" id="error-overlay">
|
||||
<div class="panel">
|
||||
<h2>UI Error</h2>
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { AppBridge, PostMessageTransport } from ${moduleUrl};
|
||||
|
||||
const SESSION_TOKEN = ${sessionToken};
|
||||
const SERVER_NAME = ${serverName};
|
||||
const TOOL_NAME = ${toolName};
|
||||
const TOOL_ARGS = ${toolArgs};
|
||||
const HOST_CONTEXT = ${hostContextJson};
|
||||
const ALLOW_ATTRIBUTE = ${allowAttribute};
|
||||
const REQUIRE_TOOL_CONSENT = ${requireToolConsent};
|
||||
const CACHE_TOOL_CONSENT = ${cacheToolConsent};
|
||||
const STREAM_CONTEXT_KEY = "pi-mcp-adapter/stream";
|
||||
const STREAM_PATCH_METHOD = "notifications/pi-mcp-adapter/ui-result-patch";
|
||||
|
||||
const iframe = document.getElementById("mcp-app");
|
||||
const statusNode = document.getElementById("status");
|
||||
const doneBtn = document.getElementById("done-btn");
|
||||
const cancelBtn = document.getElementById("cancel-btn");
|
||||
const errorOverlay = document.getElementById("error-overlay");
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
|
||||
document.getElementById("server-name").textContent = SERVER_NAME;
|
||||
document.getElementById("tool-name").textContent = TOOL_NAME;
|
||||
|
||||
const setStatus = (text, isError = false) => {
|
||||
statusNode.textContent = text;
|
||||
statusNode.style.color = isError ? "var(--bad)" : "var(--muted)";
|
||||
};
|
||||
|
||||
const showError = (message) => {
|
||||
errorMessage.textContent = message;
|
||||
errorOverlay.classList.add("visible");
|
||||
setStatus("Error", true);
|
||||
};
|
||||
|
||||
const post = async (endpoint, params) => {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: SESSION_TOKEN, params }),
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({ ok: false, error: "Invalid JSON response" }));
|
||||
if (!response.ok || !body.ok) {
|
||||
const message = body.error || ("HTTP " + response.status);
|
||||
throw new Error(message);
|
||||
}
|
||||
return body.result ?? {};
|
||||
};
|
||||
|
||||
let consentGranted = !REQUIRE_TOOL_CONSENT;
|
||||
const initialStreamContext = HOST_CONTEXT?.[STREAM_CONTEXT_KEY];
|
||||
const streamMode = initialStreamContext?.mode === "stream-first" ? "stream-first" : "eager";
|
||||
|
||||
const bridge = new AppBridge(
|
||||
null,
|
||||
{ name: "pi", version: "1.0.0" },
|
||||
{ serverTools: {}, openLinks: {}, logging: {}, updateModelContext: {}, message: {} },
|
||||
{ hostContext: HOST_CONTEXT }
|
||||
);
|
||||
|
||||
bridge.oncalltool = async (params) => {
|
||||
if (!consentGranted) {
|
||||
const accepted = window.confirm("Allow this UI to call server tools for this session?");
|
||||
if (!accepted) {
|
||||
await post("/proxy/ui/consent", { approved: false }).catch(() => {});
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Tool call denied by user." }],
|
||||
};
|
||||
}
|
||||
await post("/proxy/ui/consent", { approved: true });
|
||||
if (CACHE_TOOL_CONSENT) {
|
||||
consentGranted = true;
|
||||
}
|
||||
}
|
||||
const result = await post("/proxy/tools/call", params);
|
||||
// Notify agent about the tool call
|
||||
await post("/proxy/ui/message", {
|
||||
type: "intent",
|
||||
intent: "call_tool",
|
||||
params: { tool: params.name, arguments: params.arguments, isError: result.isError }
|
||||
}).catch(() => {});
|
||||
return result;
|
||||
};
|
||||
|
||||
bridge.onmessage = async (params) => post("/proxy/ui/message", params);
|
||||
bridge.onupdatemodelcontext = async (params) => post("/proxy/ui/context", params);
|
||||
|
||||
// Also listen for raw postMessage events with custom types (notify, prompt, intent, etc.)
|
||||
// These bypass the AppBridge protocol but are used by some MCP UI implementations
|
||||
window.addEventListener("message", async (event) => {
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
// Skip AppBridge protocol messages (handled by bridge)
|
||||
if (data.jsonrpc || (typeof data.method === "string" && (data.method.startsWith("app/") || data.method.startsWith("host/")))) return;
|
||||
|
||||
// Handle raw UI action messages
|
||||
const msgType = data.type;
|
||||
if (typeof msgType !== "string") return;
|
||||
|
||||
if (msgType === "notify" || msgType === "prompt" || msgType === "intent" || msgType === "message") {
|
||||
// Standard MCP-UI types - preserve their semantics
|
||||
// Support both { type, payload: {...} } and { type, field: value } formats
|
||||
const { type: _, payload, ...directFields } = data;
|
||||
await post("/proxy/ui/message", { type: msgType, ...directFields, ...(payload || {}) }).catch(() => {});
|
||||
} else if (!msgType.startsWith("ui-lifecycle-") && !msgType.startsWith("ui-message-")) {
|
||||
// Any other custom type - forward as intent with type as intent name
|
||||
// (Skip internal lifecycle/ack messages)
|
||||
const payload = data.payload || {};
|
||||
await post("/proxy/ui/message", {
|
||||
type: "intent",
|
||||
intent: msgType,
|
||||
params: payload,
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
bridge.ondownloadfile = async (params) => post("/proxy/ui/download-file", params);
|
||||
bridge.onrequestdisplaymode = async (params) => post("/proxy/ui/request-display-mode", params);
|
||||
bridge.onopenlink = async (params) => {
|
||||
const result = await post("/proxy/ui/open-link", params);
|
||||
if (!result.isError) {
|
||||
window.open(params.url, "_blank", "noopener,noreferrer");
|
||||
// Notify agent about the link open
|
||||
await post("/proxy/ui/message", {
|
||||
type: "intent",
|
||||
intent: "open_link",
|
||||
params: { url: params.url }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
bridge.oninitialized = () => {
|
||||
if (streamMode !== "stream-first") {
|
||||
bridge.sendToolInput({ arguments: TOOL_ARGS });
|
||||
}
|
||||
setStatus(streamMode === "stream-first" ? "Streaming…" : "Connected");
|
||||
};
|
||||
|
||||
bridge.onsizechange = ({ width, height }) => {
|
||||
if (typeof width === "number" && width > 0) {
|
||||
iframe.style.minWidth = Math.min(width, window.innerWidth - 24) + "px";
|
||||
}
|
||||
if (typeof height === "number" && height > 0) {
|
||||
iframe.style.height = Math.max(height, 320) + "px";
|
||||
}
|
||||
};
|
||||
|
||||
if (ALLOW_ATTRIBUTE) {
|
||||
iframe.setAttribute("allow", ALLOW_ATTRIBUTE);
|
||||
}
|
||||
|
||||
// Connect bridge BEFORE loading iframe to ensure we're listening when the app sends ui/initialize
|
||||
try {
|
||||
const transport = new PostMessageTransport(iframe.contentWindow, null);
|
||||
await bridge.connect(transport);
|
||||
} catch (error) {
|
||||
console.error("[host] Bridge connection failed:", error);
|
||||
showError("Failed to initialize AppBridge: " + String(error));
|
||||
}
|
||||
|
||||
const iframeLoaded = new Promise((resolve) => {
|
||||
iframe.onload = resolve;
|
||||
});
|
||||
iframe.src = "/ui-app?session=" + encodeURIComponent(SESSION_TOKEN);
|
||||
await iframeLoaded;
|
||||
|
||||
const eventSource = new EventSource("/events?session=" + encodeURIComponent(SESSION_TOKEN));
|
||||
eventSource.addEventListener("tool-input", (event) => {
|
||||
try {
|
||||
bridge.sendToolInput(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
showError("Failed to forward tool input: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("tool-result", (event) => {
|
||||
try {
|
||||
bridge.sendToolResult(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
showError("Failed to forward tool result: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("tool-cancelled", (event) => {
|
||||
try {
|
||||
bridge.sendToolCancelled(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
showError("Failed to forward cancellation: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("result-patch", async (event) => {
|
||||
try {
|
||||
await bridge.notification({
|
||||
method: STREAM_PATCH_METHOD,
|
||||
params: JSON.parse(event.data),
|
||||
});
|
||||
} catch (error) {
|
||||
showError("Failed to forward stream patch: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("host-context", (event) => {
|
||||
try {
|
||||
bridge.setHostContext(JSON.parse(event.data));
|
||||
} catch {}
|
||||
});
|
||||
eventSource.addEventListener("session-complete", async () => {
|
||||
await bridge.teardownResource({}).catch(() => {});
|
||||
eventSource.close();
|
||||
window.close();
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
setStatus("Connection lost", true);
|
||||
};
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
post("/proxy/ui/heartbeat", {}).catch(() => {});
|
||||
}, 10000);
|
||||
|
||||
const complete = async (reason) => {
|
||||
try {
|
||||
await post("/proxy/ui/complete", { reason });
|
||||
} catch {}
|
||||
try {
|
||||
await bridge.teardownResource({});
|
||||
} catch {}
|
||||
clearInterval(heartbeat);
|
||||
eventSource.close();
|
||||
window.close();
|
||||
};
|
||||
|
||||
doneBtn.addEventListener("click", () => complete("done"));
|
||||
cancelBtn.addEventListener("click", () => complete("cancel"));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
complete("cancel");
|
||||
} else if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
complete("done");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function buildCspMetaContent(csp: UiResourceCsp | undefined): string | undefined {
|
||||
if (!csp) return undefined;
|
||||
|
||||
const directives: string[] = [];
|
||||
directives.push("default-src 'none'");
|
||||
|
||||
const scriptSrc = toDirective("script-src", csp.scriptDomains);
|
||||
const styleSrc = toDirective("style-src", csp.styleDomains);
|
||||
const fontSrc = toDirective("font-src", csp.fontDomains);
|
||||
const imgSrc = toDirective("img-src", csp.imgDomains);
|
||||
const mediaSrc = toDirective("media-src", csp.mediaDomains);
|
||||
const connectSrc = toDirective("connect-src", csp.connectDomains);
|
||||
const frameSrc = toDirective("frame-src", csp.frameDomains);
|
||||
const workerSrc = toDirective("worker-src", csp.workerDomains);
|
||||
const baseUri = toDirective("base-uri", csp.baseUriDomains);
|
||||
|
||||
if (scriptSrc) directives.push(scriptSrc);
|
||||
if (styleSrc) directives.push(styleSrc);
|
||||
if (fontSrc) directives.push(fontSrc);
|
||||
if (imgSrc) directives.push(imgSrc);
|
||||
if (mediaSrc) directives.push(mediaSrc);
|
||||
if (connectSrc) directives.push(connectSrc);
|
||||
if (frameSrc) directives.push(frameSrc);
|
||||
if (workerSrc) directives.push(workerSrc);
|
||||
if (baseUri) directives.push(baseUri);
|
||||
|
||||
return directives.join("; ");
|
||||
}
|
||||
|
||||
function toDirective(name: string, domains: string[] | undefined): string | null {
|
||||
if (!domains || domains.length === 0) return null;
|
||||
return `${name} ${domains.join(" ")}`;
|
||||
}
|
||||
|
||||
export function applyCspMeta(html: string, cspContent: string | undefined): string {
|
||||
if (!cspContent) return html;
|
||||
if (/http-equiv=["']Content-Security-Policy["']/i.test(html)) return html;
|
||||
const metaTag = `<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttribute(cspContent)}">`;
|
||||
if (/<head[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head[^>]*>/i, (match) => `${match}\n${metaTag}`);
|
||||
}
|
||||
return `${metaTag}\n${html}`;
|
||||
}
|
||||
|
||||
function safeInlineJSON(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
361
agent/extensions/pi-mcp-adapter/index.ts
Normal file
361
agent/extensions/pi-mcp-adapter/index.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import { Type } from "typebox";
|
||||
import { showStatus, showTools, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
|
||||
import { loadMcpConfig } from "./config.ts";
|
||||
import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.ts";
|
||||
import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
|
||||
import { loadMetadataCache } from "./metadata-cache.ts";
|
||||
import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
|
||||
import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
|
||||
import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
|
||||
import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
|
||||
|
||||
export default function mcpAdapter(pi: ExtensionAPI) {
|
||||
let state: McpExtensionState | null = null;
|
||||
let initPromise: Promise<McpExtensionState> | null = null;
|
||||
let lifecycleGeneration = 0;
|
||||
|
||||
async function shutdownState(currentState: McpExtensionState | null, reason: string): Promise<void> {
|
||||
if (!currentState) return;
|
||||
|
||||
if (currentState.uiServer) {
|
||||
currentState.uiServer.close(reason);
|
||||
currentState.uiServer = null;
|
||||
}
|
||||
|
||||
let flushError: unknown;
|
||||
try {
|
||||
flushMetadataCache(currentState);
|
||||
} catch (error) {
|
||||
flushError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await currentState.lifecycle.gracefulShutdown();
|
||||
} catch (error) {
|
||||
if (flushError) {
|
||||
console.error("MCP: graceful shutdown failed after metadata flush error", error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (flushError) {
|
||||
throw flushError;
|
||||
}
|
||||
}
|
||||
|
||||
const earlyConfigPath = getConfigPathFromArgv();
|
||||
const earlyConfig = loadMcpConfig(earlyConfigPath);
|
||||
const earlyCache = loadMetadataCache();
|
||||
const prefix = earlyConfig.settings?.toolPrefix ?? "server";
|
||||
|
||||
const envRaw = process.env.MCP_DIRECT_TOOLS;
|
||||
const directSpecs = envRaw === "__none__"
|
||||
? []
|
||||
: resolveDirectTools(
|
||||
earlyConfig,
|
||||
earlyCache,
|
||||
prefix,
|
||||
envRaw?.split(",").map(s => s.trim()).filter(Boolean),
|
||||
);
|
||||
const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(earlyConfig, earlyCache);
|
||||
const shouldRegisterProxyTool =
|
||||
earlyConfig.settings?.disableProxyTool !== true
|
||||
|| directSpecs.length === 0
|
||||
|| missingConfiguredDirectToolServers.length > 0;
|
||||
|
||||
for (const spec of directSpecs) {
|
||||
(pi.registerTool as (tool: unknown) => unknown)({
|
||||
name: spec.prefixedName,
|
||||
label: `MCP: ${spec.originalName}`,
|
||||
description: spec.description || "(no description)",
|
||||
promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
|
||||
parameters: Type.Unsafe((spec.inputSchema || { type: "object", properties: {} }) as never),
|
||||
execute: createDirectToolExecutor(() => state, () => initPromise, spec),
|
||||
renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
|
||||
renderResult: renderMcpToolResult,
|
||||
});
|
||||
}
|
||||
|
||||
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
||||
|
||||
pi.registerFlag("mcp-config", {
|
||||
description: "Path to MCP config file",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const generation = ++lifecycleGeneration;
|
||||
const previousState = state;
|
||||
state = null;
|
||||
initPromise = null;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
shutdownState(previousState, "session_restart"),
|
||||
shutdownOAuth(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("MCP: failed to shut down previous session state", error);
|
||||
}
|
||||
|
||||
if (generation !== lifecycleGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeOAuth().catch(err => {
|
||||
console.error("MCP OAuth initialization failed:", err);
|
||||
});
|
||||
|
||||
const promise = initializeMcp(pi, ctx);
|
||||
initPromise = promise;
|
||||
|
||||
promise.then(async (nextState) => {
|
||||
if (generation !== lifecycleGeneration || initPromise !== promise) {
|
||||
try {
|
||||
await shutdownState(nextState, "stale_session_start");
|
||||
} catch (error) {
|
||||
console.error("MCP: failed to clean stale session state", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state = nextState;
|
||||
updateStatusBar(nextState);
|
||||
initPromise = null;
|
||||
}).catch(err => {
|
||||
if (generation !== lifecycleGeneration) {
|
||||
return;
|
||||
}
|
||||
if (initPromise !== promise && initPromise !== null) {
|
||||
return;
|
||||
}
|
||||
console.error("MCP initialization failed:", err);
|
||||
initPromise = null;
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
++lifecycleGeneration;
|
||||
const currentState = state;
|
||||
state = null;
|
||||
initPromise = null;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
shutdownState(currentState, "session_shutdown"),
|
||||
shutdownOAuth(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("MCP: session shutdown cleanup failed", error);
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("mcp", {
|
||||
description: "Show MCP server status",
|
||||
handler: async (args, ctx) => {
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = args?.trim()?.split(/\s+/) ?? [];
|
||||
const subcommand = parts[0] ?? "";
|
||||
const targetServer = parts[1];
|
||||
const rest = parts.slice(1).join(" ");
|
||||
|
||||
switch (subcommand) {
|
||||
case "reconnect":
|
||||
await reconnectServers(state, ctx, targetServer);
|
||||
break;
|
||||
case "tools":
|
||||
await showTools(state, ctx);
|
||||
break;
|
||||
case "setup": {
|
||||
const result = await openMcpSetup(state, pi, ctx, earlyConfigPath, "setup");
|
||||
if (result?.configChanged) {
|
||||
await ctx.reload();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "logout": {
|
||||
const serverName = rest;
|
||||
if (!serverName) {
|
||||
if (ctx.hasUI) ctx.ui.notify("Usage: /mcp logout <server>", "error");
|
||||
return;
|
||||
}
|
||||
await logoutServer(serverName, state, ctx);
|
||||
break;
|
||||
}
|
||||
case "status":
|
||||
case "":
|
||||
default:
|
||||
if (ctx.hasUI) {
|
||||
const result = await openMcpPanel(state, pi, ctx, earlyConfigPath);
|
||||
if (result?.configChanged) {
|
||||
await ctx.reload();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await showStatus(state, ctx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("mcp-auth", {
|
||||
description: "Authenticate with an MCP server (OAuth)",
|
||||
handler: async (args, ctx) => {
|
||||
const serverName = args?.trim();
|
||||
if (!serverName && !ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverName) {
|
||||
await openMcpAuthPanel(state, pi, ctx, earlyConfigPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await authenticateServer(serverName, state.config, ctx);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldRegisterProxyTool) {
|
||||
(pi.registerTool as (tool: unknown) => unknown)({
|
||||
name: "mcp",
|
||||
label: "MCP",
|
||||
description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
|
||||
promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
|
||||
renderCall: renderMcpProxyToolCall,
|
||||
parameters: Type.Object({
|
||||
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
||||
args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
|
||||
connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
|
||||
describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
|
||||
search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
|
||||
regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
|
||||
includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
|
||||
server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
|
||||
action: Type.Optional(Type.String({ description: "Action: 'ui-messages', 'auth-start', or 'auth-complete'" })),
|
||||
}),
|
||||
renderResult: renderMcpToolResult,
|
||||
async execute(_toolCallId, params: {
|
||||
tool?: string;
|
||||
args?: string;
|
||||
connect?: string;
|
||||
describe?: string;
|
||||
search?: string;
|
||||
regex?: boolean;
|
||||
includeSchemas?: boolean;
|
||||
server?: string;
|
||||
action?: string;
|
||||
}, _signal, _onUpdate, _ctx) {
|
||||
let parsedArgs: Record<string, unknown> | undefined;
|
||||
if (params.args) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(params.args);
|
||||
if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
|
||||
const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
|
||||
throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
|
||||
details: { error: "init_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
||||
details: { error: "not_initialized" },
|
||||
};
|
||||
}
|
||||
|
||||
if (params.action === "ui-messages") {
|
||||
return executeUiMessages(state);
|
||||
}
|
||||
if (params.action === "auth-start") {
|
||||
if (!params.server) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
|
||||
details: { mode: "auth-start", error: "missing_server" },
|
||||
};
|
||||
}
|
||||
return executeAuthStart(state, params.server);
|
||||
}
|
||||
if (params.action === "auth-complete") {
|
||||
if (!params.server) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
|
||||
details: { mode: "auth-complete", error: "missing_server" },
|
||||
};
|
||||
}
|
||||
const input = parsedArgs?.redirectUrl ?? parsedArgs?.code ?? parsedArgs?.input;
|
||||
if (typeof input !== "string" || input.trim().length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "auth-complete requires args with `redirectUrl`, `code`, or `input`." }],
|
||||
details: { mode: "auth-complete", error: "missing_input" },
|
||||
};
|
||||
}
|
||||
return executeAuthComplete(state, params.server, input);
|
||||
}
|
||||
if (params.tool) {
|
||||
return executeCall(state, params.tool, parsedArgs, params.server, getPiTools);
|
||||
}
|
||||
if (params.connect) {
|
||||
return executeConnect(state, params.connect);
|
||||
}
|
||||
if (params.describe) {
|
||||
return executeDescribe(state, params.describe);
|
||||
}
|
||||
if (params.search) {
|
||||
return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
|
||||
}
|
||||
if (params.server) {
|
||||
return executeList(state, params.server);
|
||||
}
|
||||
return executeStatus(state);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
347
agent/extensions/pi-mcp-adapter/init.ts
Normal file
347
agent/extensions/pi-mcp-adapter/init.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { ToolMetadata } from "./types.ts";
|
||||
import { existsSync } from "node:fs";
|
||||
import { loadMcpConfig } from "./config.ts";
|
||||
import { ConsentManager } from "./consent-manager.ts";
|
||||
import { McpLifecycleManager } from "./lifecycle.ts";
|
||||
import {
|
||||
computeServerHash,
|
||||
getMetadataCachePath,
|
||||
isServerCacheValid,
|
||||
loadMetadataCache,
|
||||
reconstructToolMetadata,
|
||||
saveMetadataCache,
|
||||
serializeResources,
|
||||
serializeTools,
|
||||
type ServerCacheEntry,
|
||||
} from "./metadata-cache.ts";
|
||||
import { McpServerManager } from "./server-manager.ts";
|
||||
import { buildToolMetadata, totalToolCount } from "./tool-metadata.ts";
|
||||
import { UiResourceHandler } from "./ui-resource-handler.ts";
|
||||
import { openUrl, parallelLimit } from "./utils.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { getMissingConfiguredDirectToolServers } from "./direct-tools.ts";
|
||||
|
||||
const FAILURE_BACKOFF_MS = 60 * 1000;
|
||||
|
||||
export function isTuiMode(ctx: Pick<ExtensionContext, "hasUI" | "mode">): boolean {
|
||||
return ctx.hasUI && ctx.mode === "tui";
|
||||
}
|
||||
|
||||
export async function initializeMcp(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext
|
||||
): Promise<McpExtensionState> {
|
||||
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
||||
const config = loadMcpConfig(configPath, ctx.cwd);
|
||||
|
||||
const manager = new McpServerManager();
|
||||
const samplingAutoApprove = config.settings?.samplingAutoApprove === true;
|
||||
if (config.settings?.sampling !== false && (ctx.hasUI || samplingAutoApprove)) {
|
||||
manager.setSamplingConfig({
|
||||
autoApprove: samplingAutoApprove,
|
||||
ui: ctx.hasUI ? ctx.ui : undefined,
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
getCurrentModel: () => ctx.model,
|
||||
getSignal: () => ctx.signal,
|
||||
});
|
||||
}
|
||||
const elicitationEnabled = config.settings?.elicitation !== false && ctx.hasUI;
|
||||
if (elicitationEnabled) {
|
||||
manager.setElicitationConfig({
|
||||
ui: ctx.ui,
|
||||
allowUrl: isTuiMode(ctx),
|
||||
});
|
||||
}
|
||||
const lifecycle = new McpLifecycleManager(manager);
|
||||
const toolMetadata = new Map<string, ToolMetadata[]>();
|
||||
const failureTracker = new Map<string, number>();
|
||||
const uiResourceHandler = new UiResourceHandler(manager);
|
||||
const consentManager = new ConsentManager("once-per-server");
|
||||
const ui = ctx.hasUI ? ctx.ui : undefined;
|
||||
const state: McpExtensionState = {
|
||||
manager,
|
||||
lifecycle,
|
||||
toolMetadata,
|
||||
config,
|
||||
failureTracker,
|
||||
uiResourceHandler,
|
||||
consentManager,
|
||||
uiServer: null,
|
||||
completedUiSessions: [],
|
||||
openBrowser: (url: string) => openUrl(pi, url, process.env.BROWSER),
|
||||
ui,
|
||||
sendMessage: (message, options) => pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options),
|
||||
};
|
||||
|
||||
const serverEntries = Object.entries(config.mcpServers);
|
||||
if (serverEntries.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
|
||||
lifecycle.setGlobalIdleTimeout(idleSetting);
|
||||
|
||||
const cachePath = getMetadataCachePath();
|
||||
const cacheFileExists = existsSync(cachePath);
|
||||
let cache = loadMetadataCache();
|
||||
let bootstrapAll = false;
|
||||
|
||||
if (!cacheFileExists) {
|
||||
bootstrapAll = true;
|
||||
saveMetadataCache({ version: 1, servers: {} });
|
||||
} else if (!cache) {
|
||||
cache = { version: 1, servers: {} };
|
||||
saveMetadataCache(cache);
|
||||
}
|
||||
|
||||
const prefix = config.settings?.toolPrefix ?? "server";
|
||||
|
||||
for (const [name, definition] of serverEntries) {
|
||||
const lifecycleMode = definition.lifecycle ?? "lazy";
|
||||
const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
|
||||
lifecycle.registerServer(
|
||||
name,
|
||||
definition,
|
||||
idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
|
||||
);
|
||||
if (lifecycleMode === "keep-alive") {
|
||||
lifecycle.markKeepAlive(name, definition);
|
||||
}
|
||||
|
||||
if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
|
||||
const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition);
|
||||
toolMetadata.set(name, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
const startupServers = bootstrapAll
|
||||
? serverEntries
|
||||
: serverEntries.filter(([, definition]) => {
|
||||
const mode = definition.lifecycle ?? "lazy";
|
||||
return mode === "keep-alive" || mode === "eager";
|
||||
});
|
||||
|
||||
if (ctx.hasUI && startupServers.length > 0) {
|
||||
ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
|
||||
}
|
||||
|
||||
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
||||
try {
|
||||
const connection = await manager.connect(name, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
|
||||
}
|
||||
return { name, definition, connection, error: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { name, definition, connection: null, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
for (const { name, definition, connection, error } of results) {
|
||||
if (error || !connection) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
|
||||
}
|
||||
console.error(`MCP: Failed to connect to ${name}: ${error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
||||
toolMetadata.set(name, metadata);
|
||||
updateMetadataCache(state, name);
|
||||
|
||||
if (failedTools.length > 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
`MCP: ${name} - ${failedTools.length} tools skipped`,
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const connectedCount = results.filter(r => r.connection).length;
|
||||
const failedCount = results.filter(r => r.error).length;
|
||||
if (ctx.hasUI && connectedCount > 0) {
|
||||
const totalTools = totalToolCount(state);
|
||||
const msg = failedCount > 0
|
||||
? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
|
||||
: `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
|
||||
ctx.ui.notify(msg, "info");
|
||||
}
|
||||
|
||||
const envDirect = process.env.MCP_DIRECT_TOOLS;
|
||||
if (envDirect !== "__none__") {
|
||||
const currentCache = loadMetadataCache();
|
||||
const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
|
||||
|
||||
if (missingCacheServers.length > 0) {
|
||||
const bootstrapResults = await parallelLimit(
|
||||
missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
|
||||
10,
|
||||
async (name) => {
|
||||
const definition = config.mcpServers[name];
|
||||
try {
|
||||
const connection = await manager.connect(name, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
return { name, ok: false };
|
||||
}
|
||||
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
||||
toolMetadata.set(name, metadata);
|
||||
updateMetadataCache(state, name);
|
||||
return { name, ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`);
|
||||
return { name, ok: false };
|
||||
}
|
||||
},
|
||||
);
|
||||
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
|
||||
if (bootstrapped.length > 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle.setReconnectCallback((serverName) => {
|
||||
updateServerMetadata(state, serverName);
|
||||
updateMetadataCache(state, serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
updateStatusBar(state);
|
||||
});
|
||||
|
||||
lifecycle.setIdleShutdownCallback((serverName) => {
|
||||
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
|
||||
logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`);
|
||||
updateStatusBar(state);
|
||||
});
|
||||
|
||||
lifecycle.startHealthChecks();
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function updateServerMetadata(state: McpExtensionState, serverName: string): void {
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (!connection || connection.status !== "connected") return;
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) return;
|
||||
|
||||
const prefix = state.config.settings?.toolPrefix ?? "server";
|
||||
|
||||
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
||||
state.toolMetadata.set(serverName, metadata);
|
||||
}
|
||||
|
||||
export function updateMetadataCache(state: McpExtensionState, serverName: string): void {
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (!connection || connection.status !== "connected") return;
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) return;
|
||||
|
||||
const configHash = computeServerHash(definition);
|
||||
const existing = loadMetadataCache();
|
||||
const existingEntry = existing?.servers?.[serverName];
|
||||
|
||||
const tools = serializeTools(connection.tools);
|
||||
let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
|
||||
|
||||
if (
|
||||
definition.exposeResources !== false &&
|
||||
resources.length === 0 &&
|
||||
existingEntry?.resources?.length &&
|
||||
existingEntry.configHash === configHash
|
||||
) {
|
||||
resources = existingEntry.resources;
|
||||
}
|
||||
|
||||
const entry: ServerCacheEntry = {
|
||||
configHash,
|
||||
tools,
|
||||
resources,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
|
||||
}
|
||||
|
||||
export function flushMetadataCache(state: McpExtensionState): void {
|
||||
for (const [name, connection] of state.manager.getAllConnections()) {
|
||||
if (connection.status === "connected") {
|
||||
updateMetadataCache(state, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateStatusBar(state: McpExtensionState): void {
|
||||
const ui = state.ui;
|
||||
if (!ui) return;
|
||||
const total = Object.keys(state.config.mcpServers).length;
|
||||
if (total === 0) {
|
||||
ui.setStatus("mcp", undefined);
|
||||
return;
|
||||
}
|
||||
const connectedCount = state.manager.getAllConnections().size;
|
||||
ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
|
||||
}
|
||||
|
||||
export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
|
||||
const failedAt = state.failureTracker.get(serverName);
|
||||
if (!failedAt) return null;
|
||||
const ageMs = Date.now() - failedAt;
|
||||
if (ageMs > FAILURE_BACKOFF_MS) return null;
|
||||
return Math.round(ageMs / 1000);
|
||||
}
|
||||
|
||||
export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (connection?.status === "needs-auth") {
|
||||
return false;
|
||||
}
|
||||
if (connection?.status === "connected") {
|
||||
updateServerMetadata(state, serverName);
|
||||
return true;
|
||||
}
|
||||
|
||||
const failedAgo = getFailureAgeSeconds(state, serverName);
|
||||
if (failedAgo !== null) return false;
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) return false;
|
||||
|
||||
try {
|
||||
if (state.ui) {
|
||||
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
||||
}
|
||||
const newConnection = await state.manager.connect(serverName, definition);
|
||||
if (newConnection.status === "needs-auth") {
|
||||
return false;
|
||||
}
|
||||
state.failureTracker.delete(serverName);
|
||||
updateServerMetadata(state, serverName);
|
||||
updateMetadataCache(state, serverName);
|
||||
updateStatusBar(state);
|
||||
return true;
|
||||
} catch (error) {
|
||||
state.failureTracker.set(serverName, Date.now());
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
|
||||
updateStatusBar(state);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
||||
}
|
||||
if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
|
||||
const mode = definition.lifecycle ?? "lazy";
|
||||
if (mode === "eager") return 0;
|
||||
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
||||
}
|
||||
93
agent/extensions/pi-mcp-adapter/lifecycle.ts
Normal file
93
agent/extensions/pi-mcp-adapter/lifecycle.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ServerDefinition } from "./types.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
|
||||
export type ReconnectCallback = (serverName: string) => void;
|
||||
|
||||
export class McpLifecycleManager {
|
||||
private manager: McpServerManager;
|
||||
private keepAliveServers = new Map<string, ServerDefinition>();
|
||||
private allServers = new Map<string, ServerDefinition>();
|
||||
private serverSettings = new Map<string, { idleTimeout?: number }>();
|
||||
private globalIdleTimeout: number = 10 * 60 * 1000;
|
||||
private healthCheckInterval?: NodeJS.Timeout;
|
||||
private onReconnect?: ReconnectCallback;
|
||||
private onIdleShutdown?: (serverName: string) => void;
|
||||
|
||||
constructor(manager: McpServerManager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback to be invoked after a successful auto-reconnect.
|
||||
* Use this to update tool metadata when a server reconnects.
|
||||
*/
|
||||
setReconnectCallback(callback: ReconnectCallback): void {
|
||||
this.onReconnect = callback;
|
||||
}
|
||||
|
||||
markKeepAlive(name: string, definition: ServerDefinition): void {
|
||||
this.keepAliveServers.set(name, definition);
|
||||
}
|
||||
|
||||
registerServer(name: string, definition: ServerDefinition, settings?: { idleTimeout?: number }): void {
|
||||
this.allServers.set(name, definition);
|
||||
if (settings?.idleTimeout !== undefined) {
|
||||
this.serverSettings.set(name, settings);
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalIdleTimeout(minutes: number): void {
|
||||
this.globalIdleTimeout = minutes * 60 * 1000;
|
||||
}
|
||||
|
||||
setIdleShutdownCallback(callback: (serverName: string) => void): void {
|
||||
this.onIdleShutdown = callback;
|
||||
}
|
||||
|
||||
startHealthChecks(intervalMs = 30000): void {
|
||||
this.healthCheckInterval = setInterval(() => {
|
||||
this.checkConnections();
|
||||
}, intervalMs);
|
||||
this.healthCheckInterval.unref();
|
||||
}
|
||||
|
||||
private async checkConnections(): Promise<void> {
|
||||
for (const [name, definition] of this.keepAliveServers) {
|
||||
const connection = this.manager.getConnection(name);
|
||||
|
||||
if (!connection || connection.status !== "connected") {
|
||||
try {
|
||||
await this.manager.connect(name, definition);
|
||||
logger.debug(`Reconnected to ${name}`);
|
||||
// Notify extension to update metadata
|
||||
this.onReconnect?.(name);
|
||||
} catch (error) {
|
||||
console.error(`MCP: Failed to reconnect to ${name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name] of this.allServers) {
|
||||
if (this.keepAliveServers.has(name)) continue;
|
||||
const timeout = this.getIdleTimeout(name);
|
||||
if (timeout > 0 && this.manager.isIdle(name, timeout)) {
|
||||
await this.manager.close(name);
|
||||
this.onIdleShutdown?.(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getIdleTimeout(name: string): number {
|
||||
const perServer = this.serverSettings.get(name)?.idleTimeout;
|
||||
if (perServer !== undefined) return perServer * 60 * 1000;
|
||||
return this.globalIdleTimeout;
|
||||
}
|
||||
|
||||
async gracefulShutdown(): Promise<void> {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
}
|
||||
await this.manager.closeAll();
|
||||
}
|
||||
}
|
||||
169
agent/extensions/pi-mcp-adapter/logger.ts
Normal file
169
agent/extensions/pi-mcp-adapter/logger.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Centralized logging for MCP UI operations.
|
||||
* Provides structured, contextual logs with levels.
|
||||
*/
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogContext {
|
||||
server?: string;
|
||||
session?: string;
|
||||
tool?: string;
|
||||
uri?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: LogContext;
|
||||
error?: Error;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
type LogHandler = (entry: LogEntry) => void;
|
||||
|
||||
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const LEVEL_PREFIX: Record<LogLevel, string> = {
|
||||
debug: "[MCP-UI:DEBUG]",
|
||||
info: "[MCP-UI]",
|
||||
warn: "[MCP-UI:WARN]",
|
||||
error: "[MCP-UI:ERROR]",
|
||||
};
|
||||
|
||||
class Logger {
|
||||
private minLevel: LogLevel = "info";
|
||||
private handlers: LogHandler[] = [];
|
||||
private defaultContext: LogContext = {};
|
||||
|
||||
setLevel(level: LogLevel): void {
|
||||
this.minLevel = level;
|
||||
}
|
||||
|
||||
setDefaultContext(context: LogContext): void {
|
||||
this.defaultContext = context;
|
||||
}
|
||||
|
||||
addHandler(handler: LogHandler): void {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
clearHandlers(): void {
|
||||
this.handlers = [];
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.minLevel];
|
||||
}
|
||||
|
||||
private emit(level: LogLevel, message: string, context?: LogContext, error?: Error): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
context: { ...this.defaultContext, ...context },
|
||||
error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Default console output
|
||||
const prefix = LEVEL_PREFIX[level];
|
||||
const contextStr = formatContext(entry.context);
|
||||
const fullMessage = contextStr ? `${prefix} ${message} ${contextStr}` : `${prefix} ${message}`;
|
||||
|
||||
if (level === "error") {
|
||||
console.error(fullMessage, error ?? "");
|
||||
} else if (level === "warn") {
|
||||
console.warn(fullMessage);
|
||||
} else if (level === "debug") {
|
||||
console.debug(fullMessage);
|
||||
} else {
|
||||
console.log(fullMessage);
|
||||
}
|
||||
|
||||
// Custom handlers
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(entry);
|
||||
} catch {
|
||||
// Ignore handler errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.emit("debug", message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.emit("info", message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.emit("warn", message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: LogContext): void {
|
||||
this.emit("error", message, context, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with additional default context.
|
||||
*/
|
||||
child(context: LogContext): ChildLogger {
|
||||
return new ChildLogger(this, context);
|
||||
}
|
||||
}
|
||||
|
||||
class ChildLogger {
|
||||
constructor(
|
||||
private parent: Logger,
|
||||
private context: LogContext
|
||||
) {}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.parent.debug(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.parent.info(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.parent.warn(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: LogContext): void {
|
||||
this.parent.error(message, error, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
child(context: LogContext): ChildLogger {
|
||||
return new ChildLogger(this.parent, { ...this.context, ...context });
|
||||
}
|
||||
}
|
||||
|
||||
function formatContext(context?: LogContext): string {
|
||||
if (!context || Object.keys(context).length === 0) return "";
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const logger = new Logger();
|
||||
|
||||
// Enable debug mode via environment variable
|
||||
if (process.env.MCP_UI_DEBUG === "1" || process.env.MCP_UI_DEBUG === "true") {
|
||||
logger.setLevel("debug");
|
||||
}
|
||||
259
agent/extensions/pi-mcp-adapter/mcp-auth-flow.test.ts
Normal file
259
agent/extensions/pi-mcp-adapter/mcp-auth-flow.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Tests for mcp-auth-flow.ts - OAuth flow using MCP SDK
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { existsSync, rmSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
authenticate,
|
||||
startAuth,
|
||||
completeAuth,
|
||||
getAuthStatus,
|
||||
removeAuth,
|
||||
supportsOAuth,
|
||||
extractOAuthConfig,
|
||||
initializeOAuth,
|
||||
shutdownOAuth,
|
||||
type AuthStatus,
|
||||
} from "./mcp-auth-flow.ts"
|
||||
import { isCallbackServerRunning } from "./mcp-callback-server.ts"
|
||||
import { updateTokens, clearAllCredentials } from "./mcp-auth.ts"
|
||||
import type { ServerEntry } from "./types.ts"
|
||||
|
||||
describe("mcp-auth-flow", () => {
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Shutdown OAuth and clean up
|
||||
await shutdownOAuth()
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("supportsOAuth", () => {
|
||||
it("should return true for OAuth HTTP server", () => {
|
||||
const definition: ServerEntry = {
|
||||
url: "https://api.example.com/mcp",
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), true)
|
||||
})
|
||||
|
||||
it("should return false for bearer auth", () => {
|
||||
const definition: ServerEntry = {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "bearer",
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
|
||||
it("should return false for stdio server", () => {
|
||||
const definition: ServerEntry = {
|
||||
command: "npx",
|
||||
args: ["-y", "@example/mcp-server"],
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
|
||||
it("should return false when no URL", () => {
|
||||
const definition: ServerEntry = {}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthStatus", () => {
|
||||
it("should return 'not_authenticated' when no tokens", async () => {
|
||||
const status = await getAuthStatus("status-test-none")
|
||||
assert.strictEqual(status, "not_authenticated")
|
||||
})
|
||||
|
||||
it("should return 'authenticated' when tokens exist and not expired", async () => {
|
||||
await updateTokens("status-test-ok", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 + 3600, // 1 hour from now
|
||||
})
|
||||
|
||||
const status = await getAuthStatus("status-test-ok")
|
||||
assert.strictEqual(status, "authenticated")
|
||||
})
|
||||
|
||||
it("should return 'expired' when tokens are expired", async () => {
|
||||
await updateTokens("status-test-expired", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 - 3600, // 1 hour ago
|
||||
})
|
||||
|
||||
const status = await getAuthStatus("status-test-expired")
|
||||
assert.strictEqual(status, "expired")
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAuth", () => {
|
||||
it("should remove all credentials", async () => {
|
||||
await updateTokens("remove-test", { accessToken: "token" })
|
||||
|
||||
await removeAuth("remove-test")
|
||||
|
||||
const status = await getAuthStatus("remove-test")
|
||||
assert.strictEqual(status, "not_authenticated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("initializeOAuth / shutdownOAuth", () => {
|
||||
it("should not start callback server on initialize", async () => {
|
||||
await shutdownOAuth()
|
||||
await initializeOAuth()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
|
||||
it("should stop callback server on shutdown", async () => {
|
||||
await initializeOAuth()
|
||||
await shutdownOAuth()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authenticate / completeAuth", () => {
|
||||
it("should throw if no server URL provided", async () => {
|
||||
await assert.rejects(
|
||||
async () => await authenticate("no-url-test", ""),
|
||||
/Invalid URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject malformed OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("bad-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "not a url" },
|
||||
}),
|
||||
/Invalid OAuth redirectUri/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-local OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("remote-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "https://example.com:3118/callback" },
|
||||
}),
|
||||
/localhost or loopback/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values without an explicit port", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("no-port-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://localhost/callback" },
|
||||
}),
|
||||
/explicit numeric port/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject blank OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("blank-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: " " },
|
||||
}),
|
||||
/redirectUri must not be empty/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-string OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("typed-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: 3118 as unknown as string },
|
||||
}),
|
||||
/redirectUri must be a string/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values with fragments", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("fragment-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://localhost:3118/callback#fragment" },
|
||||
}),
|
||||
/redirectUri must not include a fragment/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values with username or password", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("credential-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://user:pass@localhost:3118/callback" },
|
||||
}),
|
||||
/redirectUri must not include username or password/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-string OAuth clientName and clientUri values", () => {
|
||||
assert.throws(
|
||||
() => extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { clientName: 123 as unknown as string },
|
||||
}),
|
||||
/clientName must be a string/
|
||||
)
|
||||
assert.throws(
|
||||
() => extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { clientUri: 123 as unknown as string },
|
||||
}),
|
||||
/clientUri must be a string/
|
||||
)
|
||||
})
|
||||
|
||||
it("should trim OAuth redirectUri and client metadata values", () => {
|
||||
const config = extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: {
|
||||
redirectUri: " http://localhost:3118/callback ",
|
||||
clientName: " Custom MCP ",
|
||||
clientUri: " https://example.com/custom ",
|
||||
},
|
||||
})
|
||||
|
||||
assert.strictEqual(config.redirectUri, "http://localhost:3118/callback")
|
||||
assert.strictEqual(config.clientName, "Custom MCP")
|
||||
assert.strictEqual(config.clientUri, "https://example.com/custom")
|
||||
})
|
||||
})
|
||||
})
|
||||
559
agent/extensions/pi-mcp-adapter/mcp-auth-flow.ts
Normal file
559
agent/extensions/pi-mcp-adapter/mcp-auth-flow.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* MCP Auth Flow
|
||||
*
|
||||
* High-level OAuth flow management using the MCP SDK's built-in auth functions.
|
||||
*/
|
||||
|
||||
import {
|
||||
auth as runSdkAuth,
|
||||
UnauthorizedError,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import open from "open"
|
||||
import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.ts"
|
||||
import {
|
||||
ensureCallbackServer,
|
||||
waitForCallback,
|
||||
cancelPendingCallback,
|
||||
stopCallbackServer,
|
||||
releaseCallbackServer,
|
||||
} from "./mcp-callback-server.ts"
|
||||
import {
|
||||
getAuthForUrl,
|
||||
isTokenExpired,
|
||||
hasStoredTokens,
|
||||
clearAllCredentials,
|
||||
clearClientInfo,
|
||||
clearTokens,
|
||||
clearCodeVerifier,
|
||||
updateOAuthState,
|
||||
getOAuthState,
|
||||
clearOAuthState,
|
||||
type StoredTokens,
|
||||
} from "./mcp-auth.ts"
|
||||
import type { ServerEntry } from "./types.ts"
|
||||
|
||||
/** Auth status for a server */
|
||||
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
||||
|
||||
// Track pending transports for auth completion
|
||||
const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
|
||||
const pendingAuthStates = new Map<string, string>()
|
||||
const pendingAuthCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Deduplicate concurrent authenticate() calls per server.
|
||||
const pendingAuthentications = new Map<string, Promise<AuthStatus>>()
|
||||
|
||||
/** Timeout for manual auth completion (5 minutes) */
|
||||
const MANUAL_AUTH_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random state parameter.
|
||||
*/
|
||||
function generateState(): string {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract OAuth configuration from a ServerEntry.
|
||||
*/
|
||||
export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
||||
if (definition.oauth === false) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const config: McpOAuthConfig = {}
|
||||
if (definition.oauth?.grantType !== undefined) config.grantType = definition.oauth.grantType
|
||||
if (definition.oauth?.clientId !== undefined) config.clientId = definition.oauth.clientId
|
||||
if (definition.oauth?.clientSecret !== undefined) config.clientSecret = definition.oauth.clientSecret
|
||||
if (definition.oauth?.scope !== undefined) config.scope = definition.oauth.scope
|
||||
if (definition.oauth?.redirectUri !== undefined) {
|
||||
if (typeof definition.oauth.redirectUri !== "string") {
|
||||
throw new Error("OAuth redirectUri must be a string")
|
||||
}
|
||||
const redirectUri = definition.oauth.redirectUri.trim()
|
||||
if (!redirectUri) {
|
||||
throw new Error("OAuth redirectUri must not be empty")
|
||||
}
|
||||
config.redirectUri = redirectUri
|
||||
}
|
||||
if (definition.oauth?.clientName !== undefined) {
|
||||
if (typeof definition.oauth.clientName !== "string") {
|
||||
throw new Error("OAuth clientName must be a string")
|
||||
}
|
||||
const clientName = definition.oauth.clientName.trim()
|
||||
if (!clientName) {
|
||||
throw new Error("OAuth clientName must not be empty")
|
||||
}
|
||||
config.clientName = clientName
|
||||
}
|
||||
if (definition.oauth?.clientUri !== undefined) {
|
||||
if (typeof definition.oauth.clientUri !== "string") {
|
||||
throw new Error("OAuth clientUri must be a string")
|
||||
}
|
||||
const clientUri = definition.oauth.clientUri.trim()
|
||||
if (!clientUri) {
|
||||
throw new Error("OAuth clientUri must not be empty")
|
||||
}
|
||||
config.clientUri = clientUri
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHost: string; callbackPath: string } {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(redirectUri)
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid OAuth redirectUri: ${redirectUri}`, { cause: error })
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
|
||||
if (url.protocol !== "http:" || !isLocalhost) {
|
||||
throw new Error("OAuth redirectUri must be an http:// localhost or loopback URI")
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
throw new Error("OAuth redirectUri must not include username or password")
|
||||
}
|
||||
|
||||
if (url.hash) {
|
||||
throw new Error("OAuth redirectUri must not include a fragment")
|
||||
}
|
||||
|
||||
if (!url.port) {
|
||||
throw new Error("OAuth redirectUri must include an explicit numeric port")
|
||||
}
|
||||
|
||||
const port = Number.parseInt(url.port, 10)
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error("OAuth redirectUri must include an explicit numeric port")
|
||||
}
|
||||
|
||||
const callbackHost = hostname === "[::1]" ? "::1" : hostname
|
||||
return { port, callbackHost, callbackPath: url.pathname }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authentication flow for a server.
|
||||
* Returns the authorization URL when browser authorization is required.
|
||||
*/
|
||||
export async function startAuth(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
definition?: ServerEntry
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const config = definition ? extractOAuthConfig(definition) : {}
|
||||
|
||||
if (config.grantType === "client_credentials") {
|
||||
const storedAuth = await getAuthForUrl(serverName, serverUrl)
|
||||
if (storedAuth?.clientInfo && !storedAuth.tokens && !config.clientId) {
|
||||
clearClientInfo(serverName)
|
||||
clearCodeVerifier(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
}
|
||||
|
||||
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
||||
onRedirect: async () => {
|
||||
throw new Error("Browser redirect is not used for client_credentials flow")
|
||||
},
|
||||
})
|
||||
const result = await runSdkAuth(authProvider, { serverUrl })
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new UnauthorizedError("Failed to authorize")
|
||||
}
|
||||
return { authorizationUrl: "" }
|
||||
}
|
||||
|
||||
const redirectCallback = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
|
||||
const oauthState = generateState()
|
||||
|
||||
try {
|
||||
await ensureCallbackServer({
|
||||
strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
|
||||
oauthState,
|
||||
reserveState: true,
|
||||
...(redirectCallback ? { port: redirectCallback.port, callbackHost: redirectCallback.callbackHost, callbackPath: redirectCallback.callbackPath } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
await clearOAuthState(serverName)
|
||||
throw error
|
||||
}
|
||||
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
||||
onRedirect: async (url) => {
|
||||
capturedUrl = url
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const storedAuth = await getAuthForUrl(serverName, serverUrl)
|
||||
if (storedAuth?.clientInfo && !config.clientId) {
|
||||
if (!storedAuth.tokens) {
|
||||
clearClientInfo(serverName)
|
||||
clearCodeVerifier(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
} else {
|
||||
const redirectUris = storedAuth.clientInfo.redirectUris
|
||||
if (!Array.isArray(redirectUris) || !redirectUris.includes(authProvider.redirectUrl ?? "")) {
|
||||
clearClientInfo(serverName)
|
||||
clearTokens(serverName)
|
||||
clearCodeVerifier(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateOAuthState(serverName, oauthState, serverUrl)
|
||||
|
||||
const result = await runSdkAuth(authProvider, { serverUrl })
|
||||
if (result === "AUTHORIZED") {
|
||||
releaseCallbackServer(oauthState)
|
||||
await clearOAuthState(serverName)
|
||||
return { authorizationUrl: "" }
|
||||
}
|
||||
if (!capturedUrl) {
|
||||
throw new UnauthorizedError("OAuth authorization URL was not provided")
|
||||
}
|
||||
const pendingTransport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider })
|
||||
await setPendingTransport(serverName, pendingTransport, oauthState)
|
||||
return { authorizationUrl: capturedUrl.toString() }
|
||||
} catch (error) {
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function setPendingTransport(
|
||||
serverName: string,
|
||||
transport: StreamableHTTPClientTransport,
|
||||
oauthState: string,
|
||||
): Promise<void> {
|
||||
await clearPendingAuth(serverName)
|
||||
pendingTransports.set(serverName, transport)
|
||||
pendingAuthStates.set(serverName, oauthState)
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
void clearPendingAuth(serverName, oauthState)
|
||||
}, MANUAL_AUTH_TIMEOUT_MS)
|
||||
cleanupTimer.unref?.()
|
||||
pendingAuthCleanupTimers.set(serverName, cleanupTimer)
|
||||
}
|
||||
|
||||
async function clearPendingAuth(serverName: string, oauthState?: string): Promise<void> {
|
||||
const pendingState = pendingAuthStates.get(serverName)
|
||||
if (oauthState && pendingState && pendingState !== oauthState) return
|
||||
|
||||
const timer = pendingAuthCleanupTimers.get(serverName)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pendingAuthCleanupTimers.delete(serverName)
|
||||
}
|
||||
|
||||
const transport = pendingTransports.get(serverName)
|
||||
pendingTransports.delete(serverName)
|
||||
pendingAuthStates.delete(serverName)
|
||||
const stateToRelease = pendingState ?? oauthState
|
||||
if (stateToRelease) {
|
||||
releaseCallbackServer(stateToRelease)
|
||||
const storedState = await getOAuthState(serverName)
|
||||
if (storedState === stateToRelease) {
|
||||
await clearOAuthState(serverName)
|
||||
}
|
||||
}
|
||||
if (transport) {
|
||||
await transport.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchParamsFromInput(input: string): URLSearchParams | undefined {
|
||||
try {
|
||||
const url = new URL(input)
|
||||
const params = new URLSearchParams(url.search)
|
||||
if (url.hash) {
|
||||
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash
|
||||
const hashParams = new URLSearchParams(hash)
|
||||
for (const [key, value] of hashParams) {
|
||||
if (!params.has(key)) params.set(key, value)
|
||||
}
|
||||
}
|
||||
return params
|
||||
} catch {
|
||||
const query = input.includes("?") ? input.slice(input.indexOf("?") + 1) : input
|
||||
const params = new URLSearchParams(query.startsWith("#") ? query.slice(1) : query)
|
||||
return params.has("code") || params.has("state") || params.has("error") ? params : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an OAuth authorization code from either a raw code, a query string,
|
||||
* or the full localhost redirect URL copied from the browser address bar.
|
||||
*/
|
||||
export function parseAuthorizationCodeInput(input: string, expectedState?: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error("Authorization code or redirect URL is required")
|
||||
}
|
||||
|
||||
const params = getSearchParamsFromInput(trimmed)
|
||||
if (params) {
|
||||
const error = params.get("error")
|
||||
if (error) {
|
||||
const description = params.get("error_description")
|
||||
throw new Error(description ? `${error}: ${description}` : error)
|
||||
}
|
||||
|
||||
const state = params.get("state")
|
||||
if (expectedState && !state) {
|
||||
throw new Error("OAuth state missing from redirect URL")
|
||||
}
|
||||
if (expectedState && state !== expectedState) {
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
|
||||
const code = params.get("code")
|
||||
if (code) return code
|
||||
}
|
||||
|
||||
if (/^[A-Za-z0-9._~+/=-]+$/.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
throw new Error("Could not find an OAuth authorization code in the provided input")
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth authentication from manual user input.
|
||||
*/
|
||||
export async function completeAuthFromInput(
|
||||
serverName: string,
|
||||
input: string,
|
||||
): Promise<AuthStatus> {
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
const code = parseAuthorizationCodeInput(input, oauthState)
|
||||
return completeAuth(serverName, code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth authentication with the authorization code.
|
||||
*/
|
||||
export async function completeAuth(
|
||||
serverName: string,
|
||||
authorizationCode: string
|
||||
): Promise<AuthStatus> {
|
||||
const transport = pendingTransports.get(serverName)
|
||||
if (!transport) {
|
||||
throw new Error(`No pending OAuth flow for server: ${serverName}`)
|
||||
}
|
||||
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
|
||||
try {
|
||||
// Complete the auth using the transport's finishAuth method
|
||||
await transport.finishAuth(authorizationCode)
|
||||
return "authenticated"
|
||||
} finally {
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the complete OAuth authentication flow for a server.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
* @param serverUrl - The URL of the MCP server
|
||||
* @param definition - The server definition (optional)
|
||||
* @returns The final auth status
|
||||
*/
|
||||
export async function authenticate(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
definition?: ServerEntry,
|
||||
): Promise<AuthStatus> {
|
||||
const inFlight = pendingAuthentications.get(serverName)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const operation = (async (): Promise<AuthStatus> => {
|
||||
// Start auth flow
|
||||
const { authorizationUrl } = await startAuth(serverName, serverUrl, definition)
|
||||
|
||||
// If no auth URL needed, already authenticated
|
||||
if (!authorizationUrl) {
|
||||
return "authenticated"
|
||||
}
|
||||
|
||||
// Get the state that was already generated and stored in startAuth()
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
if (!oauthState) {
|
||||
throw new Error("OAuth state not found - this should not happen")
|
||||
}
|
||||
|
||||
// Register the callback BEFORE opening the browser
|
||||
const callbackPromise = waitForCallback(oauthState)
|
||||
|
||||
try {
|
||||
// Open browser. Always print the URL first so remote/headless users can copy it
|
||||
// even when the OS browser handoff is unavailable or invisible.
|
||||
console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
|
||||
try {
|
||||
await open(authorizationUrl)
|
||||
} catch (error) {
|
||||
console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
|
||||
}
|
||||
|
||||
// Wait for callback
|
||||
const code = await callbackPromise
|
||||
|
||||
// Validate state
|
||||
const storedState = await getOAuthState(serverName)
|
||||
if (storedState !== oauthState) {
|
||||
await clearOAuthState(serverName)
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
await clearOAuthState(serverName)
|
||||
|
||||
// Complete the auth
|
||||
return await completeAuth(serverName, code)
|
||||
} catch (error) {
|
||||
cancelPendingCallback(oauthState)
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
|
||||
pendingAuthentications.set(serverName, operation)
|
||||
|
||||
try {
|
||||
return await operation
|
||||
} finally {
|
||||
if (pendingAuthentications.get(serverName) === operation) {
|
||||
pendingAuthentications.delete(serverName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token for a server, refreshing if necessary.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
* @param serverUrl - The URL of the MCP server
|
||||
* @returns The valid tokens or null if not authenticated
|
||||
*/
|
||||
export async function getValidToken(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
): Promise<StoredTokens | null> {
|
||||
// Check if we have valid tokens
|
||||
const entry = await getAuthForUrl(serverName, serverUrl)
|
||||
if (!entry?.tokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const expired = await isTokenExpired(serverName)
|
||||
if (expired === false) {
|
||||
return entry.tokens
|
||||
}
|
||||
|
||||
if (expired === true && entry.tokens.refreshToken) {
|
||||
// Token is expired, try to refresh
|
||||
console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
|
||||
|
||||
try {
|
||||
// Create auth provider for token refresh
|
||||
const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (!clientInfo) {
|
||||
console.log(`MCP Auth: No client info for refresh for ${serverName}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await runSdkAuth(authProvider, { serverUrl })
|
||||
if (result !== "AUTHORIZED") {
|
||||
return null
|
||||
}
|
||||
const refreshed = await getAuthForUrl(serverName, serverUrl)
|
||||
return refreshed?.tokens ?? null
|
||||
} catch (error) {
|
||||
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// No expiration info or no refresh token, assume valid
|
||||
return entry.tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the authentication status for a server.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
* @returns The current auth status
|
||||
*/
|
||||
export async function getAuthStatus(serverName: string): Promise<AuthStatus> {
|
||||
const hasTokens = await hasStoredTokens(serverName)
|
||||
if (!hasTokens) return "not_authenticated"
|
||||
|
||||
const expired = await isTokenExpired(serverName)
|
||||
return expired ? "expired" : "authenticated"
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all OAuth credentials for a server.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
*/
|
||||
export async function removeAuth(serverName: string): Promise<void> {
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
if (oauthState) {
|
||||
cancelPendingCallback(oauthState)
|
||||
}
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
clearAllCredentials(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
console.log(`MCP Auth: Removed credentials for ${serverName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth is supported for a server configuration.
|
||||
* OAuth is supported for HTTP servers unless explicitly disabled.
|
||||
*
|
||||
* @param definition - The server definition
|
||||
* @returns True if OAuth is supported
|
||||
*/
|
||||
export function supportsOAuth(definition: ServerEntry): boolean {
|
||||
// OAuth requires a URL
|
||||
if (!definition.url) return false
|
||||
|
||||
// Explicitly disabled via auth: false or oauth: false
|
||||
if (definition.auth === false) return false
|
||||
if (definition.oauth === false) return false
|
||||
|
||||
// OAuth is enabled if auth is 'oauth' or not specified (auto-detect)
|
||||
return definition.auth === "oauth" || definition.auth === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the OAuth system on startup.
|
||||
* OAuth callback binding is lazy and starts from startAuth() only.
|
||||
*/
|
||||
export async function initializeOAuth(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Shutdown the OAuth system.
|
||||
* Stops the callback server and cancels pending auths.
|
||||
*/
|
||||
export async function shutdownOAuth(): Promise<void> {
|
||||
for (const serverName of Array.from(pendingTransports.keys())) {
|
||||
await clearPendingAuth(serverName)
|
||||
}
|
||||
await stopCallbackServer()
|
||||
}
|
||||
373
agent/extensions/pi-mcp-adapter/mcp-auth.test.ts
Normal file
373
agent/extensions/pi-mcp-adapter/mcp-auth.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Tests for mcp-auth.ts - Auth storage module
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { mkdirSync, rmSync, existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
getAuthEntry,
|
||||
getAuthForUrl,
|
||||
saveAuthEntry,
|
||||
removeAuthEntry,
|
||||
updateTokens,
|
||||
updateClientInfo,
|
||||
updateCodeVerifier,
|
||||
clearCodeVerifier,
|
||||
updateOAuthState,
|
||||
getOAuthState,
|
||||
clearOAuthState,
|
||||
isTokenExpired,
|
||||
hasStoredTokens,
|
||||
clearAllCredentials,
|
||||
clearClientInfo,
|
||||
clearTokens,
|
||||
type AuthEntry,
|
||||
} from "./mcp-auth.ts"
|
||||
|
||||
describe("mcp-auth", () => {
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("getAuthEntry", () => {
|
||||
it("should return undefined for non-existent entry", () => {
|
||||
const entry = getAuthEntry("non-existent")
|
||||
assert.strictEqual(entry, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveAuthEntry / getAuthEntry", () => {
|
||||
it("should save and retrieve an auth entry", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: {
|
||||
accessToken: "test-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1234567890,
|
||||
scope: "read write",
|
||||
},
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry, "https://api.example.com")
|
||||
const retrieved = getAuthEntry("test-server")
|
||||
|
||||
assert.deepStrictEqual(retrieved, entry)
|
||||
})
|
||||
|
||||
it("should update existing entries", () => {
|
||||
const entry1: AuthEntry = {
|
||||
tokens: { accessToken: "token1" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
const entry2: AuthEntry = {
|
||||
tokens: { accessToken: "token2" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry1, "https://api.example.com")
|
||||
saveAuthEntry("test-server", entry2, "https://api.example.com")
|
||||
const retrieved = getAuthEntry("test-server")
|
||||
|
||||
assert.strictEqual(retrieved?.tokens?.accessToken, "token2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthForUrl", () => {
|
||||
it("should return entry when URL matches", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry, "https://api.example.com")
|
||||
const retrieved = getAuthForUrl("test-server", "https://api.example.com")
|
||||
|
||||
assert.deepStrictEqual(retrieved, entry)
|
||||
})
|
||||
|
||||
it("should return undefined when URL doesn't match", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry, "https://api.example.com")
|
||||
const retrieved = getAuthForUrl("test-server", "https://different.com")
|
||||
|
||||
assert.strictEqual(retrieved, undefined)
|
||||
})
|
||||
|
||||
it("should return undefined when serverUrl is not stored", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry)
|
||||
const retrieved = getAuthForUrl("test-server", "https://api.example.com")
|
||||
|
||||
assert.strictEqual(retrieved, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAuthEntry", () => {
|
||||
it("should remove an entry", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry)
|
||||
removeAuthEntry("test-server")
|
||||
const retrieved = getAuthEntry("test-server")
|
||||
|
||||
assert.strictEqual(retrieved, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTokens", () => {
|
||||
it("should update tokens for a server", () => {
|
||||
updateTokens("test-server", {
|
||||
accessToken: "new-token",
|
||||
refreshToken: "new-refresh",
|
||||
expiresAt: 1234567890,
|
||||
scope: "read",
|
||||
})
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.tokens?.accessToken, "new-token")
|
||||
})
|
||||
|
||||
it("should preserve existing client info", () => {
|
||||
updateClientInfo("test-server", { clientId: "client-123" })
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.clientInfo?.clientId, "client-123")
|
||||
assert.strictEqual(entry?.tokens?.accessToken, "token")
|
||||
})
|
||||
|
||||
it("should clear URL-bound auth state when tokens move to a different server URL", () => {
|
||||
saveAuthEntry("token-url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
serverUrl: "https://old.example.com/mcp",
|
||||
}, "https://old.example.com/mcp")
|
||||
|
||||
updateTokens("token-url-change", { accessToken: "new-token" }, "https://new.example.com/mcp")
|
||||
|
||||
assert.strictEqual(getAuthForUrl("token-url-change", "https://old.example.com/mcp"), undefined)
|
||||
const newEntry = getAuthForUrl("token-url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.tokens?.accessToken, "new-token")
|
||||
assert.strictEqual(newEntry?.clientInfo, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
|
||||
it("should clear legacy URL-bound auth state when saving tokens with a server URL", () => {
|
||||
saveAuthEntry("token-legacy-url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
})
|
||||
|
||||
updateTokens("token-legacy-url-change", { accessToken: "new-token" }, "https://new.example.com/mcp")
|
||||
|
||||
const newEntry = getAuthForUrl("token-legacy-url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.tokens?.accessToken, "new-token")
|
||||
assert.strictEqual(newEntry?.clientInfo, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateClientInfo", () => {
|
||||
it("should update client info for a server", () => {
|
||||
updateClientInfo("test-server", {
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret",
|
||||
clientIdIssuedAt: 1234567890,
|
||||
clientSecretExpiresAt: 1234567999,
|
||||
})
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.clientInfo?.clientId, "client-123")
|
||||
assert.strictEqual(entry?.clientInfo?.clientSecret, "secret")
|
||||
})
|
||||
|
||||
it("should clear URL-bound credentials when client info moves to a different server URL", () => {
|
||||
saveAuthEntry("url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
serverUrl: "https://old.example.com/mcp",
|
||||
}, "https://old.example.com/mcp")
|
||||
|
||||
updateClientInfo("url-change", { clientId: "new-client" }, "https://new.example.com/mcp")
|
||||
|
||||
assert.strictEqual(getAuthForUrl("url-change", "https://old.example.com/mcp"), undefined)
|
||||
const newEntry = getAuthForUrl("url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.clientInfo?.clientId, "new-client")
|
||||
assert.strictEqual(newEntry?.tokens, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
|
||||
it("should clear stale verifier and state when legacy client info gains a server URL", () => {
|
||||
saveAuthEntry("legacy-url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
})
|
||||
|
||||
updateClientInfo("legacy-url-change", { clientId: "new-client" }, "https://new.example.com/mcp")
|
||||
|
||||
const newEntry = getAuthForUrl("legacy-url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.clientInfo?.clientId, "new-client")
|
||||
assert.strictEqual(newEntry?.tokens, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateCodeVerifier / clearCodeVerifier", () => {
|
||||
it("should save and retrieve code verifier", () => {
|
||||
updateCodeVerifier("test-server", "verifier-123")
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.codeVerifier, "verifier-123")
|
||||
})
|
||||
|
||||
it("should clear code verifier", () => {
|
||||
updateCodeVerifier("test-server", "verifier-123")
|
||||
clearCodeVerifier("test-server")
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.codeVerifier, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateOAuthState / getOAuthState / clearOAuthState", () => {
|
||||
it("should save and retrieve OAuth state", () => {
|
||||
updateOAuthState("test-server", "state-abc-123")
|
||||
const state = getOAuthState("test-server")
|
||||
assert.strictEqual(state, "state-abc-123")
|
||||
})
|
||||
|
||||
it("should clear OAuth state", () => {
|
||||
updateOAuthState("test-server", "state-abc-123")
|
||||
clearOAuthState("test-server")
|
||||
const state = getOAuthState("test-server")
|
||||
assert.strictEqual(state, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
it("should return null if no tokens", () => {
|
||||
const expired = isTokenExpired("expiry-test-null")
|
||||
assert.strictEqual(expired, null)
|
||||
})
|
||||
|
||||
it("should return false if no expiry", () => {
|
||||
updateTokens("expiry-test-no-expiry", { accessToken: "token" })
|
||||
const expired = isTokenExpired("expiry-test-no-expiry")
|
||||
assert.strictEqual(expired, false)
|
||||
})
|
||||
|
||||
it("should return true if expired", () => {
|
||||
updateTokens("expiry-test-expired", {
|
||||
accessToken: "token",
|
||||
expiresAt: 1, // Way in the past
|
||||
})
|
||||
const expired = isTokenExpired("expiry-test-expired")
|
||||
assert.strictEqual(expired, true)
|
||||
})
|
||||
|
||||
it("should return false if not expired", () => {
|
||||
updateTokens("expiry-test-future", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 + 3600, // 1 hour from now
|
||||
})
|
||||
const expired = isTokenExpired("expiry-test-future")
|
||||
assert.strictEqual(expired, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasStoredTokens", () => {
|
||||
it("should return false if no tokens", () => {
|
||||
assert.strictEqual(hasStoredTokens("has-tokens-test-false"), false)
|
||||
})
|
||||
|
||||
it("should return true if tokens exist", () => {
|
||||
updateTokens("has-tokens-test-true", { accessToken: "token" })
|
||||
assert.strictEqual(hasStoredTokens("has-tokens-test-true"), true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearAllCredentials", () => {
|
||||
it("should remove all credentials", () => {
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
updateClientInfo("test-server", { clientId: "client" })
|
||||
updateCodeVerifier("test-server", "verifier")
|
||||
|
||||
clearAllCredentials("test-server")
|
||||
|
||||
assert.strictEqual(getAuthEntry("test-server"), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearClientInfo", () => {
|
||||
it("should only remove client info", () => {
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
updateClientInfo("test-server", { clientId: "client" })
|
||||
|
||||
clearClientInfo("test-server")
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.clientInfo, undefined)
|
||||
assert.strictEqual(entry?.tokens?.accessToken, "token")
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearTokens", () => {
|
||||
it("should only remove tokens", () => {
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
updateClientInfo("test-server", { clientId: "client" })
|
||||
|
||||
clearTokens("test-server")
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.tokens, undefined)
|
||||
assert.strictEqual(entry?.clientInfo?.clientId, "client")
|
||||
})
|
||||
})
|
||||
})
|
||||
302
agent/extensions/pi-mcp-adapter/mcp-auth.ts
Normal file
302
agent/extensions/pi-mcp-adapter/mcp-auth.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* MCP Auth Storage Module
|
||||
*
|
||||
* Handles secure storage of OAuth credentials, tokens, client information,
|
||||
* and PKCE state for MCP servers.
|
||||
*
|
||||
* Token storage location: $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json when set,
|
||||
* otherwise <Pi agent dir>/mcp-oauth/sha256-<server-hash>/tokens.json
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getAgentPath } from './agent-dir.ts';
|
||||
|
||||
/** OAuth token storage format */
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // Unix timestamp in seconds
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** OAuth client information from dynamic or static registration */
|
||||
export interface StoredClientInfo {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
clientIdIssuedAt?: number;
|
||||
clientSecretExpiresAt?: number;
|
||||
redirectUris?: string[];
|
||||
}
|
||||
|
||||
/** Complete auth entry for a server */
|
||||
export interface AuthEntry {
|
||||
tokens?: StoredTokens;
|
||||
clientInfo?: StoredClientInfo;
|
||||
codeVerifier?: string;
|
||||
oauthState?: string;
|
||||
serverUrl?: string; // Track the URL these credentials are for
|
||||
}
|
||||
|
||||
// Base directory for auth storage - can be overridden via env var for testing
|
||||
function getAuthBaseDir(): string {
|
||||
const override = process.env.MCP_OAUTH_DIR?.trim();
|
||||
return override ? override : getAgentPath('mcp-oauth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server-specific directory path.
|
||||
*/
|
||||
function getServerDir(serverName: string): string {
|
||||
if (typeof serverName !== 'string') {
|
||||
throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`);
|
||||
}
|
||||
const storageKey = createHash('sha256').update(serverName, 'utf8').digest('hex');
|
||||
return join(getAuthBaseDir(), `sha256-${storageKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tokens file path for a server.
|
||||
*/
|
||||
export function getAuthEntryFilePath(serverName: string): string {
|
||||
return join(getServerDir(serverName), 'tokens.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the server directory exists with secure permissions.
|
||||
*/
|
||||
function ensureServerDir(serverName: string): void {
|
||||
const dir = getServerDir(serverName);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the auth entry for a server from disk.
|
||||
* Returns undefined if file doesn't exist.
|
||||
*/
|
||||
function readAuthEntry(serverName: string): AuthEntry | undefined {
|
||||
const filePath = getAuthEntryFilePath(serverName);
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return undefined;
|
||||
}
|
||||
const data = readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(data) as AuthEntry;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read auth entry for ${serverName}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the auth entry for a server to disk with secure permissions.
|
||||
*/
|
||||
function writeAuthEntry(serverName: string, entry: AuthEntry): void {
|
||||
ensureServerDir(serverName);
|
||||
const filePath = getAuthEntryFilePath(serverName);
|
||||
writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth entry for a server.
|
||||
*/
|
||||
export function getAuthEntry(serverName: string): AuthEntry | undefined {
|
||||
return readAuthEntry(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth entry and validate it's for the correct URL.
|
||||
* Returns undefined if URL has changed (credentials are invalid).
|
||||
*/
|
||||
export function getAuthForUrl(serverName: string, serverUrl: string): AuthEntry | undefined {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (!entry) return undefined;
|
||||
|
||||
// If no serverUrl is stored, this is from an old version - consider it invalid
|
||||
if (!entry.serverUrl) return undefined;
|
||||
|
||||
// If URL has changed, credentials are invalid
|
||||
if (entry.serverUrl !== serverUrl) return undefined;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save auth entry for a server.
|
||||
*/
|
||||
export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string): void {
|
||||
// Always update serverUrl if provided
|
||||
if (serverUrl) {
|
||||
entry.serverUrl = serverUrl;
|
||||
}
|
||||
writeAuthEntry(serverName, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove auth entry for a server.
|
||||
* Also removes the server directory if empty.
|
||||
*/
|
||||
export function removeAuthEntry(serverName: string): void {
|
||||
try {
|
||||
const filePath = getAuthEntryFilePath(serverName);
|
||||
if (existsSync(filePath)) {
|
||||
writeFileSync(filePath, '{}', { mode: 0o600 });
|
||||
}
|
||||
// Try to remove the directory
|
||||
const dir = getServerDir(serverName);
|
||||
if (existsSync(dir)) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true });
|
||||
} catch {
|
||||
// Directory may not be empty, ignore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove auth entry for ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tokens for a server.
|
||||
*/
|
||||
export function updateTokens(
|
||||
serverName: string,
|
||||
tokens: StoredTokens,
|
||||
serverUrl?: string
|
||||
): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.clientInfo;
|
||||
delete entry.codeVerifier;
|
||||
delete entry.oauthState;
|
||||
}
|
||||
entry.tokens = tokens;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client info for a server.
|
||||
*/
|
||||
export function updateClientInfo(
|
||||
serverName: string,
|
||||
clientInfo: StoredClientInfo,
|
||||
serverUrl?: string
|
||||
): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.tokens;
|
||||
delete entry.codeVerifier;
|
||||
delete entry.oauthState;
|
||||
}
|
||||
entry.clientInfo = clientInfo;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update code verifier for a server.
|
||||
*/
|
||||
export function updateCodeVerifier(serverName: string, codeVerifier: string, serverUrl?: string): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.tokens;
|
||||
delete entry.clientInfo;
|
||||
delete entry.oauthState;
|
||||
}
|
||||
entry.codeVerifier = codeVerifier;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear code verifier for a server.
|
||||
*/
|
||||
export function clearCodeVerifier(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.codeVerifier;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update OAuth state for a server.
|
||||
*/
|
||||
export function updateOAuthState(serverName: string, state: string, serverUrl?: string): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.tokens;
|
||||
delete entry.clientInfo;
|
||||
delete entry.codeVerifier;
|
||||
}
|
||||
entry.oauthState = state;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth state for a server.
|
||||
*/
|
||||
export function getOAuthState(serverName: string): string | undefined {
|
||||
const entry = getAuthEntry(serverName);
|
||||
return entry?.oauthState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear OAuth state for a server.
|
||||
*/
|
||||
export function clearOAuthState(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.oauthState;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stored tokens are expired.
|
||||
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
|
||||
*/
|
||||
export function isTokenExpired(serverName: string): boolean | null {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (!entry?.tokens) return null;
|
||||
if (!entry.tokens.expiresAt) return false;
|
||||
return entry.tokens.expiresAt < Date.now() / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a server has stored tokens.
|
||||
*/
|
||||
export function hasStoredTokens(serverName: string): boolean {
|
||||
const entry = getAuthEntry(serverName);
|
||||
return !!entry?.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all credentials for a server.
|
||||
*/
|
||||
export function clearAllCredentials(serverName: string): void {
|
||||
removeAuthEntry(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only client info for a server.
|
||||
*/
|
||||
export function clearClientInfo(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.clientInfo;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only tokens for a server.
|
||||
*/
|
||||
export function clearTokens(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.tokens;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
416
agent/extensions/pi-mcp-adapter/mcp-callback-server.test.ts
Normal file
416
agent/extensions/pi-mcp-adapter/mcp-callback-server.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Tests for mcp-callback-server.ts - OAuth callback server
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { createServer } from "node:http"
|
||||
import {
|
||||
ensureCallbackServer,
|
||||
waitForCallback,
|
||||
cancelPendingCallback,
|
||||
stopCallbackServer,
|
||||
isCallbackServerRunning,
|
||||
getPendingAuthCount,
|
||||
releaseCallbackServer,
|
||||
} from "./mcp-callback-server.ts"
|
||||
import { getConfiguredOAuthCallbackPort, getOAuthCallbackPath, getOAuthCallbackPort } from "./mcp-oauth-provider.ts"
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject)
|
||||
probe.listen(0, "localhost", resolve)
|
||||
})
|
||||
const address = probe.address()
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()))
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to reserve a free test port")
|
||||
}
|
||||
return address.port
|
||||
}
|
||||
|
||||
describe("mcp-callback-server", () => {
|
||||
beforeEach(async () => {
|
||||
// Stop any running server before each test
|
||||
await stopCallbackServer().catch(() => {})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Stop server after each test
|
||||
await stopCallbackServer().catch(() => {})
|
||||
})
|
||||
|
||||
describe("ensureCallbackServer", () => {
|
||||
it("should start the callback server", async () => {
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), true)
|
||||
})
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await ensureCallbackServer()
|
||||
await ensureCallbackServer()
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), true)
|
||||
})
|
||||
|
||||
it("should reserve callback state atomically with the initial bind", async () => {
|
||||
await ensureCallbackServer({ oauthState: "reserved-initial-state", reserveState: true })
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackHost: "127.0.0.1" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
|
||||
releaseCallbackServer("reserved-initial-state")
|
||||
})
|
||||
|
||||
it("should not switch callback hosts while callback state is reserved", async () => {
|
||||
await ensureCallbackServer({ oauthState: "reserved-host-state", reserveState: true })
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackHost: "127.0.0.1" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
|
||||
releaseCallbackServer("reserved-host-state")
|
||||
})
|
||||
|
||||
it("should not switch callback paths while callback state is reserved", async () => {
|
||||
await ensureCallbackServer({ callbackPath: "/first/callback", oauthState: "reserved-path-state", reserveState: true })
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackPath: "/second/callback" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/first/callback")
|
||||
|
||||
releaseCallbackServer("reserved-path-state")
|
||||
})
|
||||
|
||||
it("should release reserved callback state when strict binding fails", async () => {
|
||||
const port = await getFreePort()
|
||||
const blocker = createServer((_req, res) => {
|
||||
res.writeHead(200)
|
||||
res.end("blocked")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject)
|
||||
blocker.listen(port, "localhost", resolve)
|
||||
})
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ strictPort: true, port, oauthState: "failed-bind-state", reserveState: true }),
|
||||
/already in use/
|
||||
)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
|
||||
await ensureCallbackServer({ callbackPath: "/after-failure" })
|
||||
await ensureCallbackServer({ callbackPath: "/after-failure-switch" })
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/after-failure-switch")
|
||||
})
|
||||
|
||||
it("should bind an explicit strict host, port, and custom callback path", async () => {
|
||||
const port = await getFreePort()
|
||||
|
||||
await ensureCallbackServer({ strictPort: true, port, callbackHost: "127.0.0.1", callbackPath: "/custom/callback" })
|
||||
|
||||
assert.strictEqual(getOAuthCallbackPort(), port)
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/custom/callback")
|
||||
assert.strictEqual((await fetch(`http://127.0.0.1:${port}/callback?code=nope&state=custom-state`)).status, 404)
|
||||
|
||||
const callbackPromise = waitForCallback("custom-state")
|
||||
const response = await fetch(`http://127.0.0.1:${port}/custom/callback?code=ok&state=custom-state`)
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.strictEqual(await callbackPromise, "ok")
|
||||
})
|
||||
|
||||
it("should reject an occupied explicit strict port", async () => {
|
||||
const port = await getFreePort()
|
||||
const blocker = createServer((_req, res) => {
|
||||
res.writeHead(200)
|
||||
res.end("blocked")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject)
|
||||
blocker.listen(port, "localhost", resolve)
|
||||
})
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ strictPort: true, port }),
|
||||
/already in use/
|
||||
)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it("should use an OS-assigned port when the configured non-strict port is occupied", async () => {
|
||||
const configuredPort = getConfiguredOAuthCallbackPort()
|
||||
const blocker = createServer((_req, res) => {
|
||||
res.writeHead(200)
|
||||
res.end("blocked")
|
||||
})
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject)
|
||||
blocker.listen(configuredPort, "localhost", resolve)
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") return
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCallbackServer()
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
assert.notStrictEqual(callbackPort, configuredPort)
|
||||
|
||||
const state = "occupied-port-state"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
const response = await fetch(`http://localhost:${callbackPort}/callback?code=ok&state=${state}`)
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.strictEqual(await callbackPromise, "ok")
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ strictPort: true }),
|
||||
/already in use/
|
||||
)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitForCallback / callback handling", () => {
|
||||
it("should resolve with code on successful callback", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-123"
|
||||
const expectedCode = "auth-code-abc"
|
||||
|
||||
// Start waiting for callback
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
// Simulate callback by making HTTP request
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?code=${expectedCode}&state=${state}`
|
||||
)
|
||||
|
||||
// Should get HTML success response
|
||||
assert.strictEqual(response.status, 200)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Authorization Successful"))
|
||||
|
||||
// Callback promise should resolve
|
||||
const code = await callbackPromise
|
||||
assert.strictEqual(code, expectedCode)
|
||||
})
|
||||
|
||||
it("should reject on error parameter", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-error"
|
||||
const errorMsg = "access_denied"
|
||||
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
// Simulate error callback
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?error=${errorMsg}&state=${state}`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 200)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Authorization Failed"))
|
||||
|
||||
// Callback promise should reject
|
||||
await assert.rejects(callbackPromise, /access_denied/)
|
||||
})
|
||||
|
||||
it("should escape provider-controlled OAuth error details", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-error-escaping"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const description = `<script>alert("x")</script>&reason=bad`
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?error=access_denied&error_description=${encodeURIComponent(description)}&state=${state}`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 200)
|
||||
const html = await response.text()
|
||||
assert.ok(!html.includes("<script>"))
|
||||
assert.ok(html.includes("<script>alert("x")</script>&reason=bad"))
|
||||
await assert.rejects(callbackPromise, /<script>alert\("x"\)<\/script>&reason=bad/)
|
||||
})
|
||||
|
||||
it("should not reflect OAuth error details for invalid state", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?error=access_denied&error_description=${encodeURIComponent("<script>bad()</script>")}&state=invalid-state`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Invalid or expired state parameter"))
|
||||
assert.ok(!html.includes("<script>"))
|
||||
assert.ok(!html.includes("bad()"))
|
||||
})
|
||||
|
||||
it("should return 400 for missing state", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?code=abc123`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Missing required state parameter"))
|
||||
})
|
||||
|
||||
it("should return 400 for invalid state", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
// Register a different state
|
||||
const pendingCallback = waitForCallback("valid-state")
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?code=abc123&state=invalid-state`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Invalid or expired state parameter"))
|
||||
|
||||
cancelPendingCallback("valid-state")
|
||||
await assert.rejects(pendingCallback, /Authorization cancelled/)
|
||||
})
|
||||
|
||||
it("should return 400 for missing code", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-no-code"
|
||||
const pendingCallback = waitForCallback(state)
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?state=${state}`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("No authorization code provided"))
|
||||
|
||||
cancelPendingCallback(state)
|
||||
await assert.rejects(pendingCallback, /Authorization cancelled/)
|
||||
})
|
||||
|
||||
it("should not switch callback paths while callbacks are pending", async () => {
|
||||
await ensureCallbackServer({ callbackPath: "/first/callback" })
|
||||
|
||||
const state = "pending-path-state"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackPath: "/second/callback" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/first/callback")
|
||||
|
||||
cancelPendingCallback(state)
|
||||
await assert.rejects(callbackPromise, /Authorization cancelled/)
|
||||
})
|
||||
|
||||
it("should return 404 for wrong path", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/wrong/path`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelPendingCallback", () => {
|
||||
it("should reject pending callback", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-cancel"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
cancelPendingCallback(state)
|
||||
|
||||
await assert.rejects(callbackPromise, /Authorization cancelled/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopCallbackServer", () => {
|
||||
it("should stop the server", async () => {
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), true)
|
||||
|
||||
await stopCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
|
||||
it("should reject all pending callbacks", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state1 = "state-1"
|
||||
const state2 = "state-2"
|
||||
|
||||
const promise1 = waitForCallback(state1)
|
||||
const promise2 = waitForCallback(state2)
|
||||
|
||||
await stopCallbackServer()
|
||||
|
||||
await assert.rejects(promise1, /OAuth callback server stopped/)
|
||||
await assert.rejects(promise2, /OAuth callback server stopped/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPendingAuthCount", () => {
|
||||
it("should return 0 when no pending auths", async () => {
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(getPendingAuthCount(), 0)
|
||||
})
|
||||
|
||||
it("should return count of pending auths", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const promise1 = waitForCallback("state-1")
|
||||
assert.strictEqual(getPendingAuthCount(), 1)
|
||||
|
||||
const promise2 = waitForCallback("state-2")
|
||||
assert.strictEqual(getPendingAuthCount(), 2)
|
||||
|
||||
const promise3 = waitForCallback("state-3")
|
||||
assert.strictEqual(getPendingAuthCount(), 3)
|
||||
|
||||
cancelPendingCallback("state-1")
|
||||
cancelPendingCallback("state-2")
|
||||
cancelPendingCallback("state-3")
|
||||
await assert.rejects(promise1, /Authorization cancelled/)
|
||||
await assert.rejects(promise2, /Authorization cancelled/)
|
||||
await assert.rejects(promise3, /Authorization cancelled/)
|
||||
})
|
||||
})
|
||||
})
|
||||
372
agent/extensions/pi-mcp-adapter/mcp-callback-server.ts
Normal file
372
agent/extensions/pi-mcp-adapter/mcp-callback-server.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* MCP OAuth Callback Server
|
||||
*
|
||||
* HTTP server that handles OAuth callbacks from the authorization server.
|
||||
* Uses Node.js http module for compatibility.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
|
||||
import {
|
||||
DEFAULT_OAUTH_CALLBACK_PATH,
|
||||
getConfiguredOAuthCallbackPort,
|
||||
getOAuthCallbackPath,
|
||||
getOAuthCallbackPort,
|
||||
setOAuthCallbackPath,
|
||||
setOAuthCallbackPort,
|
||||
} from "./mcp-oauth-provider.ts"
|
||||
|
||||
// HTML templates for callback responses
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pi - Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to Pi.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pi - Authorization Failed</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
/** Pending authorization request */
|
||||
interface PendingAuth {
|
||||
resolve: (code: string) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** Server singleton state */
|
||||
let server: Server | undefined
|
||||
let bindingPromise: Promise<void> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
const reservedAuthStates = new Set<string>()
|
||||
|
||||
/** Timeout for callback completion (5 minutes) */
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
interface EnsureCallbackServerOptions {
|
||||
strictPort?: boolean
|
||||
port?: number
|
||||
callbackHost?: string
|
||||
callbackPath?: string
|
||||
oauthState?: string
|
||||
reserveState?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_OAUTH_CALLBACK_HOST = "localhost"
|
||||
let callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests to the callback server.
|
||||
*/
|
||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host}`)
|
||||
|
||||
// Only handle the callback path
|
||||
if (url.pathname !== getOAuthCallbackPath()) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" })
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
// Enforce state parameter presence for CSRF protection
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)
|
||||
const isReserved = reservedAuthStates.has(state)
|
||||
|
||||
// Handle OAuth errors only for a state that belongs to an active flow.
|
||||
if (error) {
|
||||
if (!pending && !isReserved) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const errorMsg = errorDescription || error
|
||||
// Send HTTP response first before rejecting promise
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
reservedAuthStates.delete(state)
|
||||
// Reject promise after response is sent (defer to allow test to attach handler)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
setTimeout(() => pending.reject(new Error(errorMsg)), 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pending) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
// Require authorization code
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Clear timeout and resolve the pending promise
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the callback server is running.
|
||||
* If strictPort is true, requires binding on the configured callback port.
|
||||
* If strictPort is false, asks the OS for an available local port.
|
||||
*/
|
||||
export async function ensureCallbackServer(options: EnsureCallbackServerOptions = {}): Promise<void> {
|
||||
while (bindingPromise) {
|
||||
await bindingPromise
|
||||
}
|
||||
|
||||
const operation = ensureCallbackServerLocked(options)
|
||||
bindingPromise = operation
|
||||
try {
|
||||
await operation
|
||||
} finally {
|
||||
if (bindingPromise === operation) {
|
||||
bindingPromise = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCallbackServerLocked(options: EnsureCallbackServerOptions = {}): Promise<void> {
|
||||
const requiredPort = options.port ?? getConfiguredOAuthCallbackPort()
|
||||
const strictPort = options.strictPort === true
|
||||
const requestedHost = options.callbackHost ?? DEFAULT_OAUTH_CALLBACK_HOST
|
||||
const rawRequestedPath = options.callbackPath ?? DEFAULT_OAUTH_CALLBACK_PATH
|
||||
const requestedPath = rawRequestedPath.startsWith("/") ? rawRequestedPath : `/${rawRequestedPath}`
|
||||
if (options.reserveState && !options.oauthState) {
|
||||
throw new Error("OAuth callback reservation requires an oauthState")
|
||||
}
|
||||
let reservedState: string | undefined
|
||||
|
||||
const previousServer = server
|
||||
const needsStrictRebind = Boolean(previousServer && strictPort && getOAuthCallbackPort() !== requiredPort)
|
||||
const needsHostSwitch = Boolean(previousServer && callbackServerHost !== requestedHost)
|
||||
const needsPathSwitch = Boolean(previousServer && getOAuthCallbackPath() !== requestedPath)
|
||||
|
||||
if (previousServer) {
|
||||
if (!needsStrictRebind && !needsHostSwitch) {
|
||||
if (needsPathSwitch) {
|
||||
if (pendingAuths.size > 0 || reservedAuthStates.size > 0) {
|
||||
throw new Error(
|
||||
`OAuth callback server is using path ${getOAuthCallbackPath()}, but callback path ${requestedPath} is required and cannot be switched while authorizations are pending`
|
||||
)
|
||||
}
|
||||
setOAuthCallbackPath(requestedPath)
|
||||
}
|
||||
if (options.reserveState && options.oauthState) {
|
||||
reservedAuthStates.add(options.oauthState)
|
||||
reservedState = options.oauthState
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingAuths.size > 0 || reservedAuthStates.size > 0) {
|
||||
throw new Error(
|
||||
`OAuth callback server is running on ${callbackServerHost}:${getOAuthCallbackPort()}, but strict callback endpoint ${requestedHost}:${requiredPort} is required and cannot be switched while authorizations are pending`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const candidateServer = createServer(handleRequest)
|
||||
const listenPort = strictPort ? requiredPort : 0
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
candidateServer.once("error", (err) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
candidateServer.listen(listenPort, requestedHost, () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
if (strictPort) {
|
||||
setOAuthCallbackPort(requiredPort)
|
||||
} else {
|
||||
const address = candidateServer.address()
|
||||
if (!address || typeof address === "string" || typeof address.port !== "number") {
|
||||
throw new Error("OAuth callback server did not report an assigned port")
|
||||
}
|
||||
setOAuthCallbackPort(address.port)
|
||||
}
|
||||
|
||||
if (previousServer && (needsStrictRebind || needsHostSwitch)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
previousServer.close(() => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
callbackServerHost = requestedHost
|
||||
setOAuthCallbackPath(requestedPath)
|
||||
server = candidateServer
|
||||
if (options.reserveState && options.oauthState) {
|
||||
reservedAuthStates.add(options.oauthState)
|
||||
reservedState = options.oauthState
|
||||
}
|
||||
server.unref()
|
||||
} catch (error) {
|
||||
if (reservedState) {
|
||||
reservedAuthStates.delete(reservedState)
|
||||
}
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
await new Promise<void>((resolve) => {
|
||||
candidateServer.close(() => resolve())
|
||||
})
|
||||
|
||||
if (strictPort && nodeError.code === "EADDRINUSE") {
|
||||
throw new Error(
|
||||
`OAuth callback port ${requiredPort} is already in use. Pre-registered OAuth clients require an exact redirect URI; set MCP_OAUTH_CALLBACK_PORT to your registered port or free port ${requiredPort}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveCallbackServer(oauthState: string): void {
|
||||
reservedAuthStates.add(oauthState)
|
||||
}
|
||||
|
||||
export function releaseCallbackServer(oauthState: string): void {
|
||||
reservedAuthStates.delete(oauthState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a callback with the given OAuth state.
|
||||
* Returns a promise that resolves with the authorization code.
|
||||
*/
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
reservedAuthStates.delete(oauthState)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (pendingAuths.has(oauthState)) {
|
||||
pendingAuths.delete(oauthState)
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending authorization by state.
|
||||
*/
|
||||
export function cancelPendingCallback(oauthState: string): void {
|
||||
reservedAuthStates.delete(oauthState)
|
||||
const pending = pendingAuths.get(oauthState)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(oauthState)
|
||||
pending.reject(new Error("Authorization cancelled"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the callback server and reject all pending authorizations.
|
||||
*/
|
||||
export async function stopCallbackServer(): Promise<void> {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
server!.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
server = undefined
|
||||
}
|
||||
|
||||
setOAuthCallbackPort(getConfiguredOAuthCallbackPort())
|
||||
callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
|
||||
setOAuthCallbackPath(DEFAULT_OAUTH_CALLBACK_PATH)
|
||||
|
||||
// Reject all pending auths (defer to allow any pending operations to complete)
|
||||
const pendingList = Array.from(pendingAuths.entries())
|
||||
pendingAuths.clear()
|
||||
reservedAuthStates.clear()
|
||||
setTimeout(() => {
|
||||
for (const [, pending] of pendingList) {
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error("OAuth callback server stopped"))
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the callback server is running.
|
||||
*/
|
||||
export function isCallbackServerRunning(): boolean {
|
||||
return server !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of pending authorizations.
|
||||
*/
|
||||
export function getPendingAuthCount(): number {
|
||||
return pendingAuths.size
|
||||
}
|
||||
518
agent/extensions/pi-mcp-adapter/mcp-oauth-provider.test.ts
Normal file
518
agent/extensions/pi-mcp-adapter/mcp-oauth-provider.test.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Tests for mcp-oauth-provider.ts - OAuth provider implementation
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { existsSync, rmSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
getOAuthCallbackPath,
|
||||
getOAuthCallbackPort,
|
||||
McpOAuthProvider,
|
||||
setOAuthCallbackPath,
|
||||
setOAuthCallbackPort,
|
||||
type McpOAuthConfig,
|
||||
} from "./mcp-oauth-provider.ts"
|
||||
import { getAuthForUrl, saveAuthEntry, updateOAuthState } from "./mcp-auth.ts"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
|
||||
describe("McpOAuthProvider", () => {
|
||||
const serverName = "test-server"
|
||||
const serverUrl = "https://api.example.com"
|
||||
let redirectCaptured: URL | undefined
|
||||
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
redirectCaptured = undefined
|
||||
})
|
||||
|
||||
function createProvider(config: McpOAuthConfig = {}) {
|
||||
return new McpOAuthProvider(serverName, serverUrl, config, {
|
||||
onRedirect: async (url) => {
|
||||
redirectCaptured = url
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("redirectUrl", () => {
|
||||
it("should return the correct redirect URL", () => {
|
||||
const provider = createProvider()
|
||||
assert.strictEqual(
|
||||
provider.redirectUrl,
|
||||
"http://localhost:19876/callback"
|
||||
)
|
||||
})
|
||||
|
||||
it("should use a configured redirect URI", () => {
|
||||
const provider = createProvider({ redirectUri: "http://localhost:3118/slack/callback" })
|
||||
assert.strictEqual(provider.redirectUrl, "http://localhost:3118/slack/callback")
|
||||
})
|
||||
|
||||
it("should snapshot generated redirect URI at construction", () => {
|
||||
const originalPort = getOAuthCallbackPort()
|
||||
const originalPath = getOAuthCallbackPath()
|
||||
setOAuthCallbackPort(41234)
|
||||
setOAuthCallbackPath("/snapshot/callback")
|
||||
|
||||
try {
|
||||
const provider = createProvider()
|
||||
setOAuthCallbackPort(52345)
|
||||
setOAuthCallbackPath("/changed/callback")
|
||||
|
||||
assert.strictEqual(provider.redirectUrl, "http://localhost:41234/snapshot/callback")
|
||||
assert.deepStrictEqual(provider.clientMetadata.redirect_uris, ["http://localhost:41234/snapshot/callback"])
|
||||
} finally {
|
||||
setOAuthCallbackPort(originalPort)
|
||||
setOAuthCallbackPath(originalPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("clientMetadata", () => {
|
||||
it("should return correct metadata for public client", () => {
|
||||
const provider = createProvider()
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.deepStrictEqual(metadata.redirect_uris, ["http://localhost:19876/callback"])
|
||||
assert.strictEqual(metadata.client_name, "Pi Coding Agent")
|
||||
assert.strictEqual(metadata.client_uri, "https://github.com/nicobailon/pi-mcp-adapter")
|
||||
assert.deepStrictEqual(metadata.grant_types, ["authorization_code", "refresh_token"])
|
||||
assert.deepStrictEqual(metadata.response_types, ["code"])
|
||||
assert.strictEqual(metadata.token_endpoint_auth_method, "none")
|
||||
})
|
||||
|
||||
it("should return correct metadata for confidential client", () => {
|
||||
const provider = createProvider({ clientSecret: "secret" })
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.strictEqual(metadata.token_endpoint_auth_method, "client_secret_post")
|
||||
})
|
||||
|
||||
it("should use configured redirect URI and client metadata", () => {
|
||||
const provider = createProvider({
|
||||
redirectUri: "http://localhost:3118/slack/callback",
|
||||
clientName: "Slack MCP",
|
||||
clientUri: "https://example.com/slack-mcp",
|
||||
})
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.deepStrictEqual(metadata.redirect_uris, ["http://localhost:3118/slack/callback"])
|
||||
assert.strictEqual(metadata.client_name, "Slack MCP")
|
||||
assert.strictEqual(metadata.client_uri, "https://example.com/slack-mcp")
|
||||
})
|
||||
|
||||
it("should use configured client name for client_credentials", () => {
|
||||
const provider = createProvider({
|
||||
grantType: "client_credentials",
|
||||
clientName: "Service MCP",
|
||||
})
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.strictEqual(metadata.client_name, "Service MCP")
|
||||
assert.deepStrictEqual(metadata.redirect_uris, [])
|
||||
assert.deepStrictEqual(metadata.grant_types, ["client_credentials"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("clientInformation", () => {
|
||||
it("should return config clientId when provided", async () => {
|
||||
const provider = createProvider({ clientId: "config-client", clientSecret: "config-secret" })
|
||||
const info = await provider.clientInformation()
|
||||
|
||||
assert.strictEqual(info?.client_id, "config-client")
|
||||
assert.strictEqual(info?.client_secret, "config-secret")
|
||||
})
|
||||
|
||||
it("should return stored client info when no config", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save client info directly
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
clientIdIssuedAt: Math.floor(Date.now() / 1000),
|
||||
clientSecretExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info?.client_id, "stored-client")
|
||||
assert.strictEqual(info?.client_secret, "stored-secret")
|
||||
})
|
||||
|
||||
it("should return undefined when URL doesn't match", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save client info with different URL
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
},
|
||||
serverUrl: "https://different.com",
|
||||
}, "https://different.com")
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info, undefined)
|
||||
})
|
||||
|
||||
it("should return undefined when client secret expired", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save client info with expired secret
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
clientSecretExpiresAt: 1, // Expired in 1970
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info, undefined)
|
||||
})
|
||||
|
||||
it("should prefer config over stored", async () => {
|
||||
const provider = createProvider({ clientId: "config-client" })
|
||||
|
||||
// Save different client info
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info?.client_id, "config-client")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveClientInformation", () => {
|
||||
it("should save client information", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
const info: OAuthClientInformationFull = {
|
||||
client_id: "new-client",
|
||||
client_secret: "new-secret",
|
||||
redirect_uris: ["http://localhost:3118/callback"],
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_secret_expires_at: futureTime,
|
||||
}
|
||||
|
||||
await provider.saveClientInformation(info)
|
||||
|
||||
const storedInfo = await provider.clientInformation()
|
||||
assert.strictEqual(storedInfo?.client_id, "new-client")
|
||||
assert.strictEqual(storedInfo?.client_secret, "new-secret")
|
||||
assert.deepStrictEqual(getAuthForUrl(serverName, serverUrl)?.clientInfo?.redirectUris, ["http://localhost:3118/callback"])
|
||||
})
|
||||
|
||||
it("should save the current redirect URL when registration omits redirect_uris", async () => {
|
||||
const provider = new McpOAuthProvider("redirect-fallback", serverUrl, { redirectUri: "http://localhost:3118/custom" }, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await provider.saveClientInformation({
|
||||
client_id: "fallback-client",
|
||||
client_secret: "fallback-secret",
|
||||
} as OAuthClientInformationFull)
|
||||
|
||||
assert.deepStrictEqual(getAuthForUrl("redirect-fallback", serverUrl)?.clientInfo?.redirectUris, ["http://localhost:3118/custom"])
|
||||
})
|
||||
|
||||
it("should return stored dynamic client info even when redirect URIs are stale", async () => {
|
||||
const provider = new McpOAuthProvider("stale-redirect-client", serverUrl, { redirectUri: "http://localhost:3118/current" }, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
saveAuthEntry("stale-redirect-client", {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
redirectUris: ["http://localhost:19876/callback"],
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info?.client_id, "stored-client")
|
||||
assert.strictEqual(info?.client_secret, "stored-secret")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tokens / saveTokens", () => {
|
||||
it("should save and retrieve tokens", async () => {
|
||||
const provider = createProvider()
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "access-123",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh-456",
|
||||
expires_in: 3600,
|
||||
scope: "read write",
|
||||
}
|
||||
|
||||
await provider.saveTokens(tokens)
|
||||
const stored = await provider.tokens()
|
||||
|
||||
assert.strictEqual(stored?.access_token, "access-123")
|
||||
assert.strictEqual(stored?.refresh_token, "refresh-456")
|
||||
assert.strictEqual(stored?.scope, "read write")
|
||||
})
|
||||
|
||||
it("should calculate expires_in from stored expiresAt", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "access",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
})
|
||||
|
||||
const stored = await provider.tokens()
|
||||
assert.ok(stored?.expires_in !== undefined)
|
||||
assert.ok(stored!.expires_in! > 0)
|
||||
assert.ok(stored!.expires_in! <= 3600)
|
||||
})
|
||||
|
||||
it("should return undefined when URL doesn't match", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save tokens with different URL
|
||||
saveAuthEntry(serverName, {
|
||||
tokens: {
|
||||
accessToken: "token",
|
||||
},
|
||||
serverUrl: "https://different.com",
|
||||
}, "https://different.com")
|
||||
|
||||
const stored = await provider.tokens()
|
||||
assert.strictEqual(stored, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectToAuthorization", () => {
|
||||
it("should call onRedirect with URL when a flow is in progress", async () => {
|
||||
const provider = new McpOAuthProvider("redirect-with-state", serverUrl, {}, {
|
||||
onRedirect: async (url) => {
|
||||
redirectCaptured = url
|
||||
},
|
||||
})
|
||||
await updateOAuthState("redirect-with-state", "state-abc", serverUrl)
|
||||
const testUrl = new URL("https://example.com/auth")
|
||||
|
||||
await provider.redirectToAuthorization(testUrl)
|
||||
|
||||
assert.strictEqual(redirectCaptured, testUrl)
|
||||
})
|
||||
|
||||
it("should throw UnauthorizedError when no flow is in progress", async () => {
|
||||
const provider = new McpOAuthProvider("redirect-no-state", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.redirectToAuthorization(new URL("https://example.com/auth")),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore OAuth state saved for a different server URL before redirecting", async () => {
|
||||
let redirected = false
|
||||
const provider = new McpOAuthProvider("redirect-url-bound", serverUrl, {}, {
|
||||
onRedirect: async () => {
|
||||
redirected = true
|
||||
},
|
||||
})
|
||||
saveAuthEntry("redirect-url-bound", {
|
||||
oauthState: "stale-state",
|
||||
serverUrl: "https://different.example.com",
|
||||
}, "https://different.example.com")
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.redirectToAuthorization(new URL("https://example.com/auth")),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
assert.strictEqual(redirected, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("codeVerifier / saveCodeVerifier", () => {
|
||||
it("should save and retrieve code verifier", async () => {
|
||||
const provider = new McpOAuthProvider("code-verifier-test", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await provider.saveCodeVerifier("verifier-abc-123")
|
||||
|
||||
const verifier = await provider.codeVerifier()
|
||||
assert.strictEqual(verifier, "verifier-abc-123")
|
||||
assert.strictEqual(getAuthForUrl("code-verifier-test", serverUrl)?.codeVerifier, "verifier-abc-123")
|
||||
})
|
||||
|
||||
it("should throw when no code verifier", async () => {
|
||||
const provider = new McpOAuthProvider("code-verifier-throw", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.codeVerifier(),
|
||||
/No code verifier saved/
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore code verifiers saved for a different server URL", async () => {
|
||||
const provider = new McpOAuthProvider("code-verifier-url-bound", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
saveAuthEntry("code-verifier-url-bound", {
|
||||
codeVerifier: "stale-verifier",
|
||||
serverUrl: "https://different.example.com",
|
||||
}, "https://different.example.com")
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.codeVerifier(),
|
||||
/No code verifier saved/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("state / saveState", () => {
|
||||
it("should save and retrieve state", async () => {
|
||||
const provider = new McpOAuthProvider("state-test-save", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await provider.saveState("state-xyz-789")
|
||||
|
||||
const state = await provider.state()
|
||||
assert.strictEqual(state, "state-xyz-789")
|
||||
assert.strictEqual(getAuthForUrl("state-test-save", serverUrl)?.oauthState, "state-xyz-789")
|
||||
})
|
||||
|
||||
it("should throw UnauthorizedError when no state is saved", async () => {
|
||||
const provider = new McpOAuthProvider("state-test-throw", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.state(),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore OAuth state saved for a different server URL", async () => {
|
||||
const provider = new McpOAuthProvider("state-url-bound", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
saveAuthEntry("state-url-bound", {
|
||||
oauthState: "stale-state",
|
||||
serverUrl: "https://different.example.com",
|
||||
}, "https://different.example.com")
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.state(),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalidateCredentials", () => {
|
||||
it("should remove all credentials when type is 'all'", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
})
|
||||
await provider.saveClientInformation({
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
})
|
||||
|
||||
await provider.invalidateCredentials("all")
|
||||
|
||||
assert.strictEqual(await provider.tokens(), undefined)
|
||||
assert.strictEqual(await provider.clientInformation(), undefined)
|
||||
})
|
||||
|
||||
it("should only remove tokens when type is 'tokens'", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
})
|
||||
await provider.saveClientInformation({
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_secret_expires_at: futureTime,
|
||||
})
|
||||
|
||||
await provider.invalidateCredentials("tokens")
|
||||
|
||||
assert.strictEqual(await provider.tokens(), undefined)
|
||||
const clientInfo = await provider.clientInformation()
|
||||
assert.strictEqual(clientInfo?.client_id, "client")
|
||||
})
|
||||
|
||||
it("should only remove client info when type is 'client'", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
})
|
||||
await provider.saveClientInformation({
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_secret_expires_at: futureTime,
|
||||
})
|
||||
|
||||
await provider.invalidateCredentials("client")
|
||||
|
||||
const tokens = await provider.tokens()
|
||||
assert.strictEqual(tokens?.access_token, "token")
|
||||
assert.strictEqual(await provider.clientInformation(), undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
369
agent/extensions/pi-mcp-adapter/mcp-oauth-provider.ts
Normal file
369
agent/extensions/pi-mcp-adapter/mcp-oauth-provider.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* MCP OAuth Provider
|
||||
*
|
||||
* Implementation of the MCP SDK's OAuthClientProvider interface.
|
||||
* Handles OAuth client registration, token storage, and authorization redirection.
|
||||
*/
|
||||
|
||||
import type { AddClientAuthentication, OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type {
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import {
|
||||
getAuthForUrl,
|
||||
updateTokens,
|
||||
updateClientInfo,
|
||||
updateCodeVerifier,
|
||||
updateOAuthState,
|
||||
clearAllCredentials,
|
||||
clearClientInfo,
|
||||
clearTokens,
|
||||
type StoredTokens,
|
||||
type StoredClientInfo,
|
||||
} from "./mcp-auth.ts"
|
||||
|
||||
// Callback server configuration
|
||||
const DEFAULT_OAUTH_CALLBACK_PORT = 19876
|
||||
const DEFAULT_OAUTH_CALLBACK_PATH = "/callback"
|
||||
|
||||
let configuredOAuthCallbackPort = DEFAULT_OAUTH_CALLBACK_PORT
|
||||
|
||||
if (process.env.MCP_OAUTH_CALLBACK_PORT) {
|
||||
const parsedPort = Number.parseInt(process.env.MCP_OAUTH_CALLBACK_PORT, 10)
|
||||
if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
||||
configuredOAuthCallbackPort = parsedPort
|
||||
}
|
||||
}
|
||||
|
||||
let oauthCallbackPort = configuredOAuthCallbackPort
|
||||
let oauthCallbackPath = DEFAULT_OAUTH_CALLBACK_PATH
|
||||
|
||||
export function getConfiguredOAuthCallbackPort(): number {
|
||||
return configuredOAuthCallbackPort
|
||||
}
|
||||
|
||||
export function getOAuthCallbackPort(): number {
|
||||
return oauthCallbackPort
|
||||
}
|
||||
|
||||
export function setOAuthCallbackPort(port: number): void {
|
||||
oauthCallbackPort = port
|
||||
}
|
||||
|
||||
export function getOAuthCallbackPath(): string {
|
||||
return oauthCallbackPath
|
||||
}
|
||||
|
||||
export function setOAuthCallbackPath(path: string): void {
|
||||
oauthCallbackPath = path.startsWith("/") ? path : `/${path}`
|
||||
}
|
||||
|
||||
/** Configuration options for OAuth */
|
||||
export interface McpOAuthConfig {
|
||||
grantType?: "authorization_code" | "client_credentials"
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
scope?: string
|
||||
redirectUri?: string
|
||||
clientName?: string
|
||||
clientUri?: string
|
||||
}
|
||||
|
||||
/** Callbacks for OAuth flow interactions */
|
||||
export interface McpOAuthCallbacks {
|
||||
onRedirect: (url: URL) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth provider implementation for MCP servers.
|
||||
* Implements the OAuthClientProvider interface from the MCP SDK.
|
||||
*/
|
||||
export class McpOAuthProvider implements OAuthClientProvider {
|
||||
private readonly redirectUrlSnapshot: string | undefined
|
||||
|
||||
constructor(
|
||||
private serverName: string,
|
||||
private serverUrl: string,
|
||||
private config: McpOAuthConfig,
|
||||
private callbacks: McpOAuthCallbacks,
|
||||
) {
|
||||
this.redirectUrlSnapshot = config.grantType === "client_credentials"
|
||||
? undefined
|
||||
: config.redirectUri ?? `http://localhost:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`
|
||||
}
|
||||
|
||||
private get usesClientCredentials(): boolean {
|
||||
return this.config.grantType === "client_credentials"
|
||||
}
|
||||
|
||||
/**
|
||||
* The redirect URL for OAuth callbacks.
|
||||
* This must match the redirect_uri in client metadata.
|
||||
*/
|
||||
get redirectUrl(): string | undefined {
|
||||
return this.redirectUrlSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Client metadata for dynamic registration.
|
||||
* Describes this client to the OAuth authorization server.
|
||||
*/
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
if (this.usesClientCredentials) {
|
||||
return {
|
||||
client_name: this.config.clientName ?? "Pi Coding Agent",
|
||||
client_uri: this.config.clientUri ?? "https://github.com/nicobailon/pi-mcp-adapter",
|
||||
redirect_uris: [],
|
||||
grant_types: ["client_credentials"],
|
||||
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUrl = this.redirectUrl
|
||||
if (!redirectUrl) {
|
||||
throw new Error("redirectUrl is required for authorization_code flow")
|
||||
}
|
||||
|
||||
return {
|
||||
redirect_uris: [redirectUrl],
|
||||
client_name: this.config.clientName ?? "Pi Coding Agent",
|
||||
client_uri: this.config.clientUri ?? "https://github.com/nicobailon/pi-mcp-adapter",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
|
||||
...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client information (for pre-registered or dynamically registered clients).
|
||||
* Returns undefined if no client info exists or if the server URL has changed.
|
||||
*/
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
// Check config first (pre-registered client)
|
||||
if (this.config.clientId) {
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// Check stored client info (from dynamic registration)
|
||||
// Use getAuthForUrl to validate credentials are for the current server URL
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (entry?.clientInfo) {
|
||||
// Check if client secret has expired
|
||||
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
client_id: entry.clientInfo.clientId,
|
||||
client_secret: entry.clientInfo.clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// No client info or URL changed - will trigger dynamic registration
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Save client information from dynamic registration.
|
||||
*/
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
const redirectUris = info.redirect_uris ?? (this.redirectUrl ? [this.redirectUrl] : undefined)
|
||||
const clientInfo: StoredClientInfo = {
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
clientIdIssuedAt: info.client_id_issued_at,
|
||||
clientSecretExpiresAt: info.client_secret_expires_at,
|
||||
redirectUris,
|
||||
}
|
||||
updateClientInfo(this.serverName, clientInfo, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth tokens.
|
||||
* Returns undefined if no tokens exist or if the server URL has changed.
|
||||
*/
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
// Use getAuthForUrl to validate tokens are for the current server URL
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.tokens) return undefined
|
||||
|
||||
return {
|
||||
access_token: entry.tokens.accessToken,
|
||||
token_type: "Bearer",
|
||||
refresh_token: entry.tokens.refreshToken,
|
||||
expires_in: entry.tokens.expiresAt
|
||||
? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
|
||||
: undefined,
|
||||
scope: entry.tokens.scope,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth tokens.
|
||||
*/
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const storedTokens: StoredTokens = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||
scope: tokens.scope,
|
||||
}
|
||||
updateTokens(this.serverName, storedTokens, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user to the authorization URL.
|
||||
* This opens the browser for the user to authenticate.
|
||||
*
|
||||
* Throws UnauthorizedError when called outside of a user-initiated flow
|
||||
* (no oauthState saved by startAuth). That path is reached when the SDK
|
||||
* falls through from a failed refresh into a fresh authorization_code
|
||||
* flow, which library hosts cannot complete in-process.
|
||||
*/
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
if (this.usesClientCredentials) {
|
||||
throw new Error("redirectToAuthorization is not used for client_credentials flow")
|
||||
}
|
||||
// No saved oauthState means we're on the post-refresh authorize fallback.
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.oauthState) {
|
||||
throw new UnauthorizedError(
|
||||
`Re-authentication required for MCP server: ${this.serverName}`,
|
||||
)
|
||||
}
|
||||
// URL is passed to callback, not logged (may contain sensitive params)
|
||||
await this.callbacks.onRedirect(authorizationUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the PKCE code verifier.
|
||||
*/
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
updateCodeVerifier(this.serverName, codeVerifier, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored PKCE code verifier.
|
||||
* @throws Error if no code verifier is stored
|
||||
*/
|
||||
async codeVerifier(): Promise<string> {
|
||||
if (this.usesClientCredentials) {
|
||||
throw new Error("codeVerifier is not used for client_credentials flow")
|
||||
}
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.codeVerifier) {
|
||||
throw new Error(`No code verifier saved for MCP server: ${this.serverName}`)
|
||||
}
|
||||
return entry.codeVerifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the OAuth state parameter for CSRF protection.
|
||||
*/
|
||||
async saveState(state: string): Promise<void> {
|
||||
updateOAuthState(this.serverName, state, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored OAuth state parameter.
|
||||
* @throws UnauthorizedError if no flow is in progress (see redirectToAuthorization)
|
||||
*/
|
||||
async state(): Promise<string> {
|
||||
if (this.usesClientCredentials) {
|
||||
throw new Error("state is not used for client_credentials flow")
|
||||
}
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.oauthState) {
|
||||
throw new UnauthorizedError(
|
||||
`Re-authentication required for MCP server: ${this.serverName}`,
|
||||
)
|
||||
}
|
||||
return entry.oauthState
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate credentials when authentication fails.
|
||||
* Clears tokens, client info, or all credentials based on the type.
|
||||
*/
|
||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
switch (type) {
|
||||
case "all":
|
||||
clearAllCredentials(this.serverName)
|
||||
break
|
||||
case "client":
|
||||
clearClientInfo(this.serverName)
|
||||
break
|
||||
case "tokens":
|
||||
clearTokens(this.serverName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds configured authorization-code scope without replacing the SDK's
|
||||
* default token endpoint authentication behavior.
|
||||
*/
|
||||
addClientAuthentication: AddClientAuthentication = async (headers, params, _url, metadata) => {
|
||||
if (params.get("grant_type") === "authorization_code" && !params.has("scope") && this.config.scope) {
|
||||
params.set("scope", this.config.scope)
|
||||
}
|
||||
|
||||
const clientInfo = await this.clientInformation()
|
||||
if (!clientInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []
|
||||
const hasClientSecret = clientInfo.client_secret !== undefined
|
||||
let authMethod: "client_secret_basic" | "client_secret_post" | "none"
|
||||
|
||||
if (supportedMethods.length === 0) {
|
||||
authMethod = hasClientSecret ? "client_secret_post" : "none"
|
||||
} else if (hasClientSecret && supportedMethods.includes("client_secret_basic")) {
|
||||
authMethod = "client_secret_basic"
|
||||
} else if (hasClientSecret && supportedMethods.includes("client_secret_post")) {
|
||||
authMethod = "client_secret_post"
|
||||
} else if (supportedMethods.includes("none")) {
|
||||
authMethod = "none"
|
||||
} else {
|
||||
authMethod = hasClientSecret ? "client_secret_post" : "none"
|
||||
}
|
||||
|
||||
if (authMethod === "client_secret_basic") {
|
||||
if (!clientInfo.client_secret) {
|
||||
throw new Error("client_secret_basic authentication requires a client_secret")
|
||||
}
|
||||
headers.set("Authorization", `Basic ${Buffer.from(`${clientInfo.client_id}:${clientInfo.client_secret}`).toString("base64")}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!params.has("client_id")) {
|
||||
params.set("client_id", clientInfo.client_id)
|
||||
}
|
||||
if (authMethod === "client_secret_post" && clientInfo.client_secret && !params.has("client_secret")) {
|
||||
params.set("client_secret", clientInfo.client_secret)
|
||||
}
|
||||
}
|
||||
|
||||
prepareTokenRequest(scope?: string): URLSearchParams | undefined {
|
||||
if (!this.usesClientCredentials) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ grant_type: "client_credentials" })
|
||||
const requestedScope = scope ?? this.config.scope
|
||||
if (requestedScope) {
|
||||
params.set("scope", requestedScope)
|
||||
}
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }
|
||||
829
agent/extensions/pi-mcp-adapter/mcp-panel.ts
Normal file
829
agent/extensions/pi-mcp-adapter/mcp-panel.ts
Normal file
@@ -0,0 +1,829 @@
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
||||
import { isToolExcluded } from "./types.ts";
|
||||
import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import type { MetadataCache, ServerCacheEntry, CachedTool } from "./metadata-cache.ts";
|
||||
|
||||
interface PanelTheme {
|
||||
border: string;
|
||||
title: string;
|
||||
selected: string;
|
||||
direct: string;
|
||||
needsAuth: string;
|
||||
placeholder: string;
|
||||
description: string;
|
||||
hint: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: PanelTheme = {
|
||||
border: "2",
|
||||
title: "2",
|
||||
selected: "36",
|
||||
direct: "32",
|
||||
needsAuth: "33",
|
||||
placeholder: "2;3",
|
||||
description: "2",
|
||||
hint: "2",
|
||||
confirm: "32",
|
||||
cancel: "31",
|
||||
};
|
||||
|
||||
function fg(code: string, text: string): string {
|
||||
if (!code) return text;
|
||||
return `\x1b[${code}m${text}\x1b[0m`;
|
||||
}
|
||||
|
||||
const RAINBOW_COLORS = [
|
||||
"38;2;178;129;214",
|
||||
"38;2;215;135;175",
|
||||
"38;2;254;188;56",
|
||||
"38;2;228;192;15",
|
||||
"38;2;137;210;129",
|
||||
"38;2;0;175;175",
|
||||
"38;2;23;143;185",
|
||||
];
|
||||
|
||||
function rainbowProgress(filled: number, total: number): string {
|
||||
const dots: string[] = [];
|
||||
for (let i = 0; i < total; i++) {
|
||||
const color = RAINBOW_COLORS[i % RAINBOW_COLORS.length];
|
||||
dots.push(fg(color, i < filled ? "●" : "○"));
|
||||
}
|
||||
return dots.join(" ");
|
||||
}
|
||||
|
||||
function fuzzyScore(query: string, text: string): number {
|
||||
const lq = query.toLowerCase();
|
||||
const lt = text.toLowerCase();
|
||||
if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
|
||||
let score = 0;
|
||||
let qi = 0;
|
||||
let consecutive = 0;
|
||||
for (let i = 0; i < lt.length && qi < lq.length; i++) {
|
||||
if (lt[i] === lq[qi]) {
|
||||
score += 10 + consecutive;
|
||||
consecutive += 5;
|
||||
qi++;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
return qi === lq.length ? score : 0;
|
||||
}
|
||||
|
||||
function estimateTokens(tool: CachedTool): number {
|
||||
const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
|
||||
const descLen = tool.description?.length ?? 0;
|
||||
return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "connected" | "idle" | "failed" | "needs-auth" | "connecting";
|
||||
|
||||
interface ToolState {
|
||||
name: string;
|
||||
description: string;
|
||||
isDirect: boolean;
|
||||
wasDirect: boolean;
|
||||
estimatedTokens: number;
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
source: "user" | "project" | "import";
|
||||
importKind?: string;
|
||||
excludeTools?: string[];
|
||||
exposeResources: boolean;
|
||||
connectionStatus: ConnectionStatus;
|
||||
tools: ToolState[];
|
||||
hasCachedData: boolean;
|
||||
}
|
||||
|
||||
interface VisibleItem {
|
||||
type: "server" | "tool";
|
||||
serverIndex: number;
|
||||
toolIndex?: number;
|
||||
}
|
||||
|
||||
class McpPanel {
|
||||
private noticeLines: string[];
|
||||
private prefix: "server" | "none" | "short";
|
||||
private servers: ServerState[] = [];
|
||||
private cursorIndex = 0;
|
||||
private nameQuery = "";
|
||||
private descSearchActive = false;
|
||||
private descQuery = "";
|
||||
private dirty = false;
|
||||
private confirmingDiscard = false;
|
||||
private discardSelected = 1;
|
||||
private importNotice: string | null = null;
|
||||
private authNotice: string | null = null;
|
||||
private authInFlight: string | null = null;
|
||||
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private visibleItems: VisibleItem[] = [];
|
||||
private tui: { requestRender(): void };
|
||||
private t = DEFAULT_THEME;
|
||||
private authOnly: boolean;
|
||||
private keys: PanelKeys;
|
||||
|
||||
private static readonly MAX_VISIBLE = 12;
|
||||
private static readonly INACTIVITY_MS = 60_000;
|
||||
|
||||
constructor(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
provenance: Map<string, ServerProvenance>,
|
||||
private callbacks: McpPanelCallbacks,
|
||||
tui: { requestRender(): void },
|
||||
private done: (result: McpPanelResult) => void,
|
||||
options: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings } = {},
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.noticeLines = options.noticeLines ?? [];
|
||||
this.authOnly = options.authOnly === true;
|
||||
this.keys = createPanelKeys(options.keybindings);
|
||||
this.prefix = config.settings?.toolPrefix ?? "server";
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
if (this.authOnly && !callbacks.canAuthenticate(serverName)) continue;
|
||||
const prov = provenance.get(serverName);
|
||||
const serverCache = cache?.servers?.[serverName];
|
||||
|
||||
const globalDirect = config.settings?.directTools;
|
||||
let toolFilter: true | string[] | false = false;
|
||||
if (definition.directTools !== undefined) {
|
||||
toolFilter = definition.directTools;
|
||||
} else if (globalDirect) {
|
||||
toolFilter = globalDirect;
|
||||
}
|
||||
|
||||
const tools: ToolState[] = [];
|
||||
if (serverCache && !this.authOnly) {
|
||||
for (const tool of serverCache.tools ?? []) {
|
||||
if (isToolExcluded(tool.name, serverName, this.prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name));
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
description: tool.description ?? "",
|
||||
isDirect,
|
||||
wasDirect: isDirect,
|
||||
estimatedTokens: estimateTokens(tool),
|
||||
});
|
||||
}
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of serverCache.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, serverName, this.prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(baseName));
|
||||
const ct: CachedTool = { name: baseName, description: resource.description };
|
||||
tools.push({
|
||||
name: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
isDirect,
|
||||
wasDirect: isDirect,
|
||||
estimatedTokens: estimateTokens(ct),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = callbacks.getConnectionStatus(serverName);
|
||||
|
||||
this.servers.push({
|
||||
name: serverName,
|
||||
expanded: false,
|
||||
source: prov?.kind ?? "user",
|
||||
importKind: prov?.importKind,
|
||||
excludeTools: definition.excludeTools,
|
||||
exposeResources: definition.exposeResources !== false,
|
||||
connectionStatus: status,
|
||||
tools,
|
||||
hasCachedData: !!serverCache,
|
||||
});
|
||||
}
|
||||
|
||||
this.rebuildVisibleItems();
|
||||
this.resetInactivityTimeout();
|
||||
}
|
||||
|
||||
private resetInactivityTimeout(): void {
|
||||
if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = setTimeout(() => {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
}, McpPanel.INACTIVITY_MS);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.inactivityTimeout) {
|
||||
clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildVisibleItems(): void {
|
||||
const query = this.descSearchActive ? this.descQuery : this.nameQuery;
|
||||
const mode = this.descSearchActive ? "desc" : "name";
|
||||
|
||||
this.visibleItems = [];
|
||||
for (let si = 0; si < this.servers.length; si++) {
|
||||
const server = this.servers[si];
|
||||
if (query && this.authOnly) {
|
||||
const score = mode === "name" ? fuzzyScore(query, server.name) : 0;
|
||||
if (score > 0) {
|
||||
this.visibleItems.push({ type: "server", serverIndex: si });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.visibleItems.push({ type: "server", serverIndex: si });
|
||||
if (server.expanded || query) {
|
||||
for (let ti = 0; ti < server.tools.length; ti++) {
|
||||
const tool = server.tools[ti];
|
||||
if (query) {
|
||||
const score = mode === "name"
|
||||
? Math.max(
|
||||
fuzzyScore(query, tool.name),
|
||||
fuzzyScore(query, server.name) * 0.6,
|
||||
)
|
||||
: fuzzyScore(query, tool.description);
|
||||
if (score === 0) continue;
|
||||
}
|
||||
this.visibleItems.push({ type: "tool", serverIndex: si, toolIndex: ti });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query && !this.authOnly) {
|
||||
this.visibleItems = this.visibleItems.filter((item) => {
|
||||
if (item.type === "server") {
|
||||
return this.visibleItems.some(
|
||||
(other) => other.type === "tool" && other.serverIndex === item.serverIndex,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateDirty(): void {
|
||||
this.dirty = this.servers.some((s) => s.tools.some((t) => t.isDirect !== t.wasDirect));
|
||||
}
|
||||
|
||||
private buildResult(): McpPanelResult {
|
||||
const changes = new Map<string, true | string[] | false>();
|
||||
for (const server of this.servers) {
|
||||
const changed = server.tools.some((t) => t.isDirect !== t.wasDirect);
|
||||
if (!changed) continue;
|
||||
const directTools = server.tools.filter((t) => t.isDirect);
|
||||
if (directTools.length === server.tools.length && server.tools.length > 0) {
|
||||
changes.set(server.name, true);
|
||||
} else if (directTools.length === 0) {
|
||||
changes.set(server.name, false);
|
||||
} else {
|
||||
changes.set(server.name, directTools.map((t) => t.name));
|
||||
}
|
||||
}
|
||||
return { changes, cancelled: false };
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.resetInactivityTimeout();
|
||||
this.importNotice = null;
|
||||
if (!this.authInFlight) this.authNotice = null;
|
||||
|
||||
if (this.confirmingDiscard) {
|
||||
this.handleDiscardInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Global shortcuts — always work, even during desc search
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "ctrl+s")) {
|
||||
this.cleanup();
|
||||
this.done(this.buildResult());
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal description search mode
|
||||
if (this.descSearchActive) {
|
||||
if (matchesKey(data, "escape") || this.keys.selectConfirm(data)) {
|
||||
this.descSearchActive = false;
|
||||
this.descQuery = "";
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "backspace")) {
|
||||
if (this.descQuery.length > 0) {
|
||||
this.descQuery = this.descQuery.slice(0, -1);
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
|
||||
if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
|
||||
if (matchesKey(data, "space")) {
|
||||
// Toggle even while in desc search
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (item) this.toggleItem(item);
|
||||
return;
|
||||
}
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
this.descQuery += data;
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "escape")) {
|
||||
if (this.nameQuery) {
|
||||
this.nameQuery = "";
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
if (this.dirty) {
|
||||
this.confirmingDiscard = true;
|
||||
this.discardSelected = 1;
|
||||
return;
|
||||
}
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
|
||||
if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
|
||||
|
||||
if (matchesKey(data, "space")) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (item && !this.authOnly) this.toggleItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (!item) return;
|
||||
const server = this.servers[item.serverIndex];
|
||||
if (item.type === "server") {
|
||||
if (this.authOnly || server.connectionStatus === "needs-auth") {
|
||||
this.authenticateServer(server);
|
||||
return;
|
||||
}
|
||||
server.expanded = !server.expanded;
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
} else if (item.toolIndex !== undefined) {
|
||||
const tool = server.tools[item.toolIndex];
|
||||
tool.isDirect = !tool.isDirect;
|
||||
if (tool.isDirect && server.source === "import") {
|
||||
this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
|
||||
}
|
||||
this.updateDirty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "ctrl+a")) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (item) this.authenticateSelectedServer(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "ctrl+r")) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (!item) return;
|
||||
const server = this.servers[item.serverIndex];
|
||||
if (server.connectionStatus === "connecting") return;
|
||||
server.connectionStatus = "connecting";
|
||||
this.callbacks.reconnect(server.name).then(() => {
|
||||
server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
|
||||
if (server.connectionStatus === "connected") {
|
||||
const entry = this.callbacks.refreshCacheAfterReconnect(server.name);
|
||||
if (entry) {
|
||||
this.rebuildServerTools(server, entry);
|
||||
}
|
||||
server.hasCachedData = true;
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}).catch((error) => {
|
||||
server.connectionStatus = "failed";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.authNotice = `Reconnect failed for ${server.name}: ${message}`;
|
||||
this.tui.requestRender();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "?") {
|
||||
if (this.authOnly) return;
|
||||
this.descSearchActive = true;
|
||||
this.descQuery = "";
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace removes from name query
|
||||
if (matchesKey(data, "backspace")) {
|
||||
if (this.nameQuery.length > 0) {
|
||||
this.nameQuery = this.nameQuery.slice(0, -1);
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other printable chars → always-on name search
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
this.nameQuery += data;
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private authenticateSelectedServer(item: VisibleItem): void {
|
||||
this.authenticateServer(this.servers[item.serverIndex]);
|
||||
}
|
||||
|
||||
private authenticateServer(server: ServerState): void {
|
||||
if (this.authInFlight) return;
|
||||
if (!this.callbacks.canAuthenticate(server.name)) {
|
||||
this.authNotice = `${server.name} does not use OAuth authentication.`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.authInFlight = server.name;
|
||||
this.authNotice = `Authenticating ${server.name}...`;
|
||||
this.tui.requestRender();
|
||||
|
||||
this.callbacks.authenticate(server.name).then((result) => {
|
||||
server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
|
||||
this.authNotice = result.ok
|
||||
? `OAuth finished for ${server.name}. Run reconnect if it is still idle.`
|
||||
: `OAuth failed for ${server.name}${result.message ? `: ${result.message}` : ". Check the notification for details."}`;
|
||||
this.authInFlight = null;
|
||||
this.tui.requestRender();
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
|
||||
this.authNotice = `OAuth failed for ${server.name}: ${message}`;
|
||||
this.authInFlight = null;
|
||||
this.tui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
private toggleItem(item: VisibleItem): void {
|
||||
if (this.authOnly) return;
|
||||
const server = this.servers[item.serverIndex];
|
||||
if (item.type === "server") {
|
||||
const newState = !server.tools.every((t) => t.isDirect);
|
||||
if (server.source === "import" && newState) {
|
||||
this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
|
||||
}
|
||||
for (const t of server.tools) t.isDirect = newState;
|
||||
} else if (item.toolIndex !== undefined) {
|
||||
const tool = server.tools[item.toolIndex];
|
||||
tool.isDirect = !tool.isDirect;
|
||||
if (tool.isDirect && server.source === "import") {
|
||||
this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
|
||||
}
|
||||
}
|
||||
this.updateDirty();
|
||||
}
|
||||
|
||||
private handleDiscardInput(data: string): void {
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "escape") || data === "n" || data === "N") {
|
||||
this.confirmingDiscard = false;
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
if (this.discardSelected === 0) {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
} else {
|
||||
this.confirmingDiscard = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data === "y" || data === "Y") {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab")) {
|
||||
this.discardSelected = this.discardSelected === 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private moveCursor(delta: number): void {
|
||||
if (this.visibleItems.length === 0) return;
|
||||
this.cursorIndex = Math.max(0, Math.min(this.visibleItems.length - 1, this.cursorIndex + delta));
|
||||
}
|
||||
|
||||
private rebuildServerTools(server: ServerState, entry: ServerCacheEntry): void {
|
||||
const existingState = new Map<string, boolean>();
|
||||
for (const t of server.tools) existingState.set(t.name, t.isDirect);
|
||||
|
||||
const newTools: ToolState[] = [];
|
||||
for (const tool of entry.tools ?? []) {
|
||||
if (isToolExcluded(tool.name, server.name, this.prefix, server.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = existingState.get(tool.name);
|
||||
const isDirect = prev !== undefined ? prev : false;
|
||||
newTools.push({
|
||||
name: tool.name,
|
||||
description: tool.description ?? "",
|
||||
isDirect,
|
||||
wasDirect: prev !== undefined ? server.tools.find((t) => t.name === tool.name)?.wasDirect ?? false : false,
|
||||
estimatedTokens: estimateTokens(tool),
|
||||
});
|
||||
}
|
||||
|
||||
if (server.exposeResources) {
|
||||
for (const resource of entry.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, server.name, this.prefix, server.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = existingState.get(baseName);
|
||||
const isDirect = prev !== undefined ? prev : false;
|
||||
const ct: CachedTool = { name: baseName, description: resource.description };
|
||||
newTools.push({
|
||||
name: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
isDirect,
|
||||
wasDirect: prev !== undefined ? server.tools.find((t) => t.name === baseName)?.wasDirect ?? false : false,
|
||||
estimatedTokens: estimateTokens(ct),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
server.tools = newTools;
|
||||
this.rebuildVisibleItems();
|
||||
this.updateDirty();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerW = width - 2;
|
||||
const lines: string[] = [];
|
||||
const t = this.t;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
|
||||
const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
|
||||
|
||||
const row = (content: string) =>
|
||||
fg(t.border, "│") + truncateToWidth(" " + content, innerW, "…", true) + fg(t.border, "│");
|
||||
const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│");
|
||||
const divider = () => fg(t.border, "├" + "─".repeat(innerW) + "┤");
|
||||
|
||||
const titleText = this.authOnly ? " MCP OAuth " : " MCP Servers ";
|
||||
const borderLen = innerW - visibleWidth(titleText);
|
||||
const leftB = Math.floor(borderLen / 2);
|
||||
const rightB = borderLen - leftB;
|
||||
lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
|
||||
|
||||
lines.push(emptyRow());
|
||||
|
||||
const cursor = fg(t.selected, "│");
|
||||
const searchIcon = fg(t.border, "◎");
|
||||
if (this.descSearchActive) {
|
||||
lines.push(row(`${searchIcon} ${fg(t.needsAuth, "desc:")} ${this.descQuery}${cursor}`));
|
||||
} else if (this.nameQuery) {
|
||||
lines.push(row(`${searchIcon} ${this.nameQuery}${cursor}`));
|
||||
} else {
|
||||
lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
|
||||
}
|
||||
|
||||
lines.push(emptyRow());
|
||||
if (this.noticeLines.length > 0) {
|
||||
for (const notice of this.noticeLines) {
|
||||
lines.push(row(fg(t.hint, italic(notice))));
|
||||
}
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
lines.push(divider());
|
||||
|
||||
if (this.servers.length === 0) {
|
||||
lines.push(emptyRow());
|
||||
lines.push(row(fg(t.hint, italic(this.authOnly ? "No OAuth-capable MCP servers configured." : "No MCP servers configured."))));
|
||||
lines.push(emptyRow());
|
||||
} else {
|
||||
const maxVis = McpPanel.MAX_VISIBLE;
|
||||
const total = this.visibleItems.length;
|
||||
const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
|
||||
const endIdx = Math.min(startIdx + maxVis, total);
|
||||
|
||||
lines.push(emptyRow());
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const item = this.visibleItems[i];
|
||||
const isCursor = i === this.cursorIndex;
|
||||
const server = this.servers[item.serverIndex];
|
||||
|
||||
if (item.type === "server") {
|
||||
lines.push(row(this.renderServerRow(server, isCursor)));
|
||||
} else if (item.toolIndex !== undefined) {
|
||||
lines.push(row(this.renderToolRow(server.tools[item.toolIndex], isCursor, innerW)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(emptyRow());
|
||||
|
||||
if (total > maxVis) {
|
||||
const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
|
||||
lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
|
||||
if (this.importNotice) {
|
||||
lines.push(row(fg(t.needsAuth, italic(this.importNotice))));
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
if (this.authNotice) {
|
||||
lines.push(row(fg(t.needsAuth, italic(this.authNotice))));
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(divider());
|
||||
lines.push(emptyRow());
|
||||
|
||||
if (this.confirmingDiscard) {
|
||||
const discardBtn = this.discardSelected === 0
|
||||
? inverse(bold(fg(t.cancel, " Discard ")))
|
||||
: fg(t.hint, " Discard ");
|
||||
const keepBtn = this.discardSelected === 1
|
||||
? inverse(bold(fg(t.confirm, " Keep ")))
|
||||
: fg(t.hint, " Keep ");
|
||||
lines.push(row(`Discard unsaved changes? ${discardBtn} ${keepBtn}`));
|
||||
} else {
|
||||
if (this.authOnly) {
|
||||
lines.push(row(fg(t.description, "select a server to authenticate")));
|
||||
} else {
|
||||
const directCount = this.servers.reduce((sum, s) => sum + s.tools.filter((t) => t.isDirect).length, 0);
|
||||
const totalTokens = this.servers.reduce(
|
||||
(sum, s) => sum + s.tools.filter((t) => t.isDirect).reduce((ts, t) => ts + t.estimatedTokens, 0),
|
||||
0,
|
||||
);
|
||||
const stats =
|
||||
directCount > 0 ? `${directCount} direct ~${totalTokens.toLocaleString()} tokens` : "no direct tools";
|
||||
lines.push(row(fg(t.description, stats + (this.dirty ? fg(t.needsAuth, " (unsaved)") : ""))));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(emptyRow());
|
||||
const hints = this.authOnly
|
||||
? [
|
||||
italic("↑↓") + " navigate",
|
||||
italic("⏎") + " auth",
|
||||
italic("ctrl+a") + " auth",
|
||||
italic("esc") + " clear/close",
|
||||
italic("ctrl+c") + " quit",
|
||||
]
|
||||
: [
|
||||
italic("↑↓") + " navigate",
|
||||
italic("space") + " toggle",
|
||||
italic("⏎") + " expand/auth",
|
||||
italic("ctrl+a") + " auth",
|
||||
italic("ctrl+r") + " reconnect",
|
||||
italic("?") + " desc search",
|
||||
italic("ctrl+s") + " save",
|
||||
italic("esc") + " clear/close",
|
||||
italic("ctrl+c") + " quit",
|
||||
];
|
||||
const gap = " ";
|
||||
const gapW = 2;
|
||||
const maxW = innerW - 2;
|
||||
let curLine = "";
|
||||
let curW = 0;
|
||||
for (const hint of hints) {
|
||||
const hw = visibleWidth(hint);
|
||||
const needed = curW === 0 ? hw : gapW + hw;
|
||||
if (curW > 0 && curW + needed > maxW) {
|
||||
lines.push(row(fg(t.hint, curLine)));
|
||||
curLine = hint;
|
||||
curW = hw;
|
||||
} else {
|
||||
curLine += (curW > 0 ? gap : "") + hint;
|
||||
curW += needed;
|
||||
}
|
||||
}
|
||||
if (curLine) lines.push(row(fg(t.hint, curLine)));
|
||||
|
||||
lines.push(fg(t.border, "╰" + "─".repeat(innerW) + "╯"));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderServerRow(server: ServerState, isCursor: boolean): string {
|
||||
const t = this.t;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const expandIcon = server.expanded ? "▾" : "▸";
|
||||
const prefix = isCursor ? fg(t.selected, expandIcon) : fg(t.border, server.expanded ? expandIcon : "·");
|
||||
|
||||
const nameStr = isCursor ? bold(fg(t.selected, server.name)) : server.name;
|
||||
const importLabel = server.source === "import" ? fg(t.description, ` (${server.importKind ?? "import"})`) : "";
|
||||
const statusLabel = this.renderConnectionStatus(server);
|
||||
|
||||
if (!server.hasCachedData && !this.authOnly) {
|
||||
return `${prefix} ${nameStr}${importLabel} ${fg(t.description, "(not cached)")}${statusLabel}`;
|
||||
}
|
||||
|
||||
const directCount = server.tools.filter((t) => t.isDirect).length;
|
||||
const totalCount = server.tools.length;
|
||||
let toggleIcon = fg(t.description, "○");
|
||||
if (directCount === totalCount && totalCount > 0) {
|
||||
toggleIcon = fg(t.direct, "●");
|
||||
} else if (directCount > 0) {
|
||||
toggleIcon = fg(t.needsAuth, "◐");
|
||||
}
|
||||
|
||||
let toolInfo = "";
|
||||
if (totalCount > 0) {
|
||||
toolInfo = `${directCount}/${totalCount}`;
|
||||
if (directCount > 0) {
|
||||
const tokens = server.tools.filter((t) => t.isDirect).reduce((s, t) => s + t.estimatedTokens, 0);
|
||||
toolInfo += ` ~${tokens.toLocaleString()}`;
|
||||
}
|
||||
toolInfo = fg(t.description, toolInfo);
|
||||
}
|
||||
|
||||
return `${prefix} ${toggleIcon} ${nameStr}${importLabel} ${toolInfo}${statusLabel}`;
|
||||
}
|
||||
|
||||
private renderConnectionStatus(server: ServerState): string {
|
||||
const t = this.t;
|
||||
if (this.authInFlight === server.name) return ` ${fg(t.needsAuth, "authenticating")}`;
|
||||
if (server.connectionStatus === "needs-auth") return ` ${fg(t.needsAuth, "needs auth")}`;
|
||||
if (server.connectionStatus === "connecting") return ` ${fg(t.needsAuth, "connecting")}`;
|
||||
if (server.connectionStatus === "failed") return ` ${fg(t.cancel, "failed")}`;
|
||||
if (this.authOnly && server.connectionStatus === "connected") return ` ${fg(t.direct, "connected")}`;
|
||||
if (this.authOnly) return ` ${fg(t.description, "idle")}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
private renderToolRow(tool: ToolState, isCursor: boolean, innerW: number): string {
|
||||
const t = this.t;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const toggleIcon = tool.isDirect ? fg(t.direct, "●") : fg(t.description, "○");
|
||||
const cursor = isCursor ? fg(t.selected, "▸") : " ";
|
||||
const nameStr = isCursor ? bold(fg(t.selected, tool.name)) : tool.name;
|
||||
|
||||
const prefixLen = 7 + visibleWidth(tool.name);
|
||||
const maxDescLen = Math.max(0, innerW - prefixLen - 8);
|
||||
const descStr =
|
||||
maxDescLen > 5 && tool.description
|
||||
? fg(t.description, "— " + truncateToWidth(tool.description, maxDescLen, "…"))
|
||||
: "";
|
||||
|
||||
return ` ${cursor} ${toggleIcon} ${nameStr} ${descStr}`;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
dispose(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpPanel(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
provenance: Map<string, ServerProvenance>,
|
||||
callbacks: McpPanelCallbacks,
|
||||
tui: { requestRender(): void },
|
||||
done: (result: McpPanelResult) => void,
|
||||
options?: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings },
|
||||
): McpPanel & { dispose(): void } {
|
||||
return new McpPanel(config, cache, provenance, callbacks, tui, done, options ?? {});
|
||||
}
|
||||
581
agent/extensions/pi-mcp-adapter/mcp-setup-panel.ts
Normal file
581
agent/extensions/pi-mcp-adapter/mcp-setup-panel.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
||||
import type { ImportKind } from "./types.ts";
|
||||
import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
|
||||
import type { McpOnboardingState } from "./onboarding-state.ts";
|
||||
|
||||
interface SetupTheme {
|
||||
border: string;
|
||||
title: string;
|
||||
selected: string;
|
||||
hint: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
muted: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: SetupTheme = {
|
||||
border: "2",
|
||||
title: "36",
|
||||
selected: "32",
|
||||
hint: "2",
|
||||
success: "32",
|
||||
warning: "33",
|
||||
muted: "2;3",
|
||||
};
|
||||
|
||||
function fg(code: string, text: string): string {
|
||||
return code ? `\x1b[${code}m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function wrapText(text: string, width: number): string[] {
|
||||
if (width <= 8) return [text];
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (visibleWidth(candidate) <= width) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines.length > 0 ? lines : [""];
|
||||
}
|
||||
|
||||
export interface SetupPanelCallbacks {
|
||||
previewImports: (imports: ImportKind[]) => ConfigWritePreview;
|
||||
previewStarterProject: () => ConfigWritePreview;
|
||||
previewRepoPrompt: () => ConfigWritePreview | null;
|
||||
adoptImports: (imports: ImportKind[]) => Promise<{ added: ImportKind[]; path: string }>;
|
||||
scaffoldProjectConfig: () => Promise<{ path: string }>;
|
||||
addRepoPrompt: () => Promise<{ path: string; serverName: string }>;
|
||||
openPath: (path: string) => Promise<void>;
|
||||
markSetupCompleted: () => void;
|
||||
}
|
||||
|
||||
export interface SetupPanelOptions {
|
||||
mode: "empty" | "setup";
|
||||
onboardingState: McpOnboardingState;
|
||||
keybindings?: PanelKeybindings;
|
||||
}
|
||||
|
||||
type Screen = "empty" | "setup" | "imports" | "paths";
|
||||
|
||||
type ActionId =
|
||||
| "run-setup"
|
||||
| "adopt-imports"
|
||||
| "view-example"
|
||||
| "show-precedence"
|
||||
| "open-paths"
|
||||
| "add-repoprompt"
|
||||
| "scaffold-project"
|
||||
| "close";
|
||||
|
||||
interface Action {
|
||||
id: ActionId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class McpSetupPanel {
|
||||
private screen: Screen;
|
||||
private actionCursor = 0;
|
||||
private importCursor = 0;
|
||||
private pathCursor = 0;
|
||||
private selectedImports = new Set<ImportKind>();
|
||||
private busy = false;
|
||||
private notice: { text: string; tone: "success" | "warning" | "muted" } | null = null;
|
||||
private tui: { requestRender(): void };
|
||||
private t = DEFAULT_THEME;
|
||||
private keys: PanelKeys;
|
||||
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private static readonly INACTIVITY_MS = 60_000;
|
||||
|
||||
constructor(
|
||||
private discovery: McpDiscoverySummary,
|
||||
private callbacks: SetupPanelCallbacks,
|
||||
private options: SetupPanelOptions,
|
||||
tui: { requestRender(): void },
|
||||
private done: () => void,
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.keys = createPanelKeys(options.keybindings);
|
||||
this.screen = options.mode;
|
||||
for (const entry of discovery.imports) {
|
||||
this.selectedImports.add(entry.kind);
|
||||
}
|
||||
this.resetInactivityTimeout();
|
||||
}
|
||||
|
||||
private resetInactivityTimeout(): void {
|
||||
if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = setTimeout(() => {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
}, McpSetupPanel.INACTIVITY_MS);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.inactivityTimeout) {
|
||||
clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getActions(): Action[] {
|
||||
const actions: Action[] = [];
|
||||
if (this.screen === "empty") {
|
||||
actions.push({ id: "run-setup", label: "Run setup", description: "Inspect detected configs, adopt imports, and scaffold a minimal `.mcp.json`." });
|
||||
}
|
||||
if (this.discovery.imports.length > 0) {
|
||||
actions.push({ id: "adopt-imports", label: "Adopt detected compatibility imports", description: `Choose which host-specific MCP configs Pi should import into its own override file. ${this.discovery.imports.length} source${this.discovery.imports.length === 1 ? "" : "s"} found.` });
|
||||
}
|
||||
actions.push({ id: "view-example", label: "View example `.mcp.json`", description: "Preview a working shared MCP config you can paste or adapt." });
|
||||
if (!this.discovery.sources.some((source) => source.id === "shared-project" && source.exists)) {
|
||||
actions.push({ id: "scaffold-project", label: "Scaffold project `.mcp.json`", description: "Write a minimal project config using the standard shared MCP file path, then reload Pi." });
|
||||
}
|
||||
actions.push({ id: "show-precedence", label: "Explain config precedence", description: "Show the read order and where Pi writes compatibility settings." });
|
||||
if (this.getDetectedPaths().length > 0) {
|
||||
actions.push({ id: "open-paths", label: "Open detected config paths", description: "Browse the actual config files that Pi discovered on this machine." });
|
||||
}
|
||||
if (!this.discovery.repoPrompt.configured && this.discovery.repoPrompt.executablePath && this.discovery.repoPrompt.targetPath && this.discovery.repoPrompt.entry && this.discovery.repoPrompt.serverName) {
|
||||
actions.push({ id: "add-repoprompt", label: "Add RepoPrompt to shared MCP config", description: "Write a standard MCP entry for RepoPrompt to the recommended shared target, then reload MCP in-session." });
|
||||
}
|
||||
actions.push({ id: "close", label: "Close", description: "Exit the onboarding flow." });
|
||||
return actions;
|
||||
}
|
||||
|
||||
private getDetectedPaths(): string[] {
|
||||
const paths = [
|
||||
...this.discovery.sources.filter((source) => source.exists).map((source) => source.path),
|
||||
...this.discovery.imports.map((entry) => entry.path),
|
||||
];
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
private getSelectedAction(): Action | null {
|
||||
const actions = this.getActions();
|
||||
return actions[this.actionCursor] ?? null;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.resetInactivityTimeout();
|
||||
if (!this.busy) this.notice = null;
|
||||
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "escape")) {
|
||||
if (this.screen === "imports" || this.screen === "paths") {
|
||||
this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
this.cleanup();
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.busy) return;
|
||||
|
||||
if (this.screen === "imports") {
|
||||
this.handleImportsInput(data);
|
||||
return;
|
||||
}
|
||||
if (this.screen === "paths") {
|
||||
this.handlePathsInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = this.getActions();
|
||||
if (this.keys.selectUp(data)) {
|
||||
this.actionCursor = Math.max(0, this.actionCursor - 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectDown(data)) {
|
||||
this.actionCursor = Math.min(actions.length - 1, this.actionCursor + 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
const selected = this.getSelectedAction();
|
||||
if (selected) void this.runAction(selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
private handleImportsInput(data: string): void {
|
||||
const imports = this.discovery.imports;
|
||||
if (this.keys.selectUp(data)) {
|
||||
this.importCursor = Math.max(0, this.importCursor - 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectDown(data)) {
|
||||
this.importCursor = Math.min(imports.length - 1, this.importCursor + 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "space")) {
|
||||
const current = imports[this.importCursor];
|
||||
if (!current) return;
|
||||
if (this.selectedImports.has(current.kind)) {
|
||||
this.selectedImports.delete(current.kind);
|
||||
} else {
|
||||
this.selectedImports.add(current.kind);
|
||||
}
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
void this.applySelectedImports();
|
||||
}
|
||||
}
|
||||
|
||||
private handlePathsInput(data: string): void {
|
||||
const paths = this.getDetectedPaths();
|
||||
if (this.keys.selectUp(data)) {
|
||||
this.pathCursor = Math.max(0, this.pathCursor - 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectDown(data)) {
|
||||
this.pathCursor = Math.min(paths.length - 1, this.pathCursor + 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
const selected = paths[this.pathCursor];
|
||||
if (!selected) return;
|
||||
void this.runBusy(async () => {
|
||||
await this.callbacks.openPath(selected);
|
||||
this.notice = { text: `Opened ${selected}`, tone: "success" };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async runAction(action: ActionId): Promise<void> {
|
||||
if (action === "run-setup") {
|
||||
this.screen = "setup";
|
||||
this.actionCursor = 0;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (action === "adopt-imports") {
|
||||
this.screen = "imports";
|
||||
this.importCursor = 0;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (action === "open-paths") {
|
||||
this.screen = "paths";
|
||||
this.pathCursor = 0;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (action === "scaffold-project") {
|
||||
await this.runBusy(async () => {
|
||||
const result = await this.callbacks.scaffoldProjectConfig();
|
||||
this.callbacks.markSetupCompleted();
|
||||
this.notice = { text: `Wrote starter config to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "add-repoprompt") {
|
||||
await this.runBusy(async () => {
|
||||
const result = await this.callbacks.addRepoPrompt();
|
||||
this.callbacks.markSetupCompleted();
|
||||
this.notice = { text: `Added ${result.serverName} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "close") {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
|
||||
this.notice = { text: "Review the details below. Press Enter on an action with a side effect to apply it.", tone: "muted" };
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private async applySelectedImports(): Promise<void> {
|
||||
const selected = this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind);
|
||||
if (selected.length === 0) {
|
||||
this.notice = { text: "Select at least one compatibility import first.", tone: "warning" };
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runBusy(async () => {
|
||||
const result = await this.callbacks.adoptImports(selected);
|
||||
this.callbacks.markSetupCompleted();
|
||||
this.notice = result.added.length > 0
|
||||
? { text: `Added ${result.added.join(", ")} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" }
|
||||
: { text: `No changes needed in ${result.path}.`, tone: "muted" };
|
||||
this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
|
||||
this.actionCursor = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async runBusy(fn: () => Promise<void>): Promise<void> {
|
||||
this.busy = true;
|
||||
this.notice = { text: "Working...", tone: "muted" };
|
||||
this.tui.requestRender();
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
this.notice = {
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
tone: "warning",
|
||||
};
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerW = Math.max(40, width - 2);
|
||||
const lines: string[] = [];
|
||||
const border = fg(this.t.border, "─".repeat(innerW));
|
||||
lines.push(`┌${border}┐`);
|
||||
lines.push(this.padLine(fg(this.t.title, "MCP setup"), innerW));
|
||||
lines.push(this.padLine(this.discoverySummaryLine(), innerW));
|
||||
lines.push(this.padLine(fg(this.t.muted, this.secondarySummaryLine()), innerW));
|
||||
lines.push(this.padLine("", innerW));
|
||||
|
||||
if (this.notice) {
|
||||
const tone = this.notice.tone === "success" ? this.t.success : this.notice.tone === "warning" ? this.t.warning : this.t.hint;
|
||||
for (const line of wrapText(this.notice.text, innerW - 6)) {
|
||||
lines.push(this.padLine(fg(tone, line), innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
}
|
||||
|
||||
lines.push(`├${border}┤`);
|
||||
|
||||
if (this.screen === "imports") {
|
||||
lines.push(...this.renderImports(innerW));
|
||||
} else if (this.screen === "paths") {
|
||||
lines.push(...this.renderPaths(innerW));
|
||||
} else {
|
||||
lines.push(...this.renderActions(innerW));
|
||||
}
|
||||
|
||||
lines.push(`└${border}┘`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderActions(innerW: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const actions = this.getActions();
|
||||
for (let index = 0; index < actions.length; index++) {
|
||||
const action = actions[index];
|
||||
const selected = index === this.actionCursor;
|
||||
const cursor = selected ? fg(this.t.selected, "›") : " ";
|
||||
lines.push(this.padLine(`${cursor} ${truncateToWidth(action.label, innerW - 4)}`, innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
|
||||
const preview = this.getActionPreview(this.getSelectedAction()?.id ?? "view-example");
|
||||
for (const line of preview) {
|
||||
lines.push(this.padLine(line, innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
lines.push(this.padLine(fg(this.t.muted, "Enter selects, Esc goes back, Ctrl+C closes."), innerW));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderImports(innerW: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(this.padLine("Select compatibility imports. Space toggles, Enter saves, Esc goes back.", innerW));
|
||||
lines.push(this.padLine("", innerW));
|
||||
for (let index = 0; index < this.discovery.imports.length; index++) {
|
||||
const entry = this.discovery.imports[index];
|
||||
const selected = this.selectedImports.has(entry.kind) ? "[x]" : "[ ]";
|
||||
const cursor = index === this.importCursor ? fg(this.t.selected, "›") : " ";
|
||||
lines.push(this.padLine(`${cursor} ${selected} ${entry.kind} ${entry.path}`, innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
const selected = this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind);
|
||||
const preview = this.callbacks.previewImports(selected);
|
||||
for (const line of this.formatWritePreview("Compatibility import write preview", preview)) {
|
||||
lines.push(this.padLine(line, innerW));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderPaths(innerW: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(this.padLine("Select a detected config path to open. Enter opens it, Esc goes back.", innerW));
|
||||
lines.push(this.padLine("", innerW));
|
||||
const paths = this.getDetectedPaths();
|
||||
for (let index = 0; index < paths.length; index++) {
|
||||
const cursor = index === this.pathCursor ? fg(this.t.selected, "›") : " ";
|
||||
lines.push(this.padLine(`${cursor} ${paths[index]}`, innerW));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private discoverySummaryLine(): string {
|
||||
if (!this.discovery.hasAnyConfig) {
|
||||
return fg(this.t.warning, this.options.onboardingState.setupCompleted
|
||||
? "No MCP servers are active right now."
|
||||
: "No MCP config is active yet.");
|
||||
}
|
||||
|
||||
if (this.discovery.totalServerCount === 0 && (this.discovery.imports.length > 0 || !!this.discovery.repoPrompt.executablePath)) {
|
||||
return fg(this.t.warning, "Pi found MCP-related setup options, but none are active in Pi yet.");
|
||||
}
|
||||
|
||||
const shared = this.discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0).length;
|
||||
const piOwned = this.discovery.sources.filter((source) => source.kind === "pi" && source.serverCount > 0).length;
|
||||
return fg(this.t.hint, `Detected ${this.discovery.totalServerCount} configured servers across ${shared} shared and ${piOwned} Pi-owned source${shared + piOwned === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
private secondarySummaryLine(): string {
|
||||
if (!this.discovery.hasAnyConfig) {
|
||||
return "Create a shared `.mcp.json`, adopt host imports, or quick-add RepoPrompt from this screen.";
|
||||
}
|
||||
if (this.discovery.totalServerCount === 0 && this.discovery.imports.length > 0) {
|
||||
return `Detected ${this.discovery.imports.length} compatibility import source${this.discovery.imports.length === 1 ? "" : "s"}. Adopt them into Pi or inspect the underlying files.`;
|
||||
}
|
||||
return "Shared MCP files are preferred. Pi-owned files are only for compatibility imports and adapter-specific overrides.";
|
||||
}
|
||||
|
||||
private getActionPreview(action: ActionId): string[] {
|
||||
switch (action) {
|
||||
case "run-setup":
|
||||
return this.formatPreview([
|
||||
"Run setup to adopt host-specific imports, inspect detected paths, and scaffold a minimal `.mcp.json` if needed.",
|
||||
]);
|
||||
case "adopt-imports":
|
||||
return this.formatWritePreview(
|
||||
"Compatibility import write preview",
|
||||
this.callbacks.previewImports(this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind)),
|
||||
[
|
||||
`Detected imports: ${this.discovery.imports.map((entry) => `${entry.kind} (${entry.serverCount} servers)`).join(", ")}`,
|
||||
"Selected imports are written into the Pi agent dir config as Pi-owned compatibility state.",
|
||||
],
|
||||
);
|
||||
case "view-example":
|
||||
return this.formatPreview([
|
||||
"Example shared `.mcp.json`:",
|
||||
"{",
|
||||
' "mcpServers": {',
|
||||
' "chrome-devtools": {',
|
||||
' "command": "npx",',
|
||||
' "args": ["-y", "chrome-devtools-mcp@latest"]',
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"Use Scaffold project `.mcp.json` when you want a safe empty shell instead of a live example server.",
|
||||
]);
|
||||
case "show-precedence":
|
||||
return this.formatPreview([
|
||||
"Read order:",
|
||||
"1. ~/.config/mcp/mcp.json",
|
||||
"2. <Pi agent dir>/mcp.json",
|
||||
"3. .mcp.json",
|
||||
`4. ${CONFIG_DIR_NAME}/mcp.json`,
|
||||
"Pi writes compatibility imports and adapter-only overrides to Pi-owned files.",
|
||||
]);
|
||||
case "open-paths":
|
||||
return this.formatPreview(this.getDetectedPaths().length > 0
|
||||
? ["Detected paths:", ...this.getDetectedPaths()]
|
||||
: ["No config paths were detected."]);
|
||||
case "add-repoprompt": {
|
||||
const repoPrompt = this.discovery.repoPrompt;
|
||||
const preview = this.callbacks.previewRepoPrompt();
|
||||
if (!preview) {
|
||||
return this.formatPreview(["RepoPrompt is not available to add from this setup screen."]);
|
||||
}
|
||||
return this.formatWritePreview(
|
||||
"RepoPrompt write preview",
|
||||
preview,
|
||||
[
|
||||
`Executable: ${repoPrompt.executablePath ?? "not found"}`,
|
||||
`Target: ${repoPrompt.targetPath ?? "n/a"}`,
|
||||
`Server name: ${repoPrompt.serverName ?? "repoprompt"}`,
|
||||
],
|
||||
);
|
||||
}
|
||||
case "scaffold-project":
|
||||
return this.formatWritePreview(
|
||||
"Starter project `.mcp.json` write preview",
|
||||
this.callbacks.previewStarterProject(),
|
||||
[
|
||||
"This writes a minimal `.mcp.json` in the current project using the shared MCP layout.",
|
||||
"It intentionally avoids adding a fake placeholder server that would fail on first reload.",
|
||||
],
|
||||
);
|
||||
case "close":
|
||||
default:
|
||||
return this.formatPreview(["Close the setup flow."]);
|
||||
}
|
||||
}
|
||||
|
||||
private formatPreview(lines: string[]): string[] {
|
||||
const preview: string[] = [];
|
||||
for (const line of lines) {
|
||||
preview.push(...wrapText(line, 74));
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
private formatWritePreview(title: string, preview: ConfigWritePreview, intro: string[] = []): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const line of intro) {
|
||||
lines.push(...wrapText(line, 74));
|
||||
}
|
||||
if (intro.length > 0) lines.push("");
|
||||
lines.push(...wrapText(`${title}: ${preview.path}`, 74));
|
||||
lines.push(...wrapText(preview.existed ? "Existing file detected. Showing exact before/after diff." : "New file will be created. Showing exact content diff.", 74));
|
||||
lines.push("");
|
||||
const diffLines = preview.diffText.split("\n");
|
||||
const maxLines = 18;
|
||||
const shown = diffLines.slice(0, maxLines);
|
||||
for (const line of shown) {
|
||||
lines.push(...wrapText(line, 74));
|
||||
}
|
||||
if (diffLines.length > maxLines) {
|
||||
lines.push(...wrapText(`… ${diffLines.length - maxLines} more diff line${diffLines.length - maxLines === 1 ? "" : "s"}`, 74));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private padLine(text: string, innerW: number): string {
|
||||
const inset = 2;
|
||||
const contentW = Math.max(0, innerW - inset * 2);
|
||||
const fitted = truncateToWidth(text, contentW, "…", true);
|
||||
const plainWidth = visibleWidth(fitted);
|
||||
const padding = Math.max(0, contentW - plainWidth);
|
||||
return `│${" ".repeat(inset)}${fitted}${" ".repeat(padding)}${" ".repeat(inset)}│`;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
dispose(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSetupPanel(
|
||||
discovery: McpDiscoverySummary,
|
||||
callbacks: SetupPanelCallbacks,
|
||||
options: SetupPanelOptions,
|
||||
tui: { requestRender(): void },
|
||||
done: () => void,
|
||||
): McpSetupPanel & { dispose(): void } {
|
||||
return new McpSetupPanel(discovery, callbacks, options, tui, done);
|
||||
}
|
||||
201
agent/extensions/pi-mcp-adapter/metadata-cache.ts
Normal file
201
agent/extensions/pi-mcp-adapter/metadata-cache.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// metadata-cache.ts - Persistent MCP metadata cache
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { McpTool, McpResource, ServerEntry, ToolMetadata } from "./types.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { extractToolUiStreamMode, interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CachedTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
uiResourceUri?: string;
|
||||
uiStreamMode?: "eager" | "stream-first";
|
||||
}
|
||||
|
||||
export interface CachedResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ServerCacheEntry {
|
||||
configHash: string;
|
||||
tools: CachedTool[];
|
||||
resources: CachedResource[];
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
export interface MetadataCache {
|
||||
version: number;
|
||||
servers: Record<string, ServerCacheEntry>;
|
||||
}
|
||||
|
||||
export function getMetadataCachePath(): string {
|
||||
return getAgentPath("mcp-cache.json");
|
||||
}
|
||||
|
||||
export function loadMetadataCache(): MetadataCache | null {
|
||||
const cachePath = getMetadataCachePath();
|
||||
if (!existsSync(cachePath)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (raw.version !== CACHE_VERSION) return null;
|
||||
if (!raw.servers || typeof raw.servers !== "object") return null;
|
||||
return raw as MetadataCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMetadataCache(cache: MetadataCache): void {
|
||||
const cachePath = getMetadataCachePath();
|
||||
const dir = dirname(cachePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
let merged: MetadataCache = { version: CACHE_VERSION, servers: {} };
|
||||
try {
|
||||
if (existsSync(cachePath)) {
|
||||
const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache;
|
||||
if (existing && existing.version === CACHE_VERSION && existing.servers) {
|
||||
merged.servers = { ...existing.servers };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors and proceed with empty cache
|
||||
}
|
||||
|
||||
merged.version = CACHE_VERSION;
|
||||
merged.servers = { ...merged.servers, ...cache.servers };
|
||||
|
||||
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
||||
renameSync(tmpPath, cachePath);
|
||||
}
|
||||
|
||||
export function computeServerHash(definition: ServerEntry): string {
|
||||
// Hash only fields that affect server identity and tool/resource output.
|
||||
// Exclude lifecycle, idleTimeout, debug — those are runtime behavior settings
|
||||
// that don't change which tools a server exposes.
|
||||
const identity: Record<string, unknown> = {
|
||||
command: definition.command,
|
||||
args: definition.args,
|
||||
env: interpolateEnvRecord(definition.env),
|
||||
cwd: resolveConfigPath(definition.cwd),
|
||||
url: definition.url,
|
||||
headers: interpolateEnvRecord(definition.headers),
|
||||
auth: definition.auth,
|
||||
bearerToken: resolveBearerToken(definition),
|
||||
bearerTokenEnv: definition.bearerTokenEnv,
|
||||
exposeResources: definition.exposeResources,
|
||||
excludeTools: definition.excludeTools,
|
||||
};
|
||||
const normalized = stableStringify(identity);
|
||||
return createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
export function isServerCacheValid(
|
||||
entry: ServerCacheEntry,
|
||||
definition: ServerEntry,
|
||||
maxAgeMs: number = CACHE_MAX_AGE_MS
|
||||
): boolean {
|
||||
if (!entry || entry.configHash !== computeServerHash(definition)) return false;
|
||||
if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false;
|
||||
if (maxAgeMs > 0 && Date.now() - entry.cachedAt > maxAgeMs) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reconstructToolMetadata(
|
||||
serverName: string,
|
||||
entry: ServerCacheEntry,
|
||||
prefix: "server" | "none" | "short",
|
||||
definition: Pick<ServerEntry, "exposeResources" | "excludeTools">
|
||||
): ToolMetadata[] {
|
||||
const metadata: ToolMetadata[] = [];
|
||||
|
||||
for (const tool of entry.tools ?? []) {
|
||||
if (!tool?.name) continue;
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
name: formatToolName(tool.name, serverName, prefix),
|
||||
originalName: tool.name,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri: tool.uiResourceUri,
|
||||
uiStreamMode: tool.uiStreamMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of entry.resources ?? []) {
|
||||
if (!resource?.name || !resource?.uri) continue;
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
name: formatToolName(baseName, serverName, prefix),
|
||||
originalName: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeTools(tools: McpTool[]): CachedTool[] {
|
||||
return tools
|
||||
.filter(t => t?.name)
|
||||
.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
uiResourceUri: tryGetToolUiResourceUri(t),
|
||||
uiStreamMode: extractToolUiStreamMode(t._meta),
|
||||
}));
|
||||
}
|
||||
|
||||
export function serializeResources(resources: McpResource[]): CachedResource[] {
|
||||
return resources
|
||||
.filter(r => r?.name && r?.uri)
|
||||
.map(r => ({
|
||||
uri: r.uri,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
}));
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || value === undefined || typeof value !== "object") {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? "undefined" : serialized;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(v => stableStringify(v)).join(",")}]`;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function tryGetToolUiResourceUri(tool: McpTool): string | undefined {
|
||||
try {
|
||||
return getToolUiResourceUri({ _meta: tool._meta });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
424
agent/extensions/pi-mcp-adapter/npx-resolver.ts
Normal file
424
agent/extensions/pi-mcp-adapter/npx-resolver.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
// npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes
|
||||
import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
|
||||
import { join, dirname, extname, resolve, sep } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface NpxCacheEntry {
|
||||
resolvedBin: string;
|
||||
resolvedAt: number;
|
||||
packageVersion?: string;
|
||||
isJs: boolean;
|
||||
}
|
||||
|
||||
interface NpxCache {
|
||||
version: number;
|
||||
entries: Record<string, NpxCacheEntry>;
|
||||
}
|
||||
|
||||
export interface NpxResolution {
|
||||
binPath: string;
|
||||
extraArgs: string[];
|
||||
isJs: boolean;
|
||||
}
|
||||
|
||||
interface ParsedInvocation {
|
||||
packageSpec: string;
|
||||
binName?: string;
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
export async function resolveNpxBinary(
|
||||
command: string,
|
||||
args: string[]
|
||||
): Promise<NpxResolution | null> {
|
||||
const parsed = command === "npx"
|
||||
? parseNpxArgs(args)
|
||||
: command === "npm"
|
||||
? parseNpmExecArgs(args)
|
||||
: null;
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
const cacheKey = JSON.stringify([command, ...args]);
|
||||
const cache = loadCache();
|
||||
const cached = cache?.entries?.[cacheKey];
|
||||
|
||||
if (cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS && existsSync(cached.resolvedBin)) {
|
||||
return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs };
|
||||
}
|
||||
|
||||
const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
|
||||
if (resolved) {
|
||||
saveCacheEntry(cacheKey, resolved);
|
||||
return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs };
|
||||
}
|
||||
|
||||
// Slow path: force npx cache population
|
||||
await forceNpxCache(parsed.packageSpec);
|
||||
const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
|
||||
if (resolvedAfterInstall) {
|
||||
saveCacheEntry(cacheKey, resolvedAfterInstall);
|
||||
return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNpxArgs(args: string[]): ParsedInvocation | null {
|
||||
const separatorIndex = args.indexOf("--");
|
||||
const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
|
||||
const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
|
||||
|
||||
const positionals: string[] = [];
|
||||
let packageSpec: string | undefined;
|
||||
let sawPackageFlag = false;
|
||||
let foundFirstPositional = false;
|
||||
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const arg = before[i];
|
||||
if (foundFirstPositional) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "-y" || arg === "--yes") continue;
|
||||
if (arg === "-p" || arg === "--package") {
|
||||
const value = before[i + 1];
|
||||
if (!value || value.startsWith("-")) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
sawPackageFlag = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--package=")) {
|
||||
const value = arg.slice("--package=".length);
|
||||
if (!value) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
sawPackageFlag = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
positionals.push(arg);
|
||||
foundFirstPositional = true;
|
||||
}
|
||||
|
||||
if (sawPackageFlag) {
|
||||
const binName = positionals[0];
|
||||
if (!packageSpec || !binName) return null;
|
||||
const extraArgs = positionals.slice(1).concat(after);
|
||||
return { packageSpec, binName, extraArgs };
|
||||
}
|
||||
|
||||
const packagePositional = positionals[0];
|
||||
if (!packagePositional) return null;
|
||||
const extraArgs = positionals.slice(1).concat(after);
|
||||
return { packageSpec: packagePositional, extraArgs };
|
||||
}
|
||||
|
||||
function parseNpmExecArgs(args: string[]): ParsedInvocation | null {
|
||||
if (args[0] !== "exec") return null;
|
||||
const execArgs = args.slice(1);
|
||||
const separatorIndex = execArgs.indexOf("--");
|
||||
if (separatorIndex < 0) return null;
|
||||
|
||||
const before = execArgs.slice(0, separatorIndex);
|
||||
const after = execArgs.slice(separatorIndex + 1);
|
||||
|
||||
let packageSpec: string | undefined;
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const arg = before[i];
|
||||
if (arg === "-y" || arg === "--yes") continue;
|
||||
if (arg === "--package") {
|
||||
const value = before[i + 1];
|
||||
if (!value || value.startsWith("-")) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--package=")) {
|
||||
const value = arg.slice("--package=".length);
|
||||
if (!value) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const binName = after[0];
|
||||
if (!packageSpec || !binName) return null;
|
||||
const extraArgs = after.slice(1);
|
||||
return { packageSpec, binName, extraArgs };
|
||||
}
|
||||
|
||||
function resolveFromNpmCache(packageSpec: string, binName?: string): NpxCacheEntry | null {
|
||||
const cacheDir = getNpmCacheDir();
|
||||
if (!cacheDir) return null;
|
||||
|
||||
const packageName = extractPackageName(packageSpec);
|
||||
if (!packageName) return null;
|
||||
|
||||
const packageDir = findCachedPackageDir(cacheDir, packageName);
|
||||
if (!packageDir) return null;
|
||||
|
||||
const packageJsonPath = join(packageDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
|
||||
let pkg: { bin?: string | Record<string, string>; version?: string } | null = null;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
version?: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binField = pkg?.bin;
|
||||
if (!binField) return null;
|
||||
|
||||
const candidates = buildBinCandidates(packageName, binName);
|
||||
let chosenBinName: string | undefined;
|
||||
let binRel: string | undefined;
|
||||
|
||||
if (typeof binField === "string") {
|
||||
chosenBinName = defaultBinName(packageName);
|
||||
binRel = binField;
|
||||
} else {
|
||||
for (const candidate of candidates) {
|
||||
if (binField[candidate]) {
|
||||
chosenBinName = candidate;
|
||||
binRel = binField[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!binRel) {
|
||||
const firstEntry = Object.entries(binField)[0];
|
||||
if (firstEntry) {
|
||||
chosenBinName = firstEntry[0];
|
||||
binRel = firstEntry[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!binRel) return null;
|
||||
|
||||
const nodeModulesDir = findNodeModulesDir(packageDir);
|
||||
const binLink = chosenBinName ? join(nodeModulesDir, ".bin", chosenBinName) : null;
|
||||
let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : "";
|
||||
if (!resolvedBin) {
|
||||
resolvedBin = resolve(packageDir, binRel);
|
||||
if (!existsSync(resolvedBin)) return null;
|
||||
}
|
||||
|
||||
const isJs = detectJsBinary(resolvedBin);
|
||||
return {
|
||||
resolvedBin,
|
||||
resolvedAt: Date.now(),
|
||||
packageVersion: pkg?.version,
|
||||
isJs,
|
||||
};
|
||||
}
|
||||
|
||||
const FORCE_CACHE_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function forceNpxCache(packageSpec: string): Promise<void> {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"npm",
|
||||
["exec", "--yes", "--package", packageSpec, "--", "node", "-e", "1"],
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error("timeout"));
|
||||
}, FORCE_CACHE_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
proc.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
proc.on("error", (err) => { clearTimeout(timer); reject(err); });
|
||||
});
|
||||
} catch {
|
||||
// Ignore failures, resolution will fall back to original command
|
||||
}
|
||||
}
|
||||
|
||||
function buildBinCandidates(packageName: string, explicitBin?: string): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (explicitBin) candidates.push(explicitBin);
|
||||
|
||||
if (packageName.startsWith("@")) {
|
||||
const namePart = packageName.split("/")[1] ?? "";
|
||||
const scopePart = packageName.split("/")[0]?.replace("@", "") ?? "";
|
||||
if (namePart) candidates.push(namePart);
|
||||
if (scopePart && namePart) candidates.push(`${scopePart}-${namePart}`);
|
||||
} else {
|
||||
candidates.push(packageName);
|
||||
}
|
||||
|
||||
return [...new Set(candidates.filter(Boolean))];
|
||||
}
|
||||
|
||||
function extractPackageName(spec: string): string | null {
|
||||
const trimmed = spec.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("@")) {
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex < 0) return null;
|
||||
const atIndex = trimmed.lastIndexOf("@");
|
||||
if (atIndex > slashIndex) {
|
||||
return trimmed.slice(0, atIndex);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
const atIndex = trimmed.indexOf("@");
|
||||
return atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed;
|
||||
}
|
||||
|
||||
function defaultBinName(packageName: string): string {
|
||||
if (packageName.startsWith("@")) {
|
||||
const parts = packageName.split("/");
|
||||
return parts[1] ?? packageName.replace("@", "").replace("/", "-");
|
||||
}
|
||||
return packageName;
|
||||
}
|
||||
|
||||
function findCachedPackageDir(cacheDir: string, packageName: string): string | null {
|
||||
const npxDir = join(cacheDir, "_npx");
|
||||
if (!existsSync(npxDir)) return null;
|
||||
|
||||
const packagePathParts = packageName.startsWith("@")
|
||||
? packageName.split("/")
|
||||
: [packageName];
|
||||
|
||||
const candidates = readdirSync(npxDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => {
|
||||
const full = join(npxDir, entry.name);
|
||||
const mtime = safeStatMtime(full);
|
||||
return { name: entry.name, mtime };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
for (const entry of candidates) {
|
||||
const pkgDir = join(npxDir, entry.name, "node_modules", ...packagePathParts);
|
||||
if (existsSync(join(pkgDir, "package.json"))) {
|
||||
return pkgDir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNodeModulesDir(packageDir: string): string {
|
||||
const parts = packageDir.split(sep);
|
||||
const idx = parts.lastIndexOf("node_modules");
|
||||
if (idx >= 0) {
|
||||
return parts.slice(0, idx + 1).join(sep);
|
||||
}
|
||||
return join(packageDir, "..");
|
||||
}
|
||||
|
||||
function detectJsBinary(binPath: string): boolean {
|
||||
const ext = extname(binPath).toLowerCase();
|
||||
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return true;
|
||||
try {
|
||||
const fd = openSync(binPath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(256);
|
||||
readSync(fd, buf, 0, 256, 0);
|
||||
const firstLine = buf.toString("utf-8").split("\n")[0] ?? "";
|
||||
return firstLine.startsWith("#!") && firstLine.includes("node");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let npmCacheDirCached: string | null | undefined;
|
||||
|
||||
function getNpmCacheDir(): string | null {
|
||||
if (npmCacheDirCached !== undefined) return npmCacheDirCached;
|
||||
if (process.env.NPM_CONFIG_CACHE) {
|
||||
npmCacheDirCached = process.env.NPM_CONFIG_CACHE;
|
||||
return npmCacheDirCached;
|
||||
}
|
||||
try {
|
||||
const result = spawnSync("npm", ["config", "get", "cache"], { encoding: "utf-8" });
|
||||
if (result.status === 0) {
|
||||
const path = String(result.stdout).trim();
|
||||
npmCacheDirCached = path || null;
|
||||
return npmCacheDirCached;
|
||||
}
|
||||
} catch {
|
||||
npmCacheDirCached = null;
|
||||
return null;
|
||||
}
|
||||
npmCacheDirCached = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNpxCachePath(): string {
|
||||
return getAgentPath("mcp-npx-cache.json");
|
||||
}
|
||||
|
||||
function loadCache(): NpxCache | null {
|
||||
const cachePath = getNpxCachePath();
|
||||
if (!existsSync(cachePath)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (raw.version !== CACHE_VERSION) return null;
|
||||
if (!raw.entries || typeof raw.entries !== "object") return null;
|
||||
return raw as NpxCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCacheEntry(key: string, entry: NpxCacheEntry): void {
|
||||
const cachePath = getNpxCachePath();
|
||||
const dir = dirname(cachePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
let merged: NpxCache = { version: CACHE_VERSION, entries: {} };
|
||||
try {
|
||||
if (existsSync(cachePath)) {
|
||||
const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as NpxCache;
|
||||
if (existing && existing.version === CACHE_VERSION && existing.entries) {
|
||||
merged.entries = { ...existing.entries };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
merged.entries[key] = entry;
|
||||
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
||||
renameSync(tmpPath, cachePath);
|
||||
}
|
||||
|
||||
function safeRealpath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeStatMtime(path: string): number {
|
||||
try {
|
||||
return statSync(path).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
57
agent/extensions/pi-mcp-adapter/oauth-handler.ts
Normal file
57
agent/extensions/pi-mcp-adapter/oauth-handler.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// oauth-handler.ts - OAuth token management for MCP servers
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { getAuthEntryFilePath } from "./mcp-auth.ts";
|
||||
|
||||
// Token storage path for a server
|
||||
function getTokensPath(serverName: string): string {
|
||||
return getAuthEntryFilePath(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth tokens for a server (if any).
|
||||
* Returns undefined if no tokens or tokens are expired.
|
||||
*
|
||||
* Token file location: $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json when set,
|
||||
* otherwise <Pi agent dir>/mcp-oauth/sha256-<server-hash>/tokens.json
|
||||
*
|
||||
* Expected format:
|
||||
* {
|
||||
* "access_token": "...",
|
||||
* "token_type": "bearer",
|
||||
* "refresh_token": "...", // optional
|
||||
* "expires_in": 3600, // optional, seconds
|
||||
* "expiresAt": 1234567890 // optional, absolute timestamp ms
|
||||
* }
|
||||
*/
|
||||
export function getStoredTokens(serverName: string): OAuthTokens | undefined {
|
||||
const tokensPath = getTokensPath(serverName);
|
||||
|
||||
if (!existsSync(tokensPath)) return undefined;
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(readFileSync(tokensPath, "utf-8"));
|
||||
|
||||
// Validate required field
|
||||
if (!stored.access_token || typeof stored.access_token !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check expiration if expiresAt is set
|
||||
if (stored.expiresAt && typeof stored.expiresAt === "number") {
|
||||
if (Date.now() > stored.expiresAt) {
|
||||
// Token expired
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: stored.access_token,
|
||||
token_type: stored.token_type ?? "bearer",
|
||||
refresh_token: stored.refresh_token,
|
||||
expires_in: stored.expires_in,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
68
agent/extensions/pi-mcp-adapter/onboarding-state.ts
Normal file
68
agent/extensions/pi-mcp-adapter/onboarding-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
|
||||
export interface McpOnboardingState {
|
||||
version: 1;
|
||||
sharedConfigHintShown: boolean;
|
||||
setupCompleted: boolean;
|
||||
lastDiscoveryFingerprint?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: McpOnboardingState = {
|
||||
version: 1,
|
||||
sharedConfigHintShown: false,
|
||||
setupCompleted: false,
|
||||
};
|
||||
|
||||
export function getOnboardingStatePath(): string {
|
||||
return getAgentPath("mcp-onboarding.json");
|
||||
}
|
||||
|
||||
export function loadOnboardingState(): McpOnboardingState {
|
||||
const path = getOnboardingStatePath();
|
||||
if (!existsSync(path)) return { ...DEFAULT_STATE };
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf-8")) as Partial<McpOnboardingState>;
|
||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_STATE };
|
||||
return {
|
||||
version: 1,
|
||||
sharedConfigHintShown: raw.sharedConfigHintShown === true,
|
||||
setupCompleted: raw.setupCompleted === true,
|
||||
lastDiscoveryFingerprint: typeof raw.lastDiscoveryFingerprint === "string" ? raw.lastDiscoveryFingerprint : undefined,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOnboardingState(state: McpOnboardingState): void {
|
||||
const path = getOnboardingStatePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmpPath = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
||||
renameSync(tmpPath, path);
|
||||
}
|
||||
|
||||
export function updateOnboardingState(updater: (state: McpOnboardingState) => McpOnboardingState): McpOnboardingState {
|
||||
const next = updater(loadOnboardingState());
|
||||
saveOnboardingState(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function markSharedConfigHintShown(fingerprint?: string): McpOnboardingState {
|
||||
return updateOnboardingState((state) => ({
|
||||
...state,
|
||||
sharedConfigHintShown: true,
|
||||
lastDiscoveryFingerprint: fingerprint ?? state.lastDiscoveryFingerprint,
|
||||
}));
|
||||
}
|
||||
|
||||
export function markSetupCompleted(fingerprint?: string): McpOnboardingState {
|
||||
return updateOnboardingState((state) => ({
|
||||
...state,
|
||||
setupCompleted: true,
|
||||
lastDiscoveryFingerprint: fingerprint ?? state.lastDiscoveryFingerprint,
|
||||
}));
|
||||
}
|
||||
106
agent/extensions/pi-mcp-adapter/package.json
Normal file
106
agent/extensions/pi-mcp-adapter/package.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"name": "pi-mcp-adapter",
|
||||
"version": "2.10.0",
|
||||
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Nico Bailon",
|
||||
"bin": {
|
||||
"pi-mcp-adapter": "cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:oauth-provider": "node --import tsx --test mcp-oauth-provider.test.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nicobailon/pi-mcp-adapter.git"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"ai",
|
||||
"coding-agent",
|
||||
"extension",
|
||||
"claude",
|
||||
"llm"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"video": "https://github.com/nicobailon/pi-mcp-adapter/raw/refs/heads/main/pi-mcp.mp4"
|
||||
},
|
||||
"files": [
|
||||
"cli.js",
|
||||
"agent-dir.ts",
|
||||
"index.ts",
|
||||
"state.ts",
|
||||
"utils.ts",
|
||||
"tool-metadata.ts",
|
||||
"init.ts",
|
||||
"ui-session.ts",
|
||||
"proxy-modes.ts",
|
||||
"direct-tools.ts",
|
||||
"commands.ts",
|
||||
"onboarding-state.ts",
|
||||
"mcp-setup-panel.ts",
|
||||
"types.ts",
|
||||
"ui-stream-types.ts",
|
||||
"config.ts",
|
||||
"server-manager.ts",
|
||||
"sampling-handler.ts",
|
||||
"elicitation-handler.ts",
|
||||
"tool-registrar.ts",
|
||||
"tool-result-renderer.ts",
|
||||
"resource-tools.ts",
|
||||
"lifecycle.ts",
|
||||
"metadata-cache.ts",
|
||||
"host-html-template.ts",
|
||||
"ui-resource-handler.ts",
|
||||
"consent-manager.ts",
|
||||
"ui-server.ts",
|
||||
"glimpse-ui.ts",
|
||||
"npx-resolver.ts",
|
||||
"oauth-handler.ts",
|
||||
"mcp-auth.ts",
|
||||
"mcp-oauth-provider.ts",
|
||||
"mcp-callback-server.ts",
|
||||
"mcp-auth-flow.ts",
|
||||
"mcp-panel.ts",
|
||||
"panel-keys.ts",
|
||||
"logger.ts",
|
||||
"errors.ts",
|
||||
"app-bridge.bundle.js",
|
||||
"banner.png",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.74.0",
|
||||
"@earendil-works/pi-tui": "^0.74.0",
|
||||
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"open": "^10.2.0",
|
||||
"recheck": "^4.5.0",
|
||||
"typebox": "^1.1.24",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.79.1",
|
||||
"@types/bun": "^1.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/open": "^6.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
37
agent/extensions/pi-mcp-adapter/panel-keys.ts
Normal file
37
agent/extensions/pi-mcp-adapter/panel-keys.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { matchesKey } from "@earendil-works/pi-tui";
|
||||
|
||||
/** The `tui.select.*` keybinding ids the adapter panels resolve. */
|
||||
export type PanelSelectKeybinding =
|
||||
| "tui.select.up"
|
||||
| "tui.select.down"
|
||||
| "tui.select.confirm";
|
||||
|
||||
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
|
||||
export interface PanelKeybindings {
|
||||
matches(data: string, keybinding: PanelSelectKeybinding): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key matchers for list navigation: user's `tui.select.*` bindings when a
|
||||
* manager is provided, otherwise the previous hardcoded defaults.
|
||||
*/
|
||||
export interface PanelKeys {
|
||||
selectUp(data: string): boolean;
|
||||
selectDown(data: string): boolean;
|
||||
selectConfirm(data: string): boolean;
|
||||
}
|
||||
|
||||
export function createPanelKeys(keybindings?: PanelKeybindings): PanelKeys {
|
||||
if (keybindings) {
|
||||
return {
|
||||
selectUp: (data) => keybindings.matches(data, "tui.select.up"),
|
||||
selectDown: (data) => keybindings.matches(data, "tui.select.down"),
|
||||
selectConfirm: (data) => keybindings.matches(data, "tui.select.confirm"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
selectUp: (data) => matchesKey(data, "up"),
|
||||
selectDown: (data) => matchesKey(data, "down"),
|
||||
selectConfirm: (data) => matchesKey(data, "return"),
|
||||
};
|
||||
}
|
||||
BIN
agent/extensions/pi-mcp-adapter/pi-mcp.mp4
Normal file
BIN
agent/extensions/pi-mcp-adapter/pi-mcp.mp4
Normal file
Binary file not shown.
949
agent/extensions/pi-mcp-adapter/proxy-modes.ts
Normal file
949
agent/extensions/pi-mcp-adapter/proxy-modes.ts
Normal file
@@ -0,0 +1,949 @@
|
||||
import type { AgentToolResult, ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { checkSync } from "recheck";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { ToolMetadata, McpContent } from "./types.ts";
|
||||
import { getServerPrefix, parseUiPromptHandoff } from "./types.ts";
|
||||
import { lazyConnect, updateServerMetadata, updateMetadataCache, getFailureAgeSeconds, updateStatusBar } from "./init.ts";
|
||||
import { buildToolMetadata, getToolNames, findToolByName, formatSchema } from "./tool-metadata.ts";
|
||||
import { transformMcpContent } from "./tool-registrar.ts";
|
||||
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
||||
import { formatAuthRequiredMessage, truncateAtWord } from "./utils.ts";
|
||||
import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
|
||||
type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
|
||||
|
||||
const MAX_REGEX_SEARCH_QUERY_LENGTH = 256;
|
||||
const REGEX_SAFETY_CHECK_PARAMS = {
|
||||
attackTimeout: 50,
|
||||
incubationTimeout: 50,
|
||||
timeout: 250,
|
||||
} as const;
|
||||
|
||||
type AutoAuthResult =
|
||||
| { status: "skipped" }
|
||||
| { status: "success" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
function getAuthRequiredMessage(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
defaultMessage = `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.`,
|
||||
): string {
|
||||
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
||||
}
|
||||
|
||||
function getAuthFailedMessage(state: McpExtensionState, serverName: string, message: string): string {
|
||||
const customGuidance = state.config.settings?.authRequiredMessage;
|
||||
if (customGuidance) {
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. ${getAuthRequiredMessage(state, serverName)}`;
|
||||
}
|
||||
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.`;
|
||||
}
|
||||
|
||||
function getRedirectPort(authorizationUrl: string): number | undefined {
|
||||
try {
|
||||
const redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
|
||||
if (!redirectUri) return undefined;
|
||||
const port = Number.parseInt(new URL(redirectUri).port, 10);
|
||||
return Number.isInteger(port) ? port : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatManualAuthInstructions(serverName: string, authorizationUrl: string): string {
|
||||
const port = getRedirectPort(authorizationUrl);
|
||||
const portNote = port
|
||||
? `\nThe redirect URL will use local port ${port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
|
||||
: "";
|
||||
|
||||
return [
|
||||
`MCP OAuth required for "${serverName}".`,
|
||||
"",
|
||||
"Open this URL in your local browser:",
|
||||
"",
|
||||
authorizationUrl,
|
||||
"",
|
||||
"After approving, copy the full redirected localhost URL from your browser address bar and send it back with:",
|
||||
`mcp({ action: "auth-complete", server: "${serverName}", args: '{"redirectUrl":"PASTE_REDIRECT_URL_HERE"}' })`,
|
||||
"",
|
||||
"You can also pass just the `code` query parameter as `args: '{\"code\":\"PASTE_CODE_HERE\"}'`.",
|
||||
portNote.trimEnd(),
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
async function attemptAutoAuth(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
): Promise<AutoAuthResult> {
|
||||
if (state.config.settings?.autoAuth !== true) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition || !supportsOAuth(definition) || !definition.url) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const grantType = definition.oauth ? definition.oauth.grantType ?? "authorization_code" : "authorization_code";
|
||||
if (!state.ui && grantType !== "client_credentials") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: getAuthRequiredMessage(
|
||||
state,
|
||||
serverName,
|
||||
`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.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await authenticate(serverName, definition.url, definition);
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "failed",
|
||||
message: getAuthFailedMessage(state, serverName, message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function executeUiMessages(state: McpExtensionState): ProxyToolResult {
|
||||
const sessions = state.completedUiSessions;
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No UI session messages available." }],
|
||||
details: { sessions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
output.push(`UI Session Messages (${sessions.length} session${sessions.length > 1 ? "s" : ""}):\n`);
|
||||
|
||||
const allPrompts: string[] = [];
|
||||
const allIntents = sessions.flatMap((session) => session.messages.intents);
|
||||
const parsedHandoffs: Array<{ intent: string; params: Record<string, unknown>; raw: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const timestamp = session.completedAt.toLocaleTimeString();
|
||||
output.push(`\n## ${session.serverName} / ${session.toolName} (${timestamp}, ${session.reason})`);
|
||||
|
||||
const plainPrompts: string[] = [];
|
||||
for (const prompt of session.messages.prompts) {
|
||||
allPrompts.push(prompt);
|
||||
const handoff = parseUiPromptHandoff(prompt);
|
||||
if (handoff) {
|
||||
parsedHandoffs.push(handoff);
|
||||
} else {
|
||||
plainPrompts.push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
if (plainPrompts.length > 0) {
|
||||
output.push("\n### Prompts:");
|
||||
for (const prompt of plainPrompts) {
|
||||
output.push(`- ${prompt}`);
|
||||
}
|
||||
}
|
||||
|
||||
const intentsForSession = [
|
||||
...session.messages.intents,
|
||||
...session.messages.prompts
|
||||
.map((prompt) => parseUiPromptHandoff(prompt))
|
||||
.filter((handoff): handoff is NonNullable<typeof handoff> => !!handoff)
|
||||
.map((handoff) => ({ intent: handoff.intent, params: handoff.params })),
|
||||
];
|
||||
|
||||
if (intentsForSession.length > 0) {
|
||||
output.push("\n### Intents:");
|
||||
for (const intent of intentsForSession) {
|
||||
const params = intent.params ? ` (${JSON.stringify(intent.params)})` : "";
|
||||
output.push(`- ${intent.intent}${params}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (session.messages.notifications.length > 0) {
|
||||
output.push("\n### Notifications:");
|
||||
for (const notification of session.messages.notifications) {
|
||||
output.push(`- ${notification}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = sessions.length;
|
||||
state.completedUiSessions = [];
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: output.join("\n") }],
|
||||
details: {
|
||||
sessions: count,
|
||||
prompts: allPrompts,
|
||||
intents: [...allIntents, ...parsedHandoffs.map(({ intent, params }) => ({ intent, params }))],
|
||||
handoffs: parsedHandoffs,
|
||||
cleared: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function executeStatus(state: McpExtensionState): ProxyToolResult {
|
||||
const servers: Array<{ name: string; status: string; toolCount: number; failedAgo: number | null }> = [];
|
||||
|
||||
for (const name of Object.keys(state.config.mcpServers)) {
|
||||
const connection = state.manager.getConnection(name);
|
||||
const metadata = state.toolMetadata.get(name);
|
||||
const toolCount = metadata?.length ?? 0;
|
||||
const failedAgo = getFailureAgeSeconds(state, name);
|
||||
let status = "not connected";
|
||||
if (connection?.status === "connected") {
|
||||
status = "connected";
|
||||
} else if (connection?.status === "needs-auth") {
|
||||
status = "needs-auth";
|
||||
} else if (failedAgo !== null) {
|
||||
status = "failed";
|
||||
} else if (metadata !== undefined) {
|
||||
status = "cached";
|
||||
}
|
||||
|
||||
servers.push({ name, status, toolCount, failedAgo });
|
||||
}
|
||||
|
||||
const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
||||
const connectedCount = servers.filter(s => s.status === "connected").length;
|
||||
|
||||
let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
|
||||
for (const server of servers) {
|
||||
if (server.status === "connected") {
|
||||
text += `✓ ${server.name} (${server.toolCount} tools)\n`;
|
||||
continue;
|
||||
}
|
||||
if (server.status === "needs-auth") {
|
||||
text += `⚠ ${server.name} (needs auth)\n`;
|
||||
continue;
|
||||
}
|
||||
if (server.status === "cached") {
|
||||
text += `○ ${server.name} (${server.toolCount} tools, cached)\n`;
|
||||
continue;
|
||||
}
|
||||
if (server.status === "failed") {
|
||||
text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
|
||||
continue;
|
||||
}
|
||||
text += `○ ${server.name} (not connected)\n`;
|
||||
}
|
||||
|
||||
if (servers.length > 0) {
|
||||
text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: { mode: "status", servers, totalTools, connectedCount },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeAuthStart(state: McpExtensionState, serverName: string): Promise<ProxyToolResult> {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "auth-start", error: "not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
if (!definition.url || !supportsOAuth(definition)) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" is not configured for OAuth over HTTP.` }],
|
||||
details: { mode: "auth-start", error: "oauth_not_supported", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { authorizationUrl } = await startAuth(serverName, definition.url, definition);
|
||||
if (!authorizationUrl) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}".` }],
|
||||
details: { mode: "auth-start", server: serverName, authenticated: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: formatManualAuthInstructions(serverName, authorizationUrl) }],
|
||||
details: { mode: "auth-start", server: serverName, authorizationUrl },
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to start OAuth for "${serverName}": ${message}` }],
|
||||
details: { mode: "auth-start", error: "auth_start_failed", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeAuthComplete(state: McpExtensionState, serverName: string, input: string): Promise<ProxyToolResult> {
|
||||
if (!state.config.mcpServers[serverName]) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "auth-complete", error: "not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await completeAuthFromInput(serverName, input);
|
||||
if (status !== "authenticated") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `OAuth authentication did not complete for "${serverName}".` }],
|
||||
details: { mode: "auth-complete", error: "not_authenticated", server: serverName, status },
|
||||
};
|
||||
}
|
||||
|
||||
await state.manager.close(serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
updateStatusBar(state);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}". Run mcp({ connect: "${serverName}" }) to connect with the new token.` }],
|
||||
details: { mode: "auth-complete", server: serverName, authenticated: true },
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to complete OAuth for "${serverName}": ${message}` }],
|
||||
details: { mode: "auth-complete", error: "auth_complete_failed", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function executeDescribe(state: McpExtensionState, toolName: string): ProxyToolResult {
|
||||
let serverName: string | undefined;
|
||||
let toolMeta: ToolMetadata | undefined;
|
||||
|
||||
for (const [server, metadata] of state.toolMetadata.entries()) {
|
||||
const found = findToolByName(metadata, toolName);
|
||||
if (found) {
|
||||
serverName = server;
|
||||
toolMeta = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverName || !toolMeta) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
|
||||
details: { mode: "describe", error: "tool_not_found", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
|
||||
let text = `${toolMeta.name}\n`;
|
||||
text += `Server: ${serverName}\n`;
|
||||
if (toolMeta.resourceUri) {
|
||||
text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
|
||||
}
|
||||
text += `\n${toolMeta.description || "(no description)"}\n`;
|
||||
|
||||
if (toolMeta.inputSchema && !toolMeta.resourceUri) {
|
||||
text += `\nParameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
} else if (toolMeta.resourceUri) {
|
||||
text += `\nNo parameters required (resource tool).`;
|
||||
} else {
|
||||
text += `\nNo parameters defined.`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: { mode: "describe", tool: toolMeta, server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
export function executeSearch(
|
||||
state: McpExtensionState,
|
||||
query: string,
|
||||
regex?: boolean,
|
||||
server?: string,
|
||||
includeSchemas?: boolean,
|
||||
): ProxyToolResult {
|
||||
const showSchemas = includeSchemas !== false;
|
||||
|
||||
const matches: Array<{ server: string; tool: ToolMetadata }> = [];
|
||||
|
||||
let pattern: RegExp;
|
||||
try {
|
||||
if (regex) {
|
||||
if (query.length > MAX_REGEX_SEARCH_QUERY_LENGTH) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Regex query is too long; maximum length is ${MAX_REGEX_SEARCH_QUERY_LENGTH} characters.` }],
|
||||
details: { mode: "search", error: "query_too_long", query, maxLength: MAX_REGEX_SEARCH_QUERY_LENGTH },
|
||||
};
|
||||
}
|
||||
|
||||
pattern = new RegExp(query, "i");
|
||||
let safety;
|
||||
try {
|
||||
safety = checkSync(query, "i", REGEX_SAFETY_CHECK_PARAMS);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Regex query rejected because safety analysis failed." }],
|
||||
details: { mode: "search", error: "unsafe_pattern", query, reason: message },
|
||||
};
|
||||
}
|
||||
if (safety.status !== "safe") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Regex query rejected as unsafe (${safety.status}).` }],
|
||||
details: { mode: "search", error: "unsafe_pattern", query, safetyStatus: safety.status },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
|
||||
if (terms.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Search query cannot be empty" }],
|
||||
details: { mode: "search", error: "empty_query" },
|
||||
};
|
||||
}
|
||||
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
pattern = new RegExp(escaped.join("|"), "i");
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
|
||||
details: { mode: "search", error: "invalid_pattern", query },
|
||||
};
|
||||
}
|
||||
|
||||
for (const [serverName, metadata] of state.toolMetadata.entries()) {
|
||||
if (server && serverName !== server) continue;
|
||||
for (const tool of metadata) {
|
||||
if (pattern.test(tool.name) || pattern.test(tool.description)) {
|
||||
matches.push({
|
||||
server: serverName,
|
||||
tool,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = matches.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
const msg = server
|
||||
? `No tools matching "${query}" in "${server}"`
|
||||
: `No tools matching "${query}"`;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: msg }],
|
||||
details: { mode: "search", matches: [], count: 0, query },
|
||||
};
|
||||
}
|
||||
|
||||
let text = `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}":\n\n`;
|
||||
|
||||
for (const match of matches) {
|
||||
if (showSchemas) {
|
||||
text += `${match.tool.name}\n`;
|
||||
text += ` ${match.tool.description || "(no description)"}\n`;
|
||||
if (match.tool.inputSchema && !match.tool.resourceUri) {
|
||||
text += `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`;
|
||||
} else if (match.tool.resourceUri) {
|
||||
text += ` No parameters (resource tool).\n`;
|
||||
}
|
||||
text += "\n";
|
||||
} else {
|
||||
text += `- ${match.tool.name}`;
|
||||
if (match.tool.description) {
|
||||
text += ` - ${truncateAtWord(match.tool.description, 50)}`;
|
||||
}
|
||||
text += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: {
|
||||
mode: "search",
|
||||
matches: matches.map(m => ({ server: m.server, tool: m.tool.name })),
|
||||
count: totalCount,
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function executeList(state: McpExtensionState, server: string): ProxyToolResult {
|
||||
if (!state.config.mcpServers[server]) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = state.toolMetadata.get(server);
|
||||
const toolNames = metadata?.map(m => m.name) ?? [];
|
||||
const connection = state.manager.getConnection(server);
|
||||
|
||||
if (toolNames.length === 0) {
|
||||
if (connection?.status === "connected") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
|
||||
details: { mode: "list", server, tools: [], count: 0 },
|
||||
};
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).` }],
|
||||
details: { mode: "list", server, tools: [], count: 0, cached: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.` }],
|
||||
details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
|
||||
};
|
||||
}
|
||||
|
||||
const cachedNote = connection?.status === "connected" ? "" : " (not connected, cached)";
|
||||
let text = `${server} (${toolNames.length} tools${cachedNote}):\n\n`;
|
||||
|
||||
const descMap = new Map<string, string>();
|
||||
if (metadata) {
|
||||
for (const m of metadata) {
|
||||
descMap.set(m.name, m.description);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of toolNames) {
|
||||
const desc = descMap.get(tool) ?? "";
|
||||
const truncated = truncateAtWord(desc, 50);
|
||||
text += `- ${tool}`;
|
||||
if (truncated) text += ` - ${truncated}`;
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: { mode: "list", server, tools: toolNames, count: toolNames.length },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeConnect(state: McpExtensionState, serverName: string): Promise<ProxyToolResult> {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "connect", error: "not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.ui) {
|
||||
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
||||
}
|
||||
let connection = await state.manager.connect(serverName, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "connect", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
connection = await state.manager.connect(serverName, definition);
|
||||
}
|
||||
if (connection.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "connect", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
const prefix = state.config.settings?.toolPrefix ?? "server";
|
||||
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
||||
state.toolMetadata.set(serverName, metadata);
|
||||
updateMetadataCache(state, serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
updateStatusBar(state);
|
||||
return executeList(state, serverName);
|
||||
} catch (error) {
|
||||
state.failureTracker.set(serverName, Date.now());
|
||||
updateStatusBar(state);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
||||
details: { mode: "connect", error: "connect_failed", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCall(
|
||||
state: McpExtensionState,
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
serverOverride?: string,
|
||||
getPiTools?: () => ToolInfo[],
|
||||
): Promise<ProxyToolResult> {
|
||||
let serverName: string | undefined = serverOverride;
|
||||
let toolMeta: ToolMetadata | undefined;
|
||||
let autoAuthAttempted = false;
|
||||
const prefixMode = state.config.settings?.toolPrefix ?? "server";
|
||||
|
||||
if (serverName && !state.config.mcpServers[serverName]) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "call", error: "server_not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
if (serverName) {
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
} else {
|
||||
for (const [server, metadata] of state.toolMetadata.entries()) {
|
||||
const found = findToolByName(metadata, toolName);
|
||||
if (found) {
|
||||
serverName = server;
|
||||
toolMeta = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serverName && !toolMeta) {
|
||||
const connected = await lazyConnect(state, serverName);
|
||||
if (connected) {
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
} else {
|
||||
const needsAuthConnection = state.manager.getConnection(serverName);
|
||||
if (needsAuthConnection?.status === "needs-auth") {
|
||||
if (!autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
const connectedAfterAuth = await lazyConnect(state, serverName);
|
||||
if (connectedAfterAuth) {
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
if (!toolMeta) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect.` }],
|
||||
details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolMeta && state.manager.getConnection(serverName)?.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolMeta) {
|
||||
const failedAgo = getFailureAgeSeconds(state, serverName);
|
||||
if (failedAgo !== null) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
||||
details: { mode: "call", error: "server_backoff", server: serverName },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let prefixMatchedServer: string | undefined;
|
||||
|
||||
if (!serverName && !toolMeta && prefixMode !== "none") {
|
||||
const candidates = Object.keys(state.config.mcpServers)
|
||||
.map(name => ({ name, prefix: getServerPrefix(name, prefixMode) }))
|
||||
.filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
for (const { name: configuredServer } of candidates) {
|
||||
const existingConnection = state.manager.getConnection(configuredServer);
|
||||
const failedAgo = getFailureAgeSeconds(state, configuredServer);
|
||||
if (failedAgo !== null && existingConnection?.status !== "needs-auth") continue;
|
||||
|
||||
let connected = await lazyConnect(state, configuredServer);
|
||||
if (!connected && state.manager.getConnection(configuredServer)?.status === "needs-auth" && !autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, configuredServer);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: configuredServer, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(configuredServer);
|
||||
state.failureTracker.delete(configuredServer);
|
||||
connected = await lazyConnect(state, configuredServer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) continue;
|
||||
if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
|
||||
toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
|
||||
if (toolMeta) {
|
||||
serverName = configuredServer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverName || !toolMeta) {
|
||||
const nativeTool = !serverOverride
|
||||
? getPiTools?.().find((tool) => tool.name === toolName && tool.name !== "mcp")
|
||||
: undefined;
|
||||
if (nativeTool) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ tool: "${toolName}" }).` }],
|
||||
details: { mode: "call", error: "native_tool", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
|
||||
const hintServer = serverName ?? prefixMatchedServer;
|
||||
const available = hintServer ? getToolNames(state, hintServer) : [];
|
||||
let msg = `Tool "${toolName}" not found.`;
|
||||
if (available.length > 0) {
|
||||
msg += ` Server "${hintServer}" has: ${available.join(", ")}`;
|
||||
} else {
|
||||
msg += ` Use mcp({ search: "..." }) to search.`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: msg }],
|
||||
details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer },
|
||||
};
|
||||
}
|
||||
|
||||
let connection = state.manager.getConnection(serverName);
|
||||
if (connection?.status === "needs-auth") {
|
||||
if (!autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
connection = state.manager.getConnection(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection?.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!connection || connection.status !== "connected") {
|
||||
const failedAgo = getFailureAgeSeconds(state, serverName);
|
||||
if (failedAgo !== null) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
||||
details: { mode: "call", error: "server_backoff", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
|
||||
details: { mode: "call", error: "server_not_connected", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.ui) {
|
||||
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
||||
}
|
||||
connection = await state.manager.connect(serverName, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
if (!autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
connection = await state.manager.connect(serverName, definition);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
state.failureTracker.delete(serverName);
|
||||
updateServerMetadata(state, serverName);
|
||||
updateMetadataCache(state, serverName);
|
||||
updateStatusBar(state);
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
if (!toolMeta) {
|
||||
const available = getToolNames(state, serverName);
|
||||
const hint = available.length > 0
|
||||
? `Available tools on "${serverName}": ${available.join(", ")}`
|
||||
: `Server "${serverName}" has no tools.`;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect. ${hint}` }],
|
||||
details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
state.failureTracker.set(serverName, Date.now());
|
||||
updateStatusBar(state);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
||||
details: { mode: "call", error: "connect_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let uiSession: UiSessionRuntime | null = null;
|
||||
|
||||
try {
|
||||
state.manager.touch(serverName);
|
||||
state.manager.incrementInFlight(serverName);
|
||||
|
||||
if (toolMeta.resourceUri) {
|
||||
const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
|
||||
const content = (result.contents ?? []).map(c => ({
|
||||
type: "text" as const,
|
||||
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
||||
}));
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
||||
details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
uiSession = toolMeta.uiResourceUri
|
||||
? await maybeStartUiSession(state, {
|
||||
serverName,
|
||||
toolName: toolMeta.originalName,
|
||||
toolArgs: args ?? {},
|
||||
uiResourceUri: toolMeta.uiResourceUri,
|
||||
streamMode: toolMeta.uiStreamMode,
|
||||
})
|
||||
: null;
|
||||
|
||||
const resultPromise = connection.client.callTool({
|
||||
name: toolMeta.originalName,
|
||||
arguments: args ?? {},
|
||||
_meta: uiSession?.requestMeta,
|
||||
});
|
||||
|
||||
if (toolMeta.uiResourceUri) {
|
||||
const result = await resultPromise;
|
||||
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
const mcpText = content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as { text: string }).text)
|
||||
.join("\n");
|
||||
|
||||
if (result.isError) {
|
||||
let errorWithSchema = `Error: ${mcpText || "Tool execution failed"}`;
|
||||
if (toolMeta.inputSchema) {
|
||||
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorWithSchema }],
|
||||
details: { mode: "call", error: "tool_error", mcpResult: result },
|
||||
};
|
||||
}
|
||||
|
||||
const resultText = mcpText || "(empty result)";
|
||||
const uiMessage = uiSession?.reused
|
||||
? "Updated the open UI."
|
||||
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
|
||||
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName, uiOpen: true },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
if (result.isError) {
|
||||
const errorText = content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as { text: string }).text)
|
||||
.join("\n") || "Tool execution failed";
|
||||
|
||||
let errorWithSchema = `Error: ${errorText}`;
|
||||
if (toolMeta.inputSchema) {
|
||||
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorWithSchema }],
|
||||
details: { mode: "call", error: "tool_error", mcpResult: result },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
||||
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) {
|
||||
const action = await state.manager.handleUrlElicitationRequired(serverName, error);
|
||||
const message = action === "accept"
|
||||
? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
|
||||
: `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
|
||||
uiSession?.sendToolCancelled(message);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "url_elicitation_required", server: serverName, action },
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
uiSession?.sendToolCancelled(message);
|
||||
|
||||
let errorWithSchema = `Failed to call tool: ${message}`;
|
||||
if (toolMeta.inputSchema) {
|
||||
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorWithSchema }],
|
||||
details: { mode: "call", error: "call_failed", message },
|
||||
};
|
||||
} finally {
|
||||
if (uiSession?.reused) {
|
||||
uiSession.close();
|
||||
}
|
||||
state.manager.decrementInFlight(serverName);
|
||||
state.manager.touch(serverName);
|
||||
}
|
||||
}
|
||||
17
agent/extensions/pi-mcp-adapter/resource-tools.ts
Normal file
17
agent/extensions/pi-mcp-adapter/resource-tools.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// resource-tools.ts - MCP resource name utilities
|
||||
|
||||
export function resourceNameToToolName(name: string): string {
|
||||
let result = name
|
||||
.replace(/[^a-zA-Z0-9]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+/, "") // Remove leading underscores
|
||||
.replace(/_+$/, "") // Remove trailing underscores
|
||||
.toLowerCase();
|
||||
|
||||
// Ensure we have a valid name
|
||||
if (!result || /^\d/.test(result)) {
|
||||
result = "resource" + (result ? "_" + result : "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
268
agent/extensions/pi-mcp-adapter/sampling-handler.ts
Normal file
268
agent/extensions/pi-mcp-adapter/sampling-handler.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { complete, type Api, type AssistantMessage, type Message, type Model, type TextContent } from "@earendil-works/pi-ai";
|
||||
import { truncateAtWord } from "./utils.ts";
|
||||
import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import {
|
||||
CreateMessageRequestSchema,
|
||||
type CreateMessageRequest,
|
||||
type CreateMessageResult,
|
||||
type ModelPreferences,
|
||||
type SamplingMessage,
|
||||
type SamplingMessageContentBlock,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export interface SamplingHandlerOptions {
|
||||
serverName: string;
|
||||
autoApprove: boolean;
|
||||
ui?: ExtensionUIContext;
|
||||
modelRegistry: ModelRegistry;
|
||||
getCurrentModel: () => Model<Api> | undefined;
|
||||
getSignal: () => AbortSignal | undefined;
|
||||
}
|
||||
|
||||
export type ServerSamplingConfig = Omit<SamplingHandlerOptions, "serverName">;
|
||||
|
||||
export function registerSamplingHandler(client: Client, options: SamplingHandlerOptions): void {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
|
||||
return handleSamplingRequest(options, request as CreateMessageRequest);
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleSamplingRequest(
|
||||
options: SamplingHandlerOptions,
|
||||
request: CreateMessageRequest,
|
||||
): Promise<CreateMessageResult> {
|
||||
const params = request.params;
|
||||
|
||||
if ("task" in params && params.task) {
|
||||
throw new Error("MCP sampling tasks are not supported");
|
||||
}
|
||||
if (params.includeContext && params.includeContext !== "none") {
|
||||
throw new Error("MCP sampling context inclusion is not supported");
|
||||
}
|
||||
if (params.tools?.length) {
|
||||
throw new Error("MCP sampling tool use is not supported");
|
||||
}
|
||||
if (params.toolChoice) {
|
||||
throw new Error("MCP sampling tool choice is not supported");
|
||||
}
|
||||
if (params.stopSequences?.length) {
|
||||
throw new Error("MCP sampling stop sequences are not supported");
|
||||
}
|
||||
|
||||
const messages = params.messages.map(convertSamplingMessage);
|
||||
const { model, apiKey, headers } = await resolveSamplingModel(options, params.modelPreferences);
|
||||
await confirmSampling(
|
||||
options,
|
||||
"Approve MCP sampling request",
|
||||
formatRequestApproval(options.serverName, `${model.provider}/${model.id}`, params.systemPrompt, messages),
|
||||
);
|
||||
|
||||
const result = await complete(
|
||||
model,
|
||||
{
|
||||
systemPrompt: params.systemPrompt,
|
||||
messages,
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
headers,
|
||||
maxTokens: params.maxTokens,
|
||||
temperature: params.temperature,
|
||||
metadata: params.metadata as Record<string, unknown> | undefined,
|
||||
signal: options.getSignal(),
|
||||
},
|
||||
);
|
||||
|
||||
const converted = convertAssistantResult(result);
|
||||
await confirmSampling(
|
||||
options,
|
||||
"Return MCP sampling response",
|
||||
formatResponseApproval(options.serverName, converted),
|
||||
);
|
||||
return converted;
|
||||
}
|
||||
|
||||
function formatRequestApproval(
|
||||
serverName: string,
|
||||
modelName: string,
|
||||
systemPrompt: string | undefined,
|
||||
messages: Message[],
|
||||
): string {
|
||||
const lines = [`${serverName} wants to sample ${messages.length} message${messages.length === 1 ? "" : "s"} with ${modelName}.`];
|
||||
if (systemPrompt) {
|
||||
lines.push(`System: ${truncateAtWord(systemPrompt, 400)}`);
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
lines.push(`${index + 1}. ${message.role}: ${truncateAtWord(messageText(message), 400)}`);
|
||||
}
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
|
||||
function formatResponseApproval(serverName: string, response: CreateMessageResult): string {
|
||||
const text = response.content.type === "text" ? response.content.text : `[${response.content.type} content]`;
|
||||
return `${serverName} will receive this response from ${response.model}:\n\n${truncateAtWord(text, 1000)}`;
|
||||
}
|
||||
|
||||
function messageText(message: Message): string {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return message.content.map((block) => {
|
||||
if (block.type === "text") return block.text;
|
||||
if (block.type === "image") return `[image: ${block.mimeType}]`;
|
||||
if (block.type === "thinking") return "[thinking]";
|
||||
if (block.type === "toolCall") return `[tool call: ${block.name}]`;
|
||||
return "[content]";
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
async function resolveSamplingModel(
|
||||
options: SamplingHandlerOptions,
|
||||
modelPreferences: ModelPreferences | undefined,
|
||||
): Promise<{
|
||||
model: Model<Api>;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
}> {
|
||||
const candidates: Model<Api>[] = [];
|
||||
const availableModels = options.modelRegistry.getAvailable();
|
||||
|
||||
for (const hint of modelPreferences?.hints ?? []) {
|
||||
const normalizedHint = hint.name?.trim().toLowerCase();
|
||||
if (!normalizedHint) continue;
|
||||
for (const model of availableModels) {
|
||||
const searchableNames = [`${model.provider}/${model.id}`, model.id, model.name];
|
||||
if (searchableNames.some((name) => name.toLowerCase().includes(normalizedHint))) {
|
||||
addSamplingCandidate(candidates, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentModel = options.getCurrentModel();
|
||||
if (currentModel) addSamplingCandidate(candidates, currentModel);
|
||||
|
||||
for (const model of availableModels) {
|
||||
addSamplingCandidate(candidates, model);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const model of candidates) {
|
||||
const auth = await options.modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (auth.ok === false) {
|
||||
errors.push(`${model.provider}/${model.id}: ${auth.error}`);
|
||||
continue;
|
||||
}
|
||||
return { model, apiKey: auth.apiKey, headers: auth.headers };
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`No configured auth for MCP sampling model. ${errors.join("; ")}`);
|
||||
}
|
||||
throw new Error("No Pi model is available for MCP sampling");
|
||||
}
|
||||
|
||||
function addSamplingCandidate(candidates: Model<Api>[], model: Model<Api>): void {
|
||||
if (!candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id)) {
|
||||
candidates.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSampling(options: SamplingHandlerOptions, title: string, message: string): Promise<void> {
|
||||
if (options.autoApprove) return;
|
||||
if (!options.ui) {
|
||||
throw new Error("MCP sampling requires interactive approval. Set settings.samplingAutoApprove to true to allow it without UI.");
|
||||
}
|
||||
const approved = await options.ui.confirm(title, message);
|
||||
if (!approved) {
|
||||
throw new Error("MCP sampling request was declined");
|
||||
}
|
||||
}
|
||||
|
||||
function convertSamplingMessage(message: SamplingMessage): Message {
|
||||
const blocks = Array.isArray(message.content) ? message.content : [message.content];
|
||||
if (message.role === "user") {
|
||||
return {
|
||||
role: "user",
|
||||
content: blocks.map(convertUserContent),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: blocks.map(convertAssistantContent),
|
||||
api: "mcp-sampling",
|
||||
provider: "mcp",
|
||||
model: "sampling-request",
|
||||
usage: zeroUsage(),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function convertUserContent(block: SamplingMessageContentBlock): TextContent {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
throw new Error(`MCP sampling ${block.type} content is not supported`);
|
||||
}
|
||||
|
||||
function convertAssistantContent(block: SamplingMessageContentBlock): TextContent {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
throw new Error(`MCP sampling assistant ${block.type} content is not supported`);
|
||||
}
|
||||
|
||||
function convertAssistantResult(message: AssistantMessage): CreateMessageResult {
|
||||
if (message.stopReason === "error") {
|
||||
throw new Error(message.errorMessage ?? "MCP sampling model call failed");
|
||||
}
|
||||
if (message.stopReason === "aborted") {
|
||||
throw new Error(message.errorMessage ?? "MCP sampling model call was aborted");
|
||||
}
|
||||
|
||||
const text = message.content
|
||||
.map((block) => {
|
||||
if (block.type === "text") return block.text;
|
||||
if (block.type === "thinking") return undefined;
|
||||
throw new Error(`MCP sampling result ${block.type} content is not supported`);
|
||||
})
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
|
||||
if (!text) {
|
||||
throw new Error("MCP sampling result did not contain text content");
|
||||
}
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: { type: "text", text },
|
||||
model: `${message.provider}/${message.model}`,
|
||||
stopReason: mapStopReason(message.stopReason),
|
||||
};
|
||||
}
|
||||
|
||||
function mapStopReason(reason: AssistantMessage["stopReason"]): CreateMessageResult["stopReason"] {
|
||||
if (reason === "stop") return "endTurn";
|
||||
if (reason === "length") return "maxTokens";
|
||||
if (reason === "toolUse") return "toolUse";
|
||||
return reason;
|
||||
}
|
||||
|
||||
function zeroUsage(): AssistantMessage["usage"] {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
442
agent/extensions/pi-mcp-adapter/server-manager.ts
Normal file
442
agent/extensions/pi-mcp-adapter/server-manager.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import {
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ReadResourceResult,
|
||||
type UrlElicitationRequiredError,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
McpTool,
|
||||
McpResource,
|
||||
ServerDefinition,
|
||||
ServerStreamResultPatchNotification,
|
||||
Transport,
|
||||
} from "./types.ts";
|
||||
import { serverStreamResultPatchNotificationSchema } from "./types.ts";
|
||||
import { resolveNpxBinary } from "./npx-resolver.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
|
||||
import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
|
||||
import {
|
||||
handleUrlElicitation,
|
||||
registerElicitationHandler,
|
||||
type ServerElicitationConfig,
|
||||
} from "./elicitation-handler.ts";
|
||||
import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
|
||||
|
||||
interface ServerConnection {
|
||||
client: Client;
|
||||
transport: Transport;
|
||||
definition: ServerDefinition;
|
||||
tools: McpTool[];
|
||||
resources: McpResource[];
|
||||
lastUsedAt: number;
|
||||
inFlight: number;
|
||||
status: "connected" | "closed" | "needs-auth";
|
||||
}
|
||||
|
||||
type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
|
||||
|
||||
export class McpServerManager {
|
||||
private connections = new Map<string, ServerConnection>();
|
||||
private connectPromises = new Map<string, Promise<ServerConnection>>();
|
||||
private uiStreamListeners = new Map<string, UiStreamListener>();
|
||||
private samplingConfig: ServerSamplingConfig | undefined;
|
||||
private elicitationConfig: ServerElicitationConfig | undefined;
|
||||
private acceptedUrlElicitations = new Map<string, Set<string>>();
|
||||
|
||||
setSamplingConfig(config: ServerSamplingConfig | undefined): void {
|
||||
this.samplingConfig = config;
|
||||
}
|
||||
|
||||
setElicitationConfig(config: ServerElicitationConfig | undefined): void {
|
||||
this.elicitationConfig = config;
|
||||
}
|
||||
|
||||
async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
|
||||
// Dedupe concurrent connection attempts
|
||||
if (this.connectPromises.has(name)) {
|
||||
return this.connectPromises.get(name)!;
|
||||
}
|
||||
|
||||
// Reuse existing connection if healthy
|
||||
const existing = this.connections.get(name);
|
||||
if (existing?.status === "connected") {
|
||||
existing.lastUsedAt = Date.now();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.createConnection(name, definition);
|
||||
this.connectPromises.set(name, promise);
|
||||
|
||||
try {
|
||||
const connection = await promise;
|
||||
this.connections.set(name, connection);
|
||||
return connection;
|
||||
} finally {
|
||||
this.connectPromises.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
private async createConnection(
|
||||
name: string,
|
||||
definition: ServerDefinition
|
||||
): Promise<ServerConnection> {
|
||||
const client = this.createClient(name);
|
||||
|
||||
let transport: Transport;
|
||||
|
||||
if (definition.command) {
|
||||
let command = definition.command;
|
||||
let args = definition.args ?? [];
|
||||
|
||||
if (command === "npx" || command === "npm") {
|
||||
const resolved = await resolveNpxBinary(command, args);
|
||||
if (resolved) {
|
||||
command = resolved.isJs ? "node" : resolved.binPath;
|
||||
args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
|
||||
logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
|
||||
}
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: resolveEnv(definition.env),
|
||||
cwd: resolveConfigPath(definition.cwd),
|
||||
stderr: definition.debug ? "inherit" : "ignore",
|
||||
});
|
||||
} else if (definition.url) {
|
||||
// HTTP transport with fallback
|
||||
transport = await this.createHttpTransport(definition, name);
|
||||
} else {
|
||||
throw new Error(`Server ${name} has no command or url`);
|
||||
}
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
this.attachAdapterNotificationHandlers(name, client);
|
||||
|
||||
// Discover tools and resources
|
||||
const [tools, resources] = await Promise.all([
|
||||
this.fetchAllTools(client),
|
||||
this.fetchAllResources(client),
|
||||
]);
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
definition,
|
||||
tools,
|
||||
resources,
|
||||
lastUsedAt: Date.now(),
|
||||
inFlight: 0,
|
||||
status: "connected",
|
||||
};
|
||||
} catch (error) {
|
||||
// Check for UnauthorizedError - server requires OAuth
|
||||
if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
|
||||
// Clean up both client and transport before reporting needs-auth.
|
||||
await client.close().catch(() => {});
|
||||
await transport.close().catch(() => {});
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
definition,
|
||||
tools: [],
|
||||
resources: [],
|
||||
lastUsedAt: Date.now(),
|
||||
inFlight: 0,
|
||||
status: "needs-auth",
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up both client and transport on any error
|
||||
await client.close().catch(() => {});
|
||||
await transport.close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientCapabilities() {
|
||||
return {
|
||||
...(this.samplingConfig ? { sampling: {} } : {}),
|
||||
...(this.elicitationConfig
|
||||
? {
|
||||
elicitation: {
|
||||
form: {},
|
||||
...(this.elicitationConfig.allowUrl ? { url: {} } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private createClient(serverName: string): Client {
|
||||
const capabilities = this.buildClientCapabilities();
|
||||
const client = new Client(
|
||||
{ name: `pi-mcp-${serverName}`, version: "1.0.0" },
|
||||
Object.keys(capabilities).length > 0 ? { capabilities } : undefined,
|
||||
);
|
||||
if (this.samplingConfig) {
|
||||
registerSamplingHandler(client, { ...this.samplingConfig, serverName });
|
||||
}
|
||||
if (this.elicitationConfig) {
|
||||
registerElicitationHandler(client, {
|
||||
...this.elicitationConfig,
|
||||
serverName,
|
||||
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
||||
});
|
||||
if (this.elicitationConfig.allowUrl) {
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
|
||||
const accepted = this.acceptedUrlElicitations.get(serverName);
|
||||
if (!accepted?.delete(notification.params.elicitationId)) return;
|
||||
this.elicitationConfig?.ui.notify(
|
||||
`MCP browser interaction for ${serverName} completed. You can retry the tool now.`,
|
||||
"info",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async handleUrlElicitationRequired(
|
||||
serverName: string,
|
||||
error: UrlElicitationRequiredError,
|
||||
): Promise<"accept" | "decline" | "cancel"> {
|
||||
if (!this.elicitationConfig?.allowUrl) return "cancel";
|
||||
for (const params of error.elicitations) {
|
||||
const result = await handleUrlElicitation({
|
||||
...this.elicitationConfig,
|
||||
serverName,
|
||||
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
||||
}, params);
|
||||
if (result.action !== "accept") return result.action;
|
||||
}
|
||||
return "accept";
|
||||
}
|
||||
|
||||
private rememberUrlElicitation(serverName: string, elicitationId: string): void {
|
||||
let accepted = this.acceptedUrlElicitations.get(serverName);
|
||||
if (!accepted) {
|
||||
accepted = new Set();
|
||||
this.acceptedUrlElicitations.set(serverName, accepted);
|
||||
}
|
||||
accepted.add(elicitationId);
|
||||
}
|
||||
|
||||
private async createHttpTransport(
|
||||
definition: ServerDefinition,
|
||||
serverName: string
|
||||
): Promise<Transport> {
|
||||
const url = new URL(definition.url!);
|
||||
|
||||
// Build headers first (including any bearer token)
|
||||
const headers = resolveHeaders(definition.headers) ?? {};
|
||||
|
||||
// For bearer auth, add the token to headers BEFORE creating requestInit
|
||||
if (definition.auth === "bearer") {
|
||||
const token = resolveBearerToken(definition);
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create request init with headers (Authorization now included for bearer auth)
|
||||
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
|
||||
|
||||
// For OAuth servers, create an auth provider
|
||||
let authProvider: McpOAuthProvider | undefined;
|
||||
if (supportsOAuth(definition)) {
|
||||
const oauthConfig = extractOAuthConfig(definition);
|
||||
authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
definition.url!,
|
||||
oauthConfig,
|
||||
{
|
||||
onRedirect: async (_authUrl) => {
|
||||
// URL is captured by startAuth, no need to log
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Try StreamableHTTP first (modern MCP servers)
|
||||
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
authProvider,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a test client to verify the transport works
|
||||
const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" });
|
||||
await testClient.connect(streamableTransport);
|
||||
await testClient.close().catch(() => {});
|
||||
// Close probe transport before creating fresh one
|
||||
await streamableTransport.close().catch(() => {});
|
||||
|
||||
// StreamableHTTP works - create fresh transport for actual use
|
||||
return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
||||
} catch (error) {
|
||||
// StreamableHTTP failed, close and try SSE fallback
|
||||
await streamableTransport.close().catch(() => {});
|
||||
|
||||
// If this was an UnauthorizedError, don't try SSE - the server needs auth
|
||||
if (error instanceof UnauthorizedError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// SSE is the legacy transport
|
||||
return new SSEClientTransport(url, { requestInit, authProvider });
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAllTools(client: Client): Promise<McpTool[]> {
|
||||
const allTools: McpTool[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await client.listTools(cursor ? { cursor } : undefined);
|
||||
allTools.push(...(result.tools ?? []));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
private async fetchAllResources(client: Client): Promise<McpResource[]> {
|
||||
try {
|
||||
const allResources: McpResource[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await client.listResources(cursor ? { cursor } : undefined);
|
||||
allResources.push(...(result.resources ?? []));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allResources;
|
||||
} catch {
|
||||
// Server may not support resources
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
|
||||
client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => {
|
||||
const listener = this.uiStreamListeners.get(notification.params.streamToken);
|
||||
if (!listener) return;
|
||||
listener(serverName, notification.params);
|
||||
});
|
||||
}
|
||||
|
||||
registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
|
||||
this.uiStreamListeners.set(streamToken, listener);
|
||||
}
|
||||
|
||||
removeUiStreamListener(streamToken: string): void {
|
||||
this.uiStreamListeners.delete(streamToken);
|
||||
}
|
||||
|
||||
async readResource(name: string, uri: string): Promise<ReadResourceResult> {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
throw new Error(`Server "${name}" is not connected`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.touch(name);
|
||||
this.incrementInFlight(name);
|
||||
return await connection.client.readResource({ uri });
|
||||
} finally {
|
||||
this.decrementInFlight(name);
|
||||
this.touch(name);
|
||||
}
|
||||
}
|
||||
|
||||
async close(name: string): Promise<void> {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection) return;
|
||||
|
||||
// Delete from map BEFORE async cleanup to prevent a race where a
|
||||
// concurrent connect() creates a new connection that our deferred
|
||||
// delete() would then remove, orphaning the new server process.
|
||||
connection.status = "closed";
|
||||
this.connections.delete(name);
|
||||
this.acceptedUrlElicitations.delete(name);
|
||||
await connection.client.close().catch(() => {});
|
||||
await connection.transport.close().catch(() => {});
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
const names = [...this.connections.keys()];
|
||||
await Promise.all(names.map(name => this.close(name)));
|
||||
}
|
||||
|
||||
getConnection(name: string): ServerConnection | undefined {
|
||||
return this.connections.get(name);
|
||||
}
|
||||
|
||||
getAllConnections(): Map<string, ServerConnection> {
|
||||
return new Map(this.connections);
|
||||
}
|
||||
|
||||
touch(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection) {
|
||||
connection.lastUsedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
incrementInFlight(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection) {
|
||||
connection.inFlight = (connection.inFlight ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
decrementInFlight(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection && connection.inFlight) {
|
||||
connection.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
isIdle(name: string, timeoutMs: number): boolean {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection || connection.status !== "connected") return false;
|
||||
if (connection.inFlight > 0) return false;
|
||||
return (Date.now() - connection.lastUsedAt) > timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables with interpolation.
|
||||
*/
|
||||
function resolveEnv(env?: Record<string, string>): Record<string, string> {
|
||||
// Copy process.env, filtering out undefined values
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env) return resolved;
|
||||
|
||||
const overrides = interpolateEnvRecord(env);
|
||||
return overrides ? { ...resolved, ...overrides } : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve headers with environment variable interpolation.
|
||||
*/
|
||||
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
||||
return interpolateEnvRecord(headers);
|
||||
}
|
||||
41
agent/extensions/pi-mcp-adapter/state.ts
Normal file
41
agent/extensions/pi-mcp-adapter/state.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { ConsentManager } from "./consent-manager.ts";
|
||||
import type { McpLifecycleManager } from "./lifecycle.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.ts";
|
||||
import type { UiResourceHandler } from "./ui-resource-handler.ts";
|
||||
import type { UiServerHandle } from "./ui-server.ts";
|
||||
|
||||
export interface CompletedUiSession {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
completedAt: Date;
|
||||
reason: string;
|
||||
messages: UiSessionMessages;
|
||||
stream?: UiStreamSummary;
|
||||
}
|
||||
|
||||
export type SendMessageFn = (
|
||||
message: {
|
||||
customType: string;
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
display?: string;
|
||||
details?: unknown;
|
||||
},
|
||||
options?: { triggerTurn?: boolean }
|
||||
) => void;
|
||||
|
||||
export interface McpExtensionState {
|
||||
manager: McpServerManager;
|
||||
lifecycle: McpLifecycleManager;
|
||||
toolMetadata: Map<string, ToolMetadata[]>;
|
||||
config: McpConfig;
|
||||
failureTracker: Map<string, number>;
|
||||
uiResourceHandler: UiResourceHandler;
|
||||
consentManager: ConsentManager;
|
||||
uiServer: UiServerHandle | null;
|
||||
completedUiSessions: CompletedUiSession[];
|
||||
openBrowser: (url: string) => Promise<void>;
|
||||
ui?: ExtensionContext["ui"];
|
||||
sendMessage?: SendMessageFn;
|
||||
}
|
||||
216
agent/extensions/pi-mcp-adapter/tool-metadata.ts
Normal file
216
agent/extensions/pi-mcp-adapter/tool-metadata.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { ToolMetadata, McpTool, McpResource, ServerEntry } from "./types.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { extractToolUiStreamMode } from "./utils.ts";
|
||||
|
||||
export function buildToolMetadata(
|
||||
tools: McpTool[],
|
||||
resources: McpResource[],
|
||||
definition: ServerEntry,
|
||||
serverName: string,
|
||||
prefix: "server" | "none" | "short"
|
||||
): { metadata: ToolMetadata[]; failedTools: string[] } {
|
||||
const metadata: ToolMetadata[] = [];
|
||||
const failedTools: string[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool?.name) {
|
||||
failedTools.push("(unnamed)");
|
||||
continue;
|
||||
}
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uiResourceUri: string | undefined;
|
||||
try {
|
||||
uiResourceUri = getToolUiResourceUri({ _meta: tool._meta });
|
||||
} catch {
|
||||
failedTools.push(tool.name);
|
||||
}
|
||||
metadata.push({
|
||||
name: formatToolName(tool.name, serverName, prefix),
|
||||
originalName: tool.name,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri,
|
||||
uiStreamMode: extractToolUiStreamMode(tool._meta),
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of resources) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
name: formatToolName(baseName, serverName, prefix),
|
||||
originalName: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, failedTools };
|
||||
}
|
||||
|
||||
export function getToolNames(state: McpExtensionState, serverName: string): string[] {
|
||||
return state.toolMetadata.get(serverName)?.map(m => m.name) ?? [];
|
||||
}
|
||||
|
||||
export function totalToolCount(state: McpExtensionState): number {
|
||||
let count = 0;
|
||||
for (const metadata of state.toolMetadata.values()) {
|
||||
count += metadata.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const exact = metadata.find(m => m.name === toolName);
|
||||
if (exact) return exact;
|
||||
const normalized = toolName.replace(/-/g, "_");
|
||||
return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
|
||||
}
|
||||
|
||||
export function formatSchema(schema: unknown, indent = " "): string {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return `${indent}(no schema)`;
|
||||
}
|
||||
|
||||
const s = schema as Record<string, unknown>;
|
||||
|
||||
if (s.type === "object" && s.properties && typeof s.properties === "object" && !Array.isArray(s.properties)) {
|
||||
const props = s.properties as Record<string, unknown>;
|
||||
const required = Array.isArray(s.required) ? s.required.filter((name): name is string => typeof name === "string") : [];
|
||||
|
||||
if (Object.keys(props).length === 0) {
|
||||
return `${indent}(no parameters)`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [name, propSchema] of Object.entries(props)) {
|
||||
lines.push(...formatProperty(name, propSchema, required.includes(name), indent));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const lines = formatNestedSchema(s, indent);
|
||||
if (lines.length > 0) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const typeStr = formatType(s);
|
||||
if (typeStr) {
|
||||
return `${indent}(${typeStr})`;
|
||||
}
|
||||
|
||||
return `${indent}(complex schema)`;
|
||||
}
|
||||
|
||||
function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string[] {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return [`${indent}${name}${required ? " *required*" : ""}`];
|
||||
}
|
||||
|
||||
const s = schema as Record<string, unknown>;
|
||||
const parts = [`${indent}${name}`];
|
||||
const typeStr = formatType(s);
|
||||
if (typeStr) parts.push(`(${typeStr})`);
|
||||
if (required) parts.push("*required*");
|
||||
appendSchemaAnnotations(parts, s);
|
||||
|
||||
return [parts.join(" "), ...formatNestedSchema(s, `${indent} `)];
|
||||
}
|
||||
|
||||
function formatNestedSchema(schema: Record<string, unknown>, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
lines.push(...formatVariants("anyOf", schema.anyOf, indent));
|
||||
}
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
lines.push(...formatVariants("oneOf", schema.oneOf, indent));
|
||||
}
|
||||
if (schema.items !== undefined) {
|
||||
lines.push(...formatProperty("items", schema.items, false, indent));
|
||||
}
|
||||
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
|
||||
const required = Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === "string") : [];
|
||||
for (const [name, propSchema] of Object.entries(schema.properties as Record<string, unknown>)) {
|
||||
lines.push(...formatProperty(name, propSchema, required.includes(name), indent));
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatVariants(keyword: "anyOf" | "oneOf", variants: unknown[], indent: string): string[] {
|
||||
const lines = [`${indent}${keyword}:`];
|
||||
|
||||
for (const variant of variants) {
|
||||
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
|
||||
lines.push(`${indent} - ${JSON.stringify(variant)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const s = variant as Record<string, unknown>;
|
||||
const typeStr = formatType(s) || "schema";
|
||||
const parts = [`${indent} - ${typeStr}`];
|
||||
appendSchemaAnnotations(parts, s);
|
||||
lines.push(parts.join(" "));
|
||||
lines.push(...formatNestedSchema(s, `${indent} `));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatType(schema: Record<string, unknown>): string {
|
||||
if (Object.hasOwn(schema, "const")) {
|
||||
return `const ${JSON.stringify(schema.const)}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return `enum: ${schema.enum.map(v => JSON.stringify(v)).join(", ")}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.map(type => String(type)).join(" | ");
|
||||
}
|
||||
|
||||
if (schema.type) {
|
||||
return String(schema.type);
|
||||
}
|
||||
|
||||
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
|
||||
return "object";
|
||||
}
|
||||
|
||||
if (schema.items !== undefined) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendSchemaAnnotations(parts: string[], schema: Record<string, unknown>): void {
|
||||
if (schema.description && typeof schema.description === "string") {
|
||||
parts.push(`- ${schema.description}`);
|
||||
}
|
||||
|
||||
for (const key of ["minLength", "maxLength", "minimum", "maximum", "minItems", "maxItems", "format", "pattern"] as const) {
|
||||
if (schema[key] !== undefined) {
|
||||
parts.push(`[${key}: ${JSON.stringify(schema[key])}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.default !== undefined) {
|
||||
parts.push(`[default: ${JSON.stringify(schema.default)}]`);
|
||||
}
|
||||
}
|
||||
46
agent/extensions/pi-mcp-adapter/tool-registrar.ts
Normal file
46
agent/extensions/pi-mcp-adapter/tool-registrar.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// tool-registrar.ts - MCP content transformation
|
||||
// NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered.
|
||||
// This keeps the LLM context small (1 tool instead of 100s).
|
||||
|
||||
import type { McpContent, ContentBlock } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Transform MCP content types to Pi content blocks.
|
||||
*/
|
||||
export function transformMcpContent(content: McpContent[]): ContentBlock[] {
|
||||
return content.map(c => {
|
||||
if (c.type === "text") {
|
||||
return { type: "text" as const, text: c.text ?? "" };
|
||||
}
|
||||
if (c.type === "image") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
data: c.data ?? "",
|
||||
mimeType: c.mimeType ?? "image/png",
|
||||
};
|
||||
}
|
||||
if (c.type === "resource") {
|
||||
const resourceUri = c.resource?.uri ?? "(no URI)";
|
||||
const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Resource: ${resourceUri}]\n${resourceContent}`,
|
||||
};
|
||||
}
|
||||
if (c.type === "resource_link") {
|
||||
const linkName = c.name ?? c.uri ?? "unknown";
|
||||
const linkUri = c.uri ?? "(no URI)";
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Resource Link: ${linkName}]\nURI: ${linkUri}`,
|
||||
};
|
||||
}
|
||||
if (c.type === "audio") {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
|
||||
};
|
||||
}
|
||||
return { type: "text" as const, text: JSON.stringify(c) };
|
||||
});
|
||||
}
|
||||
161
agent/extensions/pi-mcp-adapter/tool-result-renderer.ts
Normal file
161
agent/extensions/pi-mcp-adapter/tool-result-renderer.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
type McpToolResultDetails = Record<string, unknown> & { error?: unknown };
|
||||
type McpToolContentBlock = AgentToolResult<McpToolResultDetails>["content"][number];
|
||||
|
||||
interface RenderTheme {
|
||||
fg: (name: string, text: string) => string;
|
||||
bold?: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface McpProxyToolCallInput {
|
||||
tool?: string;
|
||||
args?: string;
|
||||
connect?: string;
|
||||
describe?: string;
|
||||
search?: string;
|
||||
regex?: boolean;
|
||||
includeSchemas?: boolean;
|
||||
server?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface McpToolRenderContext {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface McpToolResultDisplay {
|
||||
lines: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CALL_INPUT_CHARS = 1500;
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
function formatJsonish(value: unknown, maxChars: number): string {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return truncateText(JSON.stringify(JSON.parse(value), null, 2), maxChars);
|
||||
} catch {
|
||||
return truncateText(value, maxChars);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return truncateText(JSON.stringify(value, null, 2), maxChars);
|
||||
} catch {
|
||||
return truncateText(String(value), maxChars);
|
||||
}
|
||||
}
|
||||
|
||||
function hasUsefulObjectContent(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
export function formatMcpProxyToolCallLines(
|
||||
args: McpProxyToolCallInput,
|
||||
maxInputChars = DEFAULT_MAX_CALL_INPUT_CHARS,
|
||||
): string[] {
|
||||
if (args.action === "ui-messages") return [`mcp ${args.action}`];
|
||||
|
||||
if (args.tool) {
|
||||
const target = args.server ? `${args.tool} @ ${args.server}` : args.tool;
|
||||
const lines = [`mcp call ${target}`];
|
||||
if (args.args) lines.push(formatJsonish(args.args, maxInputChars));
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (args.connect) return [`mcp connect ${args.connect}`];
|
||||
if (args.describe) return [`mcp describe ${args.describe}`];
|
||||
|
||||
if (args.search) {
|
||||
let line = `mcp search ${args.search}`;
|
||||
if (args.server) line += ` @ ${args.server}`;
|
||||
if (args.regex === true) line += " (regex)";
|
||||
if (args.includeSchemas === false) line += " (schemas hidden)";
|
||||
return [line];
|
||||
}
|
||||
|
||||
if (args.server) return [`mcp list ${args.server}`];
|
||||
if (args.action) return [`mcp ${args.action}`];
|
||||
|
||||
return ["mcp status"];
|
||||
}
|
||||
|
||||
export function formatMcpDirectToolCallLines(
|
||||
displayName: string,
|
||||
args: Record<string, unknown>,
|
||||
maxInputChars = DEFAULT_MAX_CALL_INPUT_CHARS,
|
||||
): string[] {
|
||||
if (!hasUsefulObjectContent(args)) return [displayName];
|
||||
return [displayName, formatJsonish(args, maxInputChars)];
|
||||
}
|
||||
|
||||
function renderToolCallLines(lines: string[], theme: RenderTheme) {
|
||||
const [title = "mcp", ...rest] = lines;
|
||||
const styledTitle = theme.fg("toolTitle", theme.bold ? theme.bold(title) : title);
|
||||
const styledRest = rest.map(line => theme.fg("muted", line));
|
||||
return new Text([styledTitle, ...styledRest].join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
export function renderMcpProxyToolCall(args: McpProxyToolCallInput, theme: RenderTheme) {
|
||||
return renderToolCallLines(formatMcpProxyToolCallLines(args), theme);
|
||||
}
|
||||
|
||||
export function createMcpDirectToolCallRenderer(displayName: string) {
|
||||
return (args: Record<string, unknown>, theme: RenderTheme) => {
|
||||
return renderToolCallLines(formatMcpDirectToolCallLines(displayName, args), theme);
|
||||
};
|
||||
}
|
||||
|
||||
function blockToLines(block: McpToolContentBlock): string[] {
|
||||
if (block.type === "text") {
|
||||
return block.text.split("\n");
|
||||
}
|
||||
return [`[image: ${block.mimeType}]`];
|
||||
}
|
||||
|
||||
export function formatMcpToolResultLines(
|
||||
result: Pick<AgentToolResult<McpToolResultDetails>, "content">,
|
||||
expanded: boolean,
|
||||
maxCollapsedLines = 3,
|
||||
): McpToolResultDisplay {
|
||||
const allLines = result.content.flatMap(blockToLines);
|
||||
const lines = allLines.length > 0 ? allLines : ["(empty result)"];
|
||||
|
||||
if (expanded || lines.length <= maxCollapsedLines) {
|
||||
return { lines, truncated: false };
|
||||
}
|
||||
|
||||
return {
|
||||
lines: [...lines.slice(0, maxCollapsedLines), "…"],
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMcpToolResult(
|
||||
result: AgentToolResult<McpToolResultDetails>,
|
||||
options: ToolRenderResultOptions,
|
||||
theme: RenderTheme,
|
||||
context?: McpToolRenderContext,
|
||||
) {
|
||||
if (options.isPartial) {
|
||||
return new Text(theme.fg("warning", "Running MCP tool..."), 0, 0);
|
||||
}
|
||||
|
||||
const hasErrorDetails = Boolean(result.details.error);
|
||||
const display = formatMcpToolResultLines(result, options.expanded || context?.isError === true || hasErrorDetails);
|
||||
const output = display.lines
|
||||
.map((line) => line === "…" ? theme.fg("muted", line) : theme.fg("toolOutput", line))
|
||||
.join("\n");
|
||||
const hint = display.truncated && !options.expanded
|
||||
? `\n${theme.fg("muted", "(Ctrl+O to expand)")}`
|
||||
: "";
|
||||
|
||||
return new Text(`${output}${hint}`, 0, 0);
|
||||
}
|
||||
14
agent/extensions/pi-mcp-adapter/tsconfig.json
Normal file
14
agent/extensions/pi-mcp-adapter/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
448
agent/extensions/pi-mcp-adapter/types.ts
Normal file
448
agent/extensions/pi-mcp-adapter/types.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
// types.ts - Core type definitions
|
||||
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import type { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { TextContent, ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { UiStreamMode } from "./ui-stream-types.ts";
|
||||
|
||||
// Transport type (stdio + HTTP)
|
||||
export type Transport =
|
||||
| StdioClientTransport
|
||||
| SSEClientTransport
|
||||
| StreamableHTTPClientTransport;
|
||||
|
||||
// Import sources for config
|
||||
export type ImportKind =
|
||||
| "cursor"
|
||||
| "claude-code"
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "windsurf"
|
||||
| "vscode";
|
||||
|
||||
// Tool definition from MCP server
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown; // JSON Schema
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Resource definition from MCP server
|
||||
export interface McpResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UiResourceMeta {
|
||||
csp?: UiResourceCsp;
|
||||
permissions?: UiResourcePermissions;
|
||||
domain?: string;
|
||||
prefersBorder?: boolean;
|
||||
}
|
||||
|
||||
export interface UiResourceContent {
|
||||
uri: string;
|
||||
html: string;
|
||||
mimeType?: string;
|
||||
meta: UiResourceMeta;
|
||||
}
|
||||
|
||||
export interface UiProxyRequestBody<TParams> {
|
||||
token: string;
|
||||
params: TParams;
|
||||
}
|
||||
|
||||
export interface UiProxyResult<T = Record<string, unknown>> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UiResourceCsp {
|
||||
connectDomains?: string[];
|
||||
scriptDomains?: string[];
|
||||
styleDomains?: string[];
|
||||
fontDomains?: string[];
|
||||
imgDomains?: string[];
|
||||
mediaDomains?: string[];
|
||||
frameDomains?: string[];
|
||||
workerDomains?: string[];
|
||||
baseUriDomains?: string[];
|
||||
}
|
||||
|
||||
export interface UiResourcePermissions {
|
||||
camera?: {};
|
||||
microphone?: {};
|
||||
geolocation?: {};
|
||||
clipboardWrite?: {};
|
||||
}
|
||||
|
||||
export interface UiToolInfo {
|
||||
id?: string | number;
|
||||
tool: {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UiHostContext {
|
||||
toolInfo?: UiToolInfo;
|
||||
theme?: "light" | "dark";
|
||||
styles?: Record<string, unknown>;
|
||||
displayMode?: UiDisplayMode;
|
||||
availableDisplayModes?: UiDisplayMode[];
|
||||
containerDimensions?: {
|
||||
width?: number;
|
||||
maxWidth?: number;
|
||||
height?: number;
|
||||
maxHeight?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type UiDisplayMode = "inline" | "fullscreen" | "pip";
|
||||
|
||||
// Re-export stream types from the shared lightweight module.
|
||||
// This allows the example package to import stream schemas without pulling the full types.ts dependency graph.
|
||||
export {
|
||||
UI_STREAM_HOST_CONTEXT_KEY,
|
||||
UI_STREAM_REQUEST_META_KEY,
|
||||
UI_STREAM_RESULT_PATCH_METHOD,
|
||||
SERVER_STREAM_RESULT_PATCH_METHOD,
|
||||
UI_STREAM_STRUCTURED_CONTENT_KEY,
|
||||
uiStreamModeSchema,
|
||||
visualizationStreamPhaseSchema,
|
||||
visualizationStreamFrameTypeSchema,
|
||||
visualizationStreamStatusSchema,
|
||||
uiStreamHostContextSchema,
|
||||
visualizationStreamEnvelopeSchema,
|
||||
uiStreamCallToolResultSchema,
|
||||
uiStreamResultPatchNotificationSchema,
|
||||
serverStreamResultPatchNotificationSchema,
|
||||
getUiStreamHostContext,
|
||||
getVisualizationStreamEnvelope,
|
||||
type UiStreamMode,
|
||||
type VisualizationStreamPhase,
|
||||
type VisualizationStreamFrameType,
|
||||
type VisualizationStreamStatus,
|
||||
type UiStreamHostContext,
|
||||
type VisualizationStreamEnvelope,
|
||||
type UiStreamCallToolResult,
|
||||
type UiStreamResultPatchNotification,
|
||||
type ServerStreamResultPatchNotification,
|
||||
type UiStreamSummary,
|
||||
} from "./ui-stream-types.ts";
|
||||
|
||||
export interface UiMessageParams {
|
||||
role?: string;
|
||||
content?: unknown[];
|
||||
type?: "prompt" | "notify" | "intent" | "message";
|
||||
message?: string;
|
||||
prompt?: string;
|
||||
intent?: string;
|
||||
params?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from either legacy MCP UI message shapes or native AppBridge user messages.
|
||||
*/
|
||||
export function extractUiPromptText(params: UiMessageParams): string | undefined {
|
||||
if (params.type === "prompt" || params.prompt) {
|
||||
const prompt = params.prompt ?? String(params.message ?? "");
|
||||
return prompt || undefined;
|
||||
}
|
||||
|
||||
if (params.role === "user" && Array.isArray(params.content)) {
|
||||
const text = params.content
|
||||
.map((block) => (block && typeof block === "object" && "text" in block ? String((block as { text?: unknown }).text ?? "") : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured UI handoff recovered from a canonical prompt envelope.
|
||||
*/
|
||||
export interface UiPromptHandoff {
|
||||
intent: string;
|
||||
params: Record<string, unknown>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a canonical named UI handoff encoded as `intent\n{json}`.
|
||||
*/
|
||||
export function parseUiPromptHandoff(prompt: string): UiPromptHandoff | undefined {
|
||||
const newlineIndex = prompt.indexOf("\n");
|
||||
if (newlineIndex <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intent = prompt.slice(0, newlineIndex).trim();
|
||||
const payloadText = prompt.slice(newlineIndex + 1).trim();
|
||||
if (!intent || !payloadText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(intent)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
intent,
|
||||
params: parsed as Record<string, unknown>,
|
||||
raw: prompt,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulated messages from a UI session.
|
||||
* Collected during the session and available when it ends.
|
||||
*/
|
||||
export interface UiSessionMessages {
|
||||
prompts: string[];
|
||||
notifications: string[];
|
||||
intents: Array<{ intent: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
export interface UiModelContextParams {
|
||||
content?: unknown[];
|
||||
structuredContent?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UiOpenLinkResult {
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UiDisplayModeRequest {
|
||||
mode?: UiDisplayMode;
|
||||
}
|
||||
|
||||
export interface UiDisplayModeResult {
|
||||
mode: UiDisplayMode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Content types from MCP
|
||||
export interface McpContent {
|
||||
type: "text" | "image" | "audio" | "resource" | "resource_link";
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
resource?: {
|
||||
uri: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
uri?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Pi content block type
|
||||
export type ContentBlock = TextContent | ImageContent;
|
||||
|
||||
// OAuth configuration (SDK handles auto-discovery and dynamic registration)
|
||||
export interface OAuthConfig {
|
||||
/** OAuth grant type (defaults to authorization_code) */
|
||||
grantType?: "authorization_code" | "client_credentials";
|
||||
/** Pre-registered client ID (optional, dynamic registration used if not provided) */
|
||||
clientId?: string;
|
||||
/** Client secret for confidential clients */
|
||||
clientSecret?: string;
|
||||
/** Requested OAuth scopes */
|
||||
scope?: string;
|
||||
/** Exact authorization-code redirect URI for pre-registered clients */
|
||||
redirectUri?: string;
|
||||
/** Client display name for dynamic registration */
|
||||
clientName?: string;
|
||||
/** Client homepage URI for dynamic registration */
|
||||
clientUri?: string;
|
||||
}
|
||||
|
||||
// Server configuration
|
||||
export interface ServerEntry {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
// HTTP fields
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Authentication type:
|
||||
* - 'oauth' - Use OAuth 2.1 (auto-discovers endpoints, supports dynamic client registration)
|
||||
* - 'bearer' - Use static Bearer token
|
||||
* - false - Disable authentication
|
||||
* If not specified and url is present, OAuth will be auto-detected
|
||||
*/
|
||||
auth?: "oauth" | "bearer" | false;
|
||||
bearerToken?: string;
|
||||
bearerTokenEnv?: string;
|
||||
/**
|
||||
* OAuth configuration (optional).
|
||||
* If not provided, the SDK will attempt dynamic client registration.
|
||||
* Set to false to explicitly disable OAuth for this server.
|
||||
*/
|
||||
oauth?: OAuthConfig | false;
|
||||
lifecycle?: "keep-alive" | "lazy" | "eager";
|
||||
idleTimeout?: number; // minutes, overrides global setting
|
||||
// Resource handling
|
||||
exposeResources?: boolean;
|
||||
// Direct tool registration
|
||||
directTools?: boolean | string[];
|
||||
// Exclude specific MCP tools/resources by original or prefixed name
|
||||
excludeTools?: string[];
|
||||
// Debug
|
||||
debug?: boolean; // Show server stderr (default: false)
|
||||
}
|
||||
|
||||
// Settings
|
||||
export interface McpSettings {
|
||||
toolPrefix?: "server" | "none" | "short";
|
||||
idleTimeout?: number; // minutes, default 10, 0 to disable
|
||||
directTools?: boolean;
|
||||
disableProxyTool?: boolean;
|
||||
autoAuth?: boolean;
|
||||
sampling?: boolean;
|
||||
samplingAutoApprove?: boolean;
|
||||
elicitation?: boolean;
|
||||
/**
|
||||
* Message returned in tool results when a server needs (re-)authentication.
|
||||
* "${server}" is substituted with the server name. Defaults to a TUI
|
||||
* instruction when unset.
|
||||
*/
|
||||
authRequiredMessage?: string;
|
||||
}
|
||||
|
||||
// Root config
|
||||
export interface McpConfig {
|
||||
mcpServers: Record<string, ServerEntry>;
|
||||
imports?: ImportKind[];
|
||||
settings?: McpSettings;
|
||||
}
|
||||
|
||||
// Alias for clarity
|
||||
export type ServerDefinition = ServerEntry;
|
||||
|
||||
export interface ToolMetadata {
|
||||
name: string; // Prefixed tool name (e.g., "xcodebuild_list_sims")
|
||||
originalName: string; // Original MCP tool name (e.g., "list_sims")
|
||||
description: string;
|
||||
resourceUri?: string; // For resource tools: the URI to read
|
||||
uiResourceUri?: string; // For app-enabled tools: the UI resource URI
|
||||
inputSchema?: unknown; // JSON Schema for parameters (stored for describe/errors)
|
||||
uiStreamMode?: UiStreamMode;
|
||||
}
|
||||
|
||||
export interface DirectToolSpec {
|
||||
serverName: string;
|
||||
originalName: string;
|
||||
prefixedName: string;
|
||||
description: string;
|
||||
inputSchema?: unknown;
|
||||
resourceUri?: string;
|
||||
uiResourceUri?: string;
|
||||
uiStreamMode?: UiStreamMode;
|
||||
}
|
||||
|
||||
export interface ServerProvenance {
|
||||
path: string;
|
||||
kind: "user" | "project" | "import";
|
||||
importKind?: string;
|
||||
}
|
||||
|
||||
export interface McpAuthResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface McpPanelCallbacks {
|
||||
reconnect: (serverName: string) => Promise<boolean>;
|
||||
canAuthenticate: (serverName: string) => boolean;
|
||||
authenticate: (serverName: string) => Promise<McpAuthResult>;
|
||||
getConnectionStatus: (serverName: string) => "connected" | "idle" | "failed" | "needs-auth";
|
||||
refreshCacheAfterReconnect: (serverName: string) => import("./metadata-cache.ts").ServerCacheEntry | null;
|
||||
}
|
||||
|
||||
export interface McpPanelResult {
|
||||
changes: Map<string, true | string[] | false>;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server prefix based on tool prefix mode.
|
||||
*/
|
||||
export function getServerPrefix(
|
||||
serverName: string,
|
||||
mode: "server" | "none" | "short"
|
||||
): string {
|
||||
if (mode === "none") return "";
|
||||
if (mode === "short") {
|
||||
let short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
|
||||
if (!short) short = "mcp";
|
||||
return short;
|
||||
}
|
||||
return serverName.replace(/-/g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool name with server prefix.
|
||||
*/
|
||||
export function formatToolName(
|
||||
toolName: string,
|
||||
serverName: string,
|
||||
prefix: "server" | "none" | "short"
|
||||
): string {
|
||||
const p = getServerPrefix(serverName, prefix);
|
||||
return p ? `${p}_${toolName}` : toolName;
|
||||
}
|
||||
|
||||
function normalizeToolName(value: string): string {
|
||||
return value.replace(/-/g, "_");
|
||||
}
|
||||
|
||||
export function isToolExcluded(
|
||||
toolName: string,
|
||||
serverName: string,
|
||||
prefix: "server" | "none" | "short",
|
||||
excludeTools?: unknown
|
||||
): boolean {
|
||||
if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
|
||||
|
||||
const candidates = new Set<string>([
|
||||
normalizeToolName(toolName),
|
||||
normalizeToolName(formatToolName(toolName, serverName, prefix)),
|
||||
normalizeToolName(formatToolName(toolName, serverName, "server")),
|
||||
normalizeToolName(formatToolName(toolName, serverName, "short")),
|
||||
]);
|
||||
|
||||
for (const excluded of excludeTools) {
|
||||
if (typeof excluded !== "string") continue;
|
||||
if (candidates.has(normalizeToolName(excluded))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
146
agent/extensions/pi-mcp-adapter/ui-resource-handler.ts
Normal file
146
agent/extensions/pi-mcp-adapter/ui-resource-handler.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { UrlElicitationRequiredError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ResourceFetchError, ResourceParseError } from "./errors.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import type { UiResourceContent, UiResourceMeta } from "./types.ts";
|
||||
|
||||
interface ResourceContentRecord {
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UiResourceHandler {
|
||||
private log = logger.child({ component: "UiResourceHandler" });
|
||||
|
||||
constructor(private manager: McpServerManager) {}
|
||||
|
||||
async readUiResource(serverName: string, uri: string): Promise<UiResourceContent> {
|
||||
const log = this.log.child({ server: serverName, uri });
|
||||
|
||||
if (!uri.startsWith("ui://")) {
|
||||
throw new ResourceParseError(uri, "URI must start with ui://", { server: serverName });
|
||||
}
|
||||
|
||||
log.debug("Fetching UI resource");
|
||||
|
||||
let result: ReadResourceResult;
|
||||
try {
|
||||
result = await this.manager.readResource(serverName, uri);
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to read resource", error instanceof Error ? error : undefined);
|
||||
throw new ResourceFetchError(uri, message, {
|
||||
server: serverName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const content = selectContent(result, uri);
|
||||
const mimeType = content.mimeType;
|
||||
|
||||
if (mimeType && !isHtmlMimeType(mimeType)) {
|
||||
log.warn("Unsupported MIME type", { mimeType });
|
||||
throw new ResourceParseError(
|
||||
uri,
|
||||
`unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`,
|
||||
{ server: serverName, mimeType }
|
||||
);
|
||||
}
|
||||
|
||||
const html = toHtml(content);
|
||||
if (!html.trim()) {
|
||||
log.warn("Resource content is empty");
|
||||
throw new ResourceParseError(uri, "content is empty", { server: serverName });
|
||||
}
|
||||
|
||||
const contentMeta = extractUiMeta(content._meta);
|
||||
const listMeta = extractUiMeta(this.getListResourceMeta(serverName, uri));
|
||||
|
||||
log.debug("Resource loaded successfully", {
|
||||
contentLength: html.length,
|
||||
hasCsp: !!contentMeta.csp || !!listMeta.csp,
|
||||
});
|
||||
|
||||
return {
|
||||
uri: content.uri ?? uri,
|
||||
html,
|
||||
mimeType: mimeType ?? RESOURCE_MIME_TYPE,
|
||||
meta: {
|
||||
csp: contentMeta.csp ?? listMeta.csp,
|
||||
permissions: contentMeta.permissions ?? listMeta.permissions,
|
||||
domain: contentMeta.domain ?? listMeta.domain,
|
||||
prefersBorder: contentMeta.prefersBorder ?? listMeta.prefersBorder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getListResourceMeta(serverName: string, uri: string): Record<string, unknown> | undefined {
|
||||
const connection = this.manager.getConnection(serverName);
|
||||
if (!connection?.resources?.length) return undefined;
|
||||
const resource = connection.resources.find((entry) => entry.uri === uri);
|
||||
if (!resource || !resource._meta || typeof resource._meta !== "object") return undefined;
|
||||
return resource._meta;
|
||||
}
|
||||
}
|
||||
|
||||
function selectContent(result: ReadResourceResult, preferredUri: string): ResourceContentRecord {
|
||||
const contents = (result.contents ?? []) as ResourceContentRecord[];
|
||||
if (contents.length === 0) {
|
||||
throw new Error(`No contents returned for UI resource: ${preferredUri}`);
|
||||
}
|
||||
|
||||
const byUri = contents.find((content) => content.uri === preferredUri);
|
||||
if (byUri) return byUri;
|
||||
|
||||
const byHtmlMime = contents.find(
|
||||
(content) => content.mimeType && isHtmlMimeType(content.mimeType)
|
||||
);
|
||||
if (byHtmlMime) return byHtmlMime;
|
||||
|
||||
return contents[0];
|
||||
}
|
||||
|
||||
function isHtmlMimeType(mimeType: string): boolean {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase();
|
||||
}
|
||||
|
||||
function toHtml(content: ResourceContentRecord): string {
|
||||
if (typeof content.text === "string") {
|
||||
return content.text;
|
||||
}
|
||||
|
||||
if (typeof content.blob === "string") {
|
||||
return Buffer.from(content.blob, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
throw new Error(`UI resource ${content.uri ?? "(unknown)"} did not include text or blob content`);
|
||||
}
|
||||
|
||||
function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMeta {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const ui = meta.ui as Record<string, unknown> | undefined;
|
||||
if (!ui || typeof ui !== "object") return {};
|
||||
|
||||
const out: UiResourceMeta = {};
|
||||
|
||||
if (ui.csp && typeof ui.csp === "object") {
|
||||
out.csp = ui.csp as UiResourceMeta["csp"];
|
||||
}
|
||||
if (ui.permissions && typeof ui.permissions === "object") {
|
||||
out.permissions = ui.permissions as UiResourceMeta["permissions"];
|
||||
}
|
||||
if (typeof ui.domain === "string") {
|
||||
out.domain = ui.domain;
|
||||
}
|
||||
if (typeof ui.prefersBorder === "boolean") {
|
||||
out.prefersBorder = ui.prefersBorder;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
623
agent/extensions/pi-mcp-adapter/ui-server.ts
Normal file
623
agent/extensions/pi-mcp-adapter/ui-server.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type {
|
||||
CallToolRequest,
|
||||
CallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { ConsentManager } from "./consent-manager.ts";
|
||||
import { ServerError, wrapError } from "./errors.ts";
|
||||
import { buildHostHtmlTemplate, buildCspMetaContent, applyCspMeta } from "./host-html-template.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import {
|
||||
extractUiPromptText,
|
||||
getVisualizationStreamEnvelope,
|
||||
type UiDisplayMode,
|
||||
type UiDisplayModeRequest,
|
||||
type UiDisplayModeResult,
|
||||
type UiHostContext,
|
||||
type UiMessageParams,
|
||||
type UiModelContextParams,
|
||||
type UiOpenLinkResult,
|
||||
type UiProxyRequestBody,
|
||||
type UiProxyResult,
|
||||
type UiResourceContent,
|
||||
type UiSessionMessages,
|
||||
type UiStreamSummary,
|
||||
} from "./types.ts";
|
||||
|
||||
const MAX_BODY_SIZE = 2 * 1024 * 1024;
|
||||
const ABANDONED_GRACE_MS = 60_000;
|
||||
const WATCHDOG_INTERVAL_MS = 5_000;
|
||||
const MAX_EVENT_LOG = 128;
|
||||
|
||||
export interface UiServerOptions {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
resource: UiResourceContent;
|
||||
manager: McpServerManager;
|
||||
consentManager: ConsentManager;
|
||||
hostContext?: UiHostContext;
|
||||
initialResultPromise?: Promise<CallToolResult>;
|
||||
sessionToken?: string;
|
||||
port?: number;
|
||||
onMessage?: (params: UiMessageParams) => Promise<void> | void;
|
||||
onContextUpdate?: (params: UiModelContextParams) => Promise<void> | void;
|
||||
onComplete?: (reason: string) => void;
|
||||
}
|
||||
|
||||
export interface UiServerHandle {
|
||||
url: string;
|
||||
port: number;
|
||||
sessionToken: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
close: (reason?: string) => void;
|
||||
sendToolInput: (args: Record<string, unknown>) => void;
|
||||
sendToolResult: (result: CallToolResult) => void;
|
||||
sendResultPatch: (result: CallToolResult) => void;
|
||||
sendToolCancelled: (reason: string) => void;
|
||||
sendHostContext: (context: UiHostContext) => void;
|
||||
/** Get accumulated messages from this session */
|
||||
getSessionMessages: () => UiSessionMessages;
|
||||
getStreamSummary: () => UiStreamSummary | undefined;
|
||||
}
|
||||
|
||||
export async function startUiServer(options: UiServerOptions): Promise<UiServerHandle> {
|
||||
const sessionToken = options.sessionToken ?? randomUUID();
|
||||
const log = logger.child({
|
||||
component: "UiServer",
|
||||
server: options.serverName,
|
||||
tool: options.toolName,
|
||||
session: sessionToken.slice(0, 8),
|
||||
});
|
||||
|
||||
log.debug("Starting UI server");
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
let completed = false;
|
||||
let lastHeartbeatAt = Date.now();
|
||||
let watchdog: NodeJS.Timeout | null = null;
|
||||
let currentDisplayMode: UiDisplayMode = options.hostContext?.displayMode ?? "inline";
|
||||
let nextEventId = 1;
|
||||
const eventLog: Array<{ id: number; name: string; payload: unknown }> = [];
|
||||
let streamSummary: UiStreamSummary | undefined;
|
||||
|
||||
// Track messages from UI for retrieval
|
||||
const sessionMessages: UiSessionMessages = {
|
||||
prompts: [],
|
||||
notifications: [],
|
||||
intents: [],
|
||||
};
|
||||
|
||||
const hostContext: UiHostContext = {
|
||||
displayMode: currentDisplayMode,
|
||||
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
||||
platform: "desktop",
|
||||
...options.hostContext,
|
||||
// Only include toolInfo if caller provides full tool definition with inputSchema
|
||||
// The App validates toolInfo.tool.inputSchema as required object
|
||||
};
|
||||
|
||||
const initialStreamContext = hostContext["pi-mcp-adapter/stream"];
|
||||
if (initialStreamContext && typeof initialStreamContext === "object") {
|
||||
const streamId = (initialStreamContext as { streamId?: unknown }).streamId;
|
||||
const mode = (initialStreamContext as { mode?: unknown }).mode;
|
||||
if (typeof streamId === "string" && (mode === "eager" || mode === "stream-first")) {
|
||||
streamSummary = {
|
||||
streamId,
|
||||
mode,
|
||||
frames: 0,
|
||||
phases: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const touchHeartbeat = () => {
|
||||
lastHeartbeatAt = Date.now();
|
||||
};
|
||||
|
||||
const updateStreamSummary = (payload: unknown) => {
|
||||
const envelope = getVisualizationStreamEnvelope((payload as { structuredContent?: unknown } | null)?.structuredContent);
|
||||
if (!envelope) return;
|
||||
if (!streamSummary) {
|
||||
streamSummary = {
|
||||
streamId: envelope.streamId,
|
||||
mode: "eager",
|
||||
frames: 0,
|
||||
phases: [],
|
||||
};
|
||||
}
|
||||
streamSummary.frames += 1;
|
||||
if (!streamSummary.phases.includes(envelope.phase)) {
|
||||
streamSummary.phases.push(envelope.phase);
|
||||
}
|
||||
streamSummary.finalStatus = envelope.status;
|
||||
streamSummary.lastMessage = envelope.message;
|
||||
};
|
||||
|
||||
const serializeEvent = (eventId: number, name: string, payload: unknown): string => {
|
||||
return `id: ${eventId}\nevent: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
};
|
||||
|
||||
const getLatestCheckpointIndex = () => {
|
||||
for (let index = eventLog.length - 1; index >= 0; index -= 1) {
|
||||
const entry = eventLog[index];
|
||||
const envelope = getVisualizationStreamEnvelope((entry.payload as { structuredContent?: unknown } | null)?.structuredContent);
|
||||
if (envelope?.frameType === "checkpoint" || envelope?.frameType === "final") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const pruneEventLog = () => {
|
||||
if (eventLog.length <= MAX_EVENT_LOG) return;
|
||||
const latestCheckpointIndex = getLatestCheckpointIndex();
|
||||
|
||||
if (latestCheckpointIndex > 0) {
|
||||
eventLog.splice(0, latestCheckpointIndex);
|
||||
}
|
||||
|
||||
if (eventLog.length > MAX_EVENT_LOG) {
|
||||
eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
|
||||
}
|
||||
};
|
||||
|
||||
const pushEvent = (name: string, payload: unknown) => {
|
||||
if (completed) return;
|
||||
const eventId = nextEventId++;
|
||||
eventLog.push({ id: eventId, name, payload });
|
||||
updateStreamSummary(payload);
|
||||
pruneEventLog();
|
||||
const chunk = serializeEvent(eventId, name, payload);
|
||||
for (const client of sseClients) {
|
||||
try {
|
||||
client.write(chunk);
|
||||
} catch {
|
||||
sseClients.delete(client);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const replayEvents = (res: ServerResponse, lastEventIdHeader?: string | null) => {
|
||||
const parsedLastId = lastEventIdHeader ? Number(lastEventIdHeader) : Number.NaN;
|
||||
const eventsToReplay = Number.isFinite(parsedLastId)
|
||||
? eventLog.filter((entry) => entry.id > parsedLastId)
|
||||
: (() => {
|
||||
const latestCheckpointIndex = getLatestCheckpointIndex();
|
||||
return latestCheckpointIndex >= 0 ? eventLog.slice(latestCheckpointIndex) : eventLog;
|
||||
})();
|
||||
|
||||
for (const entry of eventsToReplay) {
|
||||
try {
|
||||
res.write(serializeEvent(entry.id, entry.name, entry.payload));
|
||||
} catch {
|
||||
sseClients.delete(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeSse = () => {
|
||||
for (const client of sseClients) {
|
||||
try {
|
||||
client.end();
|
||||
} catch {}
|
||||
}
|
||||
sseClients.clear();
|
||||
};
|
||||
|
||||
const stopWatchdog = () => {
|
||||
if (!watchdog) return;
|
||||
clearInterval(watchdog);
|
||||
watchdog = null;
|
||||
};
|
||||
|
||||
const markCompleted = (reason: string) => {
|
||||
if (completed) return;
|
||||
log.debug("Session completed", { reason });
|
||||
pushEvent("session-complete", { reason });
|
||||
completed = true;
|
||||
stopWatchdog();
|
||||
options.onComplete?.(reason);
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const method = req.method || "GET";
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
||||
|
||||
if (method === "GET" && url.pathname === "/") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
touchHeartbeat();
|
||||
|
||||
const html = buildHostHtmlTemplate({
|
||||
sessionToken,
|
||||
serverName: options.serverName,
|
||||
toolName: options.toolName,
|
||||
toolArgs: options.toolArgs,
|
||||
resource: options.resource,
|
||||
allowAttribute: buildAllowAttribute(options.resource.meta.permissions),
|
||||
requireToolConsent: options.consentManager.requiresPrompt(options.serverName),
|
||||
cacheToolConsent: options.consentManager.shouldCacheConsent(),
|
||||
hostContext,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/events") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
touchHeartbeat();
|
||||
log.debug("SSE client connected", { clientCount: sseClients.size + 1 });
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.write(": connected\n\n");
|
||||
sseClients.add(res);
|
||||
replayEvents(res, req.headers["last-event-id"] ? String(req.headers["last-event-id"]) : null);
|
||||
req.on("close", () => {
|
||||
sseClients.delete(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/health") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
sendJson(res, 200, { ok: true, result: { healthy: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/ui-app") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
touchHeartbeat();
|
||||
// Serve the MCP app's UI HTML directly (avoids blob URL security issues)
|
||||
// Apply CSP meta tag if specified in resource metadata
|
||||
const cspContent = buildCspMetaContent(options.resource.meta.csp);
|
||||
const appHtml = applyCspMeta(options.resource.html, cspContent);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(appHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/app-bridge.bundle.js") {
|
||||
// Serve the pre-bundled AppBridge module
|
||||
const bundlePath = path.join(import.meta.dirname, "app-bridge.bundle.js");
|
||||
try {
|
||||
const content = await fs.readFile(bundlePath, "utf-8");
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/javascript",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
});
|
||||
res.end(content);
|
||||
} catch {
|
||||
sendJson(res, 500, { ok: false, error: "Bundle not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method !== "POST") {
|
||||
sendJson(res, 404, { ok: false, error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await parseBody(req, res);
|
||||
if (!body) return;
|
||||
if (!validateTokenBody(body, sessionToken, res)) return;
|
||||
const params = body.params ?? {};
|
||||
touchHeartbeat();
|
||||
|
||||
if (url.pathname === "/proxy/tools/call") {
|
||||
options.consentManager.ensureApproved(options.serverName);
|
||||
const callParams = params as CallToolRequest["params"];
|
||||
if (!callParams || typeof callParams.name !== "string" || !callParams.name.trim()) {
|
||||
sendJson(res, 400, { ok: false, error: "Invalid tools/call params" });
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = options.manager.getConnection(options.serverName);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
sendJson(res, 503, { ok: false, error: `Server "${options.serverName}" is not connected` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
options.manager.touch(options.serverName);
|
||||
options.manager.incrementInFlight(options.serverName);
|
||||
const result = await connection.client.callTool({
|
||||
name: callParams.name,
|
||||
arguments:
|
||||
callParams.arguments && typeof callParams.arguments === "object" && !Array.isArray(callParams.arguments)
|
||||
? callParams.arguments
|
||||
: {},
|
||||
});
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
} finally {
|
||||
options.manager.decrementInFlight(options.serverName);
|
||||
options.manager.touch(options.serverName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/consent") {
|
||||
const approved = !!(params as { approved?: boolean }).approved;
|
||||
options.consentManager.registerDecision(options.serverName, approved);
|
||||
sendJson(res, 200, { ok: true, result: { approved } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/message") {
|
||||
const msgParams = params as UiMessageParams;
|
||||
const promptText = extractUiPromptText(msgParams);
|
||||
|
||||
// Track messages by type (order: prompt → intent → notify)
|
||||
// Must match the order in index.ts onMessage handler
|
||||
if (promptText) {
|
||||
sessionMessages.prompts.push(promptText);
|
||||
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
||||
} else if (msgParams.type === "intent" || msgParams.intent) {
|
||||
const intentName = msgParams.intent ?? "";
|
||||
if (intentName) {
|
||||
sessionMessages.intents.push({
|
||||
intent: intentName,
|
||||
params: msgParams.params
|
||||
});
|
||||
log.debug("UI intent received", { intent: intentName });
|
||||
}
|
||||
} else if (msgParams.type === "notify" || msgParams.message) {
|
||||
const notifyText = msgParams.message ?? "";
|
||||
if (notifyText) {
|
||||
sessionMessages.notifications.push(notifyText);
|
||||
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
||||
}
|
||||
}
|
||||
|
||||
await options.onMessage?.(msgParams);
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/context") {
|
||||
const ctxParams = params as UiModelContextParams;
|
||||
log.debug("UI context update", { hasContent: !!ctxParams.content });
|
||||
await options.onContextUpdate?.(ctxParams);
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/open-link") {
|
||||
const openParams = params as { url?: string };
|
||||
if (!openParams?.url || typeof openParams.url !== "string") {
|
||||
sendJson(res, 400, { ok: false, error: "Invalid open-link params" });
|
||||
return;
|
||||
}
|
||||
let result: UiOpenLinkResult = {};
|
||||
try {
|
||||
new URL(openParams.url);
|
||||
} catch {
|
||||
result = { isError: true };
|
||||
}
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/download-file") {
|
||||
sendJson(res, 200, { ok: true, result: { isError: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/request-display-mode") {
|
||||
const displayParams = params as UiDisplayModeRequest;
|
||||
const requested = displayParams?.mode;
|
||||
const available = hostContext.availableDisplayModes ?? ["inline"];
|
||||
if (requested && available.includes(requested)) {
|
||||
currentDisplayMode = requested;
|
||||
}
|
||||
hostContext.displayMode = currentDisplayMode;
|
||||
pushEvent("host-context", { displayMode: currentDisplayMode });
|
||||
const result: UiDisplayModeResult = { mode: currentDisplayMode };
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/heartbeat") {
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/complete") {
|
||||
const reason = typeof (params as { reason?: string }).reason === "string"
|
||||
? (params as { reason?: string }).reason!
|
||||
: "done";
|
||||
markCompleted(reason);
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
setTimeout(() => {
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
closeSse();
|
||||
}, 20).unref();
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { ok: false, error: "Not found" });
|
||||
} catch (error) {
|
||||
const wrapped = wrapError(error, { server: options.serverName, tool: options.toolName });
|
||||
const status = /approval required|denied/i.test(wrapped.message) ? 403 : 500;
|
||||
if (status === 500) {
|
||||
log.error("Request handler error", error instanceof Error ? error : undefined);
|
||||
}
|
||||
sendJson(res, status, { ok: false, error: wrapped.message });
|
||||
}
|
||||
});
|
||||
|
||||
if (options.initialResultPromise) {
|
||||
options.initialResultPromise.then(
|
||||
(result) => pushEvent("tool-result", result),
|
||||
(error) => {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
pushEvent("tool-cancelled", { reason });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watchdog = setInterval(() => {
|
||||
if (completed) return;
|
||||
if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return;
|
||||
markCompleted("stale");
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
closeSse();
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
watchdog.unref();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
log.error("Failed to start server", error);
|
||||
reject(new ServerError(error.message, { port: options.port, cause: error }));
|
||||
};
|
||||
|
||||
server.once("error", onError);
|
||||
server.listen(options.port ?? 0, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
const err = new ServerError("invalid address");
|
||||
log.error("Invalid server address", err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Server started", { port: address.port });
|
||||
|
||||
const handle: UiServerHandle = {
|
||||
url: `http://localhost:${address.port}/?session=${sessionToken}`,
|
||||
port: address.port,
|
||||
sessionToken,
|
||||
serverName: options.serverName,
|
||||
toolName: options.toolName,
|
||||
close: (reason?: string) => {
|
||||
markCompleted(reason ?? "closed");
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
closeSse();
|
||||
},
|
||||
sendToolInput: (args: Record<string, unknown>) => {
|
||||
pushEvent("tool-input", { arguments: args });
|
||||
},
|
||||
sendToolResult: (result: CallToolResult) => {
|
||||
pushEvent("tool-result", result);
|
||||
},
|
||||
sendResultPatch: (result: CallToolResult) => {
|
||||
pushEvent("result-patch", result);
|
||||
},
|
||||
sendToolCancelled: (reason: string) => {
|
||||
pushEvent("tool-cancelled", { reason });
|
||||
},
|
||||
sendHostContext: (context: UiHostContext) => {
|
||||
Object.assign(hostContext, context);
|
||||
pushEvent("host-context", context);
|
||||
},
|
||||
getSessionMessages: () => ({ ...sessionMessages }),
|
||||
getStreamSummary: () => streamSummary ? { ...streamSummary, phases: [...streamSummary.phases] } : undefined,
|
||||
};
|
||||
|
||||
resolve(handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function parseBody(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<UiProxyRequestBody<Record<string, unknown>> | null> {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
sendJson(res, 400, { ok: false, error: "Invalid request body" });
|
||||
return null;
|
||||
}
|
||||
return body as UiProxyRequestBody<Record<string, unknown>>;
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : "Invalid body" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_SIZE) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateTokenQuery(url: URL, expected: string, res: ServerResponse): boolean {
|
||||
const token = url.searchParams.get("session");
|
||||
if (token !== expected) {
|
||||
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTokenBody(
|
||||
body: UiProxyRequestBody<Record<string, unknown>>,
|
||||
expected: string,
|
||||
res: ServerResponse,
|
||||
): boolean {
|
||||
if (body.token !== expected) {
|
||||
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendJson<T>(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
payload: UiProxyResult<T>,
|
||||
): void {
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
385
agent/extensions/pi-mcp-adapter/ui-session.ts
Normal file
385
agent/extensions/pi-mcp-adapter/ui-session.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { UrlElicitationRequiredError, type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import {
|
||||
extractUiPromptText,
|
||||
UI_STREAM_HOST_CONTEXT_KEY,
|
||||
UI_STREAM_REQUEST_META_KEY,
|
||||
UI_STREAM_STRUCTURED_CONTENT_KEY,
|
||||
type UiHostContext,
|
||||
type UiMessageParams,
|
||||
type UiModelContextParams,
|
||||
type UiStreamMode,
|
||||
} from "./types.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { startUiServer, type UiServerHandle } from "./ui-server.ts";
|
||||
import { isGlimpseAvailable, openGlimpseWindow } from "./glimpse-ui.ts";
|
||||
|
||||
let activeGlimpseWindow: { close(): void } | null = null;
|
||||
|
||||
export interface UiSessionRequest {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
uiResourceUri: string;
|
||||
streamMode?: UiStreamMode;
|
||||
}
|
||||
|
||||
export interface UiSessionRuntime {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
reused: boolean;
|
||||
streamId?: string;
|
||||
streamToken?: string;
|
||||
streamMode?: UiStreamMode;
|
||||
requestMeta?: Record<string, unknown>;
|
||||
url: string;
|
||||
isActive: () => boolean;
|
||||
sendToolResult: (result: CallToolResult) => void;
|
||||
sendResultPatch: (result: CallToolResult) => void;
|
||||
sendToolCancelled: (reason: string) => void;
|
||||
close: (reason?: string) => void;
|
||||
}
|
||||
|
||||
const MAX_COMPLETED_SESSIONS = 10;
|
||||
|
||||
function withStreamEnvelope(
|
||||
result: CallToolResult,
|
||||
streamId: string | undefined,
|
||||
sequence: number,
|
||||
): CallToolResult {
|
||||
if (!streamId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const structuredContent = result.structuredContent && typeof result.structuredContent === "object" && !Array.isArray(result.structuredContent)
|
||||
? { ...result.structuredContent }
|
||||
: {};
|
||||
|
||||
const rawEnvelope = structuredContent[UI_STREAM_STRUCTURED_CONTENT_KEY];
|
||||
const envelope = rawEnvelope && typeof rawEnvelope === "object" && !Array.isArray(rawEnvelope)
|
||||
? { ...rawEnvelope as Record<string, unknown> }
|
||||
: {
|
||||
frameType: "final",
|
||||
phase: "settled",
|
||||
status: result.isError ? "error" : "ok",
|
||||
};
|
||||
|
||||
structuredContent[UI_STREAM_STRUCTURED_CONTENT_KEY] = {
|
||||
...envelope,
|
||||
streamId,
|
||||
sequence,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
structuredContent,
|
||||
};
|
||||
}
|
||||
|
||||
async function openInBrowser(state: McpExtensionState, url: string): Promise<void> {
|
||||
try {
|
||||
await state.openBrowser(url);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
state.ui?.notify(`MCP UI browser open failed: ${message}`, "warning");
|
||||
state.ui?.notify(`Open manually: ${url}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeStartUiSession(
|
||||
state: McpExtensionState,
|
||||
request: UiSessionRequest,
|
||||
): Promise<UiSessionRuntime | null> {
|
||||
const log = logger.child({
|
||||
component: "UiSession",
|
||||
server: request.serverName,
|
||||
tool: request.toolName,
|
||||
});
|
||||
|
||||
try {
|
||||
if (
|
||||
state.uiServer &&
|
||||
state.uiServer.serverName === request.serverName &&
|
||||
state.uiServer.toolName === request.toolName
|
||||
) {
|
||||
const existingHandle = state.uiServer;
|
||||
const streamMode = request.streamMode;
|
||||
const streamId = streamMode ? randomUUID() : undefined;
|
||||
const streamToken = streamMode ? randomUUID() : undefined;
|
||||
let active = true;
|
||||
let nextStreamSequence = 0;
|
||||
|
||||
const cleanupStreamListener = () => {
|
||||
if (streamToken) {
|
||||
state.manager.removeUiStreamListener(streamToken);
|
||||
}
|
||||
};
|
||||
|
||||
existingHandle.sendToolInput(request.toolArgs);
|
||||
|
||||
if (streamToken) {
|
||||
state.manager.registerUiStreamListener(streamToken, (serverName, notification) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
if (serverName !== request.serverName) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendResultPatch(
|
||||
withStreamEnvelope(notification.result as CallToolResult, streamId, nextStreamSequence),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: request.serverName,
|
||||
toolName: request.toolName,
|
||||
reused: true,
|
||||
streamId,
|
||||
streamToken,
|
||||
streamMode,
|
||||
requestMeta: streamToken ? { [UI_STREAM_REQUEST_META_KEY]: streamToken } : undefined,
|
||||
url: existingHandle.url,
|
||||
isActive: () => active && state.uiServer === existingHandle,
|
||||
sendToolResult: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendToolResult(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendResultPatch: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendResultPatch(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendToolCancelled: (reason: string) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendToolResult(
|
||||
withStreamEnvelope(
|
||||
{
|
||||
isError: true,
|
||||
content: [{ type: "text", text: reason }],
|
||||
},
|
||||
streamId,
|
||||
nextStreamSequence,
|
||||
),
|
||||
);
|
||||
},
|
||||
close: () => {
|
||||
active = false;
|
||||
cleanupStreamListener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resource = await state.uiResourceHandler.readUiResource(request.serverName, request.uiResourceUri);
|
||||
|
||||
if (state.uiServer) {
|
||||
state.uiServer.close("replaced");
|
||||
state.uiServer = null;
|
||||
}
|
||||
if (activeGlimpseWindow) {
|
||||
activeGlimpseWindow.close();
|
||||
activeGlimpseWindow = null;
|
||||
}
|
||||
|
||||
const streamMode = request.streamMode;
|
||||
const streamId = streamMode ? randomUUID() : undefined;
|
||||
const streamToken = streamMode ? randomUUID() : undefined;
|
||||
const hostContext: UiHostContext | undefined = streamMode && streamId
|
||||
? {
|
||||
[UI_STREAM_HOST_CONTEXT_KEY]: {
|
||||
mode: streamMode,
|
||||
streamId,
|
||||
intermediateResultPatches: streamMode === "stream-first",
|
||||
partialInput: false,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let active = true;
|
||||
let nextStreamSequence = 0;
|
||||
let handle: UiServerHandle | null = null;
|
||||
|
||||
const cleanupStreamListener = () => {
|
||||
if (streamToken) {
|
||||
state.manager.removeUiStreamListener(streamToken);
|
||||
}
|
||||
};
|
||||
|
||||
handle = await startUiServer({
|
||||
serverName: request.serverName,
|
||||
toolName: request.toolName,
|
||||
toolArgs: streamMode === "stream-first" ? {} : request.toolArgs,
|
||||
resource,
|
||||
manager: state.manager,
|
||||
consentManager: state.consentManager,
|
||||
hostContext,
|
||||
|
||||
onMessage: (params: UiMessageParams) => {
|
||||
const prompt = extractUiPromptText(params);
|
||||
if (prompt) {
|
||||
if (state.sendMessage) {
|
||||
state.sendMessage(
|
||||
{
|
||||
customType: "mcp-ui-prompt",
|
||||
content: [{ type: "text", text: `User sent prompt from ${request.serverName} UI: "${prompt}"` }],
|
||||
display: `💬 UI Prompt: ${prompt}`,
|
||||
details: { server: request.serverName, tool: request.toolName, prompt },
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
log.debug("Triggered agent turn for UI prompt", { prompt: prompt.slice(0, 50) });
|
||||
}
|
||||
} else if (params.type === "intent" || params.intent) {
|
||||
const intent = params.intent ?? "";
|
||||
const intentParams = params.params;
|
||||
if (intent && state.sendMessage) {
|
||||
const paramsStr = intentParams ? ` ${JSON.stringify(intentParams)}` : "";
|
||||
state.sendMessage(
|
||||
{
|
||||
customType: "mcp-ui-intent",
|
||||
content: [{ type: "text", text: `User triggered intent from ${request.serverName} UI: ${intent}${paramsStr}` }],
|
||||
display: `🎯 UI Intent: ${intent}`,
|
||||
details: { server: request.serverName, tool: request.toolName, intent, params: intentParams },
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
log.debug("Triggered agent turn for UI intent", { intent });
|
||||
}
|
||||
} else if (params.type === "notify" || params.message) {
|
||||
const text = params.message ?? "";
|
||||
if (text && state.ui) {
|
||||
state.ui.notify(`[${request.serverName}] ${text}`, "info");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onContextUpdate: (params: UiModelContextParams) => {
|
||||
log.debug("Model context update from UI", {
|
||||
hasContent: !!params.content,
|
||||
hasStructured: !!params.structuredContent,
|
||||
});
|
||||
},
|
||||
|
||||
onComplete: (reason: string) => {
|
||||
active = false;
|
||||
cleanupStreamListener();
|
||||
|
||||
if (state.uiServer === handle) {
|
||||
const messages = handle.getSessionMessages();
|
||||
const stream = handle.getStreamSummary();
|
||||
const hasContent =
|
||||
messages.prompts.length > 0 ||
|
||||
messages.intents.length > 0 ||
|
||||
messages.notifications.length > 0 ||
|
||||
!!stream;
|
||||
|
||||
if (hasContent) {
|
||||
state.completedUiSessions.push({
|
||||
serverName: handle.serverName,
|
||||
toolName: handle.toolName,
|
||||
completedAt: new Date(),
|
||||
reason,
|
||||
messages,
|
||||
stream,
|
||||
});
|
||||
|
||||
while (state.completedUiSessions.length > MAX_COMPLETED_SESSIONS) {
|
||||
state.completedUiSessions.shift();
|
||||
}
|
||||
|
||||
log.debug("Session completed", {
|
||||
reason,
|
||||
prompts: messages.prompts.length,
|
||||
intents: messages.intents.length,
|
||||
notifications: messages.notifications.length,
|
||||
streamFrames: stream?.frames ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
state.uiServer = null;
|
||||
if (activeGlimpseWindow) {
|
||||
activeGlimpseWindow.close();
|
||||
activeGlimpseWindow = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (streamToken) {
|
||||
state.manager.registerUiStreamListener(streamToken, (serverName, notification) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
if (serverName !== request.serverName) return;
|
||||
nextStreamSequence += 1;
|
||||
handle.sendResultPatch(withStreamEnvelope(notification.result as CallToolResult, streamId, nextStreamSequence));
|
||||
});
|
||||
}
|
||||
|
||||
state.uiServer = handle;
|
||||
|
||||
const glimpseDetected = isGlimpseAvailable();
|
||||
const viewerPref = process.env.MCP_UI_VIEWER?.toLowerCase();
|
||||
const useGlimpse = viewerPref === "glimpse" ||
|
||||
(viewerPref !== "browser" && glimpseDetected);
|
||||
|
||||
if (useGlimpse) {
|
||||
try {
|
||||
const glimpseHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{margin:0;padding:0;width:100vw;height:100vh;overflow:hidden}iframe{width:100%;height:100%;border:none}</style></head><body><iframe src="${handle.url}"></iframe></body></html>`;
|
||||
activeGlimpseWindow = await openGlimpseWindow(glimpseHtml, {
|
||||
title: `MCP · ${request.serverName} · ${request.toolName}`,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
onClosed: () => {
|
||||
if (active) handle.close("glimpse-closed");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.debug("Glimpse unavailable, using browser", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await openInBrowser(state, handle.url);
|
||||
}
|
||||
} else {
|
||||
await openInBrowser(state, handle.url);
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: request.serverName,
|
||||
toolName: request.toolName,
|
||||
reused: false,
|
||||
streamId,
|
||||
streamToken,
|
||||
streamMode,
|
||||
requestMeta: streamToken ? { [UI_STREAM_REQUEST_META_KEY]: streamToken } : undefined,
|
||||
url: handle.url,
|
||||
isActive: () => active && state.uiServer === handle,
|
||||
sendToolResult: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
nextStreamSequence += 1;
|
||||
handle.sendToolResult(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendResultPatch: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
nextStreamSequence += 1;
|
||||
handle.sendResultPatch(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendToolCancelled: (reason: string) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
handle.sendToolCancelled(reason);
|
||||
},
|
||||
close: (reason?: string) => {
|
||||
active = false;
|
||||
cleanupStreamListener();
|
||||
handle.close(reason);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to start UI session", error instanceof Error ? error : undefined);
|
||||
state.ui?.notify(
|
||||
`MCP UI unavailable for ${request.toolName} (${request.serverName}): ${message}`,
|
||||
"warning",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
89
agent/extensions/pi-mcp-adapter/ui-stream-types.ts
Normal file
89
agent/extensions/pi-mcp-adapter/ui-stream-types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UI_STREAM_HOST_CONTEXT_KEY = "pi-mcp-adapter/stream";
|
||||
export const UI_STREAM_REQUEST_META_KEY = "pi-mcp-adapter/stream-token";
|
||||
export const UI_STREAM_RESULT_PATCH_METHOD = "notifications/pi-mcp-adapter/ui-result-patch";
|
||||
export const SERVER_STREAM_RESULT_PATCH_METHOD = "notifications/pi-mcp-adapter/result-patch";
|
||||
export const UI_STREAM_STRUCTURED_CONTENT_KEY = "pi-mcp-adapter/stream";
|
||||
|
||||
export const uiStreamModeSchema = z.enum(["eager", "stream-first"]);
|
||||
export type UiStreamMode = z.infer<typeof uiStreamModeSchema>;
|
||||
|
||||
export const visualizationStreamPhaseSchema = z.enum(["shell", "narrative", "structure", "detail", "settled"]);
|
||||
export type VisualizationStreamPhase = z.infer<typeof visualizationStreamPhaseSchema>;
|
||||
|
||||
export const visualizationStreamFrameTypeSchema = z.enum(["patch", "checkpoint", "final"]);
|
||||
export type VisualizationStreamFrameType = z.infer<typeof visualizationStreamFrameTypeSchema>;
|
||||
|
||||
export const visualizationStreamStatusSchema = z.enum(["ok", "error"]);
|
||||
export type VisualizationStreamStatus = z.infer<typeof visualizationStreamStatusSchema>;
|
||||
|
||||
const looseRecordSchema = z.record(z.string(), z.unknown());
|
||||
const looseArraySchema = z.array(z.unknown());
|
||||
|
||||
export const uiStreamHostContextSchema = z.object({
|
||||
mode: uiStreamModeSchema,
|
||||
streamId: z.string().min(1),
|
||||
intermediateResultPatches: z.boolean(),
|
||||
partialInput: z.boolean(),
|
||||
});
|
||||
export type UiStreamHostContext = z.infer<typeof uiStreamHostContextSchema>;
|
||||
|
||||
export const visualizationStreamEnvelopeSchema = z.object({
|
||||
streamId: z.string().min(1),
|
||||
sequence: z.number().int().nonnegative(),
|
||||
frameType: visualizationStreamFrameTypeSchema,
|
||||
phase: visualizationStreamPhaseSchema,
|
||||
status: visualizationStreamStatusSchema,
|
||||
message: z.string().optional(),
|
||||
spec: looseRecordSchema.optional(),
|
||||
checkpoint: looseRecordSchema.optional(),
|
||||
});
|
||||
export type VisualizationStreamEnvelope = z.infer<typeof visualizationStreamEnvelopeSchema>;
|
||||
|
||||
export const uiStreamCallToolResultSchema = z.object({
|
||||
content: looseArraySchema.optional(),
|
||||
structuredContent: looseRecordSchema.optional(),
|
||||
isError: z.boolean().optional(),
|
||||
_meta: looseRecordSchema.optional(),
|
||||
}).passthrough();
|
||||
export type UiStreamCallToolResult = z.infer<typeof uiStreamCallToolResultSchema>;
|
||||
|
||||
export const uiStreamResultPatchNotificationSchema = z.object({
|
||||
method: z.literal(UI_STREAM_RESULT_PATCH_METHOD),
|
||||
params: uiStreamCallToolResultSchema,
|
||||
});
|
||||
export type UiStreamResultPatchNotification = z.infer<typeof uiStreamResultPatchNotificationSchema>;
|
||||
|
||||
export const serverStreamResultPatchNotificationSchema = z.object({
|
||||
method: z.literal(SERVER_STREAM_RESULT_PATCH_METHOD),
|
||||
params: z.object({
|
||||
streamToken: z.string().min(1),
|
||||
result: uiStreamCallToolResultSchema,
|
||||
}),
|
||||
});
|
||||
export type ServerStreamResultPatchNotification = z.infer<typeof serverStreamResultPatchNotificationSchema>;
|
||||
|
||||
export interface UiStreamSummary {
|
||||
streamId: string;
|
||||
mode: UiStreamMode;
|
||||
frames: number;
|
||||
phases: VisualizationStreamPhase[];
|
||||
finalStatus?: VisualizationStreamStatus;
|
||||
lastMessage?: string;
|
||||
}
|
||||
|
||||
export function getUiStreamHostContext(hostContext: Record<string, unknown> | undefined): UiStreamHostContext | undefined {
|
||||
const candidate = hostContext?.[UI_STREAM_HOST_CONTEXT_KEY];
|
||||
const parsed = uiStreamHostContextSchema.safeParse(candidate);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
export function getVisualizationStreamEnvelope(structuredContent: unknown): VisualizationStreamEnvelope | undefined {
|
||||
if (!structuredContent || typeof structuredContent !== "object" || Array.isArray(structuredContent)) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = (structuredContent as Record<string, unknown>)[UI_STREAM_STRUCTURED_CONTENT_KEY];
|
||||
const parsed = visualizationStreamEnvelopeSchema.safeParse(candidate);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
129
agent/extensions/pi-mcp-adapter/utils.ts
Normal file
129
agent/extensions/pi-mcp-adapter/utils.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { McpConfig, ServerEntry } from "./types.ts";
|
||||
|
||||
async function execOpen(pi: ExtensionAPI, target: string, browser?: string) {
|
||||
const os = platform();
|
||||
|
||||
if (os === "darwin") {
|
||||
return browser ? pi.exec("open", ["-a", browser, target]) : pi.exec("open", [target]);
|
||||
}
|
||||
if (os === "win32") {
|
||||
return browser
|
||||
? pi.exec("cmd", ["/c", "start", "", browser, target])
|
||||
: pi.exec("cmd", ["/c", "start", "", target]);
|
||||
}
|
||||
return browser ? pi.exec(browser, [target]) : pi.exec("xdg-open", [target]);
|
||||
}
|
||||
|
||||
export async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
|
||||
const result = await execOpen(pi, url, browser);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openPath(pi: ExtensionAPI, targetPath: string): Promise<void> {
|
||||
const result = await execOpen(pi, targetPath);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to open path (exit code ${result.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parallelLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const i = index++;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array(Math.min(limit, items.length)).fill(null).map(() => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function getConfigPathFromArgv(): string | undefined {
|
||||
const idx = process.argv.indexOf("--mcp-config");
|
||||
if (idx >= 0 && idx + 1 < process.argv.length) {
|
||||
return process.argv[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function interpolateEnvVars(value: string): string {
|
||||
return value
|
||||
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
|
||||
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
|
||||
}
|
||||
|
||||
export function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
|
||||
if (!values) return undefined;
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
resolved[key] = interpolateEnvVars(value);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveConfigPath(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
const resolved = interpolateEnvVars(value);
|
||||
if (resolved === "~") return homedir();
|
||||
if (resolved.startsWith("~/") || resolved.startsWith("~\\")) {
|
||||
return join(homedir(), resolved.slice(2));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveBearerToken(definition: Pick<ServerEntry, "bearerToken" | "bearerTokenEnv">): string | undefined {
|
||||
if (definition.bearerToken !== undefined) {
|
||||
return interpolateEnvVars(definition.bearerToken);
|
||||
}
|
||||
return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined;
|
||||
}
|
||||
|
||||
export function truncateAtWord(text: string, target: number): string {
|
||||
if (!text || text.length <= target) return text;
|
||||
|
||||
const truncated = text.slice(0, target);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
|
||||
if (lastSpace > target * 0.6) {
|
||||
return truncated.slice(0, lastSpace) + "...";
|
||||
}
|
||||
|
||||
return truncated + "...";
|
||||
}
|
||||
|
||||
export function formatAuthRequiredMessage(
|
||||
config: Pick<McpConfig, "settings">,
|
||||
serverName: string,
|
||||
defaultMessage: string,
|
||||
): string {
|
||||
const template = config.settings?.authRequiredMessage;
|
||||
return template ? template.replaceAll("${server}", serverName) : defaultMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the adapter-owned UI stream mode from tool metadata.
|
||||
*/
|
||||
export function extractToolUiStreamMode(toolMeta: Record<string, unknown> | undefined): "eager" | "stream-first" | undefined {
|
||||
const uiMeta = toolMeta?.ui;
|
||||
if (!uiMeta || typeof uiMeta !== "object") return undefined;
|
||||
const streamMode = (uiMeta as Record<string, unknown>)["pi-mcp-adapter.streamMode"];
|
||||
if (streamMode === "eager" || streamMode === "stream-first") {
|
||||
return streamMode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
14
agent/extensions/pi-mcp-adapter/vitest.config.ts
Normal file
14
agent/extensions/pi-mcp-adapter/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["__tests__/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["*.ts"],
|
||||
exclude: ["__tests__/**", "vitest.config.ts", "cli.js"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user