Compare commits
6 Commits
feat/mcp-loader
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 357fcc6eca | |||
| da34f41798 | |||
| 1381a37115 | |||
| feb7add717 | |||
| 37cc49e06f | |||
| 7eec49b9b8 |
@@ -23,7 +23,7 @@ extensions/
|
|||||||
notify.ts # Native terminal notification when agent finishes
|
notify.ts # Native terminal notification when agent finishes
|
||||||
ext-toggle.ts # /ext slash command — list & toggle extensions at runtime
|
ext-toggle.ts # /ext slash command — list & toggle extensions at runtime
|
||||||
todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example)
|
todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example)
|
||||||
mcp-loader.ts # Generic MCP server loader — reads `mcp` block from settings.json
|
mcp-loader.ts # Generic MCP server loader + /mcp slash command
|
||||||
install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
|
install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
|
||||||
package.json # pi package manifest — enables `pi install /path` as an alternative
|
package.json # pi package manifest — enables `pi install /path` as an alternative
|
||||||
README.md # User-facing docs.
|
README.md # User-facing docs.
|
||||||
@@ -33,6 +33,8 @@ LICENSE # MIT.
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
- **Extension imports are coupled to the running pi version.** Extensions are loaded by jiti at runtime; their `import { ... } from "@earendil-works/pi-*"` statements resolve against whatever node_modules the running `pi` binary has bundled. **When extension imports change package names** (e.g. the 2026-05-09 rename from `@mariozechner/pi-*` to `@earendil-works/pi-*`), the host pi MUST be upgraded to the matching version *before or at the same time as* the import-rename push, otherwise users on the older pi see `Cannot find module '@earendil-works/...'` at extension load. Container path: bump `PI_VERSION` build-arg in opencode-devbox. Host path: `npm install -g @earendil-works/pi-coding-agent` (and `brew uninstall pi-coding-agent` if previously installed via brew at the old name). `bun build --external '*'` does NOT catch this regression — it only validates the bundle shape, not module resolution against the runtime pi's deps. Real test: run `pi --version` on the target system after the rename.
|
||||||
|
|
||||||
- **One `.ts` file per extension**, flat under `extensions/`. If an extension
|
- **One `.ts` file per extension**, flat under `extensions/`. If an extension
|
||||||
grows multiple files, use a subdirectory with an `index.ts` entry point —
|
grows multiple files, use a subdirectory with an `index.ts` entry point —
|
||||||
pi's auto-discovery handles both shapes.
|
pi's auto-discovery handles both shapes.
|
||||||
@@ -73,8 +75,8 @@ untouched.
|
|||||||
1. **Drop a `.ts` file into `extensions/`.** No other config needed —
|
1. **Drop a `.ts` file into `extensions/`.** No other config needed —
|
||||||
`install.sh` discovers all `.ts` files automatically.
|
`install.sh` discovers all `.ts` files automatically.
|
||||||
2. **Export a default factory function** `(pi: ExtensionAPI) => void`.
|
2. **Export a default factory function** `(pi: ExtensionAPI) => void`.
|
||||||
See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md)
|
See the [pi extensions docs](https://github.com/earendil-works/pi/blob/main/docs/extensions.md)
|
||||||
and [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions).
|
and [built-in examples](https://github.com/earendil-works/pi/tree/main/examples/extensions).
|
||||||
3. **Register any CLI flags** via `pi.registerFlag()` so they appear in
|
3. **Register any CLI flags** via `pi.registerFlag()` so they appear in
|
||||||
`pi --help`.
|
`pi --help`.
|
||||||
4. **Keep the extension inert without its flag** (or equivalent trigger). Don't
|
4. **Keep the extension inert without its flag** (or equivalent trigger). Don't
|
||||||
@@ -242,20 +244,22 @@ upstream updates but gain reproducibility.
|
|||||||
**Refresh from upstream when needed:**
|
**Refresh from upstream when needed:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@mariozechner/pi-coding-agent/examples/extensions/todo.ts \
|
cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@earendil-works/pi-coding-agent/examples/extensions/todo.ts \
|
||||||
~/src/src_local/pi-extensions/extensions/todo.ts
|
~/src/src_local/pi-extensions/extensions/todo.ts
|
||||||
# review diff, then commit
|
# review diff, then commit
|
||||||
```
|
```
|
||||||
|
|
||||||
### `mcp-loader.ts`
|
### `mcp-loader.ts`
|
||||||
|
|
||||||
Generic MCP stdio client — reads an `mcp` block from `~/.pi/agent/settings.json`
|
Generic MCP client — reads an `mcp` block from `~/.pi/agent/settings.json`
|
||||||
and connects to each declared server, exposing the tools as namespaced pi tools.
|
and connects to each declared server (stdio subprocess or streamable-HTTP
|
||||||
|
endpoint), exposing the tools as namespaced pi tools.
|
||||||
|
|
||||||
**Why this exists:** pi has no built-in MCP loader (unlike opencode and Claude
|
**Why this exists:** pi has no built-in MCP loader (unlike opencode and Claude
|
||||||
Desktop). Adding each new MCP server as a hand-rolled extension (the way
|
Desktop). Adding each new MCP server as a hand-rolled extension (the way
|
||||||
`mempalace.ts` does it) doesn't scale past 2-3 servers. This loader is the
|
`mempalace.ts` does it) doesn't scale past 2-3 servers. This loader is the
|
||||||
config-driven generalization — one extension, any number of servers.
|
config-driven generalization — one extension, any number of servers, two
|
||||||
|
transports.
|
||||||
|
|
||||||
**Key design decisions:**
|
**Key design decisions:**
|
||||||
|
|
||||||
@@ -265,42 +269,72 @@ config-driven generalization — one extension, any number of servers.
|
|||||||
multiple servers expose tools with the same short name. Tools that already
|
multiple servers expose tools with the same short name. Tools that already
|
||||||
start with `<servername>_` (the convention some servers like mempalace
|
start with `<servername>_` (the convention some servers like mempalace
|
||||||
follow internally) skip the prefix to avoid double-prefixing.
|
follow internally) skip the prefix to avoid double-prefixing.
|
||||||
|
- **Tool names are sanitized to `^[A-Za-z][A-Za-z0-9_]{0,63}$`** before
|
||||||
|
registering with pi. This is the strictest regex we have to satisfy:
|
||||||
|
Anthropic's Messages API allows hyphens, but AWS Bedrock's Anthropic shim
|
||||||
|
rejects them outright — a single hyphenated tool name causes the whole
|
||||||
|
turn to 4xx silently, manifesting as "no output at all" once the offending
|
||||||
|
server is enabled. context7 surfaces this because its tools are named
|
||||||
|
`resolve-library-id` and `query-docs`. We replace any non-`[A-Za-z0-9_]`
|
||||||
|
char with `_`, prepend `t_` if the result doesn't start with a letter, and
|
||||||
|
truncate to 64 chars. The original MCP-side name is kept in the closure
|
||||||
|
used by `client.callTool`, so the sanitization is purely pi-facing.
|
||||||
- **Fail-soft per server.** A server that won't start (binary missing, init
|
- **Fail-soft per server.** A server that won't start (binary missing, init
|
||||||
handshake fails) logs one stderr line and is skipped. Other servers
|
handshake fails, remote 4xx/5xx) logs one stderr line and is skipped.
|
||||||
continue. Pi keeps working.
|
Other servers continue. Pi keeps working.
|
||||||
- **Stdio transport only in v1.** Remote/streamable-HTTP servers are
|
- **Two transports behind one interface.** `IMcpClient` (`start`, `tools`,
|
||||||
detected and skipped with a warning. Adding remote support is the
|
`callTool`, `stop`) is implemented by `StdioMcpClient` (subprocess +
|
||||||
obvious v2 (context7 is the prime motivator). Avoided in v1 because
|
newline-delimited JSON-RPC) and `RemoteMcpClient` (HTTP POST + JSON or
|
||||||
streamable-HTTP needs SSE parsing, session management, and reconnect
|
SSE response). The extension entry dispatches on `cfg.type` and the
|
||||||
logic that triples the implementation size.
|
rest of the code is transport-agnostic.
|
||||||
- **Does not replace `mempalace.ts`.** The mempalace bridge has bespoke
|
- **Does not replace `mempalace.ts`.** The mempalace bridge has bespoke
|
||||||
agent-identity injection from `$MEMPALACE_AGENT_NAME` that's worth
|
agent-identity injection from `$MEMPALACE_AGENT_NAME` that's worth
|
||||||
preserving. Loader and bridge coexist. Listing `mempalace` in the `mcp`
|
preserving. Loader and bridge coexist. Listing `mempalace` in the `mcp`
|
||||||
block would produce duplicate tool registrations — don't.
|
block would produce duplicate tool registrations — don't.
|
||||||
- **No reconnect on subprocess death.** If a server's subprocess crashes
|
- **No stdio reconnect on death.** If a stdio subprocess crashes
|
||||||
mid-session, its tools become permanently unavailable until pi `/reload`s.
|
mid-session, its tools become permanently unavailable until pi `/reload`s.
|
||||||
Same limitation as the mempalace bridge today; not worth complicating v1.
|
Same limitation as the mempalace bridge today.
|
||||||
|
- **Remote sessions self-heal on 404.** Per spec 2025-11-25 §2.2, a server
|
||||||
|
that has terminated a session MUST respond with HTTP 404 to requests
|
||||||
|
carrying the stale `Mcp-Session-Id`. `RemoteMcpClient` catches this
|
||||||
|
condition (404 + sessionAtStart was non-null), drops the id, runs a fresh
|
||||||
|
`initialize` + `notifications/initialized`, and retries the original
|
||||||
|
request once. Persistent 404s after refresh surface as errors.
|
||||||
|
|
||||||
**API used:**
|
**API used:**
|
||||||
|
|
||||||
- `pi.registerTool({ name, label, description, parameters, execute })` for
|
- `pi.registerTool({ name, label, description, parameters, execute })` for
|
||||||
each MCP tool exposed by each connected server.
|
each MCP tool exposed by each connected server.
|
||||||
- `pi.on("session_shutdown", ...)` to SIGTERM all subprocesses cleanly.
|
- `pi.on("session_shutdown", ...)` to SIGTERM stdio subprocesses and DELETE
|
||||||
|
remote sessions cleanly.
|
||||||
- `Type.Unsafe<...>(inputSchema)` from typebox to pass the MCP server's
|
- `Type.Unsafe<...>(inputSchema)` from typebox to pass the MCP server's
|
||||||
JSON schema through to pi without conversion (TypeBox schemas are
|
JSON schema through to pi without conversion (TypeBox schemas are
|
||||||
plain JSON Schema at runtime).
|
plain JSON Schema at runtime).
|
||||||
|
|
||||||
**Internal MCP client:**
|
**Internal MCP clients:**
|
||||||
|
|
||||||
- `class StdioMcpClient` — spawn subprocess, write newline-delimited
|
- `class StdioMcpClient` — spawn subprocess, write newline-delimited
|
||||||
JSON-RPC, parse newline-delimited responses, match by request `id`.
|
JSON-RPC, parse newline-delimited responses, match by request `id`.
|
||||||
- Sends `initialize` handshake with `protocolVersion: 2024-11-05`, then
|
Stderr drained silently unless `PI_MCP_LOADER_DEBUG=1`.
|
||||||
`notifications/initialized`, then `tools/list` to discover tools.
|
- `class RemoteMcpClient` — streamable-HTTP per MCP spec 2025-03-26.
|
||||||
- Stderr drained silently unless `PI_MCP_LOADER_DEBUG=1`.
|
POST JSON-RPC body to a single URL with `Accept: application/json,
|
||||||
|
text/event-stream`. Server replies either with one JSON response or an
|
||||||
|
SSE stream containing `event: message` / `data: <json>` blocks; we
|
||||||
|
consume the stream until the response with our `id` arrives, then
|
||||||
|
cancel the reader. Optional `Mcp-Session-Id` header is captured on
|
||||||
|
initialize and round-tripped on every subsequent request, then DELETEd
|
||||||
|
on stop. Per-server `headers` config (e.g. `Authorization`) is merged
|
||||||
|
into every request. No GET-stream subscription — server-initiated
|
||||||
|
notifications are not consumed.
|
||||||
|
- Both share `interface IMcpClient { serverName, tools, start, callTool, stop }`.
|
||||||
|
Both send `initialize` handshake with `protocolVersion: MCP_PROTOCOL_VERSION`
|
||||||
|
(currently `2025-11-25`, per the constant at the top of the file),
|
||||||
|
then `notifications/initialized`, then `tools/list` to discover tools.
|
||||||
|
|
||||||
**Future v2 extensions:**
|
**Future extensions:**
|
||||||
|
|
||||||
- Streamable-HTTP transport for remote servers (context7).
|
- OAuth flow for remote servers that require it (today: pre-issued bearer
|
||||||
|
tokens via `headers` only).
|
||||||
- Per-tool enable/disable in settings.json (e.g.
|
- Per-tool enable/disable in settings.json (e.g.
|
||||||
`"mcp.searxng.tools": ["web_search"]` to expose only a subset).
|
`"mcp.searxng.tools": ["web_search"]` to expose only a subset).
|
||||||
- Reconnect-on-crash with exponential backoff.
|
- Reconnect-on-crash with exponential backoff.
|
||||||
@@ -366,3 +400,14 @@ pi --ssh user@host --ssh-ask-pass
|
|||||||
— same pattern for agent skills rather than extensions.
|
— same pattern for agent skills rather than extensions.
|
||||||
- [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox)
|
- [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox)
|
||||||
— Docker containers; composes toolkits via independent install.sh calls.
|
— Docker containers; composes toolkits via independent install.sh calls.
|
||||||
|
|
||||||
|
## Documentation drift sweep
|
||||||
|
|
||||||
|
Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo:
|
||||||
|
|
||||||
|
- `README.md` — the per-extension feature/usage descriptions, the install instructions, the dependency list. When an extension's flags or behaviour change, the README block for that extension is the first thing to update.
|
||||||
|
- `AGENTS.md` (this file) — the `Extension-specific notes` section is large and easy to leave stale. Each extension has its own subsection; if you change a flag, hook, or default, update the subsection here as well as the README.
|
||||||
|
- Extension files themselves — the `// description:` comment header pi parses for the extension list view must stay accurate.
|
||||||
|
- `install.sh` — `--only` / `--skip` defaults, the list of artifacts it knows about, and the symlink targets. If you add a new extension file, the installer's auto-discovery picks it up; if you add a new artifact type that needs special handling, the script must be updated and the README block describing what `install.sh does` along with it.
|
||||||
|
|
||||||
|
Quick triage: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md install.sh`. For an extension rename, also grep for the old name across the whole repo.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# pi-extensions
|
# pi-extensions
|
||||||
|
|
||||||
Custom and modified extensions for the [pi coding-agent](https://github.com/mariozechner/pi-coding-agent).
|
Custom and modified extensions for the [pi coding-agent](https://github.com/earendil-works/pi).
|
||||||
|
|
||||||
This repo is the single source of truth for extensions that aren't suitable for general publishing — personal workflow tweaks, modified versions of built-in examples, and extensions written for specific infrastructure. Symlinked into `~/.pi/agent/extensions/` so pi loads them automatically.
|
This repo is the single source of truth for extensions that aren't suitable for general publishing — personal workflow tweaks, modified versions of built-in examples, and extensions written for specific infrastructure. Symlinked into `~/.pi/agent/extensions/` so pi loads them automatically.
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ This is a verbatim copy of the upstream `examples/extensions/todo.ts` shipped wi
|
|||||||
|
|
||||||
### `mcp-loader.ts`
|
### `mcp-loader.ts`
|
||||||
|
|
||||||
Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing all of their tools to pi as native tools — namespaced as `<server-name>_<tool-name>` to avoid collisions.
|
Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing all of their tools to pi as native tools — namespaced as `<server-name>_<tool-name>` to avoid collisions, with non-`[A-Za-z0-9_]` characters replaced by `_` so the names pass the strictest provider tool-name regex (e.g. AWS Bedrock).
|
||||||
|
|
||||||
**Settings.json shape:**
|
**Settings.json shape:**
|
||||||
|
|
||||||
@@ -239,18 +239,34 @@ Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json`
|
|||||||
| `type` | `"local"` (stdio subprocess) or `"remote"` (streamable-http). Default `"local"`. |
|
| `type` | `"local"` (stdio subprocess) or `"remote"` (streamable-http). Default `"local"`. |
|
||||||
| `command` | Argv array. First element is the executable, rest are args. Local servers only. |
|
| `command` | Argv array. First element is the executable, rest are args. Local servers only. |
|
||||||
| `url` | Remote MCP endpoint URL. Remote servers only. |
|
| `url` | Remote MCP endpoint URL. Remote servers only. |
|
||||||
|
| `headers` | Optional object of HTTP headers (e.g. `Authorization`, `X-API-Key`) sent with every request. Remote servers only. |
|
||||||
| `enabled` | Default `true`. Set `false` to disable a server without removing the entry. |
|
| `enabled` | Default `true`. Set `false` to disable a server without removing the entry. |
|
||||||
| `env` | Optional object of env vars injected into the subprocess. Inherits parent env first, then overlays these keys. Local servers only. |
|
| `env` | Optional object of env vars injected into the subprocess. Inherits parent env first, then overlays these keys. Local servers only. |
|
||||||
|
|
||||||
**Limitations (v1):**
|
**Transports:**
|
||||||
|
|
||||||
- **Stdio only.** Remote/streamable-HTTP transport is detected and skipped with a warning. Server like `context7` configured as `"type": "remote"` will not load until v2.
|
- `local` — stdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, mcp-server-time…).
|
||||||
- **No reconnect** if a subprocess dies mid-session — those tools become unavailable until `/reload` (same as `mempalace.ts`).
|
- `remote` — streamable-HTTP per MCP spec 2025-03-26: POST JSON-RPC, server replies either `application/json` or `text/event-stream`. Optional `Mcp-Session-Id` round-trip if the server issues one. No GET subscription stream (server-initiated notifications are not consumed).
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
|
||||||
|
- **No stdio reconnect** if a subprocess dies mid-session — those tools become unavailable until `/reload` (same as `mempalace.ts`).
|
||||||
|
- **Remote sessions self-heal on 404.** If a streamable-HTTP server forgets our session id (e.g. server restart), the client transparently re-initializes and retries the request once.
|
||||||
|
- **No OAuth flow.** Remote servers requiring OAuth must be accessed with a pre-issued bearer token via `headers`.
|
||||||
- **Coexists with `mempalace.ts`** but does not replace it. The mempalace bridge has bespoke handling (agent identity injection) that's worth keeping. Don't list `mempalace` in the `mcp` block too — you'd get duplicate tool registrations.
|
- **Coexists with `mempalace.ts`** but does not replace it. The mempalace bridge has bespoke handling (agent identity injection) that's worth keeping. Don't list `mempalace` in the `mcp` block too — you'd get duplicate tool registrations.
|
||||||
|
|
||||||
**Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs.
|
**Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs.
|
||||||
|
|
||||||
Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions) for the API surface.
|
**Slash command:** `/mcp` opens a multi-toggle overlay listing every server in the `mcp` block with its runtime status:
|
||||||
|
|
||||||
|
- `running · N tools` — connected, tools registered
|
||||||
|
- `failed: <message>` — start handshake threw
|
||||||
|
- `disabled in settings` — `enabled: false`
|
||||||
|
- `invalid: <message>` — malformed config (read-only row)
|
||||||
|
|
||||||
|
UX matches `/ext`: **space** stages a toggle, **enter** writes back to `settings.json` and reloads pi, **esc** cancels. Toggling re-enables a previously-disabled server by removing the explicit `enabled` key (the default is `true`).
|
||||||
|
|
||||||
|
Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/earendil-works/pi/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/earendil-works/pi/tree/main/examples/extensions) for the API surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* /fork so you don't accidentally throw away work.
|
* /fork so you don't accidentally throw away work.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
// ── Dangerous bash patterns ───────────────────────────────────────────────────
|
// ── Dangerous bash patterns ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ import * as os from "node:os";
|
|||||||
import {
|
import {
|
||||||
type ExtensionAPI,
|
type ExtensionAPI,
|
||||||
getSettingsListTheme,
|
getSettingsListTheme,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Key,
|
Key,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
type SettingItem,
|
type SettingItem,
|
||||||
SettingsList,
|
SettingsList,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
|
const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
|
||||||
|
|
||||||
@@ -58,6 +58,18 @@ const DISABLE_GUARDS: Record<string, () => string | null> = {
|
|||||||
if (!hasSshFlag) return null;
|
if (!hasSshFlag) return null;
|
||||||
return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh.";
|
return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh.";
|
||||||
},
|
},
|
||||||
|
// ext-toggle owns the /ext slash command. Disabling it removes the
|
||||||
|
// only TUI surface for re-enabling itself: pi auto-discovers `*.ts`
|
||||||
|
// only, so once renamed to `.ts.off` the file is invisible to pi and
|
||||||
|
// the next /reload silently drops the command. The disabled state
|
||||||
|
// also persists — in containerized setups (opencode-devbox) the
|
||||||
|
// ~/.pi volume keeps the `.ts.off` rename across container recreate,
|
||||||
|
// so even nuking the container doesn't recover the surface. Recovery
|
||||||
|
// path is manual: shell into the container (or open a host shell),
|
||||||
|
// run `mv ~/.pi/agent/extensions/ext-toggle.ts.off \
|
||||||
|
// ~/.pi/agent/extensions/ext-toggle.ts`, then /reload.
|
||||||
|
"ext-toggle": () =>
|
||||||
|
"Disabling ext-toggle would remove the /ext command itself — only manual `mv ~/.pi/agent/extensions/ext-toggle.ts.off ~/.pi/agent/extensions/ext-toggle.ts` recovers it. Refusing.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Filesystem helpers ────────────────────────────────────────────────────────
|
// ── Filesystem helpers ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* Status bar shows the number of checkpoints saved in the current session.
|
* Status bar shows the number of checkpoints saved in the current session.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
// entryId → stash ref (e.g. "refs/stash" or a full sha)
|
// entryId → stash ref (e.g. "refs/stash" or a full sha)
|
||||||
|
|||||||
+521
-48
@@ -22,7 +22,8 @@
|
|||||||
* },
|
* },
|
||||||
* "context7": {
|
* "context7": {
|
||||||
* "type": "remote",
|
* "type": "remote",
|
||||||
* "url": "https://mcp.context7.com/mcp"
|
* "url": "https://mcp.context7.com/mcp",
|
||||||
|
* "headers": { "Authorization": "Bearer …" }
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
@@ -32,11 +33,13 @@
|
|||||||
* command argv array — first element is the executable, rest are args.
|
* command argv array — first element is the executable, rest are args.
|
||||||
* For local servers only.
|
* For local servers only.
|
||||||
* url remote MCP endpoint URL. For remote servers only.
|
* url remote MCP endpoint URL. For remote servers only.
|
||||||
|
* headers optional object of HTTP headers to send with every request to
|
||||||
|
* a remote server (e.g. Authorization, X-API-Key). Remote only.
|
||||||
* enabled default true. Set false to disable a server without
|
* enabled default true. Set false to disable a server without
|
||||||
* removing it from settings.json.
|
* removing it from settings.json.
|
||||||
* env optional object of environment variables to inject into
|
* env optional object of environment variables to inject into
|
||||||
* the spawned subprocess. Inherits parent env first, then
|
* the spawned subprocess. Inherits parent env first, then
|
||||||
* overlays these keys.
|
* overlays these keys. Local only.
|
||||||
*
|
*
|
||||||
* Tool naming
|
* Tool naming
|
||||||
*
|
*
|
||||||
@@ -47,22 +50,35 @@
|
|||||||
*
|
*
|
||||||
* Lifecycle
|
* Lifecycle
|
||||||
*
|
*
|
||||||
* • Servers are spawned at extension load time (pi startup or /reload).
|
* • Servers are connected at extension load time (pi startup or /reload).
|
||||||
* • Each server's `tools/list` is awaited before pi finishes registering.
|
* • Each server's `tools/list` is awaited before pi finishes registering.
|
||||||
* • Subprocess stderr is silenced unless PI_MCP_LOADER_DEBUG=1.
|
* • Local subprocess stderr is silenced unless PI_MCP_LOADER_DEBUG=1.
|
||||||
* • Subprocesses receive SIGTERM at session_shutdown.
|
* • Local subprocesses receive SIGTERM at session_shutdown; remote
|
||||||
* • A server that fails to start (binary missing, init handshake error)
|
* servers receive an MCP DELETE if a session id was issued.
|
||||||
* logs a single line to stderr and is skipped. Other servers continue.
|
* • A server that fails to start (binary missing, init handshake error,
|
||||||
* Pi keeps working without that server's tools.
|
* remote 4xx/5xx) logs a single line to stderr and is skipped. Other
|
||||||
|
* servers continue. Pi keeps working without that server's tools.
|
||||||
*
|
*
|
||||||
* Limitations (v1)
|
* Transports
|
||||||
*
|
*
|
||||||
* • Only local/stdio transport is implemented. Remote (streamable-http,
|
* local — stdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, …).
|
||||||
* SSE) servers are detected and skipped with a warning. v2 will add
|
* remote — streamable-HTTP per MCP spec 2025-03-26: POST JSON-RPC, server
|
||||||
* remote support — context7 is the prime motivator.
|
* replies either application/json or text/event-stream. Optional
|
||||||
* • No reconnect: if a subprocess dies mid-session, its tools become
|
* Mcp-Session-Id header round-tripped if the server issues one.
|
||||||
* unavailable until pi is reloaded. The mempalace.ts extension has
|
* No GET-stream subscription (we don't consume server-initiated
|
||||||
* the same limitation today.
|
* notifications).
|
||||||
|
*
|
||||||
|
* Limitations
|
||||||
|
*
|
||||||
|
* • No stdio reconnect: if a stdio subprocess dies mid-session, its tools
|
||||||
|
* become unavailable until pi is reloaded. The mempalace.ts extension
|
||||||
|
* has the same limitation today.
|
||||||
|
* • Remote sessions self-heal on 404: if a streamable-HTTP server
|
||||||
|
* forgets our session id (e.g. server restart), the next request gets
|
||||||
|
* HTTP 404; the client transparently re-initializes and retries the
|
||||||
|
* request once. Persistent 404s after refresh surface as errors.
|
||||||
|
* • No OAuth flow: remote servers requiring OAuth must be accessed with
|
||||||
|
* a pre-issued bearer token via `headers`.
|
||||||
*
|
*
|
||||||
* Coexistence with mempalace.ts
|
* Coexistence with mempalace.ts
|
||||||
*
|
*
|
||||||
@@ -72,13 +88,30 @@
|
|||||||
* bespoke extension in place. If you also list mempalace under the `mcp`
|
* bespoke extension in place. If you also list mempalace under the `mcp`
|
||||||
* block in settings.json the loader will register a parallel set of
|
* block in settings.json the loader will register a parallel set of
|
||||||
* tools, which is harmless but redundant. Don't.
|
* tools, which is harmless but redundant. Don't.
|
||||||
|
*
|
||||||
|
* Slash command
|
||||||
|
*
|
||||||
|
* `/mcp` lists configured MCP servers with their runtime status
|
||||||
|
* (running / failed / disabled / invalid) and lets
|
||||||
|
* you toggle the `enabled` flag in settings.json. Same UX as `/ext`:
|
||||||
|
* space stages, enter applies + reloads, esc cancels.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import {
|
||||||
|
type ExtensionAPI,
|
||||||
|
getSettingsListTheme,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Key,
|
||||||
|
matchesKey,
|
||||||
|
type SettingItem,
|
||||||
|
SettingsList,
|
||||||
|
} from "@earendil-works/pi-tui";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
// ── MCP types ────────────────────────────────────────────────────────────────
|
// ── MCP types ────────────────────────────────────────────────────────────────
|
||||||
@@ -99,6 +132,7 @@ type LocalServerConfig = {
|
|||||||
type RemoteServerConfig = {
|
type RemoteServerConfig = {
|
||||||
type: "remote";
|
type: "remote";
|
||||||
url: string;
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +141,13 @@ type ServerConfig = LocalServerConfig | RemoteServerConfig;
|
|||||||
const SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
const SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
||||||
const DEBUG = process.env.PI_MCP_LOADER_DEBUG === "1";
|
const DEBUG = process.env.PI_MCP_LOADER_DEBUG === "1";
|
||||||
|
|
||||||
|
// MCP protocol version we advertise. Servers we've tested:
|
||||||
|
// - searxng (mcp-searxng) accepts both 2024-11-05 and 2025-11-25
|
||||||
|
// - context7 accepts both
|
||||||
|
// - mempalace bridge: not touched by this loader
|
||||||
|
// Bump as the spec evolves; both stdio and remote clients use this.
|
||||||
|
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
||||||
|
|
||||||
function dlog(msg: string) {
|
function dlog(msg: string) {
|
||||||
if (DEBUG) process.stderr.write(`[mcp-loader] ${msg}\n`);
|
if (DEBUG) process.stderr.write(`[mcp-loader] ${msg}\n`);
|
||||||
}
|
}
|
||||||
@@ -145,21 +186,35 @@ function readMcpServers(): Record<string, ServerConfig> {
|
|||||||
|
|
||||||
// ── Stdio MCP client ─────────────────────────────────────────────────────────
|
// ── Stdio MCP client ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class StdioMcpClient {
|
interface IMcpClient {
|
||||||
|
readonly serverName: string;
|
||||||
|
tools: McpTool[];
|
||||||
|
start(): Promise<void>;
|
||||||
|
callTool(name: string, args: Record<string, unknown>): Promise<any>;
|
||||||
|
stop(): void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StdioMcpClient implements IMcpClient {
|
||||||
private proc: ChildProcessWithoutNullStreams | null = null;
|
private proc: ChildProcessWithoutNullStreams | null = null;
|
||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
||||||
private stdoutBuf = "";
|
private stdoutBuf = "";
|
||||||
private serverName: string;
|
public readonly serverName: string;
|
||||||
|
private command: string;
|
||||||
|
private args: string[];
|
||||||
|
private env: Record<string, string> | undefined;
|
||||||
public tools: McpTool[] = [];
|
public tools: McpTool[] = [];
|
||||||
|
|
||||||
constructor(serverName: string) {
|
constructor(serverName: string, command: string, args: string[], env?: Record<string, string>) {
|
||||||
this.serverName = serverName;
|
this.serverName = serverName;
|
||||||
|
this.command = command;
|
||||||
|
this.args = args;
|
||||||
|
this.env = env;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(command: string, args: string[], env?: Record<string, string>): Promise<void> {
|
async start(): Promise<void> {
|
||||||
const childEnv = { ...process.env, ...(env ?? {}) };
|
const childEnv = { ...process.env, ...(this.env ?? {}) };
|
||||||
this.proc = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
|
this.proc = spawn(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
|
||||||
|
|
||||||
this.proc.on("error", (err) => this.failAll(err));
|
this.proc.on("error", (err) => this.failAll(err));
|
||||||
this.proc.on("exit", (code, signal) => {
|
this.proc.on("exit", (code, signal) => {
|
||||||
@@ -180,9 +235,9 @@ class StdioMcpClient {
|
|||||||
|
|
||||||
// MCP initialize handshake
|
// MCP initialize handshake
|
||||||
await this.request("initialize", {
|
await this.request("initialize", {
|
||||||
protocolVersion: "2024-11-05",
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: { name: "pi-mcp-loader", version: "0.1.0" },
|
clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
|
||||||
});
|
});
|
||||||
this.notify("notifications/initialized", {});
|
this.notify("notifications/initialized", {});
|
||||||
|
|
||||||
@@ -249,6 +304,195 @@ class StdioMcpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Streamable-HTTP MCP client (remote) ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Per MCP spec 2025-03-26: a single endpoint URL accepts POST with a
|
||||||
|
// JSON-RPC body. The server responds either with application/json (a
|
||||||
|
// single response) or text/event-stream (an SSE stream containing the
|
||||||
|
// response, possibly preceded by notifications, terminated by stream
|
||||||
|
// close). We do NOT open a separate GET subscription stream — we don't
|
||||||
|
// consume server-initiated notifications today.
|
||||||
|
//
|
||||||
|
// Session id: if the server returns an Mcp-Session-Id header on
|
||||||
|
// initialize, we round-trip it on every subsequent request and DELETE
|
||||||
|
// it on stop().
|
||||||
|
|
||||||
|
class RemoteMcpClient implements IMcpClient {
|
||||||
|
public readonly serverName: string;
|
||||||
|
private url: string;
|
||||||
|
private extraHeaders: Record<string, string>;
|
||||||
|
private sessionId: string | null = null;
|
||||||
|
private nextId = 1;
|
||||||
|
public tools: McpTool[] = [];
|
||||||
|
|
||||||
|
constructor(serverName: string, url: string, headers?: Record<string, string>) {
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.url = url;
|
||||||
|
this.extraHeaders = headers ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.request("initialize", {
|
||||||
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
|
||||||
|
});
|
||||||
|
await this.notify("notifications/initialized", {});
|
||||||
|
const listed = await this.request("tools/list", {});
|
||||||
|
this.tools = (listed?.tools as McpTool[]) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
|
||||||
|
return this.request("tools/call", { name, arguments: args });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.sessionId) return;
|
||||||
|
try {
|
||||||
|
await fetch(this.url, { method: "DELETE", headers: this.buildHeaders() });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeaders(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
||||||
|
...this.extraHeaders,
|
||||||
|
};
|
||||||
|
if (this.sessionId) h["Mcp-Session-Id"] = this.sessionId;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-establish a session after the server returned 404 to a request that
|
||||||
|
* carried our `Mcp-Session-Id`. Per spec 2025-11-25, the client MUST start
|
||||||
|
* a new session by sending a fresh InitializeRequest without a session id.
|
||||||
|
*
|
||||||
|
* Caller must clear `this.sessionId` BEFORE invoking this so the new
|
||||||
|
* initialize POST goes out without the stale id.
|
||||||
|
*/
|
||||||
|
private async reinitialize(): Promise<void> {
|
||||||
|
dlog(`${this.serverName}: session lost (404) — reinitializing`);
|
||||||
|
await this.request("initialize", {
|
||||||
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
|
||||||
|
}, /*allowReinitOn404=*/ false);
|
||||||
|
await this.notify("notifications/initialized", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(method: string, params: unknown, allowReinitOn404 = true): Promise<any> {
|
||||||
|
const id = this.nextId++;
|
||||||
|
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
||||||
|
const sessionAtStart = this.sessionId;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(this.url, { method: "POST", headers: this.buildHeaders(), body });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`${this.serverName}: fetch failed on ${method}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = res.headers.get("Mcp-Session-Id") ?? res.headers.get("mcp-session-id");
|
||||||
|
if (sid) this.sessionId = sid;
|
||||||
|
|
||||||
|
// 404 + we sent a session id → server forgot us. Drop our id, re-init,
|
||||||
|
// retry once. `allowReinitOn404=false` is passed by the recovery path to
|
||||||
|
// prevent infinite recursion if the server is permanently broken.
|
||||||
|
if (res.status === 404 && sessionAtStart && allowReinitOn404) {
|
||||||
|
try { await res.arrayBuffer(); } catch {}
|
||||||
|
this.sessionId = null;
|
||||||
|
await this.reinitialize();
|
||||||
|
return this.request(method, params, /*allowReinitOn404=*/ false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try { detail = (await res.text()).slice(0, 200); } catch {}
|
||||||
|
throw new Error(`${this.serverName}: HTTP ${res.status} on ${method}${detail ? `: ${detail}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
|
||||||
|
if (ct.includes("text/event-stream")) {
|
||||||
|
return await this.readSseForResponse(res, id);
|
||||||
|
}
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
const json: any = await res.json();
|
||||||
|
if (json.error) throw new Error(json.error.message ?? "MCP error");
|
||||||
|
return json.result;
|
||||||
|
}
|
||||||
|
if (res.status === 202) return undefined;
|
||||||
|
throw new Error(`${this.serverName}: unexpected content-type "${ct}" on ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notify(method: string, params: unknown): Promise<void> {
|
||||||
|
const body = JSON.stringify({ jsonrpc: "2.0", method, params });
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(this.url, { method: "POST", headers: this.buildHeaders(), body });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`${this.serverName}: fetch failed on notify ${method}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
try { await res.arrayBuffer(); } catch {}
|
||||||
|
if (!res.ok && res.status !== 202) {
|
||||||
|
throw new Error(`${this.serverName}: HTTP ${res.status} on notify ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readSseForResponse(res: Response, expectedId: number): Promise<any> {
|
||||||
|
if (!res.body) throw new Error(`${this.serverName}: SSE response had no body`);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
|
||||||
|
const tryDispatch = (): { matched: boolean; result?: any } => {
|
||||||
|
if (dataLines.length === 0) return { matched: false };
|
||||||
|
const data = dataLines.join("\n");
|
||||||
|
dataLines = [];
|
||||||
|
let msg: any;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return { matched: false };
|
||||||
|
}
|
||||||
|
if (typeof msg.id === "number" && msg.id === expectedId) {
|
||||||
|
if (msg.error) throw new Error(msg.error.message ?? "MCP error");
|
||||||
|
return { matched: true, result: msg.result };
|
||||||
|
}
|
||||||
|
return { matched: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
let nl: number;
|
||||||
|
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||||
|
const rawLine = buf.slice(0, nl);
|
||||||
|
buf = buf.slice(nl + 1);
|
||||||
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||||
|
if (line === "") {
|
||||||
|
const out = tryDispatch();
|
||||||
|
if (out.matched) return out.result;
|
||||||
|
} else if (line.startsWith(":")) {
|
||||||
|
// SSE comment — ignore (often a keepalive)
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice(5).replace(/^ /, ""));
|
||||||
|
}
|
||||||
|
// event:, id:, retry: — ignored; only `data` matters for our use
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = tryDispatch();
|
||||||
|
if (out.matched) return out.result;
|
||||||
|
} finally {
|
||||||
|
try { reader.cancel(); } catch {}
|
||||||
|
}
|
||||||
|
throw new Error(`${this.serverName}: SSE stream closed without response for id=${expectedId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── MCP result → pi tool result helpers ──────────────────────────────────────
|
// ── MCP result → pi tool result helpers ──────────────────────────────────────
|
||||||
|
|
||||||
function extractText(mcpResult: any): string {
|
function extractText(mcpResult: any): string {
|
||||||
@@ -267,52 +511,107 @@ function namespacedToolName(serverName: string, toolName: string): string {
|
|||||||
// Avoid double-prefix: if the MCP server already names its tools
|
// Avoid double-prefix: if the MCP server already names its tools
|
||||||
// `<serverName>_<...>`, leave them alone.
|
// `<serverName>_<...>`, leave them alone.
|
||||||
const prefix = `${serverName}_`;
|
const prefix = `${serverName}_`;
|
||||||
if (toolName.startsWith(prefix)) return toolName;
|
const raw = toolName.startsWith(prefix) ? toolName : `${prefix}${toolName}`;
|
||||||
return `${prefix}${toolName}`;
|
// Sanitize for the strictest tool-name regex we have to satisfy.
|
||||||
|
// Anthropic's Messages API allows `^[a-zA-Z0-9_-]{1,64}$` (hyphens OK),
|
||||||
|
// but AWS Bedrock's Anthropic shim rejects names containing `-` outright
|
||||||
|
// — it accepts `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`. Hyphenated MCP tool names
|
||||||
|
// (context7's `resolve-library-id`, `query-docs`) caused entire turns to
|
||||||
|
// 4xx silently on Bedrock, manifesting as no output at all once the
|
||||||
|
// server was enabled. Replace any non-[A-Za-z0-9_] with `_`, then prefix
|
||||||
|
// a leading underscore with `t` if the result starts with a non-letter.
|
||||||
|
// Truncate to 64 to also satisfy Anthropic's length cap.
|
||||||
|
let sanitized = raw.replace(/[^A-Za-z0-9_]/g, "_");
|
||||||
|
if (!/^[A-Za-z]/.test(sanitized)) sanitized = `t_${sanitized}`;
|
||||||
|
if (sanitized.length > 64) sanitized = sanitized.slice(0, 64);
|
||||||
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Settings.json writer ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip the `enabled` flag for a server in settings.json and write back.
|
||||||
|
* Preserves all other keys verbatim by round-tripping through JSON with
|
||||||
|
* 2-space indent (matches the file's existing style).
|
||||||
|
*/
|
||||||
|
function setServerEnabled(serverName: string, enabled: boolean): void {
|
||||||
|
const raw = fs.readFileSync(SETTINGS_PATH, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed.mcp || !parsed.mcp[serverName]) {
|
||||||
|
throw new Error(`server '${serverName}' not in settings.json`);
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
// Default is true — clear the explicit flag if present, keep the file tidy.
|
||||||
|
delete parsed.mcp[serverName].enabled;
|
||||||
|
} else {
|
||||||
|
parsed.mcp[serverName].enabled = false;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-server runtime state (for /mcp display) ──────────────────────────────
|
||||||
|
|
||||||
|
type ServerRuntimeState =
|
||||||
|
| { kind: "running"; toolCount: number }
|
||||||
|
| { kind: "failed"; message: string }
|
||||||
|
| { kind: "disabled" }
|
||||||
|
| { kind: "invalid"; message: string };
|
||||||
|
|
||||||
// ── Extension entry ──────────────────────────────────────────────────────────
|
// ── Extension entry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
||||||
const servers = readMcpServers();
|
const servers = readMcpServers();
|
||||||
const serverNames = Object.keys(servers);
|
const runtime = new Map<string, ServerRuntimeState>();
|
||||||
if (serverNames.length === 0) {
|
const clients: IMcpClient[] = [];
|
||||||
dlog("no MCP servers configured — nothing to do");
|
|
||||||
|
// Always register /mcp — even when no servers are configured, so users
|
||||||
|
// discover the surface. Handler reads settings.json fresh on each invoke.
|
||||||
|
registerMcpCommand(pi, runtime);
|
||||||
|
|
||||||
|
if (Object.keys(servers).length === 0) {
|
||||||
|
dlog("no MCP servers configured — nothing to load");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients: StdioMcpClient[] = [];
|
|
||||||
|
|
||||||
for (const [name, cfg] of Object.entries(servers)) {
|
for (const [name, cfg] of Object.entries(servers)) {
|
||||||
if (cfg.enabled === false) {
|
if (cfg.enabled === false) {
|
||||||
dlog(`${name}: disabled in settings.json, skipping`);
|
dlog(`${name}: disabled in settings.json, skipping`);
|
||||||
|
runtime.set(name, { kind: "disabled" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let client: IMcpClient;
|
||||||
if (cfg.type === "remote") {
|
if (cfg.type === "remote") {
|
||||||
warn(
|
const remote = cfg as RemoteServerConfig;
|
||||||
`${name}: remote MCP transport not yet supported by mcp-loader (v1 is stdio-only); skipping`,
|
if (typeof remote.url !== "string" || remote.url.length === 0) {
|
||||||
);
|
warn(`${name}: remote server missing \`url\`, skipping`);
|
||||||
continue;
|
runtime.set(name, { kind: "invalid", message: "remote server missing url" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client = new RemoteMcpClient(name, remote.url, remote.headers);
|
||||||
|
} else {
|
||||||
|
const local = cfg as LocalServerConfig;
|
||||||
|
if (!Array.isArray(local.command) || local.command.length === 0) {
|
||||||
|
warn(`${name}: invalid \`command\` (must be non-empty array), skipping`);
|
||||||
|
runtime.set(name, { kind: "invalid", message: "command must be non-empty array" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [bin, ...args] = local.command;
|
||||||
|
client = new StdioMcpClient(name, bin, args, local.env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const local = cfg as LocalServerConfig;
|
|
||||||
if (!Array.isArray(local.command) || local.command.length === 0) {
|
|
||||||
warn(`${name}: invalid \`command\` (must be non-empty array), skipping`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const [bin, ...args] = local.command;
|
|
||||||
|
|
||||||
const client = new StdioMcpClient(name);
|
|
||||||
try {
|
try {
|
||||||
await client.start(bin, args, local.env);
|
await client.start();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warn(`${name}: failed to start — ${(err as Error).message}`);
|
const message = (err as Error).message;
|
||||||
client.stop();
|
warn(`${name}: failed to start — ${message}`);
|
||||||
|
runtime.set(name, { kind: "failed", message });
|
||||||
|
try { await client.stop(); } catch {}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
dlog(`${name}: connected, ${client.tools.length} tools`);
|
dlog(`${name}: connected, ${client.tools.length} tools`);
|
||||||
|
runtime.set(name, { kind: "running", toolCount: client.tools.length });
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
|
|
||||||
// Register each MCP tool as a pi tool.
|
// Register each MCP tool as a pi tool.
|
||||||
@@ -355,8 +654,182 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down subprocesses on session shutdown so reloads don't leak procs.
|
// Tear down clients (stdio subprocesses, remote sessions) on session shutdown.
|
||||||
pi.on("session_shutdown", async () => {
|
pi.on("session_shutdown", async () => {
|
||||||
for (const c of clients) c.stop();
|
await Promise.allSettled(clients.map((c) => Promise.resolve(c.stop())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /mcp slash command ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VAL_ON = "● enabled";
|
||||||
|
const VAL_OFF = "○ disabled";
|
||||||
|
const VAL_RO = "[read-only]";
|
||||||
|
|
||||||
|
function statusLabel(state: ServerRuntimeState | undefined, configEnabled: boolean): string {
|
||||||
|
if (!state) return configEnabled ? "unknown" : "disabled";
|
||||||
|
switch (state.kind) {
|
||||||
|
case "running":
|
||||||
|
return `running · ${state.toolCount} tool${state.toolCount === 1 ? "" : "s"}`;
|
||||||
|
case "failed":
|
||||||
|
return `failed: ${state.message}`;
|
||||||
|
case "disabled":
|
||||||
|
return "disabled in settings";
|
||||||
|
case "invalid":
|
||||||
|
return `invalid: ${state.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerMcpCommand(pi: ExtensionAPI, runtime: Map<string, ServerRuntimeState>) {
|
||||||
|
pi.registerCommand("mcp", {
|
||||||
|
description: "List and toggle MCP servers configured in ~/.pi/agent/settings.json",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const servers = readMcpServers();
|
||||||
|
const names = Object.keys(servers);
|
||||||
|
if (names.length === 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`No MCP servers configured. Add an \`mcp\` block to ${SETTINGS_PATH}.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot config-level enabled state (default true if unset).
|
||||||
|
const configEnabled = new Map<string, boolean>();
|
||||||
|
for (const [n, cfg] of Object.entries(servers)) {
|
||||||
|
configEnabled.set(n, cfg.enabled !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged state: name → enabled (initialised from configEnabled).
|
||||||
|
const staged = new Map(configEnabled);
|
||||||
|
|
||||||
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||||
|
let statusText = "";
|
||||||
|
|
||||||
|
const items: SettingItem[] = names.map((n) => {
|
||||||
|
const cfg = servers[n];
|
||||||
|
const cfgEn = configEnabled.get(n) ?? true;
|
||||||
|
const state = runtime.get(n);
|
||||||
|
const isRemote = cfg.type === "remote";
|
||||||
|
const isInvalid = state?.kind === "invalid";
|
||||||
|
const transport = isRemote ? "remote" : "local";
|
||||||
|
const desc = `${transport} · ${statusLabel(state, cfgEn)}`;
|
||||||
|
|
||||||
|
if (isInvalid) {
|
||||||
|
return {
|
||||||
|
id: n,
|
||||||
|
label: n,
|
||||||
|
currentValue: VAL_RO,
|
||||||
|
description: desc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: n,
|
||||||
|
label: n,
|
||||||
|
currentValue: cfgEn ? VAL_ON : VAL_OFF,
|
||||||
|
values: [VAL_ON, VAL_OFF],
|
||||||
|
description: desc,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = new Container();
|
||||||
|
|
||||||
|
container.addChild({
|
||||||
|
render(_w: number) {
|
||||||
|
return [
|
||||||
|
theme.fg("accent", theme.bold("MCP servers — settings.json `mcp` block")),
|
||||||
|
theme.fg(
|
||||||
|
"muted",
|
||||||
|
" space: toggle (stage) · enter: apply + reload · esc: cancel",
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
},
|
||||||
|
invalidate() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsList = new SettingsList(
|
||||||
|
items,
|
||||||
|
Math.min(items.length + 2, 20),
|
||||||
|
getSettingsListTheme(),
|
||||||
|
(id, newValue) => {
|
||||||
|
const isInvalid = runtime.get(id)?.kind === "invalid";
|
||||||
|
if (isInvalid) {
|
||||||
|
settingsList.updateValue(id, VAL_RO);
|
||||||
|
statusText = `⊘ ${id}: invalid config — fix in settings.json`;
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stagingEnabled = newValue === VAL_ON;
|
||||||
|
staged.set(id, stagingEnabled);
|
||||||
|
|
||||||
|
const drift: string[] = [];
|
||||||
|
for (const n of names) {
|
||||||
|
const s = staged.get(n);
|
||||||
|
const c = configEnabled.get(n);
|
||||||
|
if (s !== undefined && s !== c) drift.push(`${n}→${s ? "on" : "off"}`);
|
||||||
|
}
|
||||||
|
statusText = drift.length ? `pending: ${drift.join(", ")}` : "";
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
done(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addChild(settingsList);
|
||||||
|
|
||||||
|
container.addChild({
|
||||||
|
render(_w: number) {
|
||||||
|
if (!statusText) return [""];
|
||||||
|
const colored = statusText.startsWith("⊘")
|
||||||
|
? theme.fg("warning", statusText)
|
||||||
|
: theme.fg("muted", statusText);
|
||||||
|
return ["", colored];
|
||||||
|
},
|
||||||
|
invalidate() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(width: number) {
|
||||||
|
return container.render(width);
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
container.invalidate();
|
||||||
|
},
|
||||||
|
handleInput(data: string) {
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let applied = 0;
|
||||||
|
for (const n of names) {
|
||||||
|
const s = staged.get(n);
|
||||||
|
const c = configEnabled.get(n);
|
||||||
|
if (s === undefined || s === c) continue;
|
||||||
|
try {
|
||||||
|
setServerEnabled(n, s);
|
||||||
|
applied++;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(`${n}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done(undefined);
|
||||||
|
if (errors.length) {
|
||||||
|
ctx.ui.notify(`mcp: ${errors.join(" | ")}`, "error");
|
||||||
|
} else if (applied > 0) {
|
||||||
|
ctx.ui.notify(`mcp: applied ${applied} change(s); reloading…`, "info");
|
||||||
|
ctx.reload().catch((err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
ctx.ui.notify(`reload failed: ${msg}`, "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settingsList.handleInput?.(data);
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* otherwise → OSC 777 (iTerm2, WezTerm, Ghostty, rxvt-unicode)
|
* otherwise → OSC 777 (iTerm2, WezTerm, Ghostty, rxvt-unicode)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
// ── Notification backends ─────────────────────────────────────────────────────
|
// ── Notification backends ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { spawn } from "node:child_process";
|
|||||||
import { writeFile, unlink } from "node:fs/promises";
|
import { writeFile, unlink } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
type BashOperations,
|
type BashOperations,
|
||||||
createBashTool,
|
createBashTool,
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
type EditOperations,
|
type EditOperations,
|
||||||
type ReadOperations,
|
type ReadOperations,
|
||||||
type WriteOperations,
|
type WriteOperations,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -10,9 +10,9 @@
|
|||||||
* correct for that point in history.
|
* correct for that point in history.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
import { StringEnum } from "@earendil-works/pi-ai";
|
||||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||||
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
interface Todo {
|
interface Todo {
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ link_into_repo() {
|
|||||||
require_pi_installed() {
|
require_pi_installed() {
|
||||||
if [[ ! -d "$PI_AGENT_DIR" ]]; then
|
if [[ ! -d "$PI_AGENT_DIR" ]]; then
|
||||||
err "pi not detected at $PI_AGENT_DIR"
|
err "pi not detected at $PI_AGENT_DIR"
|
||||||
printf ' Install pi first: https://github.com/mariozechner/pi-coding-agent\n'
|
printf ' Install pi first: https://github.com/earendil-works/pi\n'
|
||||||
printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n'
|
printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n'
|
||||||
exit 4
|
exit 4
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user