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 +21 -0
- package/README.md +237 -0
- package/package.json +54 -0
- package/src/config.ts +159 -0
- package/src/errors.ts +35 -0
- package/src/index.ts +170 -0
- package/src/server-manager.ts +410 -0
- package/src/tool-bridge.ts +427 -0
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
|
+
[](https://www.npmjs.com/package/pi-mcp)
|
|
4
|
+
[](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
|
+
}
|