@pi-unipi/mcp 0.1.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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@pi-unipi/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/mcp"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "unipi",
18
+ "mcp",
19
+ "model-context-protocol",
20
+ "tools"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "src/index.ts"
25
+ ],
26
+ "skills": [
27
+ "skills"
28
+ ]
29
+ },
30
+ "files": [
31
+ "src/**/*.ts",
32
+ "data/**/*",
33
+ "skills/**/*",
34
+ "README.md"
35
+ ],
36
+ "dependencies": {
37
+ "@pi-unipi/core": "*"
38
+ },
39
+ "peerDependencies": {
40
+ "@mariozechner/pi-coding-agent": "*",
41
+ "@mariozechner/pi-tui": "*",
42
+ "@sinclair/typebox": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.6.0"
46
+ }
47
+ }
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: mcp
3
+ description: "MCP server management — discover, connect, and use Model Context Protocol tools"
4
+ ---
5
+
6
+ # MCP Tools
7
+
8
+ MCP (Model Context Protocol) servers provide external tools that extend pi's capabilities.
9
+ Tools from MCP servers are named `{serverName}__{toolName}` — e.g., `github__search_code`.
10
+
11
+ ## Adding Servers
12
+
13
+ Use `/unipi:mcp-add` to browse the catalog of 7,800+ MCP servers and add them interactively.
14
+ The split-pane overlay lets you:
15
+ - **Browse**: Search the cached server catalog by name, description, or category
16
+ - **Select**: Pick a server to get a pre-filled config template
17
+ - **Custom**: Press `c` for an empty editor to add unlisted servers manually
18
+ - **Save**: Press Enter or Ctrl+S to validate and save
19
+
20
+ ## Managing Servers
21
+
22
+ Use `/unipi:mcp-settings` to manage configured servers:
23
+ - **Toggle**: Press Space to enable/disable a server
24
+ - **Delete**: Press `d` then `y` to remove a server
25
+ - **Scope**: Press `g` for global config, `p` for project config
26
+ - **Sync**: Press `s` to refresh the catalog from GitHub
27
+
28
+ ## Quick Status
29
+
30
+ Use `/unipi:mcp-status` for a text summary of all servers and their status.
31
+
32
+ ## Config Hierarchy
33
+
34
+ MCP config supports **global defaults** with **project overrides**:
35
+
36
+ ```
37
+ ~/.unipi/config/mcp/ ← Global defaults (shared across all projects)
38
+ {project}/.unipi/config/mcp/ ← Project overrides (when present)
39
+ ```
40
+
41
+ **Merge rules:**
42
+ 1. Server only in global → used normally
43
+ 2. Server only in project → used normally
44
+ 3. Server in both → project wins entirely
45
+ 4. `enabled: false` in project metadata → disabled even if defined globally
46
+
47
+ ### Config Files
48
+
49
+ Each level has three files:
50
+ - `mcp-config.json` — Server definitions (standard MCP format, portable)
51
+ - `config.json` — Metadata (enabled/disabled, sync prefs)
52
+ - `auth.json` — Sensitive env vars (optional, chmod 600)
53
+
54
+ ### Example mcp-config.json
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "github": {
60
+ "command": "docker",
61
+ "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
62
+ "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxx" }
63
+ },
64
+ "filesystem": {
65
+ "command": "npx",
66
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/pi/projects"]
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ ## Using MCP Tools
73
+
74
+ Once an MCP server is running, its tools are available as pi tools.
75
+ They're named with the pattern `{serverName}__{toolName}`.
76
+
77
+ For example, if you add the GitHub MCP server, you'll have tools like:
78
+ - `github__search_code`
79
+ - `github__create_issue`
80
+ - `github__list_pull_requests`
81
+
82
+ You can call these tools directly in conversations.
83
+
84
+ ## Troubleshooting
85
+
86
+ ### Server won't start
87
+ - Check `/unipi:mcp-status` for error details
88
+ - Verify the command exists: `which npx` or `which docker`
89
+ - Check that required env vars are set in the config
90
+
91
+ ### Tools not appearing
92
+ - Ensure the server status shows "running"
93
+ - Check if the server supports the MCP tools protocol
94
+ - Try restarting pi after adding a new server
95
+
96
+ ### Config issues
97
+ - Validate JSON syntax in your config files
98
+ - Check file permissions (mcp-config.json should be readable)
99
+ - Use `/unipi:mcp-settings` to view current configuration
100
+
101
+ ### Catalog sync issues
102
+ - Run `/unipi:mcp-sync` to force a refresh
103
+ - Check network connectivity to GitHub
104
+ - The seed catalog (49 servers) is available offline as fallback
@@ -0,0 +1,365 @@
1
+ /**
2
+ * @pi-unipi/mcp — MCP JSON-RPC Client
3
+ *
4
+ * Spawns an MCP server as a child process, performs the JSON-RPC initialize
5
+ * handshake, and provides listTools/callTool/disconnect methods via stdio.
6
+ */
7
+
8
+ import { type ChildProcess, spawn } from "node:child_process";
9
+ import type { McpTool, McpToolResult } from "../types.js";
10
+ import { MCP_DEFAULTS } from "@pi-unipi/core";
11
+
12
+ /** JSON-RPC request */
13
+ interface JsonRpcRequest {
14
+ jsonrpc: "2.0";
15
+ id: number;
16
+ method: string;
17
+ params?: Record<string, unknown>;
18
+ }
19
+
20
+ /** JSON-RPC response */
21
+ interface JsonRpcResponse {
22
+ jsonrpc: "2.0";
23
+ id: number;
24
+ result?: unknown;
25
+ error?: {
26
+ code: number;
27
+ message: string;
28
+ data?: unknown;
29
+ };
30
+ }
31
+
32
+ /** JSON-RPC notification (no id) */
33
+ interface JsonRpcNotification {
34
+ jsonrpc: "2.0";
35
+ method: string;
36
+ params?: Record<string, unknown>;
37
+ }
38
+
39
+ /** Pending request handler */
40
+ interface PendingRequest {
41
+ resolve: (value: unknown) => void;
42
+ reject: (reason: Error) => void;
43
+ timer: ReturnType<typeof setTimeout>;
44
+ }
45
+
46
+ /** Options for McpClient */
47
+ export interface McpClientOptions {
48
+ /** Per-request timeout in ms (default: MCP_DEFAULTS.STARTUP_TIMEOUT_MS) */
49
+ timeoutMs?: number;
50
+ /** Working directory for the spawned process */
51
+ cwd?: string;
52
+ }
53
+
54
+ /**
55
+ * MCP JSON-RPC client over stdio transport.
56
+ *
57
+ * Spawns a child process, sends JSON-RPC messages to stdin,
58
+ * reads responses from stdout, and correlates by request ID.
59
+ */
60
+ export class McpClient {
61
+ private process: ChildProcess | null = null;
62
+ private nextId = 1;
63
+ private pending = new Map<number, PendingRequest>();
64
+ private buffer = "";
65
+ private connected = false;
66
+ private stderrBuffer = "";
67
+ private readonly timeoutMs: number;
68
+ private readonly cwd?: string;
69
+
70
+ constructor(options?: McpClientOptions) {
71
+ this.timeoutMs = options?.timeoutMs ?? MCP_DEFAULTS.STARTUP_TIMEOUT_MS;
72
+ this.cwd = options?.cwd;
73
+ }
74
+
75
+ /**
76
+ * Spawn the MCP server process and perform the initialize handshake.
77
+ */
78
+ async connect(
79
+ command: string,
80
+ args: string[],
81
+ env?: Record<string, string>,
82
+ ): Promise<void> {
83
+ if (this.connected) {
84
+ throw new Error("McpClient is already connected");
85
+ }
86
+
87
+ return new Promise<void>((resolve, reject) => {
88
+ try {
89
+ const mergedEnv = { ...process.env, ...env };
90
+
91
+ this.process = spawn(command, args, {
92
+ stdio: ["pipe", "pipe", "pipe"],
93
+ env: mergedEnv,
94
+ cwd: this.cwd,
95
+ });
96
+
97
+ this.process.on("error", (err) => {
98
+ this.cleanup();
99
+ reject(new Error(`Failed to spawn MCP server: ${err.message}`));
100
+ });
101
+
102
+ this.process.on("exit", (code, signal) => {
103
+ if (!this.connected) {
104
+ this.cleanup();
105
+ reject(
106
+ new Error(
107
+ `MCP server exited during startup: code=${code}, signal=${signal}\nStderr: ${this.stderrBuffer}`,
108
+ ),
109
+ );
110
+ return;
111
+ }
112
+ // Unexpected exit after connection
113
+ this.connected = false;
114
+ this.rejectAllPending(
115
+ new Error(
116
+ `MCP server exited unexpectedly: code=${code}, signal=${signal}`,
117
+ ),
118
+ );
119
+ });
120
+
121
+ // Set up stdout reading
122
+ this.process.stdout!.on("data", (chunk: Buffer) => {
123
+ this.handleStdoutData(chunk);
124
+ });
125
+
126
+ // Capture stderr for error reporting
127
+ this.process.stderr!.on("data", (chunk: Buffer) => {
128
+ this.stderrBuffer += chunk.toString();
129
+ // Keep stderr buffer manageable
130
+ if (this.stderrBuffer.length > 10000) {
131
+ this.stderrBuffer = this.stderrBuffer.slice(-5000);
132
+ }
133
+ });
134
+
135
+ // Perform initialize handshake
136
+ this.sendRequest("initialize", {
137
+ protocolVersion: "2024-11-05",
138
+ capabilities: {},
139
+ clientInfo: {
140
+ name: "@pi-unipi/mcp",
141
+ version: "0.1.0",
142
+ },
143
+ })
144
+ .then(() => {
145
+ // Send initialized notification
146
+ this.sendNotification("notifications/initialized", {});
147
+ this.connected = true;
148
+ resolve();
149
+ })
150
+ .catch((err) => {
151
+ this.cleanup();
152
+ reject(
153
+ new Error(
154
+ `MCP initialize handshake failed: ${(err as Error).message}\nStderr: ${this.stderrBuffer}`,
155
+ ),
156
+ );
157
+ });
158
+ } catch (err) {
159
+ this.cleanup();
160
+ reject(err);
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Query available tools from the MCP server.
167
+ */
168
+ async listTools(): Promise<McpTool[]> {
169
+ this.ensureConnected();
170
+ const result = (await this.sendRequest("tools/list", {})) as {
171
+ tools: McpTool[];
172
+ };
173
+ return result.tools ?? [];
174
+ }
175
+
176
+ /**
177
+ * Execute a tool call on the MCP server.
178
+ */
179
+ async callTool(
180
+ name: string,
181
+ args: Record<string, unknown>,
182
+ ): Promise<McpToolResult> {
183
+ this.ensureConnected();
184
+ const result = (await this.sendRequest("tools/call", {
185
+ name,
186
+ arguments: args,
187
+ })) as McpToolResult;
188
+ return result;
189
+ }
190
+
191
+ /**
192
+ * Gracefully disconnect from the MCP server.
193
+ */
194
+ async disconnect(): Promise<void> {
195
+ if (!this.process) return;
196
+
197
+ try {
198
+ // Send shutdown notification
199
+ this.sendNotification("shutdown", {});
200
+ } catch {
201
+ // Ignore errors during shutdown
202
+ }
203
+
204
+ // Give the process a moment to clean up
205
+ await new Promise<void>((resolve) => {
206
+ const timer = setTimeout(() => resolve(), 500);
207
+
208
+ this.process!.on("exit", () => {
209
+ clearTimeout(timer);
210
+ resolve();
211
+ });
212
+
213
+ // Send SIGTERM
214
+ try {
215
+ this.process!.kill("SIGTERM");
216
+ } catch {
217
+ resolve();
218
+ }
219
+ });
220
+
221
+ // Force kill if still running
222
+ if (this.process && !this.process.killed) {
223
+ try {
224
+ this.process.kill("SIGKILL");
225
+ } catch {
226
+ // Already dead
227
+ }
228
+ }
229
+
230
+ this.cleanup();
231
+ }
232
+
233
+ /** Whether the client is currently connected */
234
+ get isConnected(): boolean {
235
+ return this.connected;
236
+ }
237
+
238
+ /** Process ID of the spawned MCP server, or undefined if not connected */
239
+ get pid(): number | undefined {
240
+ return this.process?.pid;
241
+ }
242
+
243
+ /** Captured stderr output */
244
+ get stderr(): string {
245
+ return this.stderrBuffer;
246
+ }
247
+
248
+ // ── Internal methods ────────────────────────────────────────────
249
+
250
+ private ensureConnected(): void {
251
+ if (!this.connected || !this.process) {
252
+ throw new Error("McpClient is not connected");
253
+ }
254
+ }
255
+
256
+ private sendRequest(
257
+ method: string,
258
+ params?: Record<string, unknown>,
259
+ ): Promise<unknown> {
260
+ return new Promise<unknown>((resolve, reject) => {
261
+ if (!this.process?.stdin) {
262
+ reject(new Error("MCP server process not available"));
263
+ return;
264
+ }
265
+
266
+ const id = this.nextId++;
267
+ const request: JsonRpcRequest = {
268
+ jsonrpc: "2.0",
269
+ id,
270
+ method,
271
+ params,
272
+ };
273
+
274
+ const timer = setTimeout(() => {
275
+ this.pending.delete(id);
276
+ reject(
277
+ new Error(
278
+ `MCP request timed out after ${this.timeoutMs}ms: ${method}`,
279
+ ),
280
+ );
281
+ }, this.timeoutMs);
282
+
283
+ this.pending.set(id, { resolve, reject, timer });
284
+
285
+ const message = JSON.stringify(request) + "\n";
286
+ this.process.stdin.write(message);
287
+ });
288
+ }
289
+
290
+ private sendNotification(
291
+ method: string,
292
+ params?: Record<string, unknown>,
293
+ ): void {
294
+ if (!this.process?.stdin) return;
295
+
296
+ const notification: JsonRpcNotification = {
297
+ jsonrpc: "2.0",
298
+ method,
299
+ params,
300
+ };
301
+
302
+ const message = JSON.stringify(notification) + "\n";
303
+ this.process.stdin.write(message);
304
+ }
305
+
306
+ private handleStdoutData(chunk: Buffer): void {
307
+ this.buffer += chunk.toString();
308
+
309
+ // Process complete lines
310
+ let newlineIdx: number;
311
+ while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
312
+ const line = this.buffer.slice(0, newlineIdx).trim();
313
+ this.buffer = this.buffer.slice(newlineIdx + 1);
314
+
315
+ if (!line) continue;
316
+
317
+ try {
318
+ const message = JSON.parse(line) as
319
+ | JsonRpcResponse
320
+ | JsonRpcNotification;
321
+
322
+ if ("id" in message && message.id !== undefined) {
323
+ // This is a response
324
+ this.handleResponse(message as JsonRpcResponse);
325
+ }
326
+ // Notifications are ignored for now
327
+ } catch {
328
+ // Skip malformed JSON lines
329
+ }
330
+ }
331
+ }
332
+
333
+ private handleResponse(response: JsonRpcResponse): void {
334
+ const pending = this.pending.get(response.id);
335
+ if (!pending) return;
336
+
337
+ this.pending.delete(response.id);
338
+ clearTimeout(pending.timer);
339
+
340
+ if (response.error) {
341
+ pending.reject(
342
+ new Error(
343
+ `MCP error ${response.error.code}: ${response.error.message}`,
344
+ ),
345
+ );
346
+ } else {
347
+ pending.resolve(response.result);
348
+ }
349
+ }
350
+
351
+ private rejectAllPending(error: Error): void {
352
+ for (const [id, pending] of this.pending) {
353
+ clearTimeout(pending.timer);
354
+ pending.reject(error);
355
+ this.pending.delete(id);
356
+ }
357
+ }
358
+
359
+ private cleanup(): void {
360
+ this.connected = false;
361
+ this.rejectAllPending(new Error("McpClient disconnected"));
362
+ this.process = null;
363
+ this.buffer = "";
364
+ }
365
+ }