decorated-pi 0.4.0 → 0.5.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.
@@ -3,11 +3,17 @@
3
3
  */
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
+ import * as os from "node:os";
7
+ import { spawnSync } from "node:child_process";
6
8
  import { loadConfig } from "../settings.js";
9
+ import type { DependencyStatus } from "../rtk";
7
10
 
8
11
  export interface McpServerConfig {
9
12
  name: string;
10
- url: string;
13
+ url?: string;
14
+ command?: string;
15
+ args?: string[];
16
+ env?: Record<string, string>;
11
17
  description?: string;
12
18
  enabled: boolean;
13
19
  source: "builtin" | "global" | "project";
@@ -29,59 +35,142 @@ export const BUILTIN_MCP_SERVERS: Omit<McpServerConfig, "source">[] = [
29
35
  },
30
36
  ];
31
37
 
32
- // ── Project-level config discovery ─────────────────────────────────────────
38
+ /** Builtin tool schemas hardcoded so builtin servers work without a prior connection. */
39
+ export const BUILTIN_MCP_CACHE: McpCache = {
40
+ servers: {
41
+ context7: {
42
+ description: "Context7 documentation and code examples",
43
+ tools: [
44
+ {
45
+ name: "resolve-library-id",
46
+ description: "Resolve a library name to its Context7 library ID",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ libraryName: {
51
+ type: "string",
52
+ description: "Library or framework name to resolve (e.g. 'react', 'vue')",
53
+ },
54
+ filters: {
55
+ type: "array",
56
+ items: {
57
+ type: "object",
58
+ properties: {
59
+ field: { type: "string" },
60
+ operator: { type: "string" },
61
+ value: { type: "string" },
62
+ },
63
+ },
64
+ description: "Optional filters to narrow down results",
65
+ },
66
+ },
67
+ required: ["libraryName"],
68
+ },
69
+ },
70
+ {
71
+ name: "query-docs",
72
+ description: "Retrieve and query documentation using a Context7 library ID",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ libraryId: {
77
+ type: "string",
78
+ description: "Library ID returned by resolve-library-id",
79
+ },
80
+ query: {
81
+ type: "string",
82
+ description: "Question or topic to search in the documentation",
83
+ },
84
+ },
85
+ required: ["libraryId", "query"],
86
+ },
87
+ },
88
+ ],
89
+ cachedAt: 0,
90
+ },
91
+ exa: {
92
+ description: "Exa web search",
93
+ tools: [
94
+ {
95
+ name: "web_search_exa",
96
+ description: "Search the web for any topic and get results",
97
+ inputSchema: {
98
+ type: "object",
99
+ properties: {
100
+ query: {
101
+ type: "string",
102
+ description: "Search query",
103
+ },
104
+ numResults: {
105
+ type: "number",
106
+ description: "Number of results to return (default: 10)",
107
+ },
108
+ },
109
+ required: ["query"],
110
+ },
111
+ },
112
+ {
113
+ name: "web_fetch_exa",
114
+ description: "Read webpage content from specific URLs",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ urls: {
119
+ type: "array",
120
+ items: { type: "string" },
121
+ description: "URLs to fetch content from",
122
+ },
123
+ maxCharacters: {
124
+ type: "number",
125
+ description: "Maximum characters per page to return",
126
+ },
127
+ },
128
+ required: ["urls"],
129
+ },
130
+ },
131
+ ],
132
+ cachedAt: 0,
133
+ },
134
+ },
135
+ };
33
136
 
34
- const PROJECT_CONFIG_PATHS = [
35
- ".pi/mcp.json",
36
- ".pi/.mcp.json",
37
- ".agents/mcp.json",
38
- ".agents/.mcp.json",
39
- ".claude/mcp.json",
40
- ".claude/.mcp.json",
41
- "mcp.json",
42
- ".mcp.json",
43
- ];
137
+ // ── Project-level config discovery ─────────────────────────────────────────
44
138
 
