Retire mcp-loader.ts in favour of pi's built-in MCP support

pi has had MCP as a built-in extension (builtin:mcp) since 0.99.0. It reads
~/.pi/agent/mcp.json and adds `pi mcp add|list|login`, a /mcp manager,
OAuth, per-tool exposure and resources. An extension that registers /mcp
REPLACES the built-in, so with mcp-loader installed pi never read mcp.json
and printed "registers command /mcp, so built-in extension mcp was not
loaded" on every start.

Measured on pi 1.1.0 (pi-devbox v1.11.0, tor-ms22) before removing it:
- time to the (auto) footer in a real pty, 7 interleaved runs each:
  -ne 0.276 s | -ne -e mcp-loader.ts 0.283 s | -ne -e builtin:mcp 0.272 s
- with no servers, the same prompt costs 79 input + 7680 cached tokens with
  the loader, the built-in, or neither
- a stdio server (server-everything) connected via `pi mcp list` in 3 s,
  and its echo tool ran end to end from `pi -p` with direct exposure
- no device in the fleet had an `mcp` block, so nothing needs migrating

install.sh now prunes links into this repo whose target is gone (.ts and
.ts.off), so the stale mcp-loader.ts link on a preserved pi-devbox volume is
removed on the first boot of an image without the file. It also warns once
if settings.json still carries an `mcp` block. Tested two-sided in a temp
HOME: the new script removes the repo link and a stale .ts.off, and keeps a
foreign dangling link and a real file. The old script leaves the dangling
link. Re-runs are idempotent, and there is no warning without an mcp block.

