@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/README.md +109 -0
- package/data/seed-servers.json +727 -0
- package/package.json +47 -0
- package/skills/mcp/SKILL.md +104 -0
- package/src/bridge/client.ts +365 -0
- package/src/bridge/registry.ts +281 -0
- package/src/bridge/translator.ts +100 -0
- package/src/config/manager.ts +267 -0
- package/src/config/schema.ts +114 -0
- package/src/config/sync.ts +416 -0
- package/src/index.ts +297 -0
- package/src/tui/add-overlay.ts +436 -0
- package/src/tui/settings-overlay.ts +369 -0
- package/src/types.ts +162 -0
|
@@ -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
|
+
}
|