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.
- package/README.md +74 -57
- package/extensions/guidance.ts +20 -13
- package/extensions/index.ts +72 -6
- package/extensions/io.ts +4 -3
- package/extensions/lsp/servers.ts +63 -3
- package/extensions/mcp/builtin.ts +348 -42
- package/extensions/mcp/client.ts +28 -19
- package/extensions/mcp/index.ts +407 -80
- package/extensions/model-integration.ts +19 -8
- package/extensions/patch.ts +232 -52
- package/extensions/rtk.ts +244 -0
- package/extensions/settings.ts +63 -1
- package/extensions/slash.ts +199 -67
- package/extensions/smart-at.ts +27 -1
- package/extensions/wakatime.ts +403 -0
- package/package.json +4 -5
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/extensions/mcp/client.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const transport = new
|
|
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
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
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([
|
|
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
|
+
}
|