mempalace.ts (mempalace-toolkit) is unaffected: it registers mempalace_*
tools itself. Its vendored RemoteMcpClient loses its sync partner; that is
handled in mempalace-toolkit.
This commit is contained in:
2026-10-13 00:15:57 +02:00
parent a88fa99d39
commit 750b2a89c8
5 changed files with 100 additions and 988 deletions
+32 -88
View File
@@ -23,7 +23,6 @@ extensions/
notify.ts # Native terminal notification when agent finishes
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)
mcp-loader.ts # Generic MCP server loader + /mcp slash command
task.ts # `task` tool — pi-task (isolated child, PASS/FAIL envelope, boundary diff) as a tool
fork-gate.ts # tool_call hook: block forks whose brief needs `task`; reason = the task(...) to make
aws-sso.ts # /aws-sso: renew the AWS SSO session when it ends (local: host browser; remote: device code + ntfy push)
@@ -68,7 +67,14 @@ LICENSE # MIT.
Creates `~/.pi/agent/extensions/` proactively.
2. Calls `build_install_set` to resolve which extensions to install based on
`--only` / `--skip` flags (default: all `.ts` files in `extensions/`).
3. For each selected extension: symlinks `extensions/<name>.ts` →
3. `prune_stale_links` (after the confirmation prompt) — removes links in
`~/.pi/agent/extensions/` (`*.ts` and `*.ts.off`) that point into THIS repo
and whose target no longer exists. That is how a retired extension leaves a
preserved pi-devbox volume: the link outlives the file in every new image.
Foreign links and real files are never touched. `warn_retired_mcp_block`
(before the prompt, read-only) warns if `settings.json` still has the
retired loader's top-level `mcp` key.
4. For each selected extension: symlinks `extensions/<name>.ts` →
`~/.pi/agent/extensions/<name>.ts`. Backs up any pre-existing real file or
foreign symlink.
@@ -345,97 +351,35 @@ cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@earendil-wor
# review diff, then commit
```
### `mcp-loader.ts`
### `mcp-loader.ts` — RETIRED 2026-10-12
Generic MCP client — reads an `mcp` block from `~/.pi/agent/settings.json`
and connects to each declared server (stdio subprocess or streamable-HTTP
endpoint), exposing the tools as namespaced pi tools.
Removed in favour of pi's built-in MCP support (`builtin:mcp`, pi ≥ 0.99.0:
`~/.pi/agent/mcp.json`, `pi mcp add|list|login`, the `/mcp` manager, OAuth,
per-tool `exposure`, resources). Reasons, measured on pi 1.1.0 before removal
(README "MCP servers" has the numbers):
**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
`mempalace.ts` does it) doesn't scale past 2-3 servers. This loader is the
config-driven generalization — one extension, any number of servers, two
transports.
- **The two could not coexist.** pi treats an extension that registers `/mcp`
as a *replacement* for the built-in, so with the loader installed `mcp.json`
was never read and every start printed `… registers command /mcp, so
built-in extension mcp was not loaded`.
- **No cost to switching.** Time-to-ready is the same within noise (0.283 s
vs 0.272 s), and with no servers configured the built-in adds zero tokens
to a request.
- **The fleet had no `mcp` block anywhere** (checked on tor-ms22; all pi-devbox
devices share one configuration), so nothing needed migrating.
**Key design decisions:**
What survives elsewhere: `mempalace-toolkit/extensions/pi/mempalace.ts`
carries a vendored copy of the loader's `RemoteMcpClient` (streamable-HTTP).
That copy is now the only one, so there is no longer a sync partner for its
`MCP-STREAMABLE-HTTP-CLIENT-SYNC` token. The design notes below the old
loader's header — tool-name sanitisation for Bedrock, fail-soft per server,
404 session re-init — live on in git history (`git show a88fa99:extensions/mcp-loader.ts`).
- **Settings.json shape matches opencode / Claude Desktop verbatim.** A user
can copy-paste their `mcp` block from one harness's config to the other.
- **Tool names are prefixed with the server name** to avoid collisions when
multiple servers expose tools with the same short name. Tools that already
start with `<servername>_` (the convention some servers like mempalace
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
handshake fails, remote 4xx/5xx) logs one stderr line and is skipped.
Other servers continue. Pi keeps working.
- **Two transports behind one interface.** `IMcpClient` (`start`, `tools`,
`callTool`, `stop`) is implemented by `StdioMcpClient` (subprocess +
newline-delimited JSON-RPC) and `RemoteMcpClient` (HTTP POST + JSON or
SSE response). The extension entry dispatches on `cfg.type` and the
rest of the code is transport-agnostic.
- **Does not replace `mempalace.ts`.** The mempalace bridge has bespoke
agent-identity injection from `$MEMPALACE_AGENT_NAME` that's worth
preserving. Loader and bridge coexist. Listing `mempalace` in the `mcp`
block would produce duplicate tool registrations — don't.
- **No stdio reconnect on death.** If a stdio subprocess crashes
mid-session, its tools become permanently unavailable until pi `/reload`s.
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.
Do **not** reintroduce an extension that registers `/mcp`, `codemode` or
`tool_search` without meaning to replace the built-in: pi then drops the
built-in of the same name, with nothing but one startup warning to show for it.
**API used:**
- `pi.registerTool({ name, label, description, parameters, execute })` for
each MCP tool exposed by each connected server.
- `pi.on("session_shutdown", ...)` to SIGTERM stdio subprocesses and DELETE
remote sessions cleanly.
- `Type.Unsafe<...>(inputSchema)` from typebox to pass the MCP server's
JSON schema through to pi without conversion (TypeBox schemas are
plain JSON Schema at runtime).
**Internal MCP clients:**
- `class StdioMcpClient` — spawn subprocess, write newline-delimited
JSON-RPC, parse newline-delimited responses, match by request `id`.
Stderr drained silently unless `PI_MCP_LOADER_DEBUG=1`.
- `class RemoteMcpClient` — streamable-HTTP per MCP spec 2025-03-26.
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 extensions:**
- 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.
`"mcp.searxng.tools": ["web_search"]` to expose only a subset).
- Reconnect-on-crash with exponential backoff.
- Schema sanitization for MCP servers that emit malformed `inputSchema`
(some return `{type: "object", properties: {...}}` without `required`).
## Testing
No framework. Manual:
+27 -57
View File
@@ -17,7 +17,7 @@ chmod +x install.sh
./install.sh
```
Each `.ts` file in `extensions/` is symlinked into `~/.pi/agent/extensions/`. Existing real files are backed up with a timestamp. Re-runs are idempotent.
Each `.ts` file in `extensions/` is symlinked into `~/.pi/agent/extensions/`. Existing real files are backed up with a timestamp. Re-runs are idempotent. A link into this repo whose file is gone (an extension that was retired, like `mcp-loader.ts`) is removed.
## Bundled skill
@@ -335,72 +335,42 @@ State lives in the session's tool result `details`, not an external file. So:
This is a verbatim copy of the upstream `examples/extensions/todo.ts` shipped with `pi-coding-agent`. Refresh from upstream when desired (see `AGENTS.md`).
### `mcp-loader.ts`
### MCP servers — use pi's built-in support (`mcp-loader.ts` is retired)
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).
`mcp-loader.ts` was removed on 2026-10-12. pi has shipped MCP support as a built-in extension since 0.99.0, and it covers everything the loader did and more:
**Settings.json shape:**
| | `mcp-loader.ts` (retired) | pi built-in (`builtin:mcp`) |
|---|---|---|
| Config | `mcp` block in `settings.json` | `~/.pi/agent/mcp.json` (`mcpServers`, Claude Desktop shape); per-project `.pi/mcp.json` after trust |
| Manage | `/mcp` toggle overlay | `/mcp` manager (inspect tools, reconnect, sign in, enable/disable, exposure), `pi mcp add/remove/list/login/logout` from a shell |
| Auth | pre-issued bearer token in `headers` | headers with `${ENV}` / `!command` values, **and OAuth** (dynamic registration, CIMD, refresh) |
| Startup | awaited every server's `tools/list` while extensions loaded | connects in the background at session start; the first prompt waits (up to 10 s) only for servers with `direct` tools |
| Tools | every tool declared to the model as `<server>_<tool>` | `mcp__<server>__<tool>`; per-server/per-tool `exposure`: `codemode` (default), `deferred`, `direct`, `hidden` |
| Also | — | resources (`list_mcp_resources`, `read_mcp_resource`), per-request `timeout`, reconnect on next call, `mcp.log` |
```jsonc
Measured before retiring it (pi 1.1.0, pi-devbox v1.11.0, 7 interleaved runs each, time to pi's `(auto)` footer in a real pty): `-ne` 0.276 s, `-ne -e mcp-loader.ts` 0.283 s, `-ne -e builtin:mcp` 0.272 s. With no servers configured, the built-in adds nothing to a request: the same prompt cost 79 input + 7680 cached tokens with either loader, and with neither. A stdio server (`npx -y @modelcontextprotocol/server-everything`) connected in 3 s via `pi mcp list` and its `echo` tool was called end to end from `pi -p`.
**Why remove it rather than keep both:** an extension that registers `/mcp` *replaces* the built-in, so with the loader installed pi never read `mcp.json` and printed `Extension … registers command /mcp, so built-in extension mcp was not loaded` on every start.
**Moving an old `mcp` block** to `~/.pi/agent/mcp.json` (pi `docs/mcp.md`, "Migrate configuration from another client"):
```json
{
// … existing pi settings …
"mcp": {
"searxng": {
"type": "local",
"command": ["uvx", "mcp-searxng"],
"env": { "SEARXNG_URL": "https://searxng.your-host.lan" }
},
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio"],
"enabled": false,
"env": { "GITEA_ACCESS_TOKEN": "...", "GITEA_HOST": "https://gitea.example.com" }
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp"
}
"mcpServers": {
"searxng": { "command": "uvx", "args": ["mcp-searxng"], "env": { "SEARXNG_URL": "https://searxng.your-host.lan" }, "exposure": "direct" },
"context7": { "url": "https://mcp.context7.com/mcp", "headers": { "Authorization": "Bearer ${CONTEXT7_TOKEN}" } }
}
}
```
**Per-server keys:**
- `"type": "local"` + `"command": [exe, ...args]` becomes `"command": exe` + `"args": [...]`. `"type": "remote"` becomes a `url` entry.
- Set `"exposure": "direct"` to keep the loader's behaviour of declaring every tool to the model. The built-in default, `codemode`, makes tools reachable only from `codemode` scripts.
- Tool names change from `<server>_<tool>` to `mcp__<server>__<tool>`. Non-`[A-Za-z0-9_]` characters still become `_`, so the names stay valid for AWS Bedrock.
- `pi mcp list` validates the file and connects every enabled server.
| Key | Description |
|---|---|
| `type` | `"local"` (stdio subprocess) or `"remote"` (streamable-http). Default `"local"`. |
| `command` | Argv array. First element is the executable, rest are args. Local 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. |
| `env` | Optional object of env vars injected into the subprocess. Inherits parent env first, then overlays these keys. Local servers only. |
`install.sh` now removes the stale `~/.pi/agent/extensions/mcp-loader.ts` link (and any other link into this repo whose file is gone). It warns once if `settings.json` still has an `mcp` block.
**Transports:**
- `local` — stdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, mcp-server-time…).
- `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.
**Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs.
**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.
---
**MemPalace is unaffected.** `mempalace.ts` (mempalace-toolkit) runs its own MCP client and registers `mempalace_*` tools directly, so `exposure` never applies to them. Don't add MemPalace to `mcp.json`: you would get a second, `mcp__mempalace__*`, copy of every tool.
### `aws-sso.ts`
-842
View File
@@ -1,842 +0,0 @@
/**
* MCP Loader Extension
*
* Reads an `mcp` block from pi's 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.
*
* Settings.json shape:
*
* {
* "mcp": {
* "searxng": {
* "type": "local",
* "command": ["uvx", "mcp-searxng"],
* "env": { "SEARXNG_URL": "https://searxng.your-host.lan" }
* },
* "gitea": {
* "type": "local",
* "command": ["gitea-mcp", "-t", "stdio"],
* "enabled": false,
* "env": { "GITEA_ACCESS_TOKEN": "...", "GITEA_HOST": "..." }
* },
* "context7": {
* "type": "remote",
* "url": "https://mcp.context7.com/mcp",
* "headers": { "Authorization": "Bearer …" }
* }
* }
* }
*
* Per-server keys:
* type "local" (stdio subprocess) | "remote" (streamable-http)
* command argv array — first element is the executable, rest are args.
* For local 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
* removing it from settings.json.
* env optional object of environment variables to inject into
* the spawned subprocess. Inherits parent env first, then
* overlays these keys. Local only.
*
* Tool naming
*
* Each MCP tool is registered with pi as `<server-name>_<tool-name>` to
* avoid collisions across servers. If the tool name already begins with
* `<server-name>_` (e.g. mempalace's tools are `mempalace_search` etc.)
* the prefix is not duplicated.
*
* Lifecycle
*
* • Servers are connected at extension load time (pi startup or /reload).
* • Each server's `tools/list` is awaited before pi finishes registering.
* • Local subprocess stderr is silenced unless PI_MCP_LOADER_DEBUG=1.
* • Local subprocesses receive SIGTERM at session_shutdown; remote
* servers receive an MCP DELETE if a session id was issued.
* • A server that fails to start (binary missing, init handshake error,
* remote 4xx/5xx) logs a single line to stderr and is skipped. Other
* servers continue. Pi keeps working without that server's tools.
*
* Transports
*
* local — stdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, …).
* 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 header round-tripped if the server issues one.
* No GET-stream subscription (we don't consume server-initiated
* 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
*
* The mempalace.ts extension already spawns mempalace-mcp directly with
* bespoke handling (agent identity injection from $MEMPALACE_AGENT_NAME,
* wake-up context). This loader does NOT touch mempalace — leave the
* bespoke extension in place. If you also list mempalace under the `mcp`
* block in settings.json the loader will register a parallel set of
* 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 * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
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";
// ── MCP types ────────────────────────────────────────────────────────────────
type McpTool = {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
};
type LocalServerConfig = {
type?: "local";
command: string[];
env?: Record<string, string>;
enabled?: boolean;
};
type RemoteServerConfig = {
type: "remote";
url: string;
headers?: Record<string, string>;
enabled?: boolean;
};
type ServerConfig = LocalServerConfig | RemoteServerConfig;
const SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
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) {
if (DEBUG) process.stderr.write(`[mcp-loader] ${msg}\n`);
}
function warn(msg: string) {
process.stderr.write(`[mcp-loader] ${msg}\n`);
}
// ── Settings.json reader ─────────────────────────────────────────────────────
function readMcpServers(): Record<string, ServerConfig> {
if (!fs.existsSync(SETTINGS_PATH)) {
dlog(`no settings.json at ${SETTINGS_PATH}`);
return {};
}
let raw: string;
try {
raw = fs.readFileSync(SETTINGS_PATH, "utf8");
} catch (err) {
warn(`could not read ${SETTINGS_PATH}: ${(err as Error).message}`);
return {};
}
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (err) {
warn(`settings.json parse error: ${(err as Error).message}`);
return {};
}
const block = parsed?.mcp;
if (!block || typeof block !== "object") {
dlog("no `mcp` block in settings.json");
return {};
}
return block as Record<string, ServerConfig>;
}
// ── Stdio MCP client ─────────────────────────────────────────────────────────
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 nextId = 1;
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
private stdoutBuf = "";
public readonly serverName: string;
private command: string;
private args: string[];
private env: Record<string, string> | undefined;
public tools: McpTool[] = [];
constructor(serverName: string, command: string, args: string[], env?: Record<string, string>) {
this.serverName = serverName;
this.command = command;
this.args = args;
this.env = env;
}
async start(): Promise<void> {
const childEnv = { ...process.env, ...(this.env ?? {}) };
this.proc = spawn(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
this.proc.on("error", (err) => this.failAll(err));
this.proc.on("exit", (code, signal) => {
this.failAll(new Error(`${this.serverName}: subprocess exited (code=${code}, signal=${signal})`));
});
this.proc.stdout.setEncoding("utf8");
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
this.proc.stderr.setEncoding("utf8");
if (DEBUG) {
this.proc.stderr.on("data", (chunk: string) => {
process.stderr.write(`[${this.serverName} stderr] ${chunk}`);
});
} else {
this.proc.stderr.resume(); // drain without logging
}
// MCP initialize handshake
await this.request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
});
this.notify("notifications/initialized", {});
const listed = await this.request("tools/list", {});
this.tools = (listed?.tools as McpTool[]) ?? [];
}
private onStdout(chunk: string) {
this.stdoutBuf += chunk;
let nl: number;
while ((nl = this.stdoutBuf.indexOf("\n")) !== -1) {
const line = this.stdoutBuf.slice(0, nl).trim();
this.stdoutBuf = this.stdoutBuf.slice(nl + 1);
if (!line) continue;
let msg: any;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message ?? "MCP error"));
else resolve(msg.result);
}
// notifications (no id) ignored.
}
}
private failAll(err: Error) {
for (const { reject } of this.pending.values()) reject(err);
this.pending.clear();
}
private write(obj: unknown) {
if (!this.proc) throw new Error(`${this.serverName}: not started`);
this.proc.stdin.write(`${JSON.stringify(obj)}\n`);
}
private notify(method: string, params: unknown) {
this.write({ jsonrpc: "2.0", method, params });
}
request(method: string, params: unknown): Promise<any> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.write({ jsonrpc: "2.0", id, method, params });
});
}
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
return this.request("tools/call", { name, arguments: args });
}
stop() {
if (this.proc) {
try {
this.proc.kill("SIGTERM");
} catch {}
this.proc = null;
}
}
}
// ── Streamable-HTTP MCP client (remote) ──────────────────────────────────────
//
// MCP-STREAMABLE-HTTP-CLIENT-SYNC: v1
// Canonical copy. This class is VENDORED into
// mempalace-toolkit/extensions/pi/mempalace.ts (adapted: pinned protocolVersion,
// AbortController timeouts, alive/ensureAlive). When you change the
// streamable-HTTP protocol handling here, bump the SYNC token in BOTH files.
// mempalace-toolkit/scripts/check-mcp-client-sync.sh enforces the match.
//
// 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 ──────────────────────────────────────
function extractText(mcpResult: any): string {
if (!mcpResult) return "";
const content = mcpResult.content;
if (!Array.isArray(content)) return JSON.stringify(mcpResult);
return content
.map((c: any) => {
if (c?.type === "text" && typeof c.text === "string") return c.text;
return JSON.stringify(c);
})
.join("\n");
}
function namespacedToolName(serverName: string, toolName: string): string {
// Avoid double-prefix: if the MCP server already names its tools
// `<serverName>_<...>`, leave them alone.
const prefix = `${serverName}_`;
const raw = toolName.startsWith(prefix) ? toolName : `${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 ──────────────────────────────────────────────────────────
export default async function mcpLoaderExtension(pi: ExtensionAPI) {
const servers = readMcpServers();
const runtime = new Map<string, ServerRuntimeState>();
const clients: IMcpClient[] = [];
// 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;
}
for (const [name, cfg] of Object.entries(servers)) {
if (cfg.enabled === false) {
dlog(`${name}: disabled in settings.json, skipping`);
runtime.set(name, { kind: "disabled" });
continue;
}
let client: IMcpClient;
if (cfg.type === "remote") {
const remote = cfg as RemoteServerConfig;
if (typeof remote.url !== "string" || remote.url.length === 0) {
warn(`${name}: remote server missing \`url\`, skipping`);
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);
}
try {
await client.start();
} catch (err) {
const message = (err as Error).message;
warn(`${name}: failed to start — ${message}`);
runtime.set(name, { kind: "failed", message });
try { await client.stop(); } catch {}
continue;
}
dlog(`${name}: connected, ${client.tools.length} tools`);
runtime.set(name, { kind: "running", toolCount: client.tools.length });
clients.push(client);
// Register each MCP tool as a pi tool.
for (const tool of client.tools) {
const piName = namespacedToolName(name, tool.name);
const schema =
tool.inputSchema && typeof tool.inputSchema === "object"
? (Type.Unsafe<Record<string, unknown>>(
tool.inputSchema as object,
) as unknown as ReturnType<typeof Type.Object>)
: Type.Object({}, { additionalProperties: true });
pi.registerTool({
name: piName,
label: piName,
description: tool.description ?? `MCP tool from ${name}`,
parameters: schema,
async execute(_toolCallId, params) {
try {
const result = await client.callTool(tool.name, params as Record<string, unknown>);
const text = extractText(result);
return {
content: [{ type: "text", text }],
details: { server: name, tool: tool.name, raw: result },
};
} catch (err) {
return {
content: [
{
type: "text",
text: `MCP call failed (${name}/${tool.name}): ${(err as Error).message}`,
},
],
details: { server: name, tool: tool.name, error: (err as Error).message },
isError: true,
};
}
},
});
}
}
// Tear down clients (stdio subprocesses, remote sessions) on session shutdown.
pi.on("session_shutdown", async () => {
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();
},
};
});
},
});
}
+38
View File
@@ -120,6 +120,41 @@ build_install_set() {
fi
}
# ── stale links ──────────────────────────────────────
# An extension removed from this repo leaves its symlink behind in
# ~/.pi/agent/extensions/ — on a pi-devbox volume that link survives every
# recreate, pointing into an image that no longer has the file. Remove links
# that point into THIS repo and whose target is gone (.ts and the /ext-disabled
# .ts.off alike). Links into other repos and real files are never touched.
prune_stale_links() {
local dest pattern
for pattern in "*.ts" "*.ts.off"; do
for dest in "${EXTENSIONS_DEST}"/$pattern; do
[[ -L "$dest" ]] || continue
[[ -e "$dest" ]] && continue # target still exists
link_into_repo "$dest" || continue # someone else's link
rm "$dest"
ok "Removed stale $(basename "$dest") (no longer in this repo)"
done
done
}
# mcp-loader.ts was retired in favour of pi's built-in MCP support. Its config
# lived in settings.json under a top-level `mcp` key, which pi ignores; say so
# once instead of letting those servers disappear silently.
warn_retired_mcp_block() {
local settings="${PI_AGENT_DIR}/settings.json" has=""
[[ -f "$settings" ]] || return 0
if command -v jq >/dev/null 2>&1; then
has=$(jq -r 'if type == "object" and has("mcp") then "yes" else "" end' "$settings" 2>/dev/null || true)
elif command -v python3 >/dev/null 2>&1; then
has=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("yes" if isinstance(d, dict) and "mcp" in d else "")' "$settings" 2>/dev/null || true)
fi
[[ "$has" == "yes" ]] || return 0
warn "${settings} has an 'mcp' block, but mcp-loader.ts is retired and pi ignores it."
warn " Move those servers to ~/.pi/agent/mcp.json (pi's built-in MCP; see README 'MCP servers')."
}
# ── install ──────────────────────────────────────────
do_install() {
echo
@@ -128,6 +163,7 @@ do_install() {
echo
require_pi_installed
warn_retired_mcp_block
build_install_set
if [[ -z "$INSTALL_SET" ]]; then
@@ -143,6 +179,8 @@ do_install() {
confirm || { echo "Aborted."; exit 0; }
echo
prune_stale_links
for src in "${EXTENSIONS_SRC}"/*.ts; do
[[ -e "$src" ]] || continue
local name
+3 -1
View File
@@ -23,11 +23,13 @@ Pi has **two distinct extension locations** and it's easy to look in the wrong o
| Location | Mechanism | Examples |
|---|---|---|
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `ext-toggle`, `task`, `fork-gate`, `secret-guard`, `aws-sso`, `confirm-destructive` |
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions (git-installed)** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
| `~/.pi/agent/npm/node_modules/<pkg>/` | **Package extensions (npm-installed)** — `pi install npm:<pkg>`; recorded in `packages[]` as `npm:<pkg>`. | `pi-atelier` (status rail + sidebar TUI) |
| `/opt/<pkg>/` — **pi-devbox containers only** | **Vendored package extensions** — cloned into an image layer at build time with `node_modules` baked, then registered at container start by `entrypoint-user.sh` via `pi install /opt/<pkg>`. Recorded in `packages[]` as a **relative** path (`../../../../opt/pi-fork`) that resolves out of `~/.pi/agent` into the image layer, so it survives volume recreate. | `/opt/pi-fork`, `/opt/pi-observational-memory`, `/opt/pi-studio` |
**MCP servers are not an extension here.** pi's built-in MCP support (`builtin:mcp`) reads `~/.pi/agent/mcp.json`; manage it with `/mcp` or `pi mcp add|list`. Its tools are named `mcp__<server>__<tool>` and default to `codemode` exposure (not declared to the model) unless the server sets `"exposure": "direct"`. The `mempalace_*` tools are different: `mempalace.ts` registers them itself, so exposure never applies. The old `mcp-loader.ts` (an `mcp` block in `settings.json`) was retired on 2026-10-12, because an extension that registers `/mcp` replaces the built-in.
When the user asks how to use "the X extension", **check all of these** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers the first three, and `ls -d /opt/*X*` the fourth. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
**In a pi-devbox container, do not conclude "pi-fork isn't installed" because `~/.pi/agent/git/` is empty.** It is deliberately absent: `Dockerfile.variant` vendors to `/opt` and installs by local path, because a build-time `pi install git:...` would write into `~/.pi/agent`, which the named volume then shadows on first run.