@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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @pi-unipi/mcp — Server registry
3
+ *
4
+ * Manages MCP server lifecycle: start, stop, restart, status tracking.
5
+ * Coordinates McpClient instances and tool registration with pi.
6
+ */
7
+
8
+ import { UNIPI_EVENTS, MCP_DEFAULTS } from "@pi-unipi/core";
9
+ import type {
10
+ ResolvedServer,
11
+ ServerState,
12
+ ServerStatus,
13
+ McpTool,
14
+ McpRegistryEntry,
15
+ } from "../types.js";
16
+ import { McpClient } from "./client.js";
17
+ import { translateMcpTool, type PiExternalTool } from "./translator.js";
18
+
19
+ /** Callback for emitting events */
20
+ export type EventEmitFn = (
21
+ event: string,
22
+ payload: Record<string, unknown>,
23
+ ) => void;
24
+
25
+ /** Callback for registering a tool with pi */
26
+ export type RegisterToolFn = (tool: PiExternalTool) => void;
27
+
28
+ /** Callback for unregistering a tool with pi */
29
+ export type UnregisterToolFn = (toolName: string) => void;
30
+
31
+ /** Options for ServerRegistry */
32
+ export interface ServerRegistryOptions {
33
+ /** Function to emit events via pi.events */
34
+ emitEvent: EventEmitFn;
35
+ /** Function to register a tool with pi */
36
+ registerTool: RegisterToolFn;
37
+ /** Function to unregister a tool from pi */
38
+ unregisterTool: UnregisterToolFn;
39
+ /** Per-server startup timeout in ms */
40
+ timeoutMs?: number;
41
+ }
42
+
43
+ /**
44
+ * Server registry — tracks all MCP server connections and their tools.
45
+ */
46
+ export class ServerRegistry {
47
+ private entries = new Map<string, McpRegistryEntry>();
48
+ private readonly emitEvent: EventEmitFn;
49
+ private readonly registerTool: RegisterToolFn;
50
+ private readonly unregisterTool: UnregisterToolFn;
51
+ private readonly timeoutMs: number;
52
+
53
+ constructor(options: ServerRegistryOptions) {
54
+ this.emitEvent = options.emitEvent;
55
+ this.registerTool = options.registerTool;
56
+ this.unregisterTool = options.unregisterTool;
57
+ this.timeoutMs = options.timeoutMs ?? MCP_DEFAULTS.STARTUP_TIMEOUT_MS;
58
+ }
59
+
60
+ /**
61
+ * Start an MCP server: spawn process, initialize, discover tools, register.
62
+ */
63
+ async startServer(resolved: ResolvedServer): Promise<void> {
64
+ const { name, def } = resolved;
65
+
66
+ // Check max servers limit
67
+ if (this.entries.size >= MCP_DEFAULTS.MAX_SERVERS) {
68
+ throw new Error(
69
+ `Maximum number of MCP servers (${MCP_DEFAULTS.MAX_SERVERS}) reached. ` +
70
+ `Stop a server before starting a new one.`,
71
+ );
72
+ }
73
+
74
+ // Stop existing server with same name if running
75
+ if (this.entries.has(name)) {
76
+ await this.stopServer(name);
77
+ }
78
+
79
+ const state: ServerState = {
80
+ name,
81
+ status: "starting",
82
+ toolCount: 0,
83
+ startedAt: new Date().toISOString(),
84
+ };
85
+
86
+ const entry: McpRegistryEntry = {
87
+ name,
88
+ resolved,
89
+ state,
90
+ client: null,
91
+ toolNames: [],
92
+ };
93
+
94
+ this.entries.set(name, entry);
95
+
96
+ try {
97
+ // Create and connect client
98
+ const client = new McpClient({ timeoutMs: this.timeoutMs });
99
+ await client.connect(def.command, def.args ?? [], def.env);
100
+
101
+ entry.client = client;
102
+
103
+ // Discover tools
104
+ const mcpTools = await client.listTools();
105
+
106
+ // Translate and register tools
107
+ const toolNames: string[] = [];
108
+ for (const mcpTool of mcpTools) {
109
+ const piTool = translateMcpTool(mcpTool, name, client);
110
+ this.registerTool(piTool);
111
+ toolNames.push(piTool.name);
112
+ }
113
+
114
+ // Update state
115
+ entry.state = {
116
+ ...state,
117
+ status: "running",
118
+ pid: client.pid,
119
+ toolCount: toolNames.length,
120
+ };
121
+ entry.toolNames = toolNames;
122
+
123
+ // Emit events
124
+ this.emitEvent(UNIPI_EVENTS.MCP_SERVER_STARTED, {
125
+ name,
126
+ toolCount: toolNames.length,
127
+ });
128
+
129
+ if (toolNames.length > 0) {
130
+ this.emitEvent(UNIPI_EVENTS.MCP_TOOLS_REGISTERED, {
131
+ serverName: name,
132
+ toolNames,
133
+ });
134
+ }
135
+ } catch (err) {
136
+ const error =
137
+ err instanceof Error ? err.message : String(err);
138
+
139
+ entry.state = {
140
+ ...state,
141
+ status: "error",
142
+ error,
143
+ };
144
+
145
+ // Clean up client if partially connected
146
+ if (entry.client) {
147
+ try {
148
+ await (entry.client as McpClient).disconnect();
149
+ } catch {
150
+ // Ignore cleanup errors
151
+ }
152
+ entry.client = null;
153
+ }
154
+
155
+ this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, {
156
+ name,
157
+ error,
158
+ });
159
+
160
+ throw err;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Stop an MCP server: unregister tools, disconnect client.
166
+ */
167
+ async stopServer(name: string): Promise<void> {
168
+ const entry = this.entries.get(name);
169
+ if (!entry) return;
170
+
171
+ // Unregister tools
172
+ for (const toolName of entry.toolNames) {
173
+ this.unregisterTool(toolName);
174
+ }
175
+
176
+ if (entry.toolNames.length > 0) {
177
+ this.emitEvent(UNIPI_EVENTS.MCP_TOOLS_UNREGISTERED, {
178
+ serverName: name,
179
+ toolNames: entry.toolNames,
180
+ });
181
+ }
182
+
183
+ // Disconnect client
184
+ if (entry.client) {
185
+ try {
186
+ await (entry.client as McpClient).disconnect();
187
+ } catch {
188
+ // Ignore disconnect errors
189
+ }
190
+ entry.client = null;
191
+ }
192
+
193
+ // Update state
194
+ entry.state = {
195
+ ...entry.state,
196
+ status: "stopped",
197
+ toolCount: 0,
198
+ };
199
+ entry.toolNames = [];
200
+
201
+ this.emitEvent(UNIPI_EVENTS.MCP_SERVER_STOPPED, { name });
202
+ }
203
+
204
+ /**
205
+ * Restart an MCP server: stop then start.
206
+ */
207
+ async restartServer(name: string): Promise<void> {
208
+ const entry = this.entries.get(name);
209
+ if (!entry) {
210
+ throw new Error(`Server '${name}' not found in registry`);
211
+ }
212
+
213
+ const resolved = entry.resolved;
214
+ await this.stopServer(name);
215
+ await this.startServer(resolved);
216
+ }
217
+
218
+ /**
219
+ * Stop all running servers.
220
+ */
221
+ async stopAll(): Promise<void> {
222
+ const names = Array.from(this.entries.keys());
223
+ await Promise.allSettled(names.map((name) => this.stopServer(name)));
224
+ }
225
+
226
+ /**
227
+ * Get all registered server states.
228
+ */
229
+ getAll(): ServerState[] {
230
+ return Array.from(this.entries.values()).map((e) => e.state);
231
+ }
232
+
233
+ /**
234
+ * Get states of running servers.
235
+ */
236
+ getActive(): ServerState[] {
237
+ return this.getAll().filter((s) => s.status === "running");
238
+ }
239
+
240
+ /**
241
+ * Get states of servers in error state.
242
+ */
243
+ getFailed(): ServerState[] {
244
+ return this.getAll().filter((s) => s.status === "error");
245
+ }
246
+
247
+ /**
248
+ * Get total number of tools across all active servers.
249
+ */
250
+ getTotalToolCount(): number {
251
+ return this.getActive().reduce((sum, s) => sum + s.toolCount, 0);
252
+ }
253
+
254
+ /**
255
+ * Get the state of a specific server.
256
+ */
257
+ getServerState(name: string): ServerState | null {
258
+ return this.entries.get(name)?.state ?? null;
259
+ }
260
+
261
+ /**
262
+ * Get the full registry entry for a server.
263
+ */
264
+ getEntry(name: string): McpRegistryEntry | null {
265
+ return this.entries.get(name) ?? null;
266
+ }
267
+
268
+ /**
269
+ * Check if a server exists in the registry.
270
+ */
271
+ hasServer(name: string): boolean {
272
+ return this.entries.has(name);
273
+ }
274
+
275
+ /**
276
+ * Get the number of registered servers.
277
+ */
278
+ get size(): number {
279
+ return this.entries.size;
280
+ }
281
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @pi-unipi/mcp — Tool translator
3
+ *
4
+ * Converts MCP tool schemas to pi-compatible ExternalTool format.
5
+ * Naming convention: {serverName}__{toolName}
6
+ */
7
+
8
+ import { MCP_DEFAULTS } from "@pi-unipi/core";
9
+ import type { McpTool, McpToolResult } from "../types.js";
10
+ import type { McpClient } from "./client.js";
11
+
12
+ /** Pi-compatible tool parameter schema */
13
+ interface ToolParameters {
14
+ type: "object";
15
+ properties: Record<string, unknown>;
16
+ required?: string[];
17
+ }
18
+
19
+ /** Pi-compatible external tool */
20
+ export interface PiExternalTool {
21
+ name: string;
22
+ description: string;
23
+ parameters: ToolParameters;
24
+ execute: (params: Record<string, unknown>) => Promise<string>;
25
+ }
26
+
27
+ /**
28
+ * Translate an MCP tool definition to a pi-compatible external tool.
29
+ *
30
+ * @param mcpTool - The MCP tool schema from tools/list
31
+ * @param serverName - Name of the MCP server this tool belongs to
32
+ * @param client - The connected McpClient for executing calls
33
+ * @returns A pi-compatible ExternalTool
34
+ */
35
+ export function translateMcpTool(
36
+ mcpTool: McpTool,
37
+ serverName: string,
38
+ client: McpClient,
39
+ ): PiExternalTool {
40
+ const separator = MCP_DEFAULTS.TOOL_NAME_SEPARATOR;
41
+ const toolName = `${serverName}${separator}${mcpTool.name}`;
42
+
43
+ // Ensure inputSchema is a valid JSON Schema object
44
+ const inputSchema = mcpTool.inputSchema ?? {};
45
+ const parameters: ToolParameters = {
46
+ type: "object",
47
+ properties:
48
+ (inputSchema.properties as Record<string, unknown>) ?? {},
49
+ required: inputSchema.required as string[] | undefined,
50
+ };
51
+
52
+ const description = [
53
+ mcpTool.description || `MCP tool: ${mcpTool.name}`,
54
+ `[Server: ${serverName}]`,
55
+ ].join(" ");
56
+
57
+ const execute = async (
58
+ params: Record<string, unknown>,
59
+ ): Promise<string> => {
60
+ try {
61
+ const result: McpToolResult = await client.callTool(
62
+ mcpTool.name,
63
+ params,
64
+ );
65
+
66
+ // Join all text content blocks
67
+ const textParts: string[] = [];
68
+ for (const block of result.content) {
69
+ if (block.type === "text" && block.text) {
70
+ textParts.push(block.text);
71
+ } else if (block.type === "image" && block.data) {
72
+ textParts.push(`[Image: ${block.mimeType ?? "unknown"}]`);
73
+ } else if (block.type === "resource") {
74
+ textParts.push(`[Resource: ${block.text ?? block.mimeType ?? "unknown"}]`);
75
+ }
76
+ }
77
+
78
+ if (result.isError) {
79
+ const joined = textParts.join("\n") || "Unknown error";
80
+ throw new Error(`MCP tool error from ${serverName}: ${joined}`);
81
+ }
82
+
83
+ return textParts.join("\n") || "(no output)";
84
+ } catch (err) {
85
+ const message =
86
+ err instanceof Error ? err.message : String(err);
87
+ throw new Error(
88
+ `MCP tool "${mcpTool.name}" on server "${serverName}" failed: ${message}\n` +
89
+ `Check server status via /unipi:mcp-settings`,
90
+ );
91
+ }
92
+ };
93
+
94
+ return {
95
+ name: toolName,
96
+ description,
97
+ parameters,
98
+ execute,
99
+ };
100
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @pi-unipi/mcp — Config manager
3
+ *
4
+ * Reads, merges, and writes MCP configuration files.
5
+ * Handles global (~/.unipi/config/mcp/) and project (.unipi/config/mcp/) configs.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import type {
12
+ McpConfig,
13
+ McpMetadata,
14
+ McpAuth,
15
+ ResolvedServer,
16
+ ServerSource,
17
+ } from "../types.js";
18
+ import {
19
+ DEFAULT_MCP_CONFIG,
20
+ DEFAULT_METADATA,
21
+ validateMcpConfig,
22
+ } from "./schema.js";
23
+
24
+ // ── Path helpers ──────────────────────────────────────────────────
25
+
26
+ /** Expand ~ to home directory */
27
+ function expandHome(p: string): string {
28
+ if (p.startsWith("~")) {
29
+ return path.join(os.homedir(), p.slice(1));
30
+ }
31
+ return p;
32
+ }
33
+
34
+ /** Get global config directory path */
35
+ export function getGlobalConfigDir(): string {
36
+ return expandHome("~/.unipi/config/mcp");
37
+ }
38
+
39
+ /** Get project config directory path */
40
+ export function getProjectConfigDir(cwd: string): string {
41
+ return path.join(cwd, ".unipi", "config", "mcp");
42
+ }
43
+
44
+ /** Ensure directory exists */
45
+ function ensureDir(dir: string): void {
46
+ if (!fs.existsSync(dir)) {
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ }
49
+ }
50
+
51
+ // ── File I/O helpers ──────────────────────────────────────────────
52
+
53
+ /**
54
+ * Read and parse a JSON file. Returns null if file doesn't exist.
55
+ * Throws on parse errors (corrupt JSON).
56
+ */
57
+ function readJsonFile<T>(filePath: string): T | null {
58
+ try {
59
+ const content = fs.readFileSync(filePath, "utf-8");
60
+ return JSON.parse(content) as T;
61
+ } catch (err: unknown) {
62
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
63
+ return null;
64
+ }
65
+ throw new Error(`Failed to read ${filePath}: ${(err as Error).message}`);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Write JSON to file with optional chmod.
71
+ * Creates parent directories if needed.
72
+ */
73
+ function writeJsonFile(
74
+ filePath: string,
75
+ data: unknown,
76
+ chmod?: number,
77
+ ): void {
78
+ ensureDir(path.dirname(filePath));
79
+ const content = JSON.stringify(data, null, 2) + "\n";
80
+ fs.writeFileSync(filePath, content, "utf-8");
81
+ if (chmod !== undefined) {
82
+ try {
83
+ fs.chmodSync(filePath, chmod);
84
+ } catch {
85
+ // chmod may fail on Windows — log but don't fail
86
+ }
87
+ }
88
+ }
89
+
90
+ // ── Config loading ────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Load MCP config (mcp-config.json) from a directory.
94
+ * Returns defaults if file doesn't exist.
95
+ */
96
+ export function loadMcpConfig(dir: string): McpConfig {
97
+ const filePath = path.join(dir, "mcp-config.json");
98
+ const raw = readJsonFile<McpConfig>(filePath);
99
+ if (!raw) return { ...DEFAULT_MCP_CONFIG };
100
+
101
+ const validation = validateMcpConfig(raw);
102
+ if (!validation.valid) {
103
+ throw new Error(
104
+ `Invalid MCP config at ${filePath}:\n${validation.errors.join("\n")}`,
105
+ );
106
+ }
107
+
108
+ return raw;
109
+ }
110
+
111
+ /**
112
+ * Load metadata (config.json) from a directory.
113
+ * Returns defaults if file doesn't exist.
114
+ */
115
+ export function loadMetadata(dir: string): McpMetadata {
116
+ const filePath = path.join(dir, "config.json");
117
+ const raw = readJsonFile<Partial<McpMetadata>>(filePath);
118
+ if (!raw) return { ...DEFAULT_METADATA, servers: {}, sync: { ...DEFAULT_METADATA.sync } };
119
+
120
+ return {
121
+ servers: raw.servers ?? {},
122
+ sync: { ...DEFAULT_METADATA.sync, ...raw.sync },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Load auth data (auth.json) from a directory.
128
+ * Returns empty object if file doesn't exist.
129
+ */
130
+ export function loadAuth(dir: string): McpAuth {
131
+ const filePath = path.join(dir, "auth.json");
132
+ return readJsonFile<McpAuth>(filePath) ?? {};
133
+ }
134
+
135
+ // ── Config saving ─────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Save MCP config (mcp-config.json) with chmod 600.
139
+ */
140
+ export function saveMcpConfig(dir: string, config: McpConfig): void {
141
+ const filePath = path.join(dir, "mcp-config.json");
142
+ writeJsonFile(filePath, config, 0o600);
143
+ }
144
+
145
+ /**
146
+ * Save metadata (config.json).
147
+ */
148
+ export function saveMetadata(dir: string, meta: McpMetadata): void {
149
+ const filePath = path.join(dir, "config.json");
150
+ writeJsonFile(filePath, meta);
151
+ }
152
+
153
+ /**
154
+ * Save auth data (auth.json) with chmod 600.
155
+ */
156
+ export function saveAuth(dir: string, auth: McpAuth): void {
157
+ const filePath = path.join(dir, "auth.json");
158
+ writeJsonFile(filePath, auth, 0o600);
159
+ }
160
+
161
+ // ── Config merging ────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Merge global and project MCP configs into a resolved server list.
165
+ *
166
+ * Rules:
167
+ * 1. Server only in global → loaded normally (source: "global")
168
+ * 2. Server only in project → loaded normally (source: "project")
169
+ * 3. Server in both → project wins entirely (source: "project-override")
170
+ * 4. Server has enabled: false in project metadata → disabled even if defined globally
171
+ */
172
+ export function resolveServers(
173
+ globalConfig: McpConfig,
174
+ globalMeta: McpMetadata,
175
+ projectConfig: McpConfig | null,
176
+ projectMeta: McpMetadata | null,
177
+ ): ResolvedServer[] {
178
+ const merged = new Map<
179
+ string,
180
+ { def: McpConfig["mcpServers"][string]; source: ServerSource; enabled: boolean }
181
+ >();
182
+
183
+ // Start with all global servers
184
+ for (const [name, def] of Object.entries(globalConfig.mcpServers)) {
185
+ const meta = globalMeta.servers[name];
186
+ merged.set(name, {
187
+ def,
188
+ source: "global",
189
+ enabled: meta?.enabled ?? true,
190
+ });
191
+ }
192
+
193
+ // Project overrides: merge or add
194
+ if (projectConfig) {
195
+ for (const [name, def] of Object.entries(projectConfig.mcpServers)) {
196
+ const existing = merged.get(name);
197
+ merged.set(name, {
198
+ def,
199
+ source: existing ? "project-override" : "project",
200
+ enabled: true, // will be refined by metadata below
201
+ });
202
+ }
203
+ }
204
+
205
+ // Apply enabled/disabled from project metadata
206
+ if (projectMeta) {
207
+ for (const [name, meta] of Object.entries(projectMeta.servers)) {
208
+ const existing = merged.get(name);
209
+ if (existing) {
210
+ existing.enabled = meta.enabled;
211
+ }
212
+ }
213
+ }
214
+
215
+ return Array.from(merged.entries()).map(([name, entry]) => ({
216
+ name,
217
+ def: entry.def,
218
+ source: entry.source,
219
+ enabled: entry.enabled,
220
+ }));
221
+ }
222
+
223
+ /**
224
+ * Merge auth.json env vars into a server definition at spawn time.
225
+ * Auth env vars are added to the server's env, but don't override
226
+ * explicitly set values in mcp-config.json.
227
+ */
228
+ export function mergeEnvWithAuth(
229
+ serverDef: McpConfig["mcpServers"][string],
230
+ auth: Record<string, string>,
231
+ ): McpConfig["mcpServers"][string] {
232
+ if (Object.keys(auth).length === 0) return serverDef;
233
+
234
+ return {
235
+ ...serverDef,
236
+ env: {
237
+ ...auth,
238
+ ...(serverDef.env ?? {}),
239
+ },
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Load both global and project configs and resolve servers.
245
+ * Convenience wrapper around the individual load + resolve functions.
246
+ */
247
+ export function loadAndResolve(
248
+ cwd: string,
249
+ ): { servers: ResolvedServer[]; globalDir: string; projectDir: string } {
250
+ const globalDir = getGlobalConfigDir();
251
+ const projectDir = getProjectConfigDir(cwd);
252
+
253
+ const globalConfig = loadMcpConfig(globalDir);
254
+ const globalMeta = loadMetadata(globalDir);
255
+
256
+ // Project config is optional
257
+ const projectConfigExists =
258
+ fs.existsSync(path.join(projectDir, "mcp-config.json")) ||
259
+ fs.existsSync(path.join(projectDir, "config.json"));
260
+
261
+ const projectConfig = projectConfigExists ? loadMcpConfig(projectDir) : null;
262
+ const projectMeta = projectConfigExists ? loadMetadata(projectDir) : null;
263
+
264
+ const servers = resolveServers(globalConfig, globalMeta, projectConfig, projectMeta);
265
+
266
+ return { servers, globalDir, projectDir };
267
+ }