pi-mcp-extension 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 irahardianto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,237 @@
1
+ # pi-mcp
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-mcp.svg)](https://www.npmjs.com/package/pi-mcp)
4
+ [![license](https://img.shields.io/npm/l/pi-mcp.svg)](https://github.com/irahardianto/pi-mcp/blob/main/LICENSE)
5
+
6
+ **Connect [Pi](https://pi.dev) to any MCP server.** Supabase, DeepSource, Playwright, Context7, filesystem, databases — if it speaks [MCP](https://modelcontextprotocol.io), Pi can use it.
7
+
8
+ `pi-mcp` is a production-ready [Model Context Protocol](https://modelcontextprotocol.io/) client extension for the [Pi coding agent](https://pi.dev). It manages server connections, discovers tools, and bridges them into Pi so the LLM can call them directly.
9
+
10
+ ## Features
11
+
12
+ - **Multi-transport** — `stdio` subprocesses, `streamable-http`, and legacy `sse` out of the box
13
+ - **Auto-discovery** — Paginated `tools/list` with cursor following (MCP 2025-03-26 spec)
14
+ - **Live tool refresh** — `notifications/tools/list_changed` triggers re-discovery without restart
15
+ - **Clean cancellation** — `AbortSignal` propagation via `notifications/cancelled`
16
+ - **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries
17
+ - **Health checks** — Opt-in periodic pings to detect stale connections
18
+ - **Safe process lifecycle** — PID tracking with safety-net `SIGKILL` for orphaned subprocesses
19
+ - **Stable tool names** — Sanitized, collision-avoiding names (max 64 chars) that stay constant across reconnects
20
+ - **No tool churn** — Activate/deactivate pattern ensures tools don't flicker during reconnection
21
+ - **Global + project config** — Layered config (project overrides global) with per-server tuning
22
+ - **Tool annotations** — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
23
+ - **Structured error handling** — Distinct error codes for config, connection, protocol, and tool-level failures
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pi install npm:pi-mcp
29
+ ```
30
+
31
+ Or try without installing:
32
+
33
+ ```bash
34
+ pi -e npm:pi-mcp
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ 1. Install the extension:
40
+ ```bash
41
+ pi install npm:pi-mcp
42
+ ```
43
+
44
+ 2. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
45
+ ```jsonc
46
+ {
47
+ "mcpServers": {
48
+ "supabase": {
49
+ "transport": "streamable-http",
50
+ "url": "https://mcp.supabase.com/mcp",
51
+ "lifecycle": "eager"
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ 3. Start Pi — your MCP tools are ready. Use `/mcp` to check server status.
58
+
59
+ ## Configuration
60
+
61
+ Config files are loaded from two locations. **Project config overrides global config** via shallow merge (server-level replacement).
62
+
63
+ | Location | Scope |
64
+ |---|---|
65
+ | `~/.pi/agent/mcp.json` | Global — applies to all projects |
66
+ | `.pi/mcp.json` | Project — overrides global per-server |
67
+
68
+ ### Full Example
69
+
70
+ ```jsonc
71
+ {
72
+ "settings": {
73
+ "toolPrefix": "mcp", // Tools appear as mcp_<server>_<tool> (default: "mcp")
74
+ "requestTimeoutMs": 30000, // Per-request timeout in ms (default: 30000)
75
+ "maxRetries": 5 // Max reconnection attempts, 0-10 (default: 5)
76
+ },
77
+ "mcpServers": {
78
+
79
+ // ── HTTP-based servers ───────────────────────────────────────────────────
80
+ "supabase": {
81
+ "transport": "streamable-http",
82
+ "url": "https://mcp.supabase.com/mcp",
83
+ "lifecycle": "eager"
84
+ },
85
+ "deepsource": {
86
+ "transport": "streamable-http",
87
+ "url": "https://mcp.deepsource.io/mcp",
88
+ "lifecycle": "eager"
89
+ },
90
+
91
+ // ── Legacy SSE servers ───────────────────────────────────────────────────
92
+ "legacy-server": {
93
+ "transport": "sse",
94
+ "url": "https://example.com/sse",
95
+ "lifecycle": "lazy"
96
+ },
97
+
98
+ // ── stdio subprocess servers ─────────────────────────────────────────────
99
+ "pathfinder": {
100
+ "command": "/path/to/pathfinder-mcp",
101
+ "transport": "stdio",
102
+ "lifecycle": "eager"
103
+ },
104
+ "context7": {
105
+ "command": "npx",
106
+ "args": ["-y", "@context7/mcp"],
107
+ "transport": "stdio",
108
+ "lifecycle": "lazy"
109
+ },
110
+ "filesystem": {
111
+ "command": "npx",
112
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
113
+ "transport": "stdio",
114
+ "lifecycle": "lazy",
115
+ "env": {
116
+ "NODE_ENV": "production"
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### Global Settings
124
+
125
+ | Field | Type | Default | Description |
126
+ |---|---|---|---|
127
+ | `toolPrefix` | `string` | `"mcp"` | Prefix for registered tools: `<prefix>_<server>_<tool>`. Must match `^[a-zA-Z][a-zA-Z0-9_]*$`. |
128
+ | `requestTimeoutMs` | `number` | `30000` | Default per-request timeout in milliseconds |
129
+ | `maxRetries` | `number` | `5` | Max reconnection attempts (0–10) before giving up |
130
+
131
+ ### Per-Server Config
132
+
133
+ | Field | Type | Default | Description |
134
+ |---|---|---|---|
135
+ | `transport` | `"stdio" \| "streamable-http" \| "sse"` | `"stdio"` | Transport protocol |
136
+ | `command` | `string` | — | Executable to spawn (**required** for stdio) |
137
+ | `args` | `string[]` | `[]` | Arguments for the command |
138
+ | `env` | `Record<string, string>` | — | Extra environment variables for the child process |
139
+ | `url` | `string` | — | Server URL (**required** for streamable-http/sse) |
140
+ | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = manual via `/mcp:start` |
141
+ | `requestTimeoutMs` | `number` | global setting | Per-server timeout override |
142
+ | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval for connection health monitoring |
143
+
144
+ ## Commands
145
+
146
+ | Command | Description |
147
+ |---|---|
148
+ | `/mcp` | Show status summary of all configured servers |
149
+ | `/mcp <name>` | Show detailed status and stderr log for a specific server |
150
+ | `/mcp:start <name>` | Start a server (resets retry count) |
151
+ | `/mcp:stop <name>` | Stop a running server and deactivate its tools |
152
+
153
+ ## How It Works
154
+
155
+ ```
156
+ ┌─────────────────────────────────────────────────────────┐
157
+ │ Pi Agent │
158
+ │ │
159
+ │ ┌──────────┐ ┌────────────────┐ ┌─────────────┐ │
160
+ │ │ index.ts │───▶│ server-manager │───▶│ tool-bridge │ │
161
+ │ │ (entry) │ │ (lifecycle) │ │ (MCP → Pi) │ │
162
+ │ └──────────┘ └───────┬────────┘ └──────┬──────┘ │
163
+ │ │ │ │ │
164
+ │ ▼ ▼ ▼ │
165
+ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
166
+ │ │ config │ │ MCP SDK │ │ TypeBox │ │
167
+ │ │ (Zod) │ │ (transport) │ │ (schema) │ │
168
+ │ └──────────┘ └──────────────┘ └──────────────┘ │
169
+ └─────────────────────────────────────────────────────────┘
170
+ │ │
171
+ ▼ ▼
172
+ Global/Project ┌──────────────┐
173
+ mcp.json files │ MCP Servers │
174
+ │ (stdio/http) │
175
+ └──────────────┘
176
+ ```
177
+
178
+ 1. **Config is loaded** from global and project files (project overrides global by server name)
179
+ 2. **Eager servers connect** at session start; lazy servers wait for `/mcp:start`
180
+ 3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
181
+ 4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
182
+ 5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix)
183
+ 6. **Tools activate/deactivate** automatically as servers connect/disconnect
184
+ 7. **AbortSignal** is wired through to `notifications/cancelled` for clean cancellation
185
+ 8. **Reconnection** uses a fixed delay schedule with configurable max retries
186
+ 9. **`list_changed` notifications** trigger re-discovery, deactivating stale tools automatically
187
+ 10. **Session shutdown** cleanly stops all servers and deactivates all tools
188
+
189
+ ## MCP Spec Compliance
190
+
191
+ Implements the **MCP 2025-03-26** specification for tool clients:
192
+
193
+ | Feature | Status |
194
+ |---|---|
195
+ | Protocol version `2025-03-26` (+ `2024-11-05` fallback) | ✅ |
196
+ | `stdio` transport | ✅ |
197
+ | `streamable-http` transport | ✅ |
198
+ | `sse` transport (legacy backwards compat) | ✅ |
199
+ | Cursor-based `tools/list` pagination | ✅ |
200
+ | `tools/call` with parameters | ✅ |
201
+ | `roots/list` capability (workspace root) | ✅ |
202
+ | `notifications/tools/list_changed` (live refresh) | ✅ |
203
+ | `notifications/cancelled` (AbortSignal) | ✅ |
204
+ | `notifications/message` (structured logging) | ✅ |
205
+ | `isError: true` distinction (protocol vs tool errors) | ✅ |
206
+ | Tool annotations (`readOnlyHint`, `destructiveHint`, etc.) | ✅ |
207
+ | Resources bridge | ⏳ v2 |
208
+ | Prompts bridge | ⏳ v2 |
209
+ | Sampling | ⏳ v2 |
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ # Install dependencies
215
+ npm install
216
+
217
+ # Type check (strict mode)
218
+ npm run typecheck
219
+
220
+ # Run all tests (47 tests)
221
+ npm test
222
+
223
+ # Run integration tests only (real stdio server)
224
+ npm run test:integration
225
+ ```
226
+
227
+ ### Testing
228
+
229
+ The test suite includes:
230
+ - **Config validation** — Zod schema, global/project merge, defaults, error cases
231
+ - **Server lifecycle** — Connect, retry, shutdown, health check, race conditions
232
+ - **Tool bridge** — JSON Schema → TypeBox conversion, name sanitization, activate/deactivate
233
+ - **End-to-end integration** — Real mock MCP server over stdio with tool discovery and execution
234
+
235
+ ## License
236
+
237
+ MIT
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "pi-mcp-extension",
3
+ "version": "1.3.0",
4
+ "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "pi",
11
+ "pi-extension",
12
+ "ai-agent",
13
+ "tool-bridge"
14
+ ],
15
+ "author": "irahardianto",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/irahardianto/pi-mcp.git"
20
+ },
21
+ "homepage": "https://github.com/irahardianto/pi-mcp",
22
+ "bugs": {
23
+ "url": "https://github.com/irahardianto/pi-mcp/issues"
24
+ },
25
+ "files": [
26
+ "src/",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ]
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "zod": "^3.25.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@mariozechner/pi-ai": "*",
41
+ "@mariozechner/pi-coding-agent": "*",
42
+ "typebox": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.0.0",
46
+ "tsx": "^4.19.4",
47
+ "typescript": "^5.7.0"
48
+ },
49
+ "scripts": {
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "node --import tsx/esm --test ./tests/*.test.ts",
52
+ "test:integration": "node --import tsx/esm --test ./tests/server-manager.test.ts"
53
+ }
54
+ }
package/src/config.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Configuration loading, validation, and merging for pi-mcp.
3
+ *
4
+ * Config file locations (Pi-native convention, highest priority first):
5
+ * 1. <cwd>/.pi/mcp.json — project-level config
6
+ * 2. ~/.pi/agent/mcp.json — global config
7
+ *
8
+ * Project servers/settings override global servers/settings per-key (shallow merge).
9
+ * No deep merge, no env var interpolation — WYSIWYG config.
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { z } from "zod";
16
+ import { McpError } from "./errors.js";
17
+
18
+ // ─── Zod Schemas ──────────────────────────────────────────────────────────────
19
+
20
+ const ServerConfigSchema = z
21
+ .object({
22
+ /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
23
+ command: z.string().optional(),
24
+ /** Arguments passed to command. */
25
+ args: z.array(z.string()).default([]),
26
+ /**
27
+ * Extra environment variables passed to the child process as literals.
28
+ * These merge with process.env; project env overrides parent env.
29
+ * No ${VAR} interpolation — set vars in your shell environment instead.
30
+ */
31
+ env: z.record(z.string()).optional(),
32
+ /** Transport protocol. Default: "stdio". */
33
+ transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
34
+ /**
35
+ * URL for streamable-http or sse transports.
36
+ * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
37
+ */
38
+ url: z.string().url().optional(),
39
+ /**
40
+ * "eager" — start at session_start.
41
+ * "lazy" — start manually via /mcp:start command.
42
+ */
43
+ lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
44
+ /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
45
+ requestTimeoutMs: z.number().positive().optional(),
46
+ /**
47
+ * Opt-in heartbeat interval (ping) in ms.
48
+ * Only useful for long-lived connections where you want proactive liveness checks.
49
+ * Default: disabled.
50
+ */
51
+ healthCheckIntervalMs: z.number().positive().optional(),
52
+ })
53
+ .refine(
54
+ (cfg) => {
55
+ if (cfg.transport === "stdio") return cfg.command !== undefined;
56
+ return cfg.url !== undefined;
57
+ },
58
+ (cfg) => ({
59
+ message:
60
+ cfg.transport === "stdio"
61
+ ? `"command" is required for stdio transport`
62
+ : `"url" is required for ${cfg.transport} transport`,
63
+ }),
64
+ );
65
+
66
+ const SettingsSchema = z.object({
67
+ /**
68
+ * Prefix used in Pi tool names: <prefix>_<server>_<tool>.
69
+ * Must match [a-zA-Z0-9_]. Default: "mcp".
70
+ */
71
+ toolPrefix: z
72
+ .string()
73
+ .regex(/^[a-zA-Z0-9_]+$/, "toolPrefix must match [a-zA-Z0-9_]")
74
+ .default("mcp"),
75
+ /** Default per-request timeout in ms for all servers. Default: 30000. */
76
+ requestTimeoutMs: z.number().positive().default(30000),
77
+ /** Maximum retry attempts when a server fails to connect. Default: 5. */
78
+ maxRetries: z.number().int().min(0).max(10).default(5),
79
+ });
80
+
81
+ const McpConfigSchema = z.object({
82
+ settings: SettingsSchema.default({}),
83
+ mcpServers: z.record(ServerConfigSchema).default({}),
84
+ });
85
+
86
+ // ─── Public Types ─────────────────────────────────────────────────────────────
87
+
88
+ export type ServerConfig = z.output<typeof ServerConfigSchema>;
89
+ export type Settings = z.output<typeof SettingsSchema>;
90
+ export type McpConfig = z.output<typeof McpConfigSchema>;
91
+
92
+ // ─── Loader ───────────────────────────────────────────────────────────────────
93
+
94
+ async function readJsonFile(path: string): Promise<unknown | null> {
95
+ try {
96
+ const text = await readFile(path, "utf8");
97
+ return JSON.parse(text) as unknown;
98
+ } catch (err) {
99
+ // ENOENT → file doesn't exist, silently skip
100
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ function parseConfig(raw: unknown, sourcePath: string): McpConfig {
106
+ const result = McpConfigSchema.safeParse(raw);
107
+ if (!result.success) {
108
+ const issues = result.error.issues
109
+ .map((i) => ` ${i.path.join(".")}: ${i.message}`)
110
+ .join("\n");
111
+ throw new McpError(
112
+ `Invalid mcp.json at ${sourcePath}:\n${issues}`,
113
+ "<config>",
114
+ "config",
115
+ );
116
+ }
117
+ return result.data;
118
+ }
119
+
120
+ function mergeConfigs(
121
+ globalCfg: McpConfig,
122
+ projectCfg: McpConfig,
123
+ ): McpConfig {
124
+ return {
125
+ // Shallow spread: project settings override global settings per key
126
+ settings: { ...globalCfg.settings, ...projectCfg.settings },
127
+ // Per-server override: project server entry completely replaces global entry with same name
128
+ mcpServers: { ...globalCfg.mcpServers, ...projectCfg.mcpServers },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Load and merge global (~/.pi/agent/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
134
+ * Project config takes precedence over global config.
135
+ * Returns a fully validated, merged config.
136
+ */
137
+ export async function loadConfig(cwd: string): Promise<McpConfig> {
138
+ const globalPath = join(homedir(), ".pi", "agent", "mcp.json");
139
+ const projectPath = join(cwd, ".pi", "mcp.json");
140
+
141
+ const [globalRaw, projectRaw] = await Promise.all([
142
+ readJsonFile(globalPath),
143
+ readJsonFile(projectPath),
144
+ ]);
145
+
146
+ // If neither file exists, return an empty valid config
147
+ if (globalRaw === null && projectRaw === null) {
148
+ return McpConfigSchema.parse({});
149
+ }
150
+
151
+ const globalCfg = globalRaw !== null
152
+ ? parseConfig(globalRaw, globalPath)
153
+ : McpConfigSchema.parse({});
154
+
155
+ if (projectRaw === null) return globalCfg;
156
+
157
+ const projectCfg = parseConfig(projectRaw, projectPath);
158
+ return mergeConfigs(globalCfg, projectCfg);
159
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Single error class for all MCP-related failures.
3
+ * `code` field enables programmatic discrimination without needing
4
+ * instanceof checks against a hierarchy of classes.
5
+ */
6
+
7
+ export type McpErrorCode =
8
+ | "config" // Configuration loading or validation failed
9
+ | "connection" // Transport/connection failed
10
+ | "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
11
+ | "tool"; // Tool execution error (isError: true from server)
12
+
13
+ export class McpError extends Error {
14
+ public readonly server: string;
15
+ public readonly code: McpErrorCode;
16
+ public readonly cause: unknown;
17
+
18
+ constructor(
19
+ message: string,
20
+ server: string,
21
+ code: McpErrorCode,
22
+ cause?: unknown,
23
+ ) {
24
+ super(message);
25
+ this.name = "McpError";
26
+ this.server = server;
27
+ this.code = code;
28
+ this.cause = cause;
29
+ }
30
+
31
+ /** Short user-facing message suitable for ctx.ui.notify() */
32
+ get userMessage(): string {
33
+ return `[${this.server}] ${this.message}`;
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * pi-mcp — MCP client extension for the Pi coding agent.
3
+ *
4
+ * Entry point registered in package.json under "pi.extensions".
5
+ * Pi loads this file via jiti (TypeScript executed directly, no build step).
6
+ *
7
+ * Wires together: config → server manager → tool bridge → Pi API.
8
+ */
9
+
10
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
11
+ import { loadConfig } from "./config.js";
12
+ import { ServerManager } from "./server-manager.js";
13
+ import { ToolBridge } from "./tool-bridge.js";
14
+ import { McpError } from "./errors.js";
15
+
16
+ export default async function (pi: ExtensionAPI): Promise<void> {
17
+ // ── 1. Load and validate config ──────────────────────────────────────────
18
+ // cwd is available on the ExtensionContext passed to event handlers.
19
+ // We load config lazily on session_start to get the correct per-session cwd.
20
+ // For the initial load we use process.cwd() as a bootstrap path to detect
21
+ // whether any config exists at all.
22
+ let config;
23
+ try {
24
+ config = await loadConfig(process.cwd());
25
+ } catch (err) {
26
+ // Can't notify yet (no ctx), so log to stderr. The session_start handler
27
+ // will re-try with the real cwd and surface errors properly.
28
+ console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
29
+ return;
30
+ }
31
+
32
+ if (Object.keys(config.mcpServers).length === 0) {
33
+ // No servers configured — silently exit. Users can create mcp.json later.
34
+ return;
35
+ }
36
+
37
+ // ── 2. Initialize bridge components ──────────────────────────────────────
38
+ const manager = new ServerManager(config);
39
+ const bridge = new ToolBridge(config.settings, pi);
40
+
41
+ // Connect tool refresh callback: called on connect and on list_changed
42
+ manager.setToolRefreshCallback(async (serverName, client) => {
43
+ await bridge.refreshTools(serverName, client);
44
+ });
45
+
46
+ // ── 3. Session lifecycle ──────────────────────────────────────────────────
47
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
48
+ // Reload config with the real session cwd (project config may differ)
49
+ let sessionConfig = config;
50
+ try {
51
+ sessionConfig = await loadConfig(ctx.cwd);
52
+ } catch (err) {
53
+ const msg = err instanceof McpError ? err.userMessage : String(err);
54
+ ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
55
+ return;
56
+ }
57
+
58
+ // If config changed (different cwd with project-level overrides),
59
+ // shut down old servers and rebuild the manager's server list
60
+ if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
61
+ // Deactivate and remove all tools from old config
62
+ for (const server of manager.getAllServers()) {
63
+ bridge.removeServer(server.name);
64
+ }
65
+ // Shut down all running servers
66
+ await manager.shutdownAll();
67
+ // Rebuild server entries from new config
68
+ manager.rebuildServers(sessionConfig);
69
+ }
70
+
71
+ const eagerServers = Object.entries(sessionConfig.mcpServers).filter(
72
+ ([, cfg]) => cfg.lifecycle === "eager",
73
+ );
74
+
75
+ // Start all eager servers concurrently
76
+ await Promise.allSettled(
77
+ eagerServers.map(async ([name]) => {
78
+ try {
79
+ await manager.startServer(name, ctx.cwd);
80
+ } catch (err) {
81
+ const msg = err instanceof McpError ? err.userMessage : String(err);
82
+ ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
83
+ }
84
+ }),
85
+ );
86
+ });
87
+
88
+ pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
89
+ // Deactivate all tools before shutting down servers
90
+ for (const server of manager.getAllServers()) {
91
+ bridge.deactivateServer(server.name);
92
+ }
93
+ await manager.shutdownAll();
94
+ });
95
+
96
+ // ── 4. /mcp — show server status ─────────────────────────────────────────
97
+ pi.registerCommand("mcp", {
98
+ description:
99
+ "Show MCP server status. Usage: /mcp [server-name] for detail.",
100
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
101
+ const serverName = args.trim();
102
+ if (serverName) {
103
+ // Detailed view: status + recent stderr
104
+ const server = manager.getServer(serverName);
105
+ if (!server) {
106
+ ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
107
+ return;
108
+ }
109
+ const logs = manager.getServerLogs(serverName);
110
+ const detail = [
111
+ `Server: ${serverName}`,
112
+ `State: ${server.state}`,
113
+ `Retries: ${server.retryCount}`,
114
+ server.lastError ? `Last error: ${server.lastError.message}` : null,
115
+ "",
116
+ "Recent output:",
117
+ logs,
118
+ ]
119
+ .filter(Boolean)
120
+ .join("\n");
121
+ ctx.ui.notify(detail, "info");
122
+ } else {
123
+ // Summary view: all servers
124
+ ctx.ui.notify(manager.getStatusSummary(), "info");
125
+ }
126
+ },
127
+ });
128
+
129
+ // ── 5. /mcp:stop — stop a server ─────────────────────────────────────────
130
+ pi.registerCommand("mcp:stop", {
131
+ description: "Stop an MCP server. Usage: /mcp:stop <server-name>",
132
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
133
+ const serverName = args.trim();
134
+ if (!serverName) {
135
+ ctx.ui.notify("Usage: /mcp:stop <server-name>", "error");
136
+ return;
137
+ }
138
+ if (!manager.getServer(serverName)) {
139
+ ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
140
+ return;
141
+ }
142
+ bridge.deactivateServer(serverName);
143
+ await manager.stopServer(serverName);
144
+ ctx.ui.notify(`pi-mcp: Stopped ${serverName}`, "info");
145
+ },
146
+ });
147
+
148
+ // ── 6. /mcp:start — manually start a lazy server ─────────────────────────
149
+ pi.registerCommand("mcp:start", {
150
+ description: "Start an MCP server. Usage: /mcp:start <server-name>",
151
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
152
+ const serverName = args.trim();
153
+ if (!serverName) {
154
+ ctx.ui.notify("Usage: /mcp:start <server-name>", "error");
155
+ return;
156
+ }
157
+ if (!manager.getServer(serverName)) {
158
+ ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
159
+ return;
160
+ }
161
+ try {
162
+ await manager.startServer(serverName, ctx.cwd);
163
+ ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
164
+ } catch (err) {
165
+ const msg = err instanceof McpError ? err.userMessage : String(err);
166
+ ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
167
+ }
168
+ },
169
+ });
170
+ }