45
- function readMcpJson(filePath: string): Record<string, { url: string; enabled?: boolean }> | null {
139
+ function readMcpJson(filePath: string): Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string>; enabled?: boolean; description?: string }> | null {
46
140
  try {
47
141
  const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
48
142
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
49
143
  const servers = raw.mcpServers ?? raw["mcp-servers"];
50
144
  if (!servers || typeof servers !== "object" || Array.isArray(servers)) return null;
51
- return servers as Record<string, { url: string; enabled?: boolean }>;
145
+ return servers as Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string>; enabled?: boolean }>;
52
146
  } catch {
53
147
  return null;
54
148
  }
55
149
  }
56
150
 
57
- /** Load project-level MCP configs from cwd and its ancestor directories. */
151
+ /** Load project-level MCP configs from cwd only. */
58
152
  export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
59
153
  const configs: McpServerConfig[] = [];
60
154
  const seen = new Set<string>();
61
155
 
62
- let current = path.resolve(cwd);
63
- while (true) {
64
- for (const relative of PROJECT_CONFIG_PATHS) {
65
- const filePath = path.join(current, relative);
66
- if (!fs.existsSync(filePath)) continue;
67
- const servers = readMcpJson(filePath);
68
- if (!servers) continue;
69
-
70
- for (const [name, entry] of Object.entries(servers)) {
71
- if (seen.has(name)) continue;
72
- seen.add(name);
73
- configs.push({
74
- name,
75
- url: entry.url,
76
- enabled: entry.enabled !== false,
77
- source: "project",
78
- });
79
- }
80
- }
156
+ const filePath = path.join(cwd, ".pi/agent/mcp.json");
157
+ if (!fs.existsSync(filePath)) return [];
158
+ const servers = readMcpJson(filePath);
159
+ if (!servers) return [];
81
160
 
82
- const parent = path.dirname(current);
83
- if (parent === current) break;
84
- current = parent;
161
+ for (const [name, entry] of Object.entries(servers)) {
162
+ if (seen.has(name)) continue;
163
+ seen.add(name);
164
+ configs.push({
165
+ name,
166
+ url: entry.url,
167
+ command: entry.command,
168
+ args: entry.args,
169
+ env: entry.env,
170
+ enabled: entry.enabled !== false,
171
+ description: entry.description,
172
+ source: "project",
173
+ });
85
174
  }
86
175
 
87
176
  return configs;
@@ -95,11 +184,20 @@ export function loadGlobalMcpConfigs(): McpServerConfig[] {
95
184
  return Object.entries(config.mcpServers).map(([name, entry]) => ({
96
185
  name,
97
186
  url: entry.url,
187
+ command: entry.command,
188
+ args: entry.args,
189
+ env: entry.env,
190
+ description: (entry as any).description as string | undefined,
98
191
  enabled: entry.enabled !== false,
99
192
  source: "global" as const,
100
193
  }));
101
194
  }
102
195
 
196
+ /** Returns true if the URL should use SSE transport (path ends with /sse). */
197
+ export function isSseUrl(url: string): boolean {
198
+ return url.endsWith("/sse") || url.endsWith("/sse/");
199
+ }
200
+
103
201
  /**
104
202
  * Merge all MCP configs: builtin → global → project.
105
203
  * Later sources override earlier ones for the same server name.
@@ -112,15 +210,223 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
112
210
  byName.set(s.name, { ...s, source: "builtin" });
113
211
  }
114
212
 
115
- // Global
213
+ // Global — preserve url/command/description from builtin if not overridden
116
214
  for (const s of loadGlobalMcpConfigs()) {
117
- byName.set(s.name, s);
215
+ const existing = byName.get(s.name);
216
+ if (existing) {
217
+ byName.set(s.name, {
218
+ ...existing,
219
+ ...s,
220
+ url: s.url ?? existing.url,
221
+ command: s.command ?? existing.command,
222
+ args: s.args ?? existing.args,
223
+ env: s.env ?? existing.env,
224
+ description: s.description ?? existing.description,
225
+ source: "global",
226
+ });
227
+ } else {
228
+ byName.set(s.name, s);
229
+ }
118
230
  }
119
231
 
120
- // Project (highest priority)
232
+ // Project (highest priority) — same preservation logic
121
233
  for (const s of loadProjectMcpConfigs(cwd)) {
122
- byName.set(s.name, s);
234
+ const existing = byName.get(s.name);
235
+ if (existing) {
236
+ byName.set(s.name, {
237
+ ...existing,
238
+ ...s,
239
+ url: s.url ?? existing.url,
240
+ command: s.command ?? existing.command,
241
+ args: s.args ?? existing.args,
242
+ env: s.env ?? existing.env,
243
+ description: s.description ?? existing.description,
244
+ source: "project",
245
+ });
246
+ } else {
247
+ byName.set(s.name, s);
248
+ }
123
249
  }
124
250
 
125
- return [...byName.values()].filter((s) => s.enabled);
251
+ return [...byName.values()].filter((s) => s.url || s.command);
252
+ }
253
+
254
+ export function collectMcpDependencyStatuses(cwd: string): DependencyStatus[] {
255
+ const seen = new Set<string>();
256
+ const statuses: DependencyStatus[] = [];
257
+ for (const cfg of resolveMcpConfigs(cwd)) {
258
+ if (!cfg.enabled || !cfg.command || seen.has(cfg.command)) continue;
259
+ seen.add(cfg.command);
260
+ statuses.push({
261
+ module: `mcp:${cfg.name}`,
262
+ label: cfg.command,
263
+ state: commandExists(cfg.command) ? "ok" : "missing",
264
+ detail: `Install the MCP server command for \"${cfg.name}\" or update its config.`,
265
+ });
266
+ }
267
+ return statuses;
268
+ }
269
+
270
+ function commandExists(command: string): boolean {
271
+ if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) {
272
+ return fs.existsSync(command);
273
+ }
274
+ const result = process.platform === "win32"
275
+ ? spawnSync("where", [command], { encoding: "utf-8" })
276
+ : spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
277
+ return result.status === 0;
278
+ }
279
+
280
+ /** Write auto-generated description back to project mcp.json. */
281
+ export function saveProjectMcpDescription(cwd: string, name: string, description: string): void {
282
+ const filePath = path.join(cwd, ".pi/agent/mcp.json");
283
+ const servers = readMcpJson(filePath);
284
+ if (!servers || !servers[name]) return;
285
+ servers[name] = { ...servers[name], description };
286
+ const dir = path.dirname(filePath);
287
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
288
+ fs.writeFileSync(filePath, JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "utf-8");
289
+ }
290
+
291
+ // ── Metadata cache (tool descriptions + schemas) ──────────────────────────
292
+
293
+ export interface McpToolCache {
294
+ name: string;
295
+ description?: string;
296
+ inputSchema: Record<string, unknown>;
297
+ }
298
+
299
+ export interface McpServerCache {
300
+ description?: string;
301
+ tools: McpToolCache[];
302
+ cachedAt: number;
303
+ }
304
+
305
+ export interface McpCache {
306
+ servers: Record<string, McpServerCache>;
307
+ }
308
+
309
+ function globalCachePath(): string {
310
+ return path.join(os.homedir(), ".pi/agent/mcp-cache.json");
311
+ }
312
+
313
+ function projectCachePath(cwd: string): string {
314
+ return path.join(cwd, ".pi/agent/mcp-cache.json");
315
+ }
316
+
317
+ function readCacheFile(p: string): McpCache | null {
318
+ try {
319
+ if (!fs.existsSync(p)) return null;
320
+ return JSON.parse(fs.readFileSync(p, "utf-8")) as McpCache;
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ function writeCacheFile(p: string, cache: McpCache): void {
327
+ const dir = path.dirname(p);
328
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
329
+ const tmp = `${p}.tmp`;
330
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
331
+ fs.renameSync(tmp, p);
332
+ }
333
+
334
+ /** Load merged cache: builtin + global + project. */
335
+ export function loadMcpCache(cwd?: string): McpCache | null {
336
+ const merged: McpCache = { servers: { ...BUILTIN_MCP_CACHE.servers } };
337
+
338
+ const globalCache = readCacheFile(globalCachePath());
339
+ if (globalCache) {
340
+ merged.servers = { ...merged.servers, ...globalCache.servers };
341
+ }
342
+
343
+ if (cwd) {
344
+ const projectCache = readCacheFile(projectCachePath(cwd));
345
+ if (projectCache) {
346
+ merged.servers = { ...merged.servers, ...projectCache.servers };
347
+ }
348
+ }
349
+
350
+ return merged;
351
+ }
352
+
353
+ /** Save cache to global or project scope. */
354
+ export function saveMcpCache(cache: McpCache, scope: "global" | "project", cwd?: string): void {
355
+ const p = scope === "project" && cwd ? projectCachePath(cwd) : globalCachePath();
356
+ writeCacheFile(p, cache);
357
+ }
358
+
359
+ /** Update a single server's entry in the appropriate cache. */
360
+ export function updateServerCache(
361
+ serverName: string,
362
+ entry: McpServerCache,
363
+ scope: "global" | "project",
364
+ cwd?: string,
365
+ ): void {
366
+ const p = scope === "project" && cwd ? projectCachePath(cwd) : globalCachePath();
367
+ const existing = readCacheFile(p) || { servers: {} };
368
+ existing.servers[serverName] = entry;
369
+ writeCacheFile(p, existing);
370
+ }
371
+
372
+ // ── Enable / Disable helpers ────────────────────────────────────────────
373
+
374
+ function readMcpJsonSafe(filePath: string): Record<string, any> | null {
375
+ try {
376
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
382
+ /** Toggle a server's enabled state in the appropriate config file. */
383
+ export function toggleMcpServerEnabled(
384
+ serverName: string,
385
+ enabled: boolean,
386
+ scope: "global" | "project",
387
+ cwd?: string,
388
+ ): boolean {
389
+ try {
390
+ if (scope === "project" && cwd) {
391
+ const filePath = path.join(cwd, ".pi/agent/mcp.json");
392
+ const raw = readMcpJsonSafe(filePath) || { mcpServers: {} };
393
+ const servers = raw.mcpServers ?? {};
394
+ servers[serverName] = { ...(servers[serverName] || {}), enabled };
395
+ raw.mcpServers = servers;
396
+ const dir = path.dirname(filePath);
397
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
398
+ fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
399
+ } else {
400
+ const { loadConfig } = require("../settings.js");
401
+ const config = loadConfig();
402
+ config.mcpServers = config.mcpServers || {};
403
+ config.mcpServers[serverName] = { ...(config.mcpServers[serverName] || {}), enabled };
404
+ const configPath = path.join(os.homedir(), ".pi/agent/decorated-pi.json");
405
+ const dir = path.dirname(configPath);
406
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
407
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
408
+ }
409
+ return true;
410
+ } catch {
411
+ return false;
412
+ }
413
+ }
414
+
415
+ function cleanupOneCache(p: string, names: Set<string>): void {
416
+ const cache = readCacheFile(p);
417
+ if (!cache) return;
418
+ let changed = false;
419
+ for (const name of Object.keys(cache.servers)) {
420
+ if (!names.has(name)) {
421
+ delete cache.servers[name];
422
+ changed = true;
423
+ }
424
+ }
425
+ if (changed) writeCacheFile(p, cache);
426
+ }
427
+
428
+ export function cleanupStaleCache(configs: McpServerConfig[], cwd?: string): void {
429
+ const names = new Set(configs.map(c => c.name));
430
+ cleanupOneCache(globalCachePath(), names);
431
+ if (cwd) cleanupOneCache(projectCachePath(cwd), names);
126
432
  }
@@ -1,6 +1,9 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
3
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import type { McpServerConfig } from "./builtin.js";
6
+ import { isSseUrl } from "./builtin.js";
4
7
 
5
8
  export interface McpToolSpec {
6
9
  name: string;
@@ -8,10 +11,10 @@ export interface McpToolSpec {
8
11
  inputSchema: Record<string, unknown>;
9
12
  }
10
13
 
11
- /** Per-server MCP client wrapper with fallback transport. */
14
+ /** Per-server MCP client wrapper. Supports stdio, http, and sse transports. */
12
15
  export class McpConnection {
13
16
  client: Client;
14
- transport: StreamableHTTPClientTransport | SSEClientTransport | undefined;
17
+ transport: StreamableHTTPClientTransport | SSEClientTransport | StdioClientTransport | undefined;
15
18
  tools: McpToolSpec[] = [];
16
19
  private connected = false;
17
20
 
@@ -19,7 +22,7 @@ export class McpConnection {
19
22
 
20
23
  constructor(
21
24
  public readonly serverName: string,
22
- public readonly url: string,
25
+ public readonly config: McpServerConfig,
23
26
  ) {
24
27
  this.client = new Client({
25
28
  name: `decorated-pi-${serverName}`,
@@ -28,26 +31,32 @@ export class McpConnection {
28
31
  }
29
32
 
30
33
  async connect(timeoutMs = 8000): Promise<void> {
31
- const connectWithFallback = async (): Promise<void> => {
32
- let lastErr: Error | undefined;
33
- try {
34
- const transport = new StreamableHTTPClientTransport(new URL(this.url));
34
+ const connectAndListTools = async (): Promise<void> => {
35
+ if (this.config.command) {
36
+ // Stdio transport — spawn a local process
37
+ const transport = new StdioClientTransport({
38
+ command: this.config.command,
39
+ args: this.config.args,
40
+ env: this.config.env,
41
+ stderr: "ignore",
42
+ });
35
43
  await this.client.connect(transport);
36
44
  this.transport = transport;
37
45
  this.connected = true;
38
- } catch (err) {
39
- lastErr = err instanceof Error ? err : new Error(String(err));
40
- try {
41
- const transport = new SSEClientTransport(new URL(this.url));
46
+ } else if (this.config.url) {
47
+ // HTTP or SSE transport determined by URL path
48
+ if (isSseUrl(this.config.url)) {
49
+ const transport = new SSEClientTransport(new URL(this.config.url));
50
+ await this.client.connect(transport);
51
+ this.transport = transport;
52
+ } else {
53
+ const transport = new StreamableHTTPClientTransport(new URL(this.config.url));
42
54
  await this.client.connect(transport);
43
55
  this.transport = transport;
44
- this.connected = true;
45
- } catch (sseErr) {
46
- const sseMessage = sseErr instanceof Error ? sseErr.message : String(sseErr);
47
- throw new Error(
48
- `MCP ${this.serverName}: StreamableHTTP failed (${lastErr.message}); SSE fallback also failed (${sseMessage})`,
49
- );
50
56
  }
57
+ this.connected = true;
58
+ } else {
59
+ throw new Error(`MCP ${this.serverName}: no url or command configured`);
51
60
  }
52
61
 
53
62
  const result = (await this.client.listTools()) as unknown as {
@@ -69,7 +78,7 @@ export class McpConnection {
69
78
  setTimeout(() => reject(new Error(`MCP ${this.serverName}: connection timed out after ${timeoutMs}ms`)), timeoutMs),
70
79
  );
71
80
 
72
- await Promise.race([connectWithFallback(), timeout]);
81
+ await Promise.race([connectAndListTools(), timeout]);
73
82
  }
74
83
 
75
84
  async callTool(name: string, args: Record<string, unknown>): Promise<string> {
@@ -103,4 +112,4 @@ export class McpConnection {
103
112
  await this.client.close();
104
113
  } catch {}
105
114
  }
106
- }
115
+ }