@agimon-ai/mcp-proxy 0.4.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/LICENSE +52 -0
- package/README.md +530 -0
- package/dist/cli.cjs +1513 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +1514 -0
- package/dist/index.cjs +27 -0
- package/dist/index.d.cts +1350 -0
- package/dist/index.d.mts +1351 -0
- package/dist/index.mjs +3 -0
- package/dist/src-6KF7hZTe.cjs +4975 -0
- package/dist/src-DQ_MagA0.mjs +4779 -0
- package/package.json +61 -0
|
@@ -0,0 +1,4779 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { access, mkdir, readFile, readdir, rm, stat, unlink, watch, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import yaml from "js-yaml";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { Liquid } from "liquidjs";
|
|
11
|
+
import { createNodeTelemetry } from "@agimon-ai/log-sink-mcp";
|
|
12
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
13
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
14
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
15
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { once } from "node:events";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
+
import express from "express";
|
|
21
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
22
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
|
+
|
|
24
|
+
//#region src/utils/mcpConfigSchema.ts
|
|
25
|
+
/**
|
|
26
|
+
* mcpConfigSchema Utilities
|
|
27
|
+
*
|
|
28
|
+
* DESIGN PATTERNS:
|
|
29
|
+
* - Schema-based validation using Zod
|
|
30
|
+
* - Pure functions with no side effects
|
|
31
|
+
* - Type inference from schemas
|
|
32
|
+
* - Transformation from Claude Code format to internal format
|
|
33
|
+
*
|
|
34
|
+
* CODING STANDARDS:
|
|
35
|
+
* - Export individual functions and schemas
|
|
36
|
+
* - Use descriptive function names with verbs
|
|
37
|
+
* - Add JSDoc comments for complex logic
|
|
38
|
+
* - Keep functions small and focused
|
|
39
|
+
*
|
|
40
|
+
* AVOID:
|
|
41
|
+
* - Side effects (mutating external state)
|
|
42
|
+
* - Stateful logic (use services for state)
|
|
43
|
+
* - Loosely typed configs (use Zod for runtime safety)
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Interpolate environment variables in a string
|
|
47
|
+
* Supports ${VAR_NAME} syntax
|
|
48
|
+
*
|
|
49
|
+
* This function replaces environment variable placeholders with their actual values.
|
|
50
|
+
* If an environment variable is not defined, the placeholder is kept as-is and a warning is logged.
|
|
51
|
+
*
|
|
52
|
+
* Examples:
|
|
53
|
+
* - "${HOME}/data" → "/Users/username/data"
|
|
54
|
+
* - "Bearer ${API_KEY}" → "Bearer sk-abc123xyz"
|
|
55
|
+
* - "${DATABASE_URL}/api" → "postgres://localhost:5432/mydb/api"
|
|
56
|
+
*
|
|
57
|
+
* Supported locations for environment variable interpolation:
|
|
58
|
+
* - Stdio config: command, args, env values
|
|
59
|
+
* - HTTP/SSE config: url, header values
|
|
60
|
+
*
|
|
61
|
+
* @param value - String that may contain environment variable references
|
|
62
|
+
* @returns String with environment variables replaced
|
|
63
|
+
*/
|
|
64
|
+
function interpolateEnvVars(value) {
|
|
65
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
66
|
+
const envValue = process.env[varName];
|
|
67
|
+
if (envValue === void 0) {
|
|
68
|
+
console.warn(`Environment variable ${varName} is not defined, keeping placeholder`);
|
|
69
|
+
return `\${${varName}}`;
|
|
70
|
+
}
|
|
71
|
+
return envValue;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Recursively interpolate environment variables in an object
|
|
76
|
+
*
|
|
77
|
+
* @param obj - Object that may contain environment variable references
|
|
78
|
+
* @returns Object with environment variables replaced
|
|
79
|
+
*/
|
|
80
|
+
function interpolateEnvVarsInObject(obj) {
|
|
81
|
+
if (typeof obj === "string") return interpolateEnvVars(obj);
|
|
82
|
+
if (Array.isArray(obj)) return obj.map((item) => interpolateEnvVarsInObject(item));
|
|
83
|
+
if (obj !== null && typeof obj === "object") {
|
|
84
|
+
const result = {};
|
|
85
|
+
for (const [key, value] of Object.entries(obj)) result[key] = interpolateEnvVarsInObject(value);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
return obj;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Private IP range patterns for SSRF protection
|
|
92
|
+
* Covers both IPv4 and IPv6 loopback, private, and link-local ranges
|
|
93
|
+
*/
|
|
94
|
+
const PRIVATE_IP_PATTERNS = [
|
|
95
|
+
/^127\./,
|
|
96
|
+
/^10\./,
|
|
97
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
98
|
+
/^192\.168\./,
|
|
99
|
+
/^169\.254\./,
|
|
100
|
+
/^0\./,
|
|
101
|
+
/^224\./,
|
|
102
|
+
/^240\./,
|
|
103
|
+
/^localhost$/i,
|
|
104
|
+
/^.*\.localhost$/i,
|
|
105
|
+
/^\[::\]/,
|
|
106
|
+
/^\[::1\]/,
|
|
107
|
+
/^\[0:0:0:0:0:0:0:1\]/,
|
|
108
|
+
/^\[0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:1\]/i,
|
|
109
|
+
/^\[fe80:/i,
|
|
110
|
+
/^\[fc00:/i,
|
|
111
|
+
/^\[fd00:/i,
|
|
112
|
+
/^\[::ffff:127\./i,
|
|
113
|
+
/^\[::ffff:7f[0-9a-f]{2}:/i,
|
|
114
|
+
/^\[::ffff:10\./i,
|
|
115
|
+
/^\[::ffff:a[0-9a-f]{2}:/i,
|
|
116
|
+
/^\[::ffff:172\.(1[6-9]|2\d|3[01])\./i,
|
|
117
|
+
/^\[::ffff:ac1[0-9a-f]:/i,
|
|
118
|
+
/^\[::ffff:192\.168\./i,
|
|
119
|
+
/^\[::ffff:c0a8:/i,
|
|
120
|
+
/^\[::ffff:169\.254\./i,
|
|
121
|
+
/^\[::ffff:a9fe:/i,
|
|
122
|
+
/^\[::ffff:0\./i,
|
|
123
|
+
/^\[::127\./i,
|
|
124
|
+
/^\[::7f[0-9a-f]{2}:/i,
|
|
125
|
+
/^\[::10\./i,
|
|
126
|
+
/^\[::a[0-9a-f]{2}:/i,
|
|
127
|
+
/^\[::192\.168\./i,
|
|
128
|
+
/^\[::c0a8:/i
|
|
129
|
+
];
|
|
130
|
+
/**
|
|
131
|
+
* Validate URL for SSRF protection
|
|
132
|
+
*
|
|
133
|
+
* @param url - The URL to validate (after env var interpolation)
|
|
134
|
+
* @param security - Security settings
|
|
135
|
+
* @throws Error if URL is unsafe
|
|
136
|
+
*/
|
|
137
|
+
function validateUrlSecurity(url, security) {
|
|
138
|
+
const allowPrivateIPs = security?.allowPrivateIPs ?? false;
|
|
139
|
+
const enforceHttps = security?.enforceHttps ?? true;
|
|
140
|
+
let parsedUrl;
|
|
141
|
+
try {
|
|
142
|
+
parsedUrl = new URL(url);
|
|
143
|
+
} catch (_error) {
|
|
144
|
+
throw new Error(`Invalid URL format: ${url}`);
|
|
145
|
+
}
|
|
146
|
+
const protocol = parsedUrl.protocol.replace(":", "");
|
|
147
|
+
if (enforceHttps && protocol !== "https") throw new Error(`HTTPS is required for security. URL uses '${protocol}://'. Set security.enforceHttps: false to allow HTTP.`);
|
|
148
|
+
if (protocol !== "http" && protocol !== "https") throw new Error(`Invalid URL protocol '${protocol}://'. Only http:// and https:// are allowed.`);
|
|
149
|
+
if (!allowPrivateIPs) {
|
|
150
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
151
|
+
if (PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(hostname))) throw new Error(`Private IP addresses and localhost are blocked for security (${hostname}). Set security.allowPrivateIPs: true to allow internal networks.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Validate a remote config source against its validation rules
|
|
156
|
+
*
|
|
157
|
+
* @param source - Remote config source with validation rules
|
|
158
|
+
* @throws Error if validation fails
|
|
159
|
+
*/
|
|
160
|
+
function validateRemoteConfigSource(source) {
|
|
161
|
+
const interpolatedUrl = interpolateEnvVars(source.url);
|
|
162
|
+
validateUrlSecurity(interpolatedUrl, source.security);
|
|
163
|
+
if (!source.validation) return;
|
|
164
|
+
if (source.validation.url) {
|
|
165
|
+
if (!new RegExp(source.validation.url).test(interpolatedUrl)) throw new Error(`Remote config URL "${interpolatedUrl}" does not match validation pattern: ${source.validation.url}`);
|
|
166
|
+
}
|
|
167
|
+
if (source.validation.headers && Object.keys(source.validation.headers).length > 0) {
|
|
168
|
+
if (!source.headers) {
|
|
169
|
+
const requiredHeaders = Object.keys(source.validation.headers);
|
|
170
|
+
throw new Error(`Remote config is missing required headers: ${requiredHeaders.join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
for (const [headerName, pattern] of Object.entries(source.validation.headers)) {
|
|
173
|
+
if (!(headerName in source.headers)) throw new Error(`Remote config is missing required header: ${headerName}`);
|
|
174
|
+
const interpolatedHeaderValue = interpolateEnvVars(source.headers[headerName]);
|
|
175
|
+
if (!new RegExp(pattern).test(interpolatedHeaderValue)) throw new Error(`Remote config header "${headerName}" value "${interpolatedHeaderValue}" does not match validation pattern: ${pattern}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Claude Code / Claude Desktop standard MCP config format
|
|
181
|
+
* This is the format users write in their config files
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* Prompt skill configuration schema
|
|
185
|
+
* Converts a prompt to an executable skill
|
|
186
|
+
*/
|
|
187
|
+
const PromptSkillConfigSchema = z.object({
|
|
188
|
+
name: z.string(),
|
|
189
|
+
description: z.string(),
|
|
190
|
+
folder: z.string().optional()
|
|
191
|
+
});
|
|
192
|
+
/**
|
|
193
|
+
* Prompt configuration schema
|
|
194
|
+
* Supports converting prompts to skills
|
|
195
|
+
*/
|
|
196
|
+
const PromptConfigSchema = z.object({ skill: PromptSkillConfigSchema.optional() });
|
|
197
|
+
const AdditionalConfigSchema = z.object({
|
|
198
|
+
instruction: z.string().optional(),
|
|
199
|
+
toolBlacklist: z.array(z.string()).optional(),
|
|
200
|
+
omitToolDescription: z.boolean().optional(),
|
|
201
|
+
prompts: z.record(z.string(), PromptConfigSchema).optional()
|
|
202
|
+
}).optional();
|
|
203
|
+
const ClaudeCodeStdioServerSchema = z.object({
|
|
204
|
+
command: z.string(),
|
|
205
|
+
args: z.array(z.string()).optional(),
|
|
206
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
207
|
+
disabled: z.boolean().optional(),
|
|
208
|
+
instruction: z.string().optional(),
|
|
209
|
+
timeout: z.number().positive().optional(),
|
|
210
|
+
requestTimeout: z.number().positive().optional(),
|
|
211
|
+
config: AdditionalConfigSchema
|
|
212
|
+
});
|
|
213
|
+
const ClaudeCodeHttpServerSchema = z.object({
|
|
214
|
+
url: z.string().url(),
|
|
215
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
216
|
+
type: z.enum(["http", "sse"]).optional(),
|
|
217
|
+
disabled: z.boolean().optional(),
|
|
218
|
+
instruction: z.string().optional(),
|
|
219
|
+
timeout: z.number().positive().optional(),
|
|
220
|
+
requestTimeout: z.number().positive().optional(),
|
|
221
|
+
config: AdditionalConfigSchema
|
|
222
|
+
});
|
|
223
|
+
const ClaudeCodeServerConfigSchema = z.union([ClaudeCodeStdioServerSchema, ClaudeCodeHttpServerSchema]);
|
|
224
|
+
const RemoteConfigValidationSchema = z.object({
|
|
225
|
+
url: z.string().optional(),
|
|
226
|
+
headers: z.record(z.string(), z.string()).optional()
|
|
227
|
+
}).optional();
|
|
228
|
+
const RemoteConfigSecuritySchema = z.object({
|
|
229
|
+
allowPrivateIPs: z.boolean().optional(),
|
|
230
|
+
enforceHttps: z.boolean().optional()
|
|
231
|
+
}).optional();
|
|
232
|
+
const RemoteConfigSourceSchema = z.object({
|
|
233
|
+
url: z.string(),
|
|
234
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
235
|
+
validation: RemoteConfigValidationSchema,
|
|
236
|
+
security: RemoteConfigSecuritySchema,
|
|
237
|
+
mergeStrategy: z.enum([
|
|
238
|
+
"local-priority",
|
|
239
|
+
"remote-priority",
|
|
240
|
+
"merge-deep"
|
|
241
|
+
]).optional()
|
|
242
|
+
});
|
|
243
|
+
/**
|
|
244
|
+
* Skills configuration schema
|
|
245
|
+
*/
|
|
246
|
+
const SkillsConfigSchema = z.object({ paths: z.array(z.string()) });
|
|
247
|
+
/**
|
|
248
|
+
* Full Claude Code MCP configuration schema
|
|
249
|
+
*/
|
|
250
|
+
const ClaudeCodeMcpConfigSchema = z.object({
|
|
251
|
+
id: z.string().optional(),
|
|
252
|
+
mcpServers: z.record(z.string(), ClaudeCodeServerConfigSchema),
|
|
253
|
+
remoteConfigs: z.array(RemoteConfigSourceSchema).optional(),
|
|
254
|
+
skills: SkillsConfigSchema.optional()
|
|
255
|
+
});
|
|
256
|
+
/**
|
|
257
|
+
* Internal MCP config format
|
|
258
|
+
* This is the normalized format used internally by the proxy
|
|
259
|
+
*/
|
|
260
|
+
const McpStdioConfigSchema = z.object({
|
|
261
|
+
command: z.string(),
|
|
262
|
+
args: z.array(z.string()).optional(),
|
|
263
|
+
env: z.record(z.string(), z.string()).optional()
|
|
264
|
+
});
|
|
265
|
+
const McpHttpConfigSchema = z.object({
|
|
266
|
+
url: z.string().url(),
|
|
267
|
+
headers: z.record(z.string(), z.string()).optional()
|
|
268
|
+
});
|
|
269
|
+
const McpSseConfigSchema = z.object({
|
|
270
|
+
url: z.string().url(),
|
|
271
|
+
headers: z.record(z.string(), z.string()).optional()
|
|
272
|
+
});
|
|
273
|
+
/**
|
|
274
|
+
* Internal prompt skill configuration schema
|
|
275
|
+
*/
|
|
276
|
+
const InternalPromptSkillConfigSchema = z.object({
|
|
277
|
+
name: z.string(),
|
|
278
|
+
description: z.string(),
|
|
279
|
+
folder: z.string().optional()
|
|
280
|
+
});
|
|
281
|
+
/**
|
|
282
|
+
* Internal prompt configuration schema
|
|
283
|
+
*/
|
|
284
|
+
const InternalPromptConfigSchema = z.object({ skill: InternalPromptSkillConfigSchema.optional() });
|
|
285
|
+
const McpServerConfigSchema = z.discriminatedUnion("transport", [
|
|
286
|
+
z.object({
|
|
287
|
+
name: z.string(),
|
|
288
|
+
instruction: z.string().optional(),
|
|
289
|
+
toolBlacklist: z.array(z.string()).optional(),
|
|
290
|
+
omitToolDescription: z.boolean().optional(),
|
|
291
|
+
prompts: z.record(z.string(), InternalPromptConfigSchema).optional(),
|
|
292
|
+
timeout: z.number().positive().optional(),
|
|
293
|
+
requestTimeout: z.number().positive().optional(),
|
|
294
|
+
transport: z.literal("stdio"),
|
|
295
|
+
config: McpStdioConfigSchema
|
|
296
|
+
}),
|
|
297
|
+
z.object({
|
|
298
|
+
name: z.string(),
|
|
299
|
+
instruction: z.string().optional(),
|
|
300
|
+
toolBlacklist: z.array(z.string()).optional(),
|
|
301
|
+
omitToolDescription: z.boolean().optional(),
|
|
302
|
+
prompts: z.record(z.string(), InternalPromptConfigSchema).optional(),
|
|
303
|
+
timeout: z.number().positive().optional(),
|
|
304
|
+
requestTimeout: z.number().positive().optional(),
|
|
305
|
+
transport: z.literal("http"),
|
|
306
|
+
config: McpHttpConfigSchema
|
|
307
|
+
}),
|
|
308
|
+
z.object({
|
|
309
|
+
name: z.string(),
|
|
310
|
+
instruction: z.string().optional(),
|
|
311
|
+
toolBlacklist: z.array(z.string()).optional(),
|
|
312
|
+
omitToolDescription: z.boolean().optional(),
|
|
313
|
+
prompts: z.record(z.string(), InternalPromptConfigSchema).optional(),
|
|
314
|
+
timeout: z.number().positive().optional(),
|
|
315
|
+
requestTimeout: z.number().positive().optional(),
|
|
316
|
+
transport: z.literal("sse"),
|
|
317
|
+
config: McpSseConfigSchema
|
|
318
|
+
})
|
|
319
|
+
]);
|
|
320
|
+
/**
|
|
321
|
+
* Full internal MCP configuration schema
|
|
322
|
+
*/
|
|
323
|
+
const InternalMcpConfigSchema = z.object({
|
|
324
|
+
id: z.string().optional(),
|
|
325
|
+
mcpServers: z.record(z.string(), McpServerConfigSchema),
|
|
326
|
+
skills: SkillsConfigSchema.optional()
|
|
327
|
+
});
|
|
328
|
+
/**
|
|
329
|
+
* Transform Claude Code config to internal format
|
|
330
|
+
* Converts standard Claude Code MCP configuration to normalized internal format
|
|
331
|
+
*
|
|
332
|
+
* @param claudeConfig - Claude Code format configuration
|
|
333
|
+
* @returns Internal format configuration
|
|
334
|
+
*/
|
|
335
|
+
function transformClaudeCodeConfig(claudeConfig) {
|
|
336
|
+
const transformedServers = {};
|
|
337
|
+
for (const [serverName, serverConfig] of Object.entries(claudeConfig.mcpServers)) {
|
|
338
|
+
if ("disabled" in serverConfig && serverConfig.disabled === true) continue;
|
|
339
|
+
if ("command" in serverConfig) {
|
|
340
|
+
const stdioConfig = serverConfig;
|
|
341
|
+
const interpolatedCommand = interpolateEnvVars(stdioConfig.command);
|
|
342
|
+
const interpolatedArgs = stdioConfig.args?.map((arg) => interpolateEnvVars(arg));
|
|
343
|
+
const interpolatedEnv = stdioConfig.env ? interpolateEnvVarsInObject(stdioConfig.env) : void 0;
|
|
344
|
+
transformedServers[serverName] = {
|
|
345
|
+
name: serverName,
|
|
346
|
+
instruction: stdioConfig.instruction || stdioConfig.config?.instruction,
|
|
347
|
+
toolBlacklist: stdioConfig.config?.toolBlacklist,
|
|
348
|
+
omitToolDescription: stdioConfig.config?.omitToolDescription,
|
|
349
|
+
prompts: stdioConfig.config?.prompts,
|
|
350
|
+
timeout: stdioConfig.timeout,
|
|
351
|
+
requestTimeout: stdioConfig.requestTimeout,
|
|
352
|
+
transport: "stdio",
|
|
353
|
+
config: {
|
|
354
|
+
command: interpolatedCommand,
|
|
355
|
+
args: interpolatedArgs,
|
|
356
|
+
env: interpolatedEnv
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
} else if ("url" in serverConfig) {
|
|
360
|
+
const httpConfig = serverConfig;
|
|
361
|
+
const transport = httpConfig.type === "sse" ? "sse" : "http";
|
|
362
|
+
const interpolatedUrl = interpolateEnvVars(httpConfig.url);
|
|
363
|
+
const interpolatedHeaders = httpConfig.headers ? interpolateEnvVarsInObject(httpConfig.headers) : void 0;
|
|
364
|
+
transformedServers[serverName] = {
|
|
365
|
+
name: serverName,
|
|
366
|
+
instruction: httpConfig.instruction || httpConfig.config?.instruction,
|
|
367
|
+
toolBlacklist: httpConfig.config?.toolBlacklist,
|
|
368
|
+
omitToolDescription: httpConfig.config?.omitToolDescription,
|
|
369
|
+
prompts: httpConfig.config?.prompts,
|
|
370
|
+
timeout: httpConfig.timeout,
|
|
371
|
+
requestTimeout: httpConfig.requestTimeout,
|
|
372
|
+
transport,
|
|
373
|
+
config: {
|
|
374
|
+
url: interpolatedUrl,
|
|
375
|
+
headers: interpolatedHeaders
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
id: claudeConfig.id,
|
|
382
|
+
mcpServers: transformedServers,
|
|
383
|
+
skills: claudeConfig.skills
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Parse and validate MCP config from raw JSON
|
|
388
|
+
* Validates against Claude Code format, transforms to internal format, and validates result
|
|
389
|
+
*
|
|
390
|
+
* @param rawConfig - Raw JSON configuration object
|
|
391
|
+
* @returns Validated and transformed internal configuration
|
|
392
|
+
* @throws ZodError if validation fails
|
|
393
|
+
*/
|
|
394
|
+
function parseMcpConfig(rawConfig) {
|
|
395
|
+
const internalConfig = transformClaudeCodeConfig(ClaudeCodeMcpConfigSchema.parse(rawConfig));
|
|
396
|
+
return InternalMcpConfigSchema.parse(internalConfig);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/utils/findConfigFile.ts
|
|
401
|
+
/**
|
|
402
|
+
* Config File Finder Utility
|
|
403
|
+
*
|
|
404
|
+
* DESIGN PATTERNS:
|
|
405
|
+
* - Utility function pattern for reusable logic
|
|
406
|
+
* - Fail-fast pattern with early returns
|
|
407
|
+
* - Environment variable configuration pattern
|
|
408
|
+
*
|
|
409
|
+
* CODING STANDARDS:
|
|
410
|
+
* - Use sync filesystem operations for config discovery (performance)
|
|
411
|
+
* - Check PROJECT_PATH environment variable first
|
|
412
|
+
* - Fall back to current working directory
|
|
413
|
+
* - Support both .yaml and .json extensions
|
|
414
|
+
* - Return null if no config file is found
|
|
415
|
+
*
|
|
416
|
+
* AVOID:
|
|
417
|
+
* - Throwing errors (return null instead for optional config)
|
|
418
|
+
* - Hardcoded file names without extension variants
|
|
419
|
+
* - Ignoring environment variables
|
|
420
|
+
*/
|
|
421
|
+
/**
|
|
422
|
+
* Find MCP configuration file by checking PROJECT_PATH first, then cwd
|
|
423
|
+
* Looks for both mcp-config.yaml and mcp-config.json
|
|
424
|
+
*
|
|
425
|
+
* @returns Absolute path to config file, or null if not found
|
|
426
|
+
*/
|
|
427
|
+
function findConfigFile() {
|
|
428
|
+
const configFileNames = [
|
|
429
|
+
"mcp-config.yaml",
|
|
430
|
+
"mcp-config.yml",
|
|
431
|
+
"mcp-config.json"
|
|
432
|
+
];
|
|
433
|
+
const projectPath = process.env.PROJECT_PATH;
|
|
434
|
+
if (projectPath) for (const fileName of configFileNames) {
|
|
435
|
+
const configPath = resolve(projectPath, fileName);
|
|
436
|
+
if (existsSync(configPath)) return configPath;
|
|
437
|
+
}
|
|
438
|
+
const cwd = process.cwd();
|
|
439
|
+
for (const fileName of configFileNames) {
|
|
440
|
+
const configPath = join(cwd, fileName);
|
|
441
|
+
if (existsSync(configPath)) return configPath;
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/utils/parseToolName.ts
|
|
448
|
+
/**
|
|
449
|
+
* Parse tool name to extract server and actual tool name
|
|
450
|
+
* Supports both plain tool names and prefixed format: {serverName}__{toolName}
|
|
451
|
+
*
|
|
452
|
+
* @param toolName - The tool name to parse (e.g., "my_tool" or "server__my_tool")
|
|
453
|
+
* @returns Parsed result with optional serverName and actualToolName
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* parseToolName("my_tool") // { actualToolName: "my_tool" }
|
|
457
|
+
* parseToolName("server__my_tool") // { serverName: "server", actualToolName: "my_tool" }
|
|
458
|
+
*/
|
|
459
|
+
function parseToolName(toolName) {
|
|
460
|
+
const separatorIndex = toolName.indexOf("__");
|
|
461
|
+
if (separatorIndex > 0) return {
|
|
462
|
+
serverName: toolName.substring(0, separatorIndex),
|
|
463
|
+
actualToolName: toolName.substring(separatorIndex + 2)
|
|
464
|
+
};
|
|
465
|
+
return { actualToolName: toolName };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/utils/parseFrontMatter.ts
|
|
470
|
+
/**
|
|
471
|
+
* Parses YAML front matter from a string content.
|
|
472
|
+
* Front matter must be at the start of the content, delimited by `---`.
|
|
473
|
+
*
|
|
474
|
+
* Supports:
|
|
475
|
+
* - Simple key: value pairs
|
|
476
|
+
* - Literal block scalar (|) for multi-line preserving newlines
|
|
477
|
+
* - Folded block scalar (>) for multi-line folding to single line
|
|
478
|
+
*
|
|
479
|
+
* @param content - The content string that may contain front matter
|
|
480
|
+
* @returns Object with parsed front matter (or null) and remaining content
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* const result = parseFrontMatter(`---
|
|
484
|
+
* name: my-skill
|
|
485
|
+
* description: A skill description
|
|
486
|
+
* ---
|
|
487
|
+
* The actual content here`);
|
|
488
|
+
* // result.frontMatter = { name: 'my-skill', description: 'A skill description' }
|
|
489
|
+
* // result.content = 'The actual content here'
|
|
490
|
+
*
|
|
491
|
+
* @example
|
|
492
|
+
* // Multi-line with literal block scalar
|
|
493
|
+
* const result = parseFrontMatter(`---
|
|
494
|
+
* name: my-skill
|
|
495
|
+
* description: |
|
|
496
|
+
* Line 1
|
|
497
|
+
* Line 2
|
|
498
|
+
* ---
|
|
499
|
+
* Content`);
|
|
500
|
+
* // result.frontMatter.description = 'Line 1\nLine 2'
|
|
501
|
+
*/
|
|
502
|
+
function parseFrontMatter(content) {
|
|
503
|
+
const trimmedContent = content.trimStart();
|
|
504
|
+
if (!trimmedContent.startsWith("---")) return {
|
|
505
|
+
frontMatter: null,
|
|
506
|
+
content
|
|
507
|
+
};
|
|
508
|
+
const endDelimiterIndex = trimmedContent.indexOf("\n---", 3);
|
|
509
|
+
if (endDelimiterIndex === -1) return {
|
|
510
|
+
frontMatter: null,
|
|
511
|
+
content
|
|
512
|
+
};
|
|
513
|
+
const yamlContent = trimmedContent.slice(4, endDelimiterIndex).trim();
|
|
514
|
+
if (!yamlContent) return {
|
|
515
|
+
frontMatter: null,
|
|
516
|
+
content
|
|
517
|
+
};
|
|
518
|
+
const frontMatter = {};
|
|
519
|
+
const lines = yamlContent.split("\n");
|
|
520
|
+
let currentKey = null;
|
|
521
|
+
let currentValue = [];
|
|
522
|
+
let multiLineMode = null;
|
|
523
|
+
let baseIndent = 0;
|
|
524
|
+
const saveCurrentKey = () => {
|
|
525
|
+
if (currentKey && currentValue.length > 0) if (multiLineMode === "literal") frontMatter[currentKey] = currentValue.join("\n").trimEnd();
|
|
526
|
+
else if (multiLineMode === "folded") frontMatter[currentKey] = currentValue.join(" ").trim();
|
|
527
|
+
else frontMatter[currentKey] = currentValue.join("").trim();
|
|
528
|
+
currentKey = null;
|
|
529
|
+
currentValue = [];
|
|
530
|
+
multiLineMode = null;
|
|
531
|
+
baseIndent = 0;
|
|
532
|
+
};
|
|
533
|
+
for (let i = 0; i < lines.length; i++) {
|
|
534
|
+
const line = lines[i];
|
|
535
|
+
const trimmedLine = line.trim();
|
|
536
|
+
const colonIndex = line.indexOf(":");
|
|
537
|
+
if (colonIndex !== -1 && !line.startsWith(" ") && !line.startsWith(" ")) {
|
|
538
|
+
saveCurrentKey();
|
|
539
|
+
const key = line.slice(0, colonIndex).trim();
|
|
540
|
+
let value = line.slice(colonIndex + 1).trim();
|
|
541
|
+
if (value === "|" || value === "|-") {
|
|
542
|
+
currentKey = key;
|
|
543
|
+
multiLineMode = "literal";
|
|
544
|
+
if (i + 1 < lines.length) {
|
|
545
|
+
const match = lines[i + 1].match(/^(\s+)/);
|
|
546
|
+
baseIndent = match ? match[1].length : 2;
|
|
547
|
+
}
|
|
548
|
+
} else if (value === ">" || value === ">-") {
|
|
549
|
+
currentKey = key;
|
|
550
|
+
multiLineMode = "folded";
|
|
551
|
+
if (i + 1 < lines.length) {
|
|
552
|
+
const match = lines[i + 1].match(/^(\s+)/);
|
|
553
|
+
baseIndent = match ? match[1].length : 2;
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
557
|
+
if (key && value) frontMatter[key] = value;
|
|
558
|
+
}
|
|
559
|
+
} else if (multiLineMode && currentKey) {
|
|
560
|
+
const lineIndent = line.match(/^(\s*)/)?.[1].length || 0;
|
|
561
|
+
if (trimmedLine === "") currentValue.push("");
|
|
562
|
+
else if (lineIndent >= baseIndent) {
|
|
563
|
+
const unindentedLine = line.slice(baseIndent);
|
|
564
|
+
currentValue.push(unindentedLine);
|
|
565
|
+
} else saveCurrentKey();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
saveCurrentKey();
|
|
569
|
+
return {
|
|
570
|
+
frontMatter,
|
|
571
|
+
content: trimmedContent.slice(endDelimiterIndex + 4).trimStart()
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Checks if parsed front matter contains valid skill metadata.
|
|
576
|
+
* A valid skill front matter must have both `name` and `description` fields.
|
|
577
|
+
*
|
|
578
|
+
* @param frontMatter - The parsed front matter object
|
|
579
|
+
* @returns True if front matter contains valid skill metadata
|
|
580
|
+
*/
|
|
581
|
+
function isValidSkillFrontMatter(frontMatter) {
|
|
582
|
+
return frontMatter !== null && typeof frontMatter.name === "string" && frontMatter.name.length > 0 && typeof frontMatter.description === "string" && frontMatter.description.length > 0;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Extracts skill front matter from content if present and valid.
|
|
586
|
+
*
|
|
587
|
+
* @param content - The content string that may contain skill front matter
|
|
588
|
+
* @returns Object with skill metadata and content, or null if no valid skill front matter
|
|
589
|
+
*/
|
|
590
|
+
function extractSkillFrontMatter(content) {
|
|
591
|
+
const { frontMatter, content: remainingContent } = parseFrontMatter(content);
|
|
592
|
+
if (frontMatter && isValidSkillFrontMatter(frontMatter)) return {
|
|
593
|
+
skill: {
|
|
594
|
+
name: frontMatter.name,
|
|
595
|
+
description: frontMatter.description
|
|
596
|
+
},
|
|
597
|
+
content: remainingContent
|
|
598
|
+
};
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/utils/generateServerId.ts
|
|
604
|
+
/**
|
|
605
|
+
* generateServerId Utilities
|
|
606
|
+
*
|
|
607
|
+
* DESIGN PATTERNS:
|
|
608
|
+
* - Pure functions with no side effects
|
|
609
|
+
* - Single responsibility per function
|
|
610
|
+
* - Functional programming approach
|
|
611
|
+
*
|
|
612
|
+
* CODING STANDARDS:
|
|
613
|
+
* - Export individual functions, not classes
|
|
614
|
+
* - Use descriptive function names with verbs
|
|
615
|
+
* - Add JSDoc comments for complex logic
|
|
616
|
+
* - Keep functions small and focused
|
|
617
|
+
*
|
|
618
|
+
* AVOID:
|
|
619
|
+
* - Side effects (mutating external state)
|
|
620
|
+
* - Stateful logic (use services for state)
|
|
621
|
+
* - Complex external dependencies
|
|
622
|
+
*/
|
|
623
|
+
/**
|
|
624
|
+
* Character set for generating human-readable IDs.
|
|
625
|
+
* Excludes confusing characters: 0, O, 1, l, I
|
|
626
|
+
*/
|
|
627
|
+
const CHARSET = "23456789abcdefghjkmnpqrstuvwxyz";
|
|
628
|
+
/**
|
|
629
|
+
* Default length for generated server IDs (6 characters)
|
|
630
|
+
*/
|
|
631
|
+
const DEFAULT_ID_LENGTH = 6;
|
|
632
|
+
/**
|
|
633
|
+
* Generate a short, human-readable server ID.
|
|
634
|
+
*
|
|
635
|
+
* Uses Node.js crypto.randomBytes for cryptographically secure randomness
|
|
636
|
+
* with rejection sampling to avoid modulo bias.
|
|
637
|
+
*
|
|
638
|
+
* The generated ID:
|
|
639
|
+
* - Is 6 characters long by default
|
|
640
|
+
* - Uses only lowercase alphanumeric characters
|
|
641
|
+
* - Excludes confusing characters (0, O, 1, l, I)
|
|
642
|
+
*
|
|
643
|
+
* @param length - Length of the ID to generate (default: 6)
|
|
644
|
+
* @returns A random, human-readable ID
|
|
645
|
+
*
|
|
646
|
+
* @example
|
|
647
|
+
* generateServerId() // "abc234"
|
|
648
|
+
* generateServerId(4) // "x7mn"
|
|
649
|
+
*/
|
|
650
|
+
function generateServerId(length = DEFAULT_ID_LENGTH) {
|
|
651
|
+
const charsetLength = 31;
|
|
652
|
+
const maxUnbiased = Math.floor(256 / charsetLength) * charsetLength - 1;
|
|
653
|
+
let result = "";
|
|
654
|
+
let remaining = length;
|
|
655
|
+
while (remaining > 0) {
|
|
656
|
+
const bytes = randomBytes(remaining);
|
|
657
|
+
for (let i = 0; i < bytes.length && remaining > 0; i++) {
|
|
658
|
+
const byte = bytes[i];
|
|
659
|
+
if (byte > maxUnbiased) continue;
|
|
660
|
+
result += CHARSET[byte % charsetLength];
|
|
661
|
+
remaining--;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/constants/index.ts
|
|
669
|
+
/**
|
|
670
|
+
* Shared constants for mcp-proxy package
|
|
671
|
+
*/
|
|
672
|
+
/**
|
|
673
|
+
* Prefix added to skill names when they clash with MCP tool names.
|
|
674
|
+
* This ensures skills can be uniquely identified even when a tool has the same name.
|
|
675
|
+
*/
|
|
676
|
+
const SKILL_PREFIX = "skill__";
|
|
677
|
+
/**
|
|
678
|
+
* Log prefix for skill detection messages.
|
|
679
|
+
* Used to easily filter skill detection logs in stderr output.
|
|
680
|
+
*/
|
|
681
|
+
const LOG_PREFIX_SKILL_DETECTION = "[skill-detection]";
|
|
682
|
+
/**
|
|
683
|
+
* Log prefix for general MCP capability discovery messages.
|
|
684
|
+
*/
|
|
685
|
+
const LOG_PREFIX_CAPABILITY_DISCOVERY = "[capability-discovery]";
|
|
686
|
+
/**
|
|
687
|
+
* Prefix for prompt-based skill locations.
|
|
688
|
+
* Format: "prompt:{serverName}:{promptName}"
|
|
689
|
+
*/
|
|
690
|
+
const PROMPT_LOCATION_PREFIX = "prompt:";
|
|
691
|
+
/**
|
|
692
|
+
* Default server ID used when no ID is provided via CLI or config.
|
|
693
|
+
* This fallback is used when auto-generation also fails.
|
|
694
|
+
*/
|
|
695
|
+
const DEFAULT_SERVER_ID = "unknown";
|
|
696
|
+
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/services/DefinitionsCacheService.ts
|
|
699
|
+
/**
|
|
700
|
+
* DefinitionsCacheService
|
|
701
|
+
*
|
|
702
|
+
* Provides shared discovery, caching, and serialization for startup-time MCP
|
|
703
|
+
* capability metadata. This avoids repeated remote enumeration during
|
|
704
|
+
* mcp-serve startup and describe_tools generation.
|
|
705
|
+
*/
|
|
706
|
+
function isYamlPath(filePath) {
|
|
707
|
+
return filePath.endsWith(".yaml") || filePath.endsWith(".yml");
|
|
708
|
+
}
|
|
709
|
+
function toErrorMessage$3(error) {
|
|
710
|
+
return error instanceof Error ? error.message : String(error);
|
|
711
|
+
}
|
|
712
|
+
function sanitizeConfigPathForFilename(configFilePath) {
|
|
713
|
+
const absoluteConfigPath = resolve(configFilePath);
|
|
714
|
+
const normalizedPath = absoluteConfigPath.length >= 2 && absoluteConfigPath[1] === ":" && (absoluteConfigPath[0] >= "A" && absoluteConfigPath[0] <= "Z" || absoluteConfigPath[0] >= "a" && absoluteConfigPath[0] <= "z") ? `${absoluteConfigPath[0].toLowerCase()}${absoluteConfigPath.slice(1)}` : absoluteConfigPath;
|
|
715
|
+
let result = "";
|
|
716
|
+
let previousWasUnderscore = false;
|
|
717
|
+
for (const char of normalizedPath) {
|
|
718
|
+
if (char >= "a" && char <= "z" || char >= "A" && char <= "Z" || char >= "0" && char <= "9" || char === "." || char === "_" || char === "-") {
|
|
719
|
+
result += char;
|
|
720
|
+
previousWasUnderscore = false;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (!previousWasUnderscore) {
|
|
724
|
+
result += "_";
|
|
725
|
+
previousWasUnderscore = true;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
let start = 0;
|
|
729
|
+
let end = result.length;
|
|
730
|
+
while (start < end && result[start] === "_") start += 1;
|
|
731
|
+
while (end > start && result[end - 1] === "_") end -= 1;
|
|
732
|
+
return result.slice(start, end);
|
|
733
|
+
}
|
|
734
|
+
function cloneCache(cache) {
|
|
735
|
+
return {
|
|
736
|
+
...cache,
|
|
737
|
+
failures: [...cache.failures ?? []],
|
|
738
|
+
skills: (cache.skills ?? []).map((skill) => ({ ...skill })),
|
|
739
|
+
servers: Object.fromEntries(Object.entries(cache.servers).map(([serverName, server]) => [serverName, {
|
|
740
|
+
...server,
|
|
741
|
+
tools: (server.tools ?? []).map((tool) => ({ ...tool })),
|
|
742
|
+
resources: (server.resources ?? []).map((resource) => ({ ...resource })),
|
|
743
|
+
prompts: (server.prompts ?? []).map((prompt) => ({
|
|
744
|
+
...prompt,
|
|
745
|
+
arguments: prompt.arguments?.map((arg) => ({ ...arg }))
|
|
746
|
+
})),
|
|
747
|
+
promptSkills: (server.promptSkills ?? []).map((promptSkill) => ({
|
|
748
|
+
...promptSkill,
|
|
749
|
+
skill: { ...promptSkill.skill }
|
|
750
|
+
}))
|
|
751
|
+
}]))
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
var DefinitionsCacheService = class {
|
|
755
|
+
clientManager;
|
|
756
|
+
skillService;
|
|
757
|
+
cacheData;
|
|
758
|
+
liveDefinitionsPromise = null;
|
|
759
|
+
mergedDefinitionsPromise = null;
|
|
760
|
+
logger;
|
|
761
|
+
constructor(clientManager, skillService, options, logger = console) {
|
|
762
|
+
this.clientManager = clientManager;
|
|
763
|
+
this.skillService = skillService;
|
|
764
|
+
this.cacheData = options?.cacheData;
|
|
765
|
+
this.logger = logger;
|
|
766
|
+
}
|
|
767
|
+
static async readFromFile(filePath) {
|
|
768
|
+
const content = await readFile(filePath, "utf-8");
|
|
769
|
+
const parsed = isYamlPath(filePath) ? yaml.load(content) : JSON.parse(content);
|
|
770
|
+
if (!parsed || typeof parsed !== "object") throw new Error("Definitions cache must be an object");
|
|
771
|
+
const cache = parsed;
|
|
772
|
+
if (cache.version !== 1 || !cache.servers) throw new Error("Definitions cache is missing required fields");
|
|
773
|
+
return {
|
|
774
|
+
...cache,
|
|
775
|
+
failures: Array.isArray(cache.failures) ? cache.failures : [],
|
|
776
|
+
skills: Array.isArray(cache.skills) ? cache.skills : [],
|
|
777
|
+
servers: Object.fromEntries(Object.entries(cache.servers).map(([serverName, server]) => [serverName, {
|
|
778
|
+
...server,
|
|
779
|
+
tools: Array.isArray(server.tools) ? server.tools : [],
|
|
780
|
+
resources: Array.isArray(server.resources) ? server.resources : [],
|
|
781
|
+
prompts: Array.isArray(server.prompts) ? server.prompts : [],
|
|
782
|
+
promptSkills: Array.isArray(server.promptSkills) ? server.promptSkills : []
|
|
783
|
+
}]))
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
static async writeToFile(filePath, cache) {
|
|
787
|
+
const serialized = isYamlPath(filePath) ? yaml.dump(cache, { noRefs: true }) : JSON.stringify(cache, null, 2);
|
|
788
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
789
|
+
await writeFile(filePath, serialized, "utf-8");
|
|
790
|
+
}
|
|
791
|
+
static getDefaultCachePath(configFilePath) {
|
|
792
|
+
const sanitizedPath = sanitizeConfigPathForFilename(configFilePath);
|
|
793
|
+
return join(homedir(), ".aicode-toolkit", `${sanitizedPath}.definitions-cache.json`);
|
|
794
|
+
}
|
|
795
|
+
static generateConfigHash(config) {
|
|
796
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
797
|
+
}
|
|
798
|
+
static isCacheValid(cache, options) {
|
|
799
|
+
if (options.configHash && cache.configHash && cache.configHash !== options.configHash) return false;
|
|
800
|
+
if (options.oneMcpVersion && cache.oneMcpVersion && cache.oneMcpVersion !== options.oneMcpVersion) return false;
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
static async clearFile(filePath) {
|
|
804
|
+
await rm(filePath, { force: true });
|
|
805
|
+
}
|
|
806
|
+
clearLiveCache() {
|
|
807
|
+
this.liveDefinitionsPromise = null;
|
|
808
|
+
this.mergedDefinitionsPromise = null;
|
|
809
|
+
}
|
|
810
|
+
setCacheData(cacheData) {
|
|
811
|
+
this.cacheData = cacheData;
|
|
812
|
+
this.mergedDefinitionsPromise = null;
|
|
813
|
+
}
|
|
814
|
+
async collectForCache(options) {
|
|
815
|
+
const liveDefinitions = await this.collectLiveDefinitions(options);
|
|
816
|
+
this.setCacheData(liveDefinitions);
|
|
817
|
+
this.liveDefinitionsPromise = Promise.resolve(cloneCache(liveDefinitions));
|
|
818
|
+
return cloneCache(liveDefinitions);
|
|
819
|
+
}
|
|
820
|
+
async getDefinitions() {
|
|
821
|
+
if (this.mergedDefinitionsPromise) return this.mergedDefinitionsPromise;
|
|
822
|
+
this.mergedDefinitionsPromise = (async () => {
|
|
823
|
+
const clients = this.clientManager.getAllClients();
|
|
824
|
+
if (!this.cacheData) return this.getLiveDefinitions();
|
|
825
|
+
const missingServers = clients.map((client) => client.serverName).filter((serverName) => !this.cacheData?.servers[serverName]);
|
|
826
|
+
if (missingServers.length === 0) return cloneCache(this.cacheData);
|
|
827
|
+
const liveDefinitions = await this.getLiveDefinitions();
|
|
828
|
+
const merged = cloneCache(this.cacheData);
|
|
829
|
+
for (const serverName of missingServers) {
|
|
830
|
+
const serverDefinition = liveDefinitions.servers[serverName];
|
|
831
|
+
if (serverDefinition) merged.servers[serverName] = serverDefinition;
|
|
832
|
+
}
|
|
833
|
+
const failureMap = /* @__PURE__ */ new Map();
|
|
834
|
+
for (const failure of [...merged.failures, ...liveDefinitions.failures]) failureMap.set(failure.serverName, failure.error);
|
|
835
|
+
merged.failures = Array.from(failureMap.entries()).map(([serverName, error]) => ({
|
|
836
|
+
serverName,
|
|
837
|
+
error
|
|
838
|
+
}));
|
|
839
|
+
if (merged.skills.length === 0 && liveDefinitions.skills.length > 0) merged.skills = liveDefinitions.skills.map((skill) => ({ ...skill }));
|
|
840
|
+
return merged;
|
|
841
|
+
})();
|
|
842
|
+
return this.mergedDefinitionsPromise;
|
|
843
|
+
}
|
|
844
|
+
async getServerDefinitions() {
|
|
845
|
+
const definitions = await this.getDefinitions();
|
|
846
|
+
const serverOrder = this.clientManager.getKnownServerNames();
|
|
847
|
+
if (serverOrder.length === 0) return Object.values(definitions.servers);
|
|
848
|
+
return serverOrder.map((serverName) => definitions.servers[serverName]).filter((server) => server !== void 0);
|
|
849
|
+
}
|
|
850
|
+
async getServersForTool(toolName) {
|
|
851
|
+
return (await this.getServerDefinitions()).filter((serverDefinition) => serverDefinition.tools.some((tool) => tool.name === toolName)).map((serverDefinition) => serverDefinition.serverName);
|
|
852
|
+
}
|
|
853
|
+
async getServersForResource(uri) {
|
|
854
|
+
return (await this.getServerDefinitions()).filter((serverDefinition) => serverDefinition.resources.some((resource) => resource.uri === uri)).map((serverDefinition) => serverDefinition.serverName);
|
|
855
|
+
}
|
|
856
|
+
async getPromptSkillByName(skillName) {
|
|
857
|
+
const definitions = await this.getDefinitions();
|
|
858
|
+
for (const [serverName, server] of Object.entries(definitions.servers)) for (const promptSkill of server.promptSkills) if (promptSkill.skill.name === skillName) return {
|
|
859
|
+
serverName,
|
|
860
|
+
promptName: promptSkill.promptName,
|
|
861
|
+
skill: promptSkill.skill,
|
|
862
|
+
autoDetected: promptSkill.autoDetected
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
async getCachedFileSkills() {
|
|
866
|
+
return (await this.getDefinitions()).skills.map((skill) => ({ ...skill }));
|
|
867
|
+
}
|
|
868
|
+
async getLiveDefinitions() {
|
|
869
|
+
if (!this.liveDefinitionsPromise) this.liveDefinitionsPromise = this.collectLiveDefinitions();
|
|
870
|
+
return this.liveDefinitionsPromise;
|
|
871
|
+
}
|
|
872
|
+
async collectLiveDefinitions(options) {
|
|
873
|
+
const clients = this.clientManager.getAllClients();
|
|
874
|
+
const failures = [];
|
|
875
|
+
const servers = {};
|
|
876
|
+
const serverResults = await Promise.all(clients.map(async (client) => {
|
|
877
|
+
try {
|
|
878
|
+
const tools = await client.listTools();
|
|
879
|
+
const resources = await this.listResourcesSafe(client);
|
|
880
|
+
const prompts = await this.listPromptsSafe(client);
|
|
881
|
+
const blacklist = new Set(client.toolBlacklist || []);
|
|
882
|
+
const filteredTools = tools.filter((tool) => !blacklist.has(tool.name));
|
|
883
|
+
const promptSkills = await this.collectPromptSkillsForClient(client, prompts);
|
|
884
|
+
return {
|
|
885
|
+
serverName: client.serverName,
|
|
886
|
+
serverInstruction: client.serverInstruction,
|
|
887
|
+
omitToolDescription: client.omitToolDescription,
|
|
888
|
+
toolBlacklist: client.toolBlacklist,
|
|
889
|
+
tools: filteredTools.map((tool) => ({
|
|
890
|
+
name: tool.name,
|
|
891
|
+
description: tool.description,
|
|
892
|
+
inputSchema: tool.inputSchema,
|
|
893
|
+
_meta: tool._meta
|
|
894
|
+
})),
|
|
895
|
+
resources,
|
|
896
|
+
prompts,
|
|
897
|
+
promptSkills
|
|
898
|
+
};
|
|
899
|
+
} catch (error) {
|
|
900
|
+
failures.push({
|
|
901
|
+
serverName: client.serverName,
|
|
902
|
+
error: toErrorMessage$3(error)
|
|
903
|
+
});
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
}));
|
|
907
|
+
for (const serverDefinition of serverResults) if (serverDefinition) servers[serverDefinition.serverName] = serverDefinition;
|
|
908
|
+
return {
|
|
909
|
+
version: 1,
|
|
910
|
+
oneMcpVersion: options?.oneMcpVersion,
|
|
911
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
912
|
+
configPath: options?.configPath,
|
|
913
|
+
configHash: options?.configHash,
|
|
914
|
+
serverId: options?.serverId,
|
|
915
|
+
servers,
|
|
916
|
+
skills: await this.collectFileSkills(),
|
|
917
|
+
failures
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
async collectFileSkills() {
|
|
921
|
+
if (!this.skillService) return [];
|
|
922
|
+
return (await this.skillService.getSkills()).map((skill) => this.toCachedFileSkill(skill));
|
|
923
|
+
}
|
|
924
|
+
toCachedFileSkill(skill) {
|
|
925
|
+
return {
|
|
926
|
+
name: skill.name,
|
|
927
|
+
description: skill.description,
|
|
928
|
+
location: skill.location,
|
|
929
|
+
basePath: skill.basePath
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
async listPromptsSafe(client) {
|
|
933
|
+
try {
|
|
934
|
+
return (await client.listPrompts()).map((prompt) => ({
|
|
935
|
+
name: prompt.name,
|
|
936
|
+
description: prompt.description,
|
|
937
|
+
arguments: prompt.arguments?.map((arg) => ({ ...arg }))
|
|
938
|
+
}));
|
|
939
|
+
} catch (error) {
|
|
940
|
+
this.logger.warn(`${LOG_PREFIX_SKILL_DETECTION} Failed to list prompts from ${client.serverName}: ${toErrorMessage$3(error)}`);
|
|
941
|
+
return [];
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async listResourcesSafe(client) {
|
|
945
|
+
try {
|
|
946
|
+
return (await client.listResources()).map((resource) => ({
|
|
947
|
+
uri: resource.uri,
|
|
948
|
+
name: resource.name,
|
|
949
|
+
description: resource.description,
|
|
950
|
+
mimeType: resource.mimeType
|
|
951
|
+
}));
|
|
952
|
+
} catch (error) {
|
|
953
|
+
this.logger.warn(`${LOG_PREFIX_CAPABILITY_DISCOVERY} Failed to list resources from ${client.serverName}: ${toErrorMessage$3(error)}`);
|
|
954
|
+
return [];
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async collectPromptSkillsForClient(client, prompts) {
|
|
958
|
+
const configuredPromptNames = new Set(client.prompts ? Object.keys(client.prompts) : []);
|
|
959
|
+
const promptSkills = [];
|
|
960
|
+
if (client.prompts) {
|
|
961
|
+
for (const [promptName, promptConfig] of Object.entries(client.prompts)) if (promptConfig.skill) promptSkills.push({
|
|
962
|
+
promptName,
|
|
963
|
+
skill: { ...promptConfig.skill }
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
const autoDetectedSkills = await Promise.all(prompts.map(async (prompt) => {
|
|
967
|
+
if (configuredPromptNames.has(prompt.name)) return null;
|
|
968
|
+
try {
|
|
969
|
+
const skillExtraction = extractSkillFrontMatter((await client.getPrompt(prompt.name)).messages?.map((message) => {
|
|
970
|
+
const content = message.content;
|
|
971
|
+
if (typeof content === "string") return content;
|
|
972
|
+
if (content && typeof content === "object" && "text" in content) return String(content.text);
|
|
973
|
+
return "";
|
|
974
|
+
}).join("\n") || "");
|
|
975
|
+
if (!skillExtraction) return null;
|
|
976
|
+
return {
|
|
977
|
+
promptName: prompt.name,
|
|
978
|
+
skill: skillExtraction.skill,
|
|
979
|
+
autoDetected: true
|
|
980
|
+
};
|
|
981
|
+
} catch (error) {
|
|
982
|
+
this.logger.warn(`${LOG_PREFIX_SKILL_DETECTION} Failed to fetch prompt '${prompt.name}' from ${client.serverName}: ${toErrorMessage$3(error)}`);
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
}));
|
|
986
|
+
for (const autoDetectedSkill of autoDetectedSkills) if (autoDetectedSkill) promptSkills.push(autoDetectedSkill);
|
|
987
|
+
return promptSkills;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
//#endregion
|
|
992
|
+
//#region src/templates/toolkit-description.liquid?raw
|
|
993
|
+
var toolkit_description_default = "<toolkit id=\"{{ serverId }}\">\n<instruction>\nBefore you use any capabilities below, you MUST call this tool with a list of names to learn how to use them properly; this includes:\n- For tools: Arguments schema needed to pass to use_tool\n- For skills: Detailed instructions that will expand when invoked (Prefer to be explored first when relevant)\n\nThis tool is optimized for batch queries - you can request multiple capabilities at once for better performance.\n\nHow to invoke:\n- For MCP tools: Use use_tool with toolName and toolArgs based on the schema\n- For skills: Use this tool with the skill name to get expanded instructions\n</instruction>\n\n<available_capabilities>\n{% for server in servers -%}\n<group name=\"{{ server.name }}\">\n{% if server.instruction -%}\n<group_instruction>{{ server.instruction }}</group_instruction>\n{% endif -%}\n{% if server.omitToolDescription -%}\n{% for toolName in server.toolNames -%}\n<item name=\"{{ toolName }}\"></item>\n{% endfor -%}\n{% else -%}\n{% for tool in server.tools -%}\n<item name=\"{{ tool.displayName }}\"><description>{{ tool.description | default: \"No description\" }}</description></item>\n{% endfor -%}\n{% endif -%}\n</group>\n{% endfor -%}\n{% if skills.size > 0 -%}\n<group name=\"skills\">\n{% for skill in skills -%}\n<item name=\"{{ skill.displayName }}\"><description>{{ skill.description }}</description></item>\n{% endfor -%}\n</group>\n{% endif -%}\n</available_capabilities>\n</toolkit>\n";
|
|
994
|
+
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/tools/DescribeToolsTool.ts
|
|
997
|
+
/**
|
|
998
|
+
* Formats skill instructions with the loading command message prefix.
|
|
999
|
+
* This message is used by Claude Code to indicate that a skill is being loaded.
|
|
1000
|
+
*
|
|
1001
|
+
* @param name - The skill name
|
|
1002
|
+
* @param instructions - The raw skill instructions/content
|
|
1003
|
+
* @returns Formatted instructions with command message prefix
|
|
1004
|
+
*/
|
|
1005
|
+
function formatSkillInstructions(name, instructions) {
|
|
1006
|
+
return `<command-message>The "${name}" skill is loading</command-message>\n${instructions}`;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* DescribeToolsTool provides progressive disclosure of MCP tools and skills.
|
|
1010
|
+
*
|
|
1011
|
+
* This tool lists available tools from all connected MCP servers and skills
|
|
1012
|
+
* from the configured skills directories. Users can query for specific tools
|
|
1013
|
+
* or skills to get detailed input schemas and descriptions.
|
|
1014
|
+
*
|
|
1015
|
+
* Tool naming conventions:
|
|
1016
|
+
* - Unique tools: use plain name (e.g., "browser_click")
|
|
1017
|
+
* - Clashing tools: use serverName__toolName format (e.g., "playwright__click")
|
|
1018
|
+
* - Skills: use skill__skillName format (e.g., "skill__pdf")
|
|
1019
|
+
*
|
|
1020
|
+
* @example
|
|
1021
|
+
* const tool = new DescribeToolsTool(clientManager, skillService);
|
|
1022
|
+
* const definition = await tool.getDefinition();
|
|
1023
|
+
* const result = await tool.execute({ toolNames: ['browser_click', 'skill__pdf'] });
|
|
1024
|
+
*/
|
|
1025
|
+
var DescribeToolsTool = class DescribeToolsTool {
|
|
1026
|
+
static TOOL_NAME = "describe_tools";
|
|
1027
|
+
clientManager;
|
|
1028
|
+
skillService;
|
|
1029
|
+
definitionsCacheService;
|
|
1030
|
+
liquid = new Liquid();
|
|
1031
|
+
/** Cache for auto-detected skills from prompt front-matter */
|
|
1032
|
+
autoDetectedSkillsCache = null;
|
|
1033
|
+
/** Unique server identifier for this mcp-proxy instance */
|
|
1034
|
+
serverId;
|
|
1035
|
+
/**
|
|
1036
|
+
* Creates a new DescribeToolsTool instance
|
|
1037
|
+
* @param clientManager - The MCP client manager for accessing remote servers
|
|
1038
|
+
* @param skillService - Optional skill service for loading skills
|
|
1039
|
+
* @param serverId - Unique server identifier for this mcp-proxy instance
|
|
1040
|
+
*/
|
|
1041
|
+
constructor(clientManager, skillService, serverId, definitionsCacheService) {
|
|
1042
|
+
this.clientManager = clientManager;
|
|
1043
|
+
this.skillService = skillService;
|
|
1044
|
+
this.serverId = serverId || DEFAULT_SERVER_ID;
|
|
1045
|
+
this.definitionsCacheService = definitionsCacheService || new DefinitionsCacheService(clientManager, skillService);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Clears the cached auto-detected skills from prompt front-matter.
|
|
1049
|
+
* Use this when prompt configurations may have changed or when
|
|
1050
|
+
* the skill service cache is invalidated.
|
|
1051
|
+
*/
|
|
1052
|
+
clearAutoDetectedSkillsCache() {
|
|
1053
|
+
this.autoDetectedSkillsCache = null;
|
|
1054
|
+
this.definitionsCacheService.clearLiveCache();
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Detects and caches skills from prompt front-matter across all connected MCP servers.
|
|
1058
|
+
* Fetches all prompts and checks their content for YAML front-matter with name/description.
|
|
1059
|
+
* Results are cached to avoid repeated fetches.
|
|
1060
|
+
*
|
|
1061
|
+
* Error Handling Strategy:
|
|
1062
|
+
* - Errors are logged to stderr but do not fail the overall detection process
|
|
1063
|
+
* - This ensures partial results are returned even if some servers/prompts fail
|
|
1064
|
+
* - Common failure reasons: server temporarily unavailable, prompt requires arguments,
|
|
1065
|
+
* network timeout, or server doesn't support listPrompts
|
|
1066
|
+
* - Errors are prefixed with [skill-detection] for easy filtering in logs
|
|
1067
|
+
*
|
|
1068
|
+
* @returns Array of auto-detected skills from prompt front-matter
|
|
1069
|
+
*/
|
|
1070
|
+
async detectSkillsFromPromptFrontMatter() {
|
|
1071
|
+
if (this.autoDetectedSkillsCache !== null) return this.autoDetectedSkillsCache;
|
|
1072
|
+
const clients = this.clientManager.getAllClients();
|
|
1073
|
+
let listPromptsFailures = 0;
|
|
1074
|
+
let fetchPromptFailures = 0;
|
|
1075
|
+
const autoDetectedSkills = (await Promise.all(clients.map(async (client) => {
|
|
1076
|
+
const detectedSkills = [];
|
|
1077
|
+
const configuredPromptNames = new Set(client.prompts ? Object.keys(client.prompts) : []);
|
|
1078
|
+
try {
|
|
1079
|
+
const prompts = await client.listPrompts();
|
|
1080
|
+
if (!prompts || prompts.length === 0) return detectedSkills;
|
|
1081
|
+
const promptResults = await Promise.all(prompts.map(async (promptInfo) => {
|
|
1082
|
+
if (configuredPromptNames.has(promptInfo.name)) return null;
|
|
1083
|
+
try {
|
|
1084
|
+
const skillExtraction = extractSkillFrontMatter(((await client.getPrompt(promptInfo.name)).messages || []).map((m) => {
|
|
1085
|
+
const content = m.content;
|
|
1086
|
+
if (typeof content === "string") return content;
|
|
1087
|
+
if (content && typeof content === "object" && "text" in content) return String(content.text);
|
|
1088
|
+
return "";
|
|
1089
|
+
}).join("\n"));
|
|
1090
|
+
if (skillExtraction) return {
|
|
1091
|
+
serverName: client.serverName,
|
|
1092
|
+
promptName: promptInfo.name,
|
|
1093
|
+
skill: skillExtraction.skill
|
|
1094
|
+
};
|
|
1095
|
+
return null;
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
fetchPromptFailures++;
|
|
1098
|
+
console.error(`${LOG_PREFIX_SKILL_DETECTION} Failed to fetch prompt '${promptInfo.name}' from ${client.serverName}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
}));
|
|
1102
|
+
for (const result of promptResults) if (result) detectedSkills.push(result);
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
listPromptsFailures++;
|
|
1105
|
+
console.error(`${LOG_PREFIX_SKILL_DETECTION} Failed to list prompts from ${client.serverName}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1106
|
+
}
|
|
1107
|
+
return detectedSkills;
|
|
1108
|
+
}))).flat();
|
|
1109
|
+
if (listPromptsFailures > 0 || fetchPromptFailures > 0) console.error(`${LOG_PREFIX_SKILL_DETECTION} Completed with ${listPromptsFailures} server failure(s) and ${fetchPromptFailures} prompt failure(s). Detected ${autoDetectedSkills.length} skill(s).`);
|
|
1110
|
+
this.autoDetectedSkillsCache = autoDetectedSkills;
|
|
1111
|
+
return autoDetectedSkills;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Collects skills derived from prompt configurations across all connected MCP servers.
|
|
1115
|
+
* Includes both explicitly configured prompts and auto-detected skills from front-matter.
|
|
1116
|
+
*
|
|
1117
|
+
* @returns Array of skill template data derived from prompts
|
|
1118
|
+
*/
|
|
1119
|
+
async collectPromptSkills() {
|
|
1120
|
+
const promptSkills = [];
|
|
1121
|
+
const serverDefinitions = await this.definitionsCacheService.getServerDefinitions();
|
|
1122
|
+
for (const serverDefinition of serverDefinitions) for (const promptSkill of serverDefinition.promptSkills) promptSkills.push({
|
|
1123
|
+
name: promptSkill.skill.name,
|
|
1124
|
+
displayName: promptSkill.skill.name,
|
|
1125
|
+
description: promptSkill.skill.description
|
|
1126
|
+
});
|
|
1127
|
+
return promptSkills;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Finds a prompt-based skill by name from all connected MCP servers.
|
|
1131
|
+
* Searches both explicitly configured prompts and auto-detected skills from front-matter.
|
|
1132
|
+
*
|
|
1133
|
+
* @param skillName - The skill name to search for
|
|
1134
|
+
* @returns Object with serverName, promptName, and skill config, or undefined if not found
|
|
1135
|
+
*/
|
|
1136
|
+
async findPromptSkill(skillName) {
|
|
1137
|
+
if (!skillName) return void 0;
|
|
1138
|
+
return await this.definitionsCacheService.getPromptSkillByName(skillName);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Retrieves skill content from a prompt-based skill configuration.
|
|
1142
|
+
* Fetches the prompt from the MCP server and extracts text content.
|
|
1143
|
+
* Handles both explicitly configured prompts and auto-detected skills from front-matter.
|
|
1144
|
+
*
|
|
1145
|
+
* @param skillName - The skill name being requested
|
|
1146
|
+
* @returns SkillDescription if found and successfully fetched, undefined otherwise
|
|
1147
|
+
*/
|
|
1148
|
+
async getPromptSkillContent(skillName) {
|
|
1149
|
+
const promptSkill = await this.findPromptSkill(skillName);
|
|
1150
|
+
if (!promptSkill) return void 0;
|
|
1151
|
+
try {
|
|
1152
|
+
const rawInstructions = (await (await this.clientManager.ensureConnected(promptSkill.serverName)).getPrompt(promptSkill.promptName)).messages?.map((m) => {
|
|
1153
|
+
const content = m.content;
|
|
1154
|
+
if (typeof content === "string") return content;
|
|
1155
|
+
if (content && typeof content === "object" && "text" in content) return String(content.text);
|
|
1156
|
+
return "";
|
|
1157
|
+
}).join("\n") || "";
|
|
1158
|
+
return {
|
|
1159
|
+
name: promptSkill.skill.name,
|
|
1160
|
+
location: promptSkill.skill.folder || `${PROMPT_LOCATION_PREFIX}${promptSkill.serverName}/${promptSkill.promptName}`,
|
|
1161
|
+
instructions: formatSkillInstructions(promptSkill.skill.name, rawInstructions)
|
|
1162
|
+
};
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
console.error(`Failed to get prompt-based skill '${skillName}': ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Builds the combined toolkit description using a single Liquid template.
|
|
1170
|
+
*
|
|
1171
|
+
* Collects all tools from connected MCP servers and all skills, then renders
|
|
1172
|
+
* them together using the toolkit-description.liquid template.
|
|
1173
|
+
*
|
|
1174
|
+
* Tool names are prefixed with serverName__ when the same tool exists
|
|
1175
|
+
* on multiple servers. Skill names are prefixed with skill__ when they
|
|
1176
|
+
* clash with MCP tools or other skills.
|
|
1177
|
+
*
|
|
1178
|
+
* @returns Object with rendered description and set of all tool names
|
|
1179
|
+
*/
|
|
1180
|
+
async buildToolkitDescription() {
|
|
1181
|
+
const serverDefinitions = await this.definitionsCacheService.getServerDefinitions();
|
|
1182
|
+
const toolToServers = /* @__PURE__ */ new Map();
|
|
1183
|
+
for (const serverDefinition of serverDefinitions) for (const tool of serverDefinition.tools) {
|
|
1184
|
+
if (!toolToServers.has(tool.name)) toolToServers.set(tool.name, []);
|
|
1185
|
+
toolToServers.get(tool.name)?.push(serverDefinition.serverName);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Formats tool name with server prefix if the tool exists on multiple servers
|
|
1189
|
+
*/
|
|
1190
|
+
const formatToolName = (toolName, serverName) => {
|
|
1191
|
+
return (toolToServers.get(toolName) || []).length > 1 ? `${serverName}__${toolName}` : toolName;
|
|
1192
|
+
};
|
|
1193
|
+
const allToolNames = /* @__PURE__ */ new Set();
|
|
1194
|
+
const servers = serverDefinitions.map((serverDefinition) => {
|
|
1195
|
+
const formattedTools = serverDefinition.tools.map((t) => ({
|
|
1196
|
+
displayName: formatToolName(t.name, serverDefinition.serverName),
|
|
1197
|
+
description: t.description
|
|
1198
|
+
}));
|
|
1199
|
+
for (const tool of formattedTools) allToolNames.add(tool.displayName);
|
|
1200
|
+
return {
|
|
1201
|
+
name: serverDefinition.serverName,
|
|
1202
|
+
instruction: serverDefinition.serverInstruction,
|
|
1203
|
+
omitToolDescription: serverDefinition.omitToolDescription || false,
|
|
1204
|
+
tools: formattedTools,
|
|
1205
|
+
toolNames: formattedTools.map((t) => t.displayName)
|
|
1206
|
+
};
|
|
1207
|
+
});
|
|
1208
|
+
const [rawSkills, cachedSkills, promptSkills] = await Promise.all([
|
|
1209
|
+
this.skillService ? this.skillService.getSkills() : Promise.resolve([]),
|
|
1210
|
+
this.definitionsCacheService.getCachedFileSkills(),
|
|
1211
|
+
this.collectPromptSkills()
|
|
1212
|
+
]);
|
|
1213
|
+
const seenSkillNames = /* @__PURE__ */ new Set();
|
|
1214
|
+
const allSkillsData = [];
|
|
1215
|
+
for (const skill of rawSkills) if (!seenSkillNames.has(skill.name)) {
|
|
1216
|
+
seenSkillNames.add(skill.name);
|
|
1217
|
+
allSkillsData.push({
|
|
1218
|
+
name: skill.name,
|
|
1219
|
+
displayName: skill.name,
|
|
1220
|
+
description: skill.description
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
for (const skill of cachedSkills) if (!seenSkillNames.has(skill.name)) {
|
|
1224
|
+
seenSkillNames.add(skill.name);
|
|
1225
|
+
allSkillsData.push({
|
|
1226
|
+
name: skill.name,
|
|
1227
|
+
displayName: skill.name,
|
|
1228
|
+
description: skill.description
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
for (const skill of promptSkills) if (!seenSkillNames.has(skill.name)) {
|
|
1232
|
+
seenSkillNames.add(skill.name);
|
|
1233
|
+
allSkillsData.push(skill);
|
|
1234
|
+
}
|
|
1235
|
+
const skills = allSkillsData.map((skill) => {
|
|
1236
|
+
const clashesWithMcpTool = allToolNames.has(skill.name);
|
|
1237
|
+
return {
|
|
1238
|
+
name: skill.name,
|
|
1239
|
+
displayName: clashesWithMcpTool ? `${SKILL_PREFIX}${skill.name}` : skill.name,
|
|
1240
|
+
description: skill.description
|
|
1241
|
+
};
|
|
1242
|
+
});
|
|
1243
|
+
return {
|
|
1244
|
+
content: await this.liquid.parseAndRender(toolkit_description_default, {
|
|
1245
|
+
servers,
|
|
1246
|
+
skills,
|
|
1247
|
+
serverId: this.serverId
|
|
1248
|
+
}),
|
|
1249
|
+
toolNames: allToolNames
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Gets the tool definition including available tools and skills in a unified format.
|
|
1254
|
+
*
|
|
1255
|
+
* The definition includes:
|
|
1256
|
+
* - All MCP tools from connected servers
|
|
1257
|
+
* - All available skills (file-based and prompt-based)
|
|
1258
|
+
* - Unified instructions for querying capability details
|
|
1259
|
+
*
|
|
1260
|
+
* Tool names are prefixed with serverName__ when clashing.
|
|
1261
|
+
* Skill names are prefixed with skill__ when clashing.
|
|
1262
|
+
*
|
|
1263
|
+
* @returns Tool definition with description and input schema
|
|
1264
|
+
*/
|
|
1265
|
+
async getDefinition() {
|
|
1266
|
+
const { content } = await this.buildToolkitDescription();
|
|
1267
|
+
return {
|
|
1268
|
+
name: DescribeToolsTool.TOOL_NAME,
|
|
1269
|
+
description: content,
|
|
1270
|
+
inputSchema: {
|
|
1271
|
+
type: "object",
|
|
1272
|
+
properties: { toolNames: {
|
|
1273
|
+
type: "array",
|
|
1274
|
+
items: {
|
|
1275
|
+
type: "string",
|
|
1276
|
+
minLength: 1
|
|
1277
|
+
},
|
|
1278
|
+
description: "List of tool names to get detailed information about",
|
|
1279
|
+
minItems: 1
|
|
1280
|
+
} },
|
|
1281
|
+
required: ["toolNames"],
|
|
1282
|
+
additionalProperties: false
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Executes tool description lookup for the requested tool and skill names.
|
|
1288
|
+
*
|
|
1289
|
+
* Handles three types of lookups:
|
|
1290
|
+
* 1. skill__name - Returns skill information from SkillService
|
|
1291
|
+
* 2. serverName__toolName - Returns tool from specific server
|
|
1292
|
+
* 3. plainToolName - Returns tool(s) from all servers (multiple if clashing)
|
|
1293
|
+
*
|
|
1294
|
+
* @param input - Object containing toolNames array
|
|
1295
|
+
* @returns CallToolResult with tool/skill descriptions or error
|
|
1296
|
+
*/
|
|
1297
|
+
async execute(input) {
|
|
1298
|
+
try {
|
|
1299
|
+
const { toolNames } = input;
|
|
1300
|
+
const serverDefinitions = await this.definitionsCacheService.getServerDefinitions();
|
|
1301
|
+
if (!toolNames || toolNames.length === 0) return {
|
|
1302
|
+
content: [{
|
|
1303
|
+
type: "text",
|
|
1304
|
+
text: "No tool names provided. Please specify at least one tool name."
|
|
1305
|
+
}],
|
|
1306
|
+
isError: true
|
|
1307
|
+
};
|
|
1308
|
+
const serverToolsMap = /* @__PURE__ */ new Map();
|
|
1309
|
+
const toolToServers = /* @__PURE__ */ new Map();
|
|
1310
|
+
for (const serverDefinition of serverDefinitions) {
|
|
1311
|
+
const typedTools = serverDefinition.tools.map((tool) => ({
|
|
1312
|
+
name: tool.name,
|
|
1313
|
+
description: tool.description,
|
|
1314
|
+
inputSchema: tool.inputSchema
|
|
1315
|
+
}));
|
|
1316
|
+
serverToolsMap.set(serverDefinition.serverName, typedTools);
|
|
1317
|
+
for (const tool of typedTools) {
|
|
1318
|
+
if (!toolToServers.has(tool.name)) toolToServers.set(tool.name, []);
|
|
1319
|
+
toolToServers.get(tool.name)?.push(serverDefinition.serverName);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const lookupResults = await Promise.all(toolNames.map(async (requestedName) => {
|
|
1323
|
+
const result$1 = {
|
|
1324
|
+
tools: [],
|
|
1325
|
+
skills: [],
|
|
1326
|
+
notFound: null
|
|
1327
|
+
};
|
|
1328
|
+
if (requestedName.startsWith(SKILL_PREFIX)) {
|
|
1329
|
+
const skillName = requestedName.slice(SKILL_PREFIX.length);
|
|
1330
|
+
if (this.skillService) {
|
|
1331
|
+
const skill = await this.skillService.getSkill(skillName);
|
|
1332
|
+
if (skill) {
|
|
1333
|
+
result$1.skills.push({
|
|
1334
|
+
name: skill.name,
|
|
1335
|
+
location: skill.basePath,
|
|
1336
|
+
instructions: formatSkillInstructions(skill.name, skill.content)
|
|
1337
|
+
});
|
|
1338
|
+
return result$1;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const promptSkillContent = await this.getPromptSkillContent(skillName);
|
|
1342
|
+
if (promptSkillContent) {
|
|
1343
|
+
result$1.skills.push(promptSkillContent);
|
|
1344
|
+
return result$1;
|
|
1345
|
+
}
|
|
1346
|
+
result$1.notFound = requestedName;
|
|
1347
|
+
return result$1;
|
|
1348
|
+
}
|
|
1349
|
+
const { serverName, actualToolName } = parseToolName(requestedName);
|
|
1350
|
+
if (serverName) {
|
|
1351
|
+
const serverTools = serverToolsMap.get(serverName);
|
|
1352
|
+
if (!serverTools) {
|
|
1353
|
+
result$1.notFound = requestedName;
|
|
1354
|
+
return result$1;
|
|
1355
|
+
}
|
|
1356
|
+
const tool = serverTools.find((t) => t.name === actualToolName);
|
|
1357
|
+
if (tool) result$1.tools.push({
|
|
1358
|
+
server: serverName,
|
|
1359
|
+
tool: {
|
|
1360
|
+
name: tool.name,
|
|
1361
|
+
description: tool.description,
|
|
1362
|
+
inputSchema: tool.inputSchema
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
else result$1.notFound = requestedName;
|
|
1366
|
+
return result$1;
|
|
1367
|
+
}
|
|
1368
|
+
const servers = toolToServers.get(actualToolName);
|
|
1369
|
+
if (!servers || servers.length === 0) {
|
|
1370
|
+
if (this.skillService) {
|
|
1371
|
+
const skill = await this.skillService.getSkill(actualToolName);
|
|
1372
|
+
if (skill) {
|
|
1373
|
+
result$1.skills.push({
|
|
1374
|
+
name: skill.name,
|
|
1375
|
+
location: skill.basePath,
|
|
1376
|
+
instructions: formatSkillInstructions(skill.name, skill.content)
|
|
1377
|
+
});
|
|
1378
|
+
return result$1;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
const promptSkillContent = await this.getPromptSkillContent(actualToolName);
|
|
1382
|
+
if (promptSkillContent) {
|
|
1383
|
+
result$1.skills.push(promptSkillContent);
|
|
1384
|
+
return result$1;
|
|
1385
|
+
}
|
|
1386
|
+
result$1.notFound = requestedName;
|
|
1387
|
+
return result$1;
|
|
1388
|
+
}
|
|
1389
|
+
if (servers.length === 1) {
|
|
1390
|
+
const server = servers[0];
|
|
1391
|
+
const tool = serverToolsMap.get(server).find((t) => t.name === actualToolName);
|
|
1392
|
+
result$1.tools.push({
|
|
1393
|
+
server,
|
|
1394
|
+
tool: {
|
|
1395
|
+
name: tool.name,
|
|
1396
|
+
description: tool.description,
|
|
1397
|
+
inputSchema: tool.inputSchema
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
} else for (const server of servers) {
|
|
1401
|
+
const tool = serverToolsMap.get(server).find((t) => t.name === actualToolName);
|
|
1402
|
+
result$1.tools.push({
|
|
1403
|
+
server,
|
|
1404
|
+
tool: {
|
|
1405
|
+
name: tool.name,
|
|
1406
|
+
description: tool.description,
|
|
1407
|
+
inputSchema: tool.inputSchema
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
return result$1;
|
|
1412
|
+
}));
|
|
1413
|
+
const foundTools = [];
|
|
1414
|
+
const foundSkills = [];
|
|
1415
|
+
const notFoundItems = [];
|
|
1416
|
+
for (const result$1 of lookupResults) {
|
|
1417
|
+
foundTools.push(...result$1.tools);
|
|
1418
|
+
foundSkills.push(...result$1.skills);
|
|
1419
|
+
if (result$1.notFound) notFoundItems.push(result$1.notFound);
|
|
1420
|
+
}
|
|
1421
|
+
if (foundTools.length === 0 && foundSkills.length === 0) return {
|
|
1422
|
+
content: [{
|
|
1423
|
+
type: "text",
|
|
1424
|
+
text: `None of the requested tools/skills found.\nRequested: ${toolNames.join(", ")}\nUse describe_tools to see available tools and skills.`
|
|
1425
|
+
}],
|
|
1426
|
+
isError: true
|
|
1427
|
+
};
|
|
1428
|
+
const result = {};
|
|
1429
|
+
const nextSteps = [];
|
|
1430
|
+
if (foundTools.length > 0) {
|
|
1431
|
+
result.tools = foundTools;
|
|
1432
|
+
nextSteps.push("For MCP tools: Use the use_tool function with toolName and toolArgs based on the inputSchema above.");
|
|
1433
|
+
}
|
|
1434
|
+
if (foundSkills.length > 0) {
|
|
1435
|
+
result.skills = foundSkills;
|
|
1436
|
+
nextSteps.push(`For skill, just follow skill's description to continue.`);
|
|
1437
|
+
}
|
|
1438
|
+
if (nextSteps.length > 0) result.nextSteps = nextSteps;
|
|
1439
|
+
if (notFoundItems.length > 0) {
|
|
1440
|
+
result.notFound = notFoundItems;
|
|
1441
|
+
result.warnings = [`Items not found: ${notFoundItems.join(", ")}`];
|
|
1442
|
+
}
|
|
1443
|
+
return { content: [{
|
|
1444
|
+
type: "text",
|
|
1445
|
+
text: JSON.stringify(result, null, 2)
|
|
1446
|
+
}] };
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
return {
|
|
1449
|
+
content: [{
|
|
1450
|
+
type: "text",
|
|
1451
|
+
text: `Error describing tools: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1452
|
+
}],
|
|
1453
|
+
isError: true
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region src/utils/toolCapabilities.ts
|
|
1461
|
+
const TOOL_CAPABILITIES_META_KEY = "agiflowai/capabilities";
|
|
1462
|
+
function getToolCapabilities(tool) {
|
|
1463
|
+
const rawCapabilities = tool._meta?.[TOOL_CAPABILITIES_META_KEY];
|
|
1464
|
+
if (!Array.isArray(rawCapabilities)) return [];
|
|
1465
|
+
return rawCapabilities.filter((value) => typeof value === "string");
|
|
1466
|
+
}
|
|
1467
|
+
function getUniqueSortedCapabilities(tools) {
|
|
1468
|
+
return Array.from(new Set(tools.flatMap((tool) => getToolCapabilities(tool)))).sort();
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/tools/SearchListToolsTool.ts
|
|
1473
|
+
var SearchListToolsTool = class SearchListToolsTool {
|
|
1474
|
+
static TOOL_NAME = "list_tools";
|
|
1475
|
+
constructor(_clientManager, definitionsCacheService) {
|
|
1476
|
+
this._clientManager = _clientManager;
|
|
1477
|
+
this.definitionsCacheService = definitionsCacheService;
|
|
1478
|
+
}
|
|
1479
|
+
formatToolName(toolName, serverName, toolToServers) {
|
|
1480
|
+
return (toolToServers.get(toolName) || []).length > 1 ? `${serverName}__${toolName}` : toolName;
|
|
1481
|
+
}
|
|
1482
|
+
async getDefinition() {
|
|
1483
|
+
const serverDefinitions = await this.definitionsCacheService.getServerDefinitions();
|
|
1484
|
+
const capabilitySummary = serverDefinitions.length > 0 ? serverDefinitions.map((server) => {
|
|
1485
|
+
const capabilities = getUniqueSortedCapabilities(server.tools);
|
|
1486
|
+
const summary = capabilities.length > 0 ? capabilities.join(", ") : server.serverInstruction || "No capability summary available";
|
|
1487
|
+
return `${server.serverName}: ${summary}`;
|
|
1488
|
+
}).join("\n") : "No proxied servers available.";
|
|
1489
|
+
return {
|
|
1490
|
+
name: SearchListToolsTool.TOOL_NAME,
|
|
1491
|
+
description: `Search proxied MCP tools by server capability summary.\n\nAvailable capabilities:\n${capabilitySummary}`,
|
|
1492
|
+
inputSchema: {
|
|
1493
|
+
type: "object",
|
|
1494
|
+
properties: {
|
|
1495
|
+
capability: {
|
|
1496
|
+
type: "string",
|
|
1497
|
+
description: "Optional capability filter. Matches explicit capability tags first, then server summaries, server names, tool names, and tool descriptions."
|
|
1498
|
+
},
|
|
1499
|
+
serverName: {
|
|
1500
|
+
type: "string",
|
|
1501
|
+
description: "Optional server name filter."
|
|
1502
|
+
}
|
|
1503
|
+
},
|
|
1504
|
+
additionalProperties: false
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
async execute(input) {
|
|
1509
|
+
const serverDefinitions = await this.definitionsCacheService.getServerDefinitions();
|
|
1510
|
+
const capabilityFilter = input.capability?.trim().toLowerCase();
|
|
1511
|
+
const serverNameFilter = input.serverName?.trim().toLowerCase();
|
|
1512
|
+
const toolToServers = /* @__PURE__ */ new Map();
|
|
1513
|
+
for (const serverDefinition of serverDefinitions) for (const tool of serverDefinition.tools) {
|
|
1514
|
+
if (!toolToServers.has(tool.name)) toolToServers.set(tool.name, []);
|
|
1515
|
+
toolToServers.get(tool.name)?.push(serverDefinition.serverName);
|
|
1516
|
+
}
|
|
1517
|
+
const filteredServers = serverDefinitions.filter((serverDefinition) => {
|
|
1518
|
+
if (serverNameFilter && serverDefinition.serverName.toLowerCase() !== serverNameFilter) return false;
|
|
1519
|
+
if (!capabilityFilter) return true;
|
|
1520
|
+
if ((serverDefinition.serverInstruction?.toLowerCase() || "").includes(capabilityFilter)) return true;
|
|
1521
|
+
if (getUniqueSortedCapabilities(serverDefinition.tools).some((capability) => capability.toLowerCase().includes(capabilityFilter))) return true;
|
|
1522
|
+
return serverDefinition.tools.some((tool) => {
|
|
1523
|
+
const toolName = this.formatToolName(tool.name, serverDefinition.serverName, toolToServers);
|
|
1524
|
+
const toolCapabilities = getToolCapabilities(tool);
|
|
1525
|
+
return toolName.toLowerCase().includes(capabilityFilter) || (tool.description || "").toLowerCase().includes(capabilityFilter) || toolCapabilities.some((capability) => capability.toLowerCase().includes(capabilityFilter));
|
|
1526
|
+
});
|
|
1527
|
+
}).map((serverDefinition) => ({
|
|
1528
|
+
server: serverDefinition.serverName,
|
|
1529
|
+
capabilities: getUniqueSortedCapabilities(serverDefinition.tools),
|
|
1530
|
+
summary: serverDefinition.serverInstruction,
|
|
1531
|
+
tools: serverDefinition.tools.map((tool) => ({
|
|
1532
|
+
name: this.formatToolName(tool.name, serverDefinition.serverName, toolToServers),
|
|
1533
|
+
description: serverDefinition.omitToolDescription ? void 0 : tool.description,
|
|
1534
|
+
capabilities: getToolCapabilities(tool)
|
|
1535
|
+
}))
|
|
1536
|
+
})).filter((server) => server.tools.length > 0);
|
|
1537
|
+
const result = { servers: filteredServers };
|
|
1538
|
+
return {
|
|
1539
|
+
content: [{
|
|
1540
|
+
type: "text",
|
|
1541
|
+
text: JSON.stringify(result, null, 2)
|
|
1542
|
+
}],
|
|
1543
|
+
isError: filteredServers.length === 0 ? true : void 0
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region src/tools/UseToolTool.ts
|
|
1550
|
+
/**
|
|
1551
|
+
* UseToolTool executes MCP tools and skills with proper error handling.
|
|
1552
|
+
*
|
|
1553
|
+
* This tool supports three invocation patterns:
|
|
1554
|
+
* 1. skill__skillName - Invokes a skill from the configured skills directory
|
|
1555
|
+
* 2. serverName__toolName - Invokes a tool on a specific MCP server
|
|
1556
|
+
* 3. plainToolName - Searches all servers for a unique tool match
|
|
1557
|
+
*
|
|
1558
|
+
* @example
|
|
1559
|
+
* const tool = new UseToolTool(clientManager, skillService);
|
|
1560
|
+
* await tool.execute({ toolName: 'skill__pdf' }); // Invoke a skill
|
|
1561
|
+
* await tool.execute({ toolName: 'playwright__browser_click', toolArgs: { ref: 'btn' } });
|
|
1562
|
+
*/
|
|
1563
|
+
var UseToolTool = class UseToolTool {
|
|
1564
|
+
static TOOL_NAME = "use_tool";
|
|
1565
|
+
clientManager;
|
|
1566
|
+
skillService;
|
|
1567
|
+
definitionsCacheService;
|
|
1568
|
+
/** Unique server identifier for this mcp-proxy instance */
|
|
1569
|
+
serverId;
|
|
1570
|
+
/**
|
|
1571
|
+
* Creates a new UseToolTool instance
|
|
1572
|
+
* @param clientManager - The MCP client manager for accessing remote servers
|
|
1573
|
+
* @param skillService - Optional skill service for loading and executing skills
|
|
1574
|
+
* @param serverId - Unique server identifier for this mcp-proxy instance
|
|
1575
|
+
*/
|
|
1576
|
+
constructor(clientManager, skillService, serverId, definitionsCacheService) {
|
|
1577
|
+
this.clientManager = clientManager;
|
|
1578
|
+
this.skillService = skillService;
|
|
1579
|
+
this.definitionsCacheService = definitionsCacheService || new DefinitionsCacheService(clientManager, skillService);
|
|
1580
|
+
this.serverId = serverId || DEFAULT_SERVER_ID;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Returns the MCP tool definition with name, description, and input schema.
|
|
1584
|
+
*
|
|
1585
|
+
* The definition describes how to use this tool to execute MCP tools or skills,
|
|
1586
|
+
* including the skill__ prefix format for skill invocations.
|
|
1587
|
+
*
|
|
1588
|
+
* @returns The tool definition conforming to MCP spec
|
|
1589
|
+
*/
|
|
1590
|
+
getDefinition() {
|
|
1591
|
+
return {
|
|
1592
|
+
name: UseToolTool.TOOL_NAME,
|
|
1593
|
+
description: `Execute an MCP tool (NOT Skill) with provided arguments. You MUST call describe_tools first to discover the tool's correct arguments. Then to use tool:
|
|
1594
|
+
- Provide toolName and toolArgs based on the schema
|
|
1595
|
+
- If multiple servers provide the same tool, specify serverName
|
|
1596
|
+
|
|
1597
|
+
IMPORTANT: Only use tools discovered from describe_tools with id="${this.serverId}".
|
|
1598
|
+
`,
|
|
1599
|
+
inputSchema: {
|
|
1600
|
+
type: "object",
|
|
1601
|
+
properties: {
|
|
1602
|
+
toolName: {
|
|
1603
|
+
type: "string",
|
|
1604
|
+
description: "Name of the tool to execute",
|
|
1605
|
+
minLength: 1
|
|
1606
|
+
},
|
|
1607
|
+
toolArgs: {
|
|
1608
|
+
type: "object",
|
|
1609
|
+
description: "Arguments to pass to the tool, as discovered from describe_tools"
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
required: ["toolName"],
|
|
1613
|
+
additionalProperties: false
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Returns guidance message for skill invocation.
|
|
1619
|
+
*
|
|
1620
|
+
* Skills are not executed via use_tool - they provide instructions that should
|
|
1621
|
+
* be followed directly. This method returns a message directing users to use
|
|
1622
|
+
* describe_tools to get the skill details and follow its instructions.
|
|
1623
|
+
*
|
|
1624
|
+
* @param skill - The skill that was requested
|
|
1625
|
+
* @returns CallToolResult with guidance message
|
|
1626
|
+
*/
|
|
1627
|
+
executeSkill(skill) {
|
|
1628
|
+
return { content: [{
|
|
1629
|
+
type: "text",
|
|
1630
|
+
text: `Skill "${skill.name}" found. Skills provide instructions and should not be executed via use_tool.\n\nUse describe_tools to view the skill details at: ${skill.basePath}\n\nThen follow the skill's instructions directly.`
|
|
1631
|
+
}] };
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Finds a prompt-based skill by name from all connected MCP servers.
|
|
1635
|
+
*
|
|
1636
|
+
* @param skillName - The skill name to search for
|
|
1637
|
+
* @returns PromptSkillMatch if found, undefined otherwise
|
|
1638
|
+
*/
|
|
1639
|
+
async findPromptSkill(skillName) {
|
|
1640
|
+
if (!skillName) return void 0;
|
|
1641
|
+
return await this.definitionsCacheService.getPromptSkillByName(skillName);
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Returns guidance message for prompt-based skill invocation.
|
|
1645
|
+
*
|
|
1646
|
+
* @param promptSkill - The prompt skill match that was found
|
|
1647
|
+
* @returns CallToolResult with guidance message
|
|
1648
|
+
*/
|
|
1649
|
+
executePromptSkill(promptSkill) {
|
|
1650
|
+
const location = promptSkill.skill.folder || `prompt:${promptSkill.serverName}/${promptSkill.promptName}`;
|
|
1651
|
+
return { content: [{
|
|
1652
|
+
type: "text",
|
|
1653
|
+
text: `Skill "${promptSkill.skill.name}" found. Skills provide instructions and should not be executed via use_tool.\n\nUse describe_tools to view the skill details at: ${location}\n\nThen follow the skill's instructions directly.`
|
|
1654
|
+
}] };
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Executes a tool or skill based on the provided input.
|
|
1658
|
+
*
|
|
1659
|
+
* Handles three invocation patterns:
|
|
1660
|
+
* 1. skill__skillName - Routes to skill execution via SkillService
|
|
1661
|
+
* 2. serverName__toolName - Routes to specific MCP server
|
|
1662
|
+
* 3. plainToolName - Searches all servers for unique match
|
|
1663
|
+
*
|
|
1664
|
+
* Edge cases:
|
|
1665
|
+
* - Returns error if skill not found when using skill__ prefix
|
|
1666
|
+
* - Returns error if tool is blacklisted on target server
|
|
1667
|
+
* - Returns disambiguation message if tool exists on multiple servers
|
|
1668
|
+
*
|
|
1669
|
+
* @param input - The tool/skill name and optional arguments
|
|
1670
|
+
* @returns CallToolResult with execution output or error
|
|
1671
|
+
*/
|
|
1672
|
+
async execute(input) {
|
|
1673
|
+
try {
|
|
1674
|
+
const { toolName: inputToolName, toolArgs = {} } = input;
|
|
1675
|
+
if (inputToolName.startsWith(SKILL_PREFIX)) {
|
|
1676
|
+
const skillName = inputToolName.slice(SKILL_PREFIX.length);
|
|
1677
|
+
if (this.skillService) {
|
|
1678
|
+
const skill = await this.skillService.getSkill(skillName);
|
|
1679
|
+
if (skill) return this.executeSkill(skill);
|
|
1680
|
+
}
|
|
1681
|
+
const promptSkill = await this.findPromptSkill(skillName);
|
|
1682
|
+
if (promptSkill) return this.executePromptSkill(promptSkill);
|
|
1683
|
+
return {
|
|
1684
|
+
content: [{
|
|
1685
|
+
type: "text",
|
|
1686
|
+
text: `Skill "${skillName}" not found. Use describe_tools to see available skills.`
|
|
1687
|
+
}],
|
|
1688
|
+
isError: true
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
const knownServerNames = this.clientManager.getKnownServerNames();
|
|
1692
|
+
const { serverName, actualToolName } = parseToolName(inputToolName);
|
|
1693
|
+
if (serverName) try {
|
|
1694
|
+
const client = await this.clientManager.ensureConnected(serverName);
|
|
1695
|
+
if (client.toolBlacklist?.includes(actualToolName)) return {
|
|
1696
|
+
content: [{
|
|
1697
|
+
type: "text",
|
|
1698
|
+
text: `Tool "${actualToolName}" is blacklisted on server "${serverName}" and cannot be executed.`
|
|
1699
|
+
}],
|
|
1700
|
+
isError: true
|
|
1701
|
+
};
|
|
1702
|
+
const reqTimeout = this.clientManager.getServerRequestTimeout(serverName);
|
|
1703
|
+
return await client.callTool(actualToolName, toolArgs, reqTimeout ? { timeout: reqTimeout } : void 0);
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
return {
|
|
1706
|
+
content: [{
|
|
1707
|
+
type: "text",
|
|
1708
|
+
text: `Failed to call tool "${actualToolName}" on server "${serverName}". Available servers: ${knownServerNames.join(", ")}. ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1709
|
+
}],
|
|
1710
|
+
isError: true
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
const matchingServers = await this.definitionsCacheService.getServersForTool(actualToolName);
|
|
1714
|
+
if (matchingServers.length === 0) {
|
|
1715
|
+
if (this.skillService) {
|
|
1716
|
+
const skill = await this.skillService.getSkill(actualToolName);
|
|
1717
|
+
if (skill) return this.executeSkill(skill);
|
|
1718
|
+
}
|
|
1719
|
+
const promptSkill = await this.findPromptSkill(actualToolName);
|
|
1720
|
+
if (promptSkill) return this.executePromptSkill(promptSkill);
|
|
1721
|
+
return {
|
|
1722
|
+
content: [{
|
|
1723
|
+
type: "text",
|
|
1724
|
+
text: `Tool or skill "${actualToolName}" not found. Use describe_tools to see available tools and skills.`
|
|
1725
|
+
}],
|
|
1726
|
+
isError: true
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
if (matchingServers.length > 1) return {
|
|
1730
|
+
content: [{
|
|
1731
|
+
type: "text",
|
|
1732
|
+
text: `Tool "${actualToolName}" found on multiple servers. Use prefixed format to specify: ${matchingServers.map((s) => `${s}__${actualToolName}`).join(", ")}`
|
|
1733
|
+
}],
|
|
1734
|
+
isError: true
|
|
1735
|
+
};
|
|
1736
|
+
try {
|
|
1737
|
+
const targetServerName = matchingServers[0];
|
|
1738
|
+
const client = await this.clientManager.ensureConnected(targetServerName);
|
|
1739
|
+
const targetReqTimeout = this.clientManager.getServerRequestTimeout(targetServerName);
|
|
1740
|
+
return await client.callTool(actualToolName, toolArgs, targetReqTimeout ? { timeout: targetReqTimeout } : void 0);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
return {
|
|
1743
|
+
content: [{
|
|
1744
|
+
type: "text",
|
|
1745
|
+
text: `Failed to call tool "${actualToolName}": ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1746
|
+
}],
|
|
1747
|
+
isError: true
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
return {
|
|
1752
|
+
content: [{
|
|
1753
|
+
type: "text",
|
|
1754
|
+
text: `Error executing tool: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1755
|
+
}],
|
|
1756
|
+
isError: true
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
//#endregion
|
|
1763
|
+
//#region src/services/RemoteConfigCacheService.ts
|
|
1764
|
+
/**
|
|
1765
|
+
* RemoteConfigCacheService
|
|
1766
|
+
*
|
|
1767
|
+
* DESIGN PATTERNS:
|
|
1768
|
+
* - Service pattern for cache management
|
|
1769
|
+
* - Single responsibility principle
|
|
1770
|
+
* - File-based caching with TTL support
|
|
1771
|
+
*
|
|
1772
|
+
* CODING STANDARDS:
|
|
1773
|
+
* - Use async/await for asynchronous operations
|
|
1774
|
+
* - Handle file system errors gracefully
|
|
1775
|
+
* - Keep cache organized by URL hash
|
|
1776
|
+
* - Implement automatic cache expiration
|
|
1777
|
+
*
|
|
1778
|
+
* AVOID:
|
|
1779
|
+
* - Storing sensitive data in cache (headers with tokens)
|
|
1780
|
+
* - Unbounded cache growth
|
|
1781
|
+
* - Missing error handling for file operations
|
|
1782
|
+
*/
|
|
1783
|
+
/**
|
|
1784
|
+
* Service for caching remote MCP configurations
|
|
1785
|
+
*/
|
|
1786
|
+
var RemoteConfigCacheService = class {
|
|
1787
|
+
cacheDir;
|
|
1788
|
+
cacheTTL;
|
|
1789
|
+
readEnabled;
|
|
1790
|
+
writeEnabled;
|
|
1791
|
+
logger;
|
|
1792
|
+
constructor(options, logger = console) {
|
|
1793
|
+
this.cacheDir = join(tmpdir(), "mcp-proxy-cache", "remote-configs");
|
|
1794
|
+
this.cacheTTL = options?.ttl || 3600 * 1e3;
|
|
1795
|
+
this.readEnabled = options?.readEnabled !== void 0 ? options.readEnabled : true;
|
|
1796
|
+
this.writeEnabled = options?.writeEnabled !== void 0 ? options.writeEnabled : true;
|
|
1797
|
+
this.logger = logger;
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Generate a hash key from remote config URL
|
|
1801
|
+
* Only uses URL for hashing to avoid caching credentials in the key
|
|
1802
|
+
*/
|
|
1803
|
+
generateCacheKey(url) {
|
|
1804
|
+
return createHash("sha256").update(url).digest("hex");
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Get the cache file path for a given cache key
|
|
1808
|
+
*/
|
|
1809
|
+
getCacheFilePath(cacheKey) {
|
|
1810
|
+
return join(this.cacheDir, `${cacheKey}.json`);
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Initialize cache directory
|
|
1814
|
+
* Uses mkdir with recursive option which handles existing directories gracefully
|
|
1815
|
+
* (no TOCTOU race condition from existsSync check)
|
|
1816
|
+
*/
|
|
1817
|
+
async ensureCacheDir() {
|
|
1818
|
+
try {
|
|
1819
|
+
await mkdir(this.cacheDir, { recursive: true });
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Get cached data for a remote config URL
|
|
1826
|
+
*/
|
|
1827
|
+
async get(url) {
|
|
1828
|
+
if (!this.readEnabled) return null;
|
|
1829
|
+
try {
|
|
1830
|
+
await this.ensureCacheDir();
|
|
1831
|
+
const cacheKey = this.generateCacheKey(url);
|
|
1832
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
1833
|
+
if (!existsSync(cacheFilePath)) return null;
|
|
1834
|
+
const cacheContent = await readFile(cacheFilePath, "utf-8");
|
|
1835
|
+
const cacheEntry = JSON.parse(cacheContent);
|
|
1836
|
+
const now = Date.now();
|
|
1837
|
+
if (now > cacheEntry.expiresAt) {
|
|
1838
|
+
await unlink(cacheFilePath).catch(() => {});
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
const expiresInSeconds = Math.round((cacheEntry.expiresAt - now) / 1e3);
|
|
1842
|
+
this.logger.debug(`Remote config cache hit for ${url} (expires in ${expiresInSeconds}s)`);
|
|
1843
|
+
return cacheEntry.data;
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
this.logger.warn(`Failed to read remote config cache for ${url}`, error);
|
|
1846
|
+
return null;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Set cached data for a remote config URL
|
|
1851
|
+
*/
|
|
1852
|
+
async set(url, data) {
|
|
1853
|
+
if (!this.writeEnabled) return;
|
|
1854
|
+
try {
|
|
1855
|
+
await this.ensureCacheDir();
|
|
1856
|
+
const cacheKey = this.generateCacheKey(url);
|
|
1857
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
1858
|
+
const now = Date.now();
|
|
1859
|
+
const cacheEntry = {
|
|
1860
|
+
data,
|
|
1861
|
+
timestamp: now,
|
|
1862
|
+
expiresAt: now + this.cacheTTL,
|
|
1863
|
+
url
|
|
1864
|
+
};
|
|
1865
|
+
await writeFile(cacheFilePath, JSON.stringify(cacheEntry, null, 2), "utf-8");
|
|
1866
|
+
this.logger.debug(`Cached remote config for ${url} (TTL: ${Math.round(this.cacheTTL / 1e3)}s)`);
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
this.logger.warn(`Failed to write remote config cache for ${url}`, error);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Clear cache for a specific URL
|
|
1873
|
+
*/
|
|
1874
|
+
async clear(url) {
|
|
1875
|
+
try {
|
|
1876
|
+
const cacheKey = this.generateCacheKey(url);
|
|
1877
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
1878
|
+
if (existsSync(cacheFilePath)) {
|
|
1879
|
+
await unlink(cacheFilePath);
|
|
1880
|
+
this.logger.info(`Cleared remote config cache for ${url}`);
|
|
1881
|
+
}
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
this.logger.warn(`Failed to clear remote config cache for ${url}`, error);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Clear all cached remote configs
|
|
1888
|
+
*/
|
|
1889
|
+
async clearAll() {
|
|
1890
|
+
try {
|
|
1891
|
+
if (!existsSync(this.cacheDir)) return;
|
|
1892
|
+
const files = await readdir(this.cacheDir);
|
|
1893
|
+
const deletePromises = files.filter((file) => file.endsWith(".json")).map((file) => unlink(join(this.cacheDir, file)).catch((error) => {
|
|
1894
|
+
this.logger.debug(`Failed to delete remote config cache file ${file}`, error);
|
|
1895
|
+
}));
|
|
1896
|
+
await Promise.all(deletePromises);
|
|
1897
|
+
this.logger.info(`Cleared all remote config cache entries (${files.length} files)`);
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
this.logger.warn("Failed to clear all remote config cache", error);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Clean up expired cache entries
|
|
1904
|
+
*/
|
|
1905
|
+
async cleanExpired() {
|
|
1906
|
+
try {
|
|
1907
|
+
if (!existsSync(this.cacheDir)) return;
|
|
1908
|
+
const now = Date.now();
|
|
1909
|
+
const jsonFiles = (await readdir(this.cacheDir)).filter((file) => file.endsWith(".json"));
|
|
1910
|
+
const expiredCount = (await Promise.all(jsonFiles.map(async (file) => {
|
|
1911
|
+
const filePath = join(this.cacheDir, file);
|
|
1912
|
+
try {
|
|
1913
|
+
const content = await readFile(filePath, "utf-8");
|
|
1914
|
+
if (now > JSON.parse(content).expiresAt) {
|
|
1915
|
+
await unlink(filePath);
|
|
1916
|
+
return true;
|
|
1917
|
+
}
|
|
1918
|
+
return false;
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
await unlink(filePath).catch(() => {
|
|
1921
|
+
this.logger.debug(`Failed to delete corrupt cache file ${filePath}`, error);
|
|
1922
|
+
});
|
|
1923
|
+
this.logger.warn(`Removed unreadable remote config cache file ${filePath}`, error);
|
|
1924
|
+
return true;
|
|
1925
|
+
}
|
|
1926
|
+
}))).filter(Boolean).length;
|
|
1927
|
+
if (expiredCount > 0) this.logger.info(`Cleaned up ${expiredCount} expired remote config cache entries`);
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
this.logger.warn("Failed to clean expired remote config cache", error);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Get cache statistics
|
|
1934
|
+
*/
|
|
1935
|
+
async getStats() {
|
|
1936
|
+
try {
|
|
1937
|
+
if (!existsSync(this.cacheDir)) return {
|
|
1938
|
+
totalEntries: 0,
|
|
1939
|
+
totalSize: 0
|
|
1940
|
+
};
|
|
1941
|
+
const jsonFiles = (await readdir(this.cacheDir)).filter((file) => file.endsWith(".json"));
|
|
1942
|
+
const totalSize = (await Promise.all(jsonFiles.map(async (file) => {
|
|
1943
|
+
const filePath = join(this.cacheDir, file);
|
|
1944
|
+
try {
|
|
1945
|
+
const content = await readFile(filePath, "utf-8");
|
|
1946
|
+
return Buffer.byteLength(content, "utf-8");
|
|
1947
|
+
} catch {
|
|
1948
|
+
return 0;
|
|
1949
|
+
}
|
|
1950
|
+
}))).reduce((sum, size) => sum + size, 0);
|
|
1951
|
+
return {
|
|
1952
|
+
totalEntries: jsonFiles.length,
|
|
1953
|
+
totalSize
|
|
1954
|
+
};
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
this.logger.warn("Failed to get remote config cache stats", error);
|
|
1957
|
+
return {
|
|
1958
|
+
totalEntries: 0,
|
|
1959
|
+
totalSize: 0
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Check if read from cache is enabled
|
|
1965
|
+
*/
|
|
1966
|
+
isReadEnabled() {
|
|
1967
|
+
return this.readEnabled;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Check if write to cache is enabled
|
|
1971
|
+
*/
|
|
1972
|
+
isWriteEnabled() {
|
|
1973
|
+
return this.writeEnabled;
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Set read enabled state
|
|
1977
|
+
*/
|
|
1978
|
+
setReadEnabled(enabled) {
|
|
1979
|
+
this.readEnabled = enabled;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Set write enabled state
|
|
1983
|
+
*/
|
|
1984
|
+
setWriteEnabled(enabled) {
|
|
1985
|
+
this.writeEnabled = enabled;
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
//#endregion
|
|
1990
|
+
//#region src/services/ConfigFetcherService.ts
|
|
1991
|
+
/**
|
|
1992
|
+
* ConfigFetcherService
|
|
1993
|
+
*
|
|
1994
|
+
* DESIGN PATTERNS:
|
|
1995
|
+
* - Service pattern for business logic encapsulation
|
|
1996
|
+
* - Single responsibility principle
|
|
1997
|
+
* - Caching pattern for performance optimization
|
|
1998
|
+
*
|
|
1999
|
+
* CODING STANDARDS:
|
|
2000
|
+
* - Use async/await for asynchronous operations
|
|
2001
|
+
* - Throw descriptive errors for error cases
|
|
2002
|
+
* - Keep methods focused and well-named
|
|
2003
|
+
* - Document complex logic with comments
|
|
2004
|
+
*
|
|
2005
|
+
* AVOID:
|
|
2006
|
+
* - Mixing concerns (keep focused on single domain)
|
|
2007
|
+
* - Direct tool implementation (services should be tool-agnostic)
|
|
2008
|
+
*/
|
|
2009
|
+
/**
|
|
2010
|
+
* Service for fetching and caching MCP server configurations from local file and remote sources
|
|
2011
|
+
* Supports merging multiple remote configs with local config
|
|
2012
|
+
*/
|
|
2013
|
+
var ConfigFetcherService = class {
|
|
2014
|
+
configFilePath;
|
|
2015
|
+
cacheTtlMs;
|
|
2016
|
+
cachedConfig = null;
|
|
2017
|
+
lastFetchTime = 0;
|
|
2018
|
+
remoteConfigCache;
|
|
2019
|
+
logger;
|
|
2020
|
+
constructor(options, logger = console) {
|
|
2021
|
+
this.configFilePath = options.configFilePath;
|
|
2022
|
+
this.cacheTtlMs = options.cacheTtlMs || 6e4;
|
|
2023
|
+
this.logger = logger;
|
|
2024
|
+
const useCache = options.useCache !== void 0 ? options.useCache : true;
|
|
2025
|
+
this.remoteConfigCache = new RemoteConfigCacheService({
|
|
2026
|
+
ttl: options.remoteCacheTtlMs || 3600 * 1e3,
|
|
2027
|
+
readEnabled: useCache,
|
|
2028
|
+
writeEnabled: true
|
|
2029
|
+
}, logger);
|
|
2030
|
+
if (!this.configFilePath) throw new Error("configFilePath must be provided");
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Fetch MCP configuration from local file and remote sources with caching
|
|
2034
|
+
* Merges remote configs with local config based on merge strategy
|
|
2035
|
+
* @param forceRefresh - Force reload from source, bypassing cache
|
|
2036
|
+
*/
|
|
2037
|
+
async fetchConfiguration(forceRefresh = false) {
|
|
2038
|
+
const now = Date.now();
|
|
2039
|
+
if (!forceRefresh && this.cachedConfig && now - this.lastFetchTime < this.cacheTtlMs) return this.cachedConfig;
|
|
2040
|
+
const localConfigData = await this.loadRawConfigFromFile();
|
|
2041
|
+
const remoteConfigSources = localConfigData.remoteConfigs || [];
|
|
2042
|
+
let mergedConfig = await this.parseConfig(localConfigData);
|
|
2043
|
+
const remoteConfigPromises = remoteConfigSources.map(async (remoteSource) => {
|
|
2044
|
+
try {
|
|
2045
|
+
validateRemoteConfigSource(remoteSource);
|
|
2046
|
+
return {
|
|
2047
|
+
config: await this.loadFromUrl(remoteSource),
|
|
2048
|
+
mergeStrategy: remoteSource.mergeStrategy || "local-priority",
|
|
2049
|
+
url: remoteSource.url
|
|
2050
|
+
};
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
this.logger.warn(`Failed to fetch remote config from ${remoteSource.url}`, error);
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
const remoteConfigResults = await Promise.all(remoteConfigPromises);
|
|
2057
|
+
for (const result of remoteConfigResults) if (result !== null) mergedConfig = this.mergeConfigurations(mergedConfig, result.config, result.mergeStrategy);
|
|
2058
|
+
if (!mergedConfig.mcpServers || typeof mergedConfig.mcpServers !== "object") throw new Error("Invalid MCP configuration: missing or invalid mcpServers");
|
|
2059
|
+
this.cachedConfig = mergedConfig;
|
|
2060
|
+
this.lastFetchTime = now;
|
|
2061
|
+
return mergedConfig;
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Load raw configuration data from a local file (supports JSON and YAML)
|
|
2065
|
+
* Returns unparsed config data to allow access to remoteConfigs
|
|
2066
|
+
*/
|
|
2067
|
+
async loadRawConfigFromFile() {
|
|
2068
|
+
if (!this.configFilePath) throw new Error("No config file path provided");
|
|
2069
|
+
if (!existsSync(this.configFilePath)) throw new Error(`Config file not found: ${this.configFilePath}`);
|
|
2070
|
+
try {
|
|
2071
|
+
const content = await readFile(this.configFilePath, "utf-8");
|
|
2072
|
+
let rawConfig;
|
|
2073
|
+
if (this.configFilePath.endsWith(".yaml") || this.configFilePath.endsWith(".yml")) rawConfig = yaml.load(content);
|
|
2074
|
+
else rawConfig = JSON.parse(content);
|
|
2075
|
+
return rawConfig;
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
if (error instanceof Error) throw new Error(`Failed to load config file: ${error.message}`);
|
|
2078
|
+
throw new Error("Failed to load config file: Unknown error");
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Parse raw config data using Zod schema
|
|
2083
|
+
* Filters out remoteConfigs to avoid including them in the final config
|
|
2084
|
+
*/
|
|
2085
|
+
async parseConfig(rawConfig) {
|
|
2086
|
+
try {
|
|
2087
|
+
const { remoteConfigs, ...configWithoutRemote } = rawConfig;
|
|
2088
|
+
return parseMcpConfig(configWithoutRemote);
|
|
2089
|
+
} catch (error) {
|
|
2090
|
+
if (error instanceof Error) throw new Error(`Failed to parse config: ${error.message}`);
|
|
2091
|
+
throw new Error("Failed to parse config: Unknown error");
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Load configuration from a remote URL with caching
|
|
2096
|
+
*
|
|
2097
|
+
* SECURITY NOTE: This method fetches remote configs based on URLs from the local config file.
|
|
2098
|
+
* This is intentional and safe because:
|
|
2099
|
+
* 1. URLs are user-controlled via their local config file (not external input)
|
|
2100
|
+
* 2. SSRF protection validates URLs before fetching (blocks private IPs, enforces HTTPS)
|
|
2101
|
+
* 3. Users explicitly opt-in to remote configs in their local configuration
|
|
2102
|
+
* 4. This enables centralized config management (intended feature, not a vulnerability)
|
|
2103
|
+
*
|
|
2104
|
+
* CodeQL alert "file-access-to-http" is a false positive here - we're not leaking
|
|
2105
|
+
* file contents to arbitrary URLs, we're fetching configs from user-specified sources.
|
|
2106
|
+
*/
|
|
2107
|
+
async loadFromUrl(source) {
|
|
2108
|
+
try {
|
|
2109
|
+
const interpolatedUrl = this.interpolateEnvVars(source.url);
|
|
2110
|
+
const cachedConfig = await this.remoteConfigCache.get(interpolatedUrl);
|
|
2111
|
+
if (cachedConfig) return cachedConfig;
|
|
2112
|
+
const interpolatedHeaders = source.headers ? Object.fromEntries(Object.entries(source.headers).map(([key, value]) => [key, this.interpolateEnvVars(value)])) : {};
|
|
2113
|
+
const response = await fetch(interpolatedUrl, { headers: interpolatedHeaders });
|
|
2114
|
+
if (!response.ok) throw new Error(`Failed to fetch remote config: ${response.status} ${response.statusText}`);
|
|
2115
|
+
const config = parseMcpConfig(await response.json());
|
|
2116
|
+
await this.remoteConfigCache.set(interpolatedUrl, config);
|
|
2117
|
+
return config;
|
|
2118
|
+
} catch (error) {
|
|
2119
|
+
if (error instanceof Error) throw new Error(`Failed to fetch remote config from ${source.url}: ${error.message}`, { cause: error });
|
|
2120
|
+
throw new Error(`Failed to fetch remote config from ${source.url}: Unknown error`);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Interpolate environment variables in a string
|
|
2125
|
+
* Supports ${VAR_NAME} syntax
|
|
2126
|
+
*/
|
|
2127
|
+
interpolateEnvVars(value) {
|
|
2128
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
2129
|
+
const envValue = process.env[varName];
|
|
2130
|
+
if (envValue === void 0) {
|
|
2131
|
+
this.logger.warn(`Environment variable ${varName} is not defined, keeping placeholder`);
|
|
2132
|
+
return `\${${varName}}`;
|
|
2133
|
+
}
|
|
2134
|
+
return envValue;
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Merge two MCP configurations based on the specified merge strategy
|
|
2139
|
+
* @param localConfig Configuration loaded from local file
|
|
2140
|
+
* @param remoteConfig Configuration loaded from remote URL
|
|
2141
|
+
* @param mergeStrategy Strategy for merging configs
|
|
2142
|
+
* @returns Merged configuration
|
|
2143
|
+
*/
|
|
2144
|
+
mergeConfigurations(localConfig, remoteConfig, mergeStrategy) {
|
|
2145
|
+
switch (mergeStrategy) {
|
|
2146
|
+
case "local-priority": return {
|
|
2147
|
+
id: localConfig.id ?? remoteConfig.id,
|
|
2148
|
+
mcpServers: {
|
|
2149
|
+
...remoteConfig.mcpServers,
|
|
2150
|
+
...localConfig.mcpServers
|
|
2151
|
+
},
|
|
2152
|
+
skills: localConfig.skills ?? remoteConfig.skills
|
|
2153
|
+
};
|
|
2154
|
+
case "remote-priority": return {
|
|
2155
|
+
id: remoteConfig.id ?? localConfig.id,
|
|
2156
|
+
mcpServers: {
|
|
2157
|
+
...localConfig.mcpServers,
|
|
2158
|
+
...remoteConfig.mcpServers
|
|
2159
|
+
},
|
|
2160
|
+
skills: remoteConfig.skills ?? localConfig.skills
|
|
2161
|
+
};
|
|
2162
|
+
case "merge-deep": {
|
|
2163
|
+
const merged = { ...remoteConfig.mcpServers };
|
|
2164
|
+
for (const [serverName, localServerConfig] of Object.entries(localConfig.mcpServers)) if (merged[serverName]) {
|
|
2165
|
+
const remoteServer = merged[serverName];
|
|
2166
|
+
const mergedConfig = {
|
|
2167
|
+
...remoteServer.config,
|
|
2168
|
+
...localServerConfig.config
|
|
2169
|
+
};
|
|
2170
|
+
const remoteEnv = "env" in remoteServer.config ? remoteServer.config.env : void 0;
|
|
2171
|
+
const localEnv = "env" in localServerConfig.config ? localServerConfig.config.env : void 0;
|
|
2172
|
+
if (remoteEnv || localEnv) mergedConfig.env = {
|
|
2173
|
+
...remoteEnv || {},
|
|
2174
|
+
...localEnv || {}
|
|
2175
|
+
};
|
|
2176
|
+
const remoteHeaders = "headers" in remoteServer.config ? remoteServer.config.headers : void 0;
|
|
2177
|
+
const localHeaders = "headers" in localServerConfig.config ? localServerConfig.config.headers : void 0;
|
|
2178
|
+
if (remoteHeaders || localHeaders) mergedConfig.headers = {
|
|
2179
|
+
...remoteHeaders || {},
|
|
2180
|
+
...localHeaders || {}
|
|
2181
|
+
};
|
|
2182
|
+
merged[serverName] = {
|
|
2183
|
+
...remoteServer,
|
|
2184
|
+
...localServerConfig,
|
|
2185
|
+
config: mergedConfig
|
|
2186
|
+
};
|
|
2187
|
+
} else merged[serverName] = localServerConfig;
|
|
2188
|
+
return {
|
|
2189
|
+
id: localConfig.id ?? remoteConfig.id,
|
|
2190
|
+
mcpServers: merged,
|
|
2191
|
+
skills: localConfig.skills ?? remoteConfig.skills
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
default: throw new Error(`Unknown merge strategy: ${mergeStrategy}`);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Clear the cached configuration
|
|
2199
|
+
*/
|
|
2200
|
+
clearCache() {
|
|
2201
|
+
this.cachedConfig = null;
|
|
2202
|
+
this.lastFetchTime = 0;
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Check if cache is valid
|
|
2206
|
+
*/
|
|
2207
|
+
isCacheValid() {
|
|
2208
|
+
const now = Date.now();
|
|
2209
|
+
return this.cachedConfig !== null && now - this.lastFetchTime < this.cacheTtlMs;
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region src/services/logger.ts
|
|
2215
|
+
function isRecord(value) {
|
|
2216
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2217
|
+
}
|
|
2218
|
+
function toLogOptions(data) {
|
|
2219
|
+
if (data === void 0) return;
|
|
2220
|
+
if (data instanceof Error) return { exception: data };
|
|
2221
|
+
if (isRecord(data) && ("attributes" in data || "exception" in data || "context" in data)) return data;
|
|
2222
|
+
if (isRecord(data)) return { attributes: data };
|
|
2223
|
+
return { attributes: { value: data } };
|
|
2224
|
+
}
|
|
2225
|
+
function createLoggerFacade(handle) {
|
|
2226
|
+
const logger = handle.logger;
|
|
2227
|
+
return {
|
|
2228
|
+
backend: handle.backend,
|
|
2229
|
+
enabled: handle.enabled,
|
|
2230
|
+
endpoint: handle.endpoint,
|
|
2231
|
+
filePath: handle.filePath,
|
|
2232
|
+
trace(message, data) {
|
|
2233
|
+
logger.trace(message, toLogOptions(data));
|
|
2234
|
+
},
|
|
2235
|
+
debug(message, data) {
|
|
2236
|
+
logger.debug(message, toLogOptions(data));
|
|
2237
|
+
},
|
|
2238
|
+
info(message, data) {
|
|
2239
|
+
logger.info(message, toLogOptions(data));
|
|
2240
|
+
},
|
|
2241
|
+
warn(message, data) {
|
|
2242
|
+
logger.warn(message, toLogOptions(data));
|
|
2243
|
+
},
|
|
2244
|
+
error(message, data) {
|
|
2245
|
+
logger.error(message, toLogOptions(data));
|
|
2246
|
+
},
|
|
2247
|
+
getTraceContext() {
|
|
2248
|
+
return logger.getTraceContext();
|
|
2249
|
+
},
|
|
2250
|
+
flush: () => handle.flush(),
|
|
2251
|
+
shutdown: () => handle.shutdown()
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
async function createProxyLogger(options = {}) {
|
|
2255
|
+
return createLoggerFacade(await createNodeTelemetry({
|
|
2256
|
+
env: options.env,
|
|
2257
|
+
workspaceRoot: options.workspaceRoot,
|
|
2258
|
+
serviceName: options.serviceName ?? "@agimon-ai/mcp-proxy",
|
|
2259
|
+
serviceVersion: options.serviceVersion,
|
|
2260
|
+
logDir: options.logDir,
|
|
2261
|
+
logFileName: options.logFileName,
|
|
2262
|
+
maxFileSizeBytes: options.maxFileSizeBytes,
|
|
2263
|
+
maxFileCount: options.maxFileCount
|
|
2264
|
+
}));
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
//#endregion
|
|
2268
|
+
//#region src/services/McpClientManagerService.ts
|
|
2269
|
+
/** Default connection timeout in milliseconds (30 seconds) */
|
|
2270
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 3e4;
|
|
2271
|
+
/**
|
|
2272
|
+
* Checks if an error is a session-related error from an HTTP backend
|
|
2273
|
+
* (e.g., downstream server restarted and no longer recognizes the session ID).
|
|
2274
|
+
*/
|
|
2275
|
+
function isSessionError(error) {
|
|
2276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2277
|
+
return message.includes("unknown session") || message.includes("Session not found");
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* MCP Client wrapper for managing individual server connections
|
|
2281
|
+
* This is an internal class used by McpClientManagerService
|
|
2282
|
+
*/
|
|
2283
|
+
var McpClient = class {
|
|
2284
|
+
serverName;
|
|
2285
|
+
serverInstruction;
|
|
2286
|
+
toolBlacklist;
|
|
2287
|
+
omitToolDescription;
|
|
2288
|
+
prompts;
|
|
2289
|
+
transport;
|
|
2290
|
+
logger;
|
|
2291
|
+
client;
|
|
2292
|
+
childProcess;
|
|
2293
|
+
connected = false;
|
|
2294
|
+
reconnectFn;
|
|
2295
|
+
constructor(serverName, transport, client, logger, config) {
|
|
2296
|
+
this.serverName = serverName;
|
|
2297
|
+
this.serverInstruction = config.instruction;
|
|
2298
|
+
this.toolBlacklist = config.toolBlacklist;
|
|
2299
|
+
this.omitToolDescription = config.omitToolDescription;
|
|
2300
|
+
this.prompts = config.prompts;
|
|
2301
|
+
this.transport = transport;
|
|
2302
|
+
this.logger = logger;
|
|
2303
|
+
this.client = client;
|
|
2304
|
+
}
|
|
2305
|
+
setChildProcess(process$1) {
|
|
2306
|
+
this.childProcess = process$1;
|
|
2307
|
+
}
|
|
2308
|
+
setConnected(connected) {
|
|
2309
|
+
this.connected = connected;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Sets a reconnection function that creates a fresh Client and transport.
|
|
2313
|
+
* Called automatically by withSessionRetry when a session error is detected
|
|
2314
|
+
* (e.g., downstream HTTP server restarted and the old session ID is invalid).
|
|
2315
|
+
*/
|
|
2316
|
+
setReconnectFn(fn) {
|
|
2317
|
+
this.reconnectFn = fn;
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Wraps an operation with automatic retry on session errors.
|
|
2321
|
+
* If the operation fails with a session error (e.g., downstream server restarted),
|
|
2322
|
+
* reconnects and retries once.
|
|
2323
|
+
*/
|
|
2324
|
+
async withSessionRetry(operation) {
|
|
2325
|
+
try {
|
|
2326
|
+
return await operation();
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
if (!this.reconnectFn || !isSessionError(error)) throw error;
|
|
2329
|
+
this.logger.warn(`Session error for ${this.serverName}, reconnecting: ${error instanceof Error ? error.message : String(error)}`);
|
|
2330
|
+
try {
|
|
2331
|
+
await this.client.close();
|
|
2332
|
+
} catch (closeError) {
|
|
2333
|
+
this.logger.warn(`Failed to close stale client for ${this.serverName}`, closeError);
|
|
2334
|
+
}
|
|
2335
|
+
const result = await this.reconnectFn();
|
|
2336
|
+
this.client = result.client;
|
|
2337
|
+
if (result.childProcess) this.childProcess = result.childProcess;
|
|
2338
|
+
return await operation();
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
async listTools() {
|
|
2342
|
+
if (!this.connected) throw new Error(`Client for ${this.serverName} is not connected`);
|
|
2343
|
+
return this.withSessionRetry(async () => {
|
|
2344
|
+
return (await this.client.listTools()).tools;
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
async listResources() {
|
|
2348
|
+
if (!this.connected) throw new Error(`Client for ${this.serverName} is not connected`);
|
|
2349
|
+
return this.withSessionRetry(async () => {
|
|
2350
|
+
return (await this.client.listResources()).resources;
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
async listPrompts() {
|
|
2354
|
+
if (!this.connected) throw new Error(`Client for ${this.serverName} is not connected`);
|
|
2355
|
+
return this.withSessionRetry(async () => {
|
|
2356
|
+
return (await this.client.listPrompts()).prompts;
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
async callTool(name, args, options) {
|
|
2360
|
+
if (!this.connected) throw new Error(`Client for ${this.serverName} is not connected`);
|
|
2361
|
+
return this.withSessionRetry(async () => {
|
|
2362
|
+
const requestOptions = options?.timeout ? { timeout: options.timeout } : void 0;
|
|
2363
|
+
return await this.client.callTool({
|
|
2364
|
+
name,
|
|
2365
|
+
arguments: args
|
|
2366
|
+
}, void 0, requestOptions);
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
async readResource(uri) {
|
|
2370
|
+
if (!this.connected) throw new Error(`Client for ${this.serverName} is not connected`);
|
|
2371
|
+
return this.withSessionRetry(async () => {
|
|
2372
|
+
return await this.client.readResource({ uri });
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
async getPrompt(name, args) {
|
|
2376
|
+
if (!this.connected) throw new Error(`Client for ${this.serverName} is not connected`);
|
|
2377
|
+
return this.withSessionRetry(async () => {
|
|
2378
|
+
return await this.client.getPrompt({
|
|
2379
|
+
name,
|
|
2380
|
+
arguments: args
|
|
2381
|
+
});
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
async close() {
|
|
2385
|
+
if (this.childProcess) this.childProcess.kill();
|
|
2386
|
+
await this.client.close();
|
|
2387
|
+
this.connected = false;
|
|
2388
|
+
}
|
|
2389
|
+
};
|
|
2390
|
+
/**
|
|
2391
|
+
* Service for managing MCP client connections to remote servers
|
|
2392
|
+
*/
|
|
2393
|
+
var McpClientManagerService = class {
|
|
2394
|
+
clients = /* @__PURE__ */ new Map();
|
|
2395
|
+
serverConfigs = /* @__PURE__ */ new Map();
|
|
2396
|
+
connectionPromises = /* @__PURE__ */ new Map();
|
|
2397
|
+
logger;
|
|
2398
|
+
constructor(logger = console) {
|
|
2399
|
+
this.logger = logger;
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Synchronously kill all stdio MCP server child processes.
|
|
2403
|
+
* Must be called by the owner (e.g. transport/command layer) during shutdown.
|
|
2404
|
+
*/
|
|
2405
|
+
cleanupChildProcesses() {
|
|
2406
|
+
for (const [serverName, client] of this.clients) try {
|
|
2407
|
+
const childProcess = client["childProcess"];
|
|
2408
|
+
if (childProcess && !childProcess.killed) {
|
|
2409
|
+
this.logger.info(`Killing stdio MCP server: ${serverName} (PID: ${childProcess.pid})`);
|
|
2410
|
+
childProcess.kill("SIGTERM");
|
|
2411
|
+
setTimeout(() => {
|
|
2412
|
+
if (!childProcess.killed) {
|
|
2413
|
+
this.logger.warn(`Force killing stdio MCP server: ${serverName} (PID: ${childProcess.pid})`);
|
|
2414
|
+
childProcess.kill("SIGKILL");
|
|
2415
|
+
}
|
|
2416
|
+
}, 1e3);
|
|
2417
|
+
}
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
this.logger.warn(`Failed to kill child process for ${serverName}`, error);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Connect to an MCP server based on its configuration with timeout
|
|
2424
|
+
* Uses the timeout from server config, falling back to default (30s)
|
|
2425
|
+
*/
|
|
2426
|
+
async connectToServer(serverName, config) {
|
|
2427
|
+
this.serverConfigs.set(serverName, config);
|
|
2428
|
+
await this.ensureConnected(serverName);
|
|
2429
|
+
}
|
|
2430
|
+
registerServerConfigs(configs) {
|
|
2431
|
+
for (const [serverName, config] of Object.entries(configs)) this.serverConfigs.set(serverName, config);
|
|
2432
|
+
}
|
|
2433
|
+
getKnownServerNames() {
|
|
2434
|
+
return Array.from(this.serverConfigs.keys());
|
|
2435
|
+
}
|
|
2436
|
+
getServerRequestTimeout(serverName) {
|
|
2437
|
+
return this.serverConfigs.get(serverName)?.requestTimeout;
|
|
2438
|
+
}
|
|
2439
|
+
async ensureConnected(serverName) {
|
|
2440
|
+
const existingClient = this.clients.get(serverName);
|
|
2441
|
+
if (existingClient) return existingClient;
|
|
2442
|
+
const inflightConnection = this.connectionPromises.get(serverName);
|
|
2443
|
+
if (inflightConnection) return await inflightConnection;
|
|
2444
|
+
const config = this.serverConfigs.get(serverName);
|
|
2445
|
+
if (!config) throw new Error(`No configuration found for server "${serverName}"`);
|
|
2446
|
+
const connectionPromise = this.createConnection(serverName, config);
|
|
2447
|
+
this.connectionPromises.set(serverName, connectionPromise);
|
|
2448
|
+
try {
|
|
2449
|
+
return await connectionPromise;
|
|
2450
|
+
} finally {
|
|
2451
|
+
this.connectionPromises.delete(serverName);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
async createConnection(serverName, config) {
|
|
2455
|
+
const timeoutMs = config.timeout ?? DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
2456
|
+
const client = new Client({
|
|
2457
|
+
name: `@agimon-ai/mcp-proxy-client`,
|
|
2458
|
+
version: "0.1.0"
|
|
2459
|
+
}, { capabilities: {} });
|
|
2460
|
+
const mcpClient = new McpClient(serverName, config.transport, client, this.logger, {
|
|
2461
|
+
instruction: config.instruction,
|
|
2462
|
+
toolBlacklist: config.toolBlacklist,
|
|
2463
|
+
omitToolDescription: config.omitToolDescription,
|
|
2464
|
+
prompts: config.prompts
|
|
2465
|
+
});
|
|
2466
|
+
try {
|
|
2467
|
+
await Promise.race([this.performConnection(mcpClient, config), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Connection timeout after ${timeoutMs}ms`)), timeoutMs))]);
|
|
2468
|
+
mcpClient.setConnected(true);
|
|
2469
|
+
if (config.transport === "http" || config.transport === "sse") mcpClient.setReconnectFn(async () => {
|
|
2470
|
+
try {
|
|
2471
|
+
const newClient = new Client({
|
|
2472
|
+
name: "@agimon-ai/mcp-proxy-client",
|
|
2473
|
+
version: "0.1.0"
|
|
2474
|
+
}, { capabilities: {} });
|
|
2475
|
+
const newMcpClient = new McpClient(serverName, config.transport, newClient, this.logger, {});
|
|
2476
|
+
await this.performConnection(newMcpClient, config);
|
|
2477
|
+
return { client: newClient };
|
|
2478
|
+
} catch (error) {
|
|
2479
|
+
this.logger.warn(`Failed to reconnect to ${serverName}`, error);
|
|
2480
|
+
throw error;
|
|
2481
|
+
}
|
|
2482
|
+
});
|
|
2483
|
+
if (!mcpClient.serverInstruction) try {
|
|
2484
|
+
const serverInstruction = mcpClient["client"].getInstructions();
|
|
2485
|
+
if (serverInstruction) mcpClient.serverInstruction = serverInstruction;
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
this.logger.warn(`Failed to get server instruction from ${serverName}`, error);
|
|
2488
|
+
}
|
|
2489
|
+
this.clients.set(serverName, mcpClient);
|
|
2490
|
+
return mcpClient;
|
|
2491
|
+
} catch (error) {
|
|
2492
|
+
await mcpClient.close();
|
|
2493
|
+
throw error;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Perform the actual connection to MCP server
|
|
2498
|
+
*/
|
|
2499
|
+
async performConnection(mcpClient, config) {
|
|
2500
|
+
if (config.transport === "stdio") await this.connectStdioClient(mcpClient, config.config);
|
|
2501
|
+
else if (config.transport === "http") await this.connectHttpClient(mcpClient, config.config);
|
|
2502
|
+
else if (config.transport === "sse") await this.connectSseClient(mcpClient, config.config);
|
|
2503
|
+
else throw new Error(`Unsupported transport type: ${config.transport}`);
|
|
2504
|
+
}
|
|
2505
|
+
async connectStdioClient(mcpClient, config) {
|
|
2506
|
+
const transport = new StdioClientTransport({
|
|
2507
|
+
command: config.command,
|
|
2508
|
+
args: config.args,
|
|
2509
|
+
env: {
|
|
2510
|
+
...process.env,
|
|
2511
|
+
...config.env ?? {}
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
await mcpClient["client"].connect(transport);
|
|
2515
|
+
const childProcess = transport["_process"];
|
|
2516
|
+
if (childProcess) mcpClient.setChildProcess(childProcess);
|
|
2517
|
+
}
|
|
2518
|
+
async connectHttpClient(mcpClient, config) {
|
|
2519
|
+
const transport = new StreamableHTTPClientTransport(new URL(config.url), { requestInit: config.headers ? { headers: config.headers } : void 0 });
|
|
2520
|
+
await mcpClient["client"].connect(transport);
|
|
2521
|
+
}
|
|
2522
|
+
async connectSseClient(mcpClient, config) {
|
|
2523
|
+
const transport = new SSEClientTransport(new URL(config.url));
|
|
2524
|
+
await mcpClient["client"].connect(transport);
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Get a connected client by server name
|
|
2528
|
+
*/
|
|
2529
|
+
getClient(serverName) {
|
|
2530
|
+
return this.clients.get(serverName);
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Get all connected clients
|
|
2534
|
+
*/
|
|
2535
|
+
getAllClients() {
|
|
2536
|
+
return Array.from(this.clients.values());
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Disconnect from a specific server
|
|
2540
|
+
*/
|
|
2541
|
+
async disconnectServer(serverName) {
|
|
2542
|
+
const client = this.clients.get(serverName);
|
|
2543
|
+
if (client) {
|
|
2544
|
+
await client.close();
|
|
2545
|
+
this.clients.delete(serverName);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Disconnect from all servers
|
|
2550
|
+
*/
|
|
2551
|
+
async disconnectAll() {
|
|
2552
|
+
const disconnectPromises = Array.from(this.clients.values()).map((client) => client.close());
|
|
2553
|
+
await Promise.all(disconnectPromises);
|
|
2554
|
+
this.clients.clear();
|
|
2555
|
+
this.connectionPromises.clear();
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Check if a server is connected
|
|
2559
|
+
*/
|
|
2560
|
+
isConnected(serverName) {
|
|
2561
|
+
return this.clients.has(serverName);
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
|
|
2565
|
+
//#endregion
|
|
2566
|
+
//#region src/services/RuntimeStateService.ts
|
|
2567
|
+
/**
|
|
2568
|
+
* RuntimeStateService
|
|
2569
|
+
*
|
|
2570
|
+
* Persists runtime metadata for HTTP mcp-proxy instances so external commands
|
|
2571
|
+
* (for example `mcp-proxy stop`) can discover and target the correct server.
|
|
2572
|
+
*/
|
|
2573
|
+
const RUNTIME_DIR_NAME = "runtimes";
|
|
2574
|
+
const RUNTIME_FILE_SUFFIX = ".runtime.json";
|
|
2575
|
+
function isObject(value) {
|
|
2576
|
+
return typeof value === "object" && value !== null;
|
|
2577
|
+
}
|
|
2578
|
+
function isRuntimeStateRecord(value) {
|
|
2579
|
+
if (!isObject(value)) return false;
|
|
2580
|
+
return typeof value.serverId === "string" && typeof value.host === "string" && typeof value.port === "number" && value.transport === "http" && typeof value.shutdownToken === "string" && typeof value.startedAt === "string" && typeof value.pid === "number" && (value.configPath === void 0 || typeof value.configPath === "string");
|
|
2581
|
+
}
|
|
2582
|
+
function toErrorMessage$2(error) {
|
|
2583
|
+
return error instanceof Error ? error.message : String(error);
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Runtime state persistence implementation.
|
|
2587
|
+
*/
|
|
2588
|
+
var RuntimeStateService = class RuntimeStateService {
|
|
2589
|
+
runtimeDir;
|
|
2590
|
+
logger;
|
|
2591
|
+
constructor(runtimeDir, logger = console) {
|
|
2592
|
+
this.runtimeDir = runtimeDir ?? RuntimeStateService.getDefaultRuntimeDir();
|
|
2593
|
+
this.logger = logger;
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Resolve default runtime directory under the user's home cache path.
|
|
2597
|
+
* @returns Absolute runtime directory path
|
|
2598
|
+
*/
|
|
2599
|
+
static getDefaultRuntimeDir() {
|
|
2600
|
+
return join(homedir(), ".aicode-toolkit", "mcp-proxy", RUNTIME_DIR_NAME);
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Build runtime state file path for a given server ID.
|
|
2604
|
+
* @param serverId - Target mcp-proxy server identifier
|
|
2605
|
+
* @returns Absolute runtime file path
|
|
2606
|
+
*/
|
|
2607
|
+
getRecordPath(serverId) {
|
|
2608
|
+
return join(this.runtimeDir, `${serverId}${RUNTIME_FILE_SUFFIX}`);
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Persist a runtime state record.
|
|
2612
|
+
* @param record - Runtime metadata to persist
|
|
2613
|
+
* @returns Promise that resolves when write completes
|
|
2614
|
+
*/
|
|
2615
|
+
async write(record) {
|
|
2616
|
+
await mkdir(this.runtimeDir, { recursive: true });
|
|
2617
|
+
await writeFile(this.getRecordPath(record.serverId), JSON.stringify(record, null, 2), "utf-8");
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Read a runtime state record by server ID.
|
|
2621
|
+
* @param serverId - Target mcp-proxy server identifier
|
|
2622
|
+
* @returns Matching runtime record, or null when no record exists
|
|
2623
|
+
*/
|
|
2624
|
+
async read(serverId) {
|
|
2625
|
+
const filePath = this.getRecordPath(serverId);
|
|
2626
|
+
try {
|
|
2627
|
+
const content = await readFile(filePath, "utf-8");
|
|
2628
|
+
const parsed = JSON.parse(content);
|
|
2629
|
+
return isRuntimeStateRecord(parsed) ? parsed : null;
|
|
2630
|
+
} catch (error) {
|
|
2631
|
+
if (isObject(error) && "code" in error && error.code === "ENOENT") return null;
|
|
2632
|
+
throw new Error(`Failed to read runtime state for server '${serverId}' from '${filePath}': ${toErrorMessage$2(error)}`);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* List all persisted runtime records.
|
|
2637
|
+
* @returns Array of runtime records
|
|
2638
|
+
*/
|
|
2639
|
+
async list() {
|
|
2640
|
+
try {
|
|
2641
|
+
const files = (await readdir(this.runtimeDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(RUNTIME_FILE_SUFFIX));
|
|
2642
|
+
return (await Promise.all(files.map(async (file) => {
|
|
2643
|
+
try {
|
|
2644
|
+
const content = await readFile(join(this.runtimeDir, file.name), "utf-8");
|
|
2645
|
+
const parsed = JSON.parse(content);
|
|
2646
|
+
return isRuntimeStateRecord(parsed) ? parsed : null;
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
this.logger.debug(`Skipping unreadable runtime state file ${file.name}`, error);
|
|
2649
|
+
return null;
|
|
2650
|
+
}
|
|
2651
|
+
}))).filter((record) => record !== null);
|
|
2652
|
+
} catch (error) {
|
|
2653
|
+
if (isObject(error) && "code" in error && error.code === "ENOENT") return [];
|
|
2654
|
+
throw new Error(`Failed to list runtime states from '${this.runtimeDir}': ${toErrorMessage$2(error)}`);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Remove a runtime state record by server ID.
|
|
2659
|
+
* @param serverId - Target mcp-proxy server identifier
|
|
2660
|
+
* @returns Promise that resolves when delete completes
|
|
2661
|
+
*/
|
|
2662
|
+
async remove(serverId) {
|
|
2663
|
+
await rm(this.getRecordPath(serverId), { force: true });
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
|
|
2667
|
+
//#endregion
|
|
2668
|
+
//#region src/services/StopServerService/constants.ts
|
|
2669
|
+
/**
|
|
2670
|
+
* StopServerService constants.
|
|
2671
|
+
*/
|
|
2672
|
+
/** Maximum time in milliseconds to wait for a shutdown to complete. */
|
|
2673
|
+
const DEFAULT_STOP_TIMEOUT_MS = 5e3;
|
|
2674
|
+
/** Minimum timeout in milliseconds for individual health check requests. */
|
|
2675
|
+
const HEALTH_REQUEST_TIMEOUT_FLOOR_MS = 250;
|
|
2676
|
+
/** Delay in milliseconds between shutdown polling attempts. */
|
|
2677
|
+
const SHUTDOWN_POLL_INTERVAL_MS = 200;
|
|
2678
|
+
/** Path for the runtime health check endpoint. */
|
|
2679
|
+
const HEALTH_CHECK_PATH = "/health";
|
|
2680
|
+
/** Path for the authenticated admin shutdown endpoint. */
|
|
2681
|
+
const ADMIN_SHUTDOWN_PATH = "/admin/shutdown";
|
|
2682
|
+
/** HTTP GET method identifier. */
|
|
2683
|
+
const HTTP_METHOD_GET = "GET";
|
|
2684
|
+
/** HTTP POST method identifier. */
|
|
2685
|
+
const HTTP_METHOD_POST = "POST";
|
|
2686
|
+
/** HTTP header name for bearer token authorization. */
|
|
2687
|
+
const AUTHORIZATION_HEADER_NAME = "Authorization";
|
|
2688
|
+
/** Prefix for bearer token values in the Authorization header. */
|
|
2689
|
+
const BEARER_TOKEN_PREFIX = "Bearer ";
|
|
2690
|
+
/** HTTP protocol scheme prefix for URL construction. */
|
|
2691
|
+
const HTTP_PROTOCOL = "http://";
|
|
2692
|
+
/** Separator between host and port in URL construction. */
|
|
2693
|
+
const URL_PORT_SEPARATOR = ":";
|
|
2694
|
+
/** Loopback hostname. */
|
|
2695
|
+
const LOOPBACK_HOST_LOCALHOST = "localhost";
|
|
2696
|
+
/** IPv4 loopback address. */
|
|
2697
|
+
const LOOPBACK_HOST_IPV4 = "127.0.0.1";
|
|
2698
|
+
/** IPv6 loopback address. */
|
|
2699
|
+
const LOOPBACK_HOST_IPV6 = "::1";
|
|
2700
|
+
/** Hosts that are safe to send admin requests to (loopback only). */
|
|
2701
|
+
const ALLOWED_HOSTS = new Set([
|
|
2702
|
+
LOOPBACK_HOST_LOCALHOST,
|
|
2703
|
+
LOOPBACK_HOST_IPV4,
|
|
2704
|
+
LOOPBACK_HOST_IPV6
|
|
2705
|
+
]);
|
|
2706
|
+
/** Expected status value in a healthy runtime response. */
|
|
2707
|
+
const HEALTH_STATUS_OK = "ok";
|
|
2708
|
+
/** Expected transport value in a healthy runtime response. */
|
|
2709
|
+
const HEALTH_TRANSPORT_HTTP = "http";
|
|
2710
|
+
/** Property key for status field in health responses. */
|
|
2711
|
+
const KEY_STATUS = "status";
|
|
2712
|
+
/** Property key for transport field in health responses. */
|
|
2713
|
+
const KEY_TRANSPORT = "transport";
|
|
2714
|
+
/** Property key for serverId field in runtime responses. */
|
|
2715
|
+
const KEY_SERVER_ID = "serverId";
|
|
2716
|
+
/** Property key for ok field in shutdown responses. */
|
|
2717
|
+
const KEY_OK = "ok";
|
|
2718
|
+
/** Property key for message field in shutdown responses. */
|
|
2719
|
+
const KEY_MESSAGE = "message";
|
|
2720
|
+
|
|
2721
|
+
//#endregion
|
|
2722
|
+
//#region src/services/StopServerService/types.ts
|
|
2723
|
+
/**
|
|
2724
|
+
* Safely cast a non-null object to a string-keyed record for property access.
|
|
2725
|
+
* @param value - Object value already verified as non-null
|
|
2726
|
+
* @returns The same value typed as a record
|
|
2727
|
+
*/
|
|
2728
|
+
function toRecord(value) {
|
|
2729
|
+
return value;
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Type guard for health responses.
|
|
2733
|
+
* @param value - Candidate payload to validate
|
|
2734
|
+
* @returns True when payload matches health response shape
|
|
2735
|
+
*/
|
|
2736
|
+
function isHealthResponse(value) {
|
|
2737
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2738
|
+
const record = toRecord(value);
|
|
2739
|
+
return KEY_STATUS in record && record[KEY_STATUS] === HEALTH_STATUS_OK && KEY_TRANSPORT in record && record[KEY_TRANSPORT] === HEALTH_TRANSPORT_HTTP && (!(KEY_SERVER_ID in record) || record[KEY_SERVER_ID] === void 0 || typeof record[KEY_SERVER_ID] === "string");
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Type guard for shutdown responses.
|
|
2743
|
+
* @param value - Candidate payload to validate
|
|
2744
|
+
* @returns True when payload matches shutdown response shape
|
|
2745
|
+
*/
|
|
2746
|
+
function isShutdownResponse(value) {
|
|
2747
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2748
|
+
const record = toRecord(value);
|
|
2749
|
+
return KEY_OK in record && typeof record[KEY_OK] === "boolean" && KEY_MESSAGE in record && typeof record[KEY_MESSAGE] === "string" && (!(KEY_SERVER_ID in record) || record[KEY_SERVER_ID] === void 0 || typeof record[KEY_SERVER_ID] === "string");
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
//#endregion
|
|
2753
|
+
//#region src/services/StopServerService/StopServerService.ts
|
|
2754
|
+
/**
|
|
2755
|
+
* Format runtime endpoint URL after validating the host is a loopback address.
|
|
2756
|
+
* Rejects non-loopback hosts to prevent SSRF via tampered runtime state files.
|
|
2757
|
+
* @param runtime - Runtime record to format
|
|
2758
|
+
* @param path - Request path to append
|
|
2759
|
+
* @returns Full runtime URL
|
|
2760
|
+
*/
|
|
2761
|
+
function buildRuntimeUrl(runtime, path) {
|
|
2762
|
+
if (!ALLOWED_HOSTS.has(runtime.host)) throw new Error(`Refusing to connect to non-loopback host '${runtime.host}'. Only ${Array.from(ALLOWED_HOSTS).join(", ")} are allowed.`);
|
|
2763
|
+
return `${HTTP_PROTOCOL}${runtime.host}${URL_PORT_SEPARATOR}${runtime.port}${path}`;
|
|
2764
|
+
}
|
|
2765
|
+
function toErrorMessage$1(error) {
|
|
2766
|
+
return error instanceof Error ? error.message : String(error);
|
|
2767
|
+
}
|
|
2768
|
+
function sleep(delayMs) {
|
|
2769
|
+
return new Promise((resolve$1) => {
|
|
2770
|
+
setTimeout(resolve$1, delayMs);
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* Service for resolving runtime targets and stopping them safely.
|
|
2775
|
+
*/
|
|
2776
|
+
var StopServerService = class {
|
|
2777
|
+
runtimeStateService;
|
|
2778
|
+
logger;
|
|
2779
|
+
constructor(runtimeStateService = new RuntimeStateService(), logger = console) {
|
|
2780
|
+
this.runtimeStateService = runtimeStateService;
|
|
2781
|
+
this.logger = logger;
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Resolve a target runtime and stop it cooperatively.
|
|
2785
|
+
* @param request - Stop request options
|
|
2786
|
+
* @returns Stop result payload
|
|
2787
|
+
*/
|
|
2788
|
+
async stop(request) {
|
|
2789
|
+
const timeoutMs = request.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
|
2790
|
+
const runtime = await this.resolveRuntime(request);
|
|
2791
|
+
const health = await this.fetchHealth(runtime, timeoutMs);
|
|
2792
|
+
if (!health.reachable) {
|
|
2793
|
+
await this.runtimeStateService.remove(runtime.serverId);
|
|
2794
|
+
throw new Error(`Runtime '${runtime.serverId}' is not reachable at http://${runtime.host}:${runtime.port}. Removed stale runtime record.`);
|
|
2795
|
+
}
|
|
2796
|
+
if (!request.force && health.payload?.serverId && health.payload.serverId !== runtime.serverId) throw new Error(`Refusing to stop runtime at http://${runtime.host}:${runtime.port}: expected server ID '${runtime.serverId}' but health endpoint reported '${health.payload.serverId}'. Use --force to override.`);
|
|
2797
|
+
const shutdownToken = request.token ?? runtime.shutdownToken;
|
|
2798
|
+
if (!shutdownToken) throw new Error(`No shutdown token available for runtime '${runtime.serverId}'.`);
|
|
2799
|
+
const shutdownResponse = await this.requestShutdown(runtime, shutdownToken, timeoutMs);
|
|
2800
|
+
await this.waitForShutdown(runtime, timeoutMs);
|
|
2801
|
+
await this.runtimeStateService.remove(runtime.serverId);
|
|
2802
|
+
return {
|
|
2803
|
+
ok: true,
|
|
2804
|
+
serverId: runtime.serverId,
|
|
2805
|
+
host: runtime.host,
|
|
2806
|
+
port: runtime.port,
|
|
2807
|
+
message: shutdownResponse.message
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* Resolve a runtime record from explicit ID or a unique host/port pair.
|
|
2812
|
+
* @param request - Stop request options
|
|
2813
|
+
* @returns Matching runtime record
|
|
2814
|
+
*/
|
|
2815
|
+
async resolveRuntime(request) {
|
|
2816
|
+
if (request.serverId) {
|
|
2817
|
+
const runtime = await this.runtimeStateService.read(request.serverId);
|
|
2818
|
+
if (!runtime) throw new Error(`No runtime record found for server ID '${request.serverId}'. Start the server with 'mcp-proxy mcp-serve --type http' first.`);
|
|
2819
|
+
return runtime;
|
|
2820
|
+
}
|
|
2821
|
+
if (request.host === void 0 || request.port === void 0) throw new Error("Provide --id or both --host and --port to select a runtime.");
|
|
2822
|
+
const matches = (await this.runtimeStateService.list()).filter((runtime) => runtime.host === request.host && runtime.port === request.port);
|
|
2823
|
+
if (matches.length === 0) throw new Error(`No runtime record found for http://${request.host}:${request.port}. Start the server with 'mcp-proxy mcp-serve --type http' first.`);
|
|
2824
|
+
if (matches.length > 1) throw new Error(`Multiple runtime records match http://${request.host}:${request.port}. Retry with --id to avoid stopping the wrong server.`);
|
|
2825
|
+
return matches[0];
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Read the runtime health payload.
|
|
2829
|
+
* @param runtime - Runtime to query
|
|
2830
|
+
* @param timeoutMs - Request timeout in milliseconds
|
|
2831
|
+
* @returns Reachability status and optional payload
|
|
2832
|
+
*/
|
|
2833
|
+
async fetchHealth(runtime, timeoutMs) {
|
|
2834
|
+
try {
|
|
2835
|
+
const response = await this.fetchWithTimeout(buildRuntimeUrl(runtime, HEALTH_CHECK_PATH), { method: HTTP_METHOD_GET }, timeoutMs);
|
|
2836
|
+
if (!response.ok) return { reachable: false };
|
|
2837
|
+
const payload = await response.json();
|
|
2838
|
+
if (!isHealthResponse(payload)) throw new Error("Received invalid health response payload.");
|
|
2839
|
+
return {
|
|
2840
|
+
reachable: true,
|
|
2841
|
+
payload
|
|
2842
|
+
};
|
|
2843
|
+
} catch (error) {
|
|
2844
|
+
this.logger.debug(`Health check failed for ${runtime.serverId}`, error);
|
|
2845
|
+
return { reachable: false };
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Send authenticated shutdown request to the admin endpoint.
|
|
2850
|
+
* @param runtime - Runtime to stop
|
|
2851
|
+
* @param shutdownToken - Bearer token for the admin endpoint
|
|
2852
|
+
* @param timeoutMs - Request timeout in milliseconds
|
|
2853
|
+
* @returns Parsed shutdown response payload
|
|
2854
|
+
*/
|
|
2855
|
+
async requestShutdown(runtime, shutdownToken, timeoutMs) {
|
|
2856
|
+
const response = await this.fetchWithTimeout(buildRuntimeUrl(runtime, ADMIN_SHUTDOWN_PATH), {
|
|
2857
|
+
method: HTTP_METHOD_POST,
|
|
2858
|
+
headers: { [AUTHORIZATION_HEADER_NAME]: `${BEARER_TOKEN_PREFIX}${shutdownToken}` }
|
|
2859
|
+
}, timeoutMs);
|
|
2860
|
+
const payload = await response.json();
|
|
2861
|
+
if (!isShutdownResponse(payload)) throw new Error("Received invalid shutdown response payload.");
|
|
2862
|
+
if (!response.ok || !payload.ok) throw new Error(payload.message);
|
|
2863
|
+
return payload;
|
|
2864
|
+
}
|
|
2865
|
+
/**
|
|
2866
|
+
* Poll until the target runtime is no longer reachable.
|
|
2867
|
+
* @param runtime - Runtime expected to stop
|
|
2868
|
+
* @param timeoutMs - Maximum wait time in milliseconds
|
|
2869
|
+
* @returns Promise that resolves when shutdown is observed
|
|
2870
|
+
*/
|
|
2871
|
+
async waitForShutdown(runtime, timeoutMs) {
|
|
2872
|
+
const deadline = Date.now() + timeoutMs;
|
|
2873
|
+
while (Date.now() < deadline) {
|
|
2874
|
+
if (!(await this.fetchHealth(runtime, Math.max(HEALTH_REQUEST_TIMEOUT_FLOOR_MS, deadline - Date.now()))).reachable) return;
|
|
2875
|
+
await sleep(SHUTDOWN_POLL_INTERVAL_MS);
|
|
2876
|
+
}
|
|
2877
|
+
throw new Error(`Timed out waiting for runtime '${runtime.serverId}' to stop at http://${runtime.host}:${runtime.port}.`);
|
|
2878
|
+
}
|
|
2879
|
+
/**
|
|
2880
|
+
* Perform a fetch with an abort timeout.
|
|
2881
|
+
* @param url - Target URL
|
|
2882
|
+
* @param init - Fetch options
|
|
2883
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
2884
|
+
* @returns Fetch response
|
|
2885
|
+
*/
|
|
2886
|
+
async fetchWithTimeout(url, init, timeoutMs) {
|
|
2887
|
+
const controller = new AbortController();
|
|
2888
|
+
const timeoutId = setTimeout(() => {
|
|
2889
|
+
controller.abort();
|
|
2890
|
+
}, timeoutMs);
|
|
2891
|
+
try {
|
|
2892
|
+
return await fetch(url, {
|
|
2893
|
+
...init,
|
|
2894
|
+
signal: controller.signal
|
|
2895
|
+
});
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
throw new Error(`Request to '${url}' failed: ${toErrorMessage$1(error)}`);
|
|
2898
|
+
} finally {
|
|
2899
|
+
clearTimeout(timeoutId);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
|
|
2904
|
+
//#endregion
|
|
2905
|
+
//#region src/services/SkillService.ts
|
|
2906
|
+
/**
|
|
2907
|
+
* SkillService
|
|
2908
|
+
*
|
|
2909
|
+
* DESIGN PATTERNS:
|
|
2910
|
+
* - Service pattern for business logic encapsulation
|
|
2911
|
+
* - Single responsibility principle
|
|
2912
|
+
* - Lazy loading pattern for skill discovery
|
|
2913
|
+
*
|
|
2914
|
+
* CODING STANDARDS:
|
|
2915
|
+
* - Use async/await for asynchronous operations
|
|
2916
|
+
* - Throw descriptive errors for error cases
|
|
2917
|
+
* - Keep methods focused and well-named
|
|
2918
|
+
* - Document complex logic with comments
|
|
2919
|
+
*
|
|
2920
|
+
* AVOID:
|
|
2921
|
+
* - Mixing concerns (keep focused on single domain)
|
|
2922
|
+
* - Direct tool implementation (services should be tool-agnostic)
|
|
2923
|
+
*/
|
|
2924
|
+
/**
|
|
2925
|
+
* Error thrown when skill loading fails
|
|
2926
|
+
*/
|
|
2927
|
+
var SkillLoadError = class extends Error {
|
|
2928
|
+
constructor(message, filePath, cause) {
|
|
2929
|
+
super(message);
|
|
2930
|
+
this.filePath = filePath;
|
|
2931
|
+
this.cause = cause;
|
|
2932
|
+
this.name = "SkillLoadError";
|
|
2933
|
+
}
|
|
2934
|
+
};
|
|
2935
|
+
/**
|
|
2936
|
+
* Check if a path exists asynchronously
|
|
2937
|
+
* @param path - Path to check
|
|
2938
|
+
* @returns true if path exists, false otherwise
|
|
2939
|
+
* @throws Error for unexpected filesystem errors (permission denied, etc.)
|
|
2940
|
+
*/
|
|
2941
|
+
async function pathExists(path) {
|
|
2942
|
+
try {
|
|
2943
|
+
await access(path);
|
|
2944
|
+
return true;
|
|
2945
|
+
} catch (error) {
|
|
2946
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
2947
|
+
throw new Error(`Failed to check path existence for "${path}": ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Service for loading and managing skills from configured skill directories.
|
|
2952
|
+
*
|
|
2953
|
+
* Skills are markdown files with YAML frontmatter that can be invoked via
|
|
2954
|
+
* the skill__ prefix in describe_tools and use_tool.
|
|
2955
|
+
*
|
|
2956
|
+
* Skills are only enabled when explicitly configured via the `skills.paths` array
|
|
2957
|
+
* in the MCP config.
|
|
2958
|
+
*
|
|
2959
|
+
* @example
|
|
2960
|
+
* // Config with skills enabled:
|
|
2961
|
+
* // skills:
|
|
2962
|
+
* // paths:
|
|
2963
|
+
* // - ".claude/skills"
|
|
2964
|
+
* // - "/absolute/path/to/skills"
|
|
2965
|
+
*
|
|
2966
|
+
* const skillService = new SkillService('/project/root', ['.claude/skills']);
|
|
2967
|
+
* const skills = await skillService.getSkills();
|
|
2968
|
+
*/
|
|
2969
|
+
var SkillService = class {
|
|
2970
|
+
cwd;
|
|
2971
|
+
skillPaths;
|
|
2972
|
+
cachedSkills = null;
|
|
2973
|
+
skillsByName = null;
|
|
2974
|
+
/** Active file watchers for skill directories */
|
|
2975
|
+
watchers = [];
|
|
2976
|
+
/** Polling timers used when native file watching is unavailable */
|
|
2977
|
+
pollingTimers = [];
|
|
2978
|
+
/** Callback invoked when cache is invalidated due to file changes */
|
|
2979
|
+
onCacheInvalidated;
|
|
2980
|
+
logger;
|
|
2981
|
+
/**
|
|
2982
|
+
* Creates a new SkillService instance
|
|
2983
|
+
* @param cwd - Current working directory for resolving relative paths
|
|
2984
|
+
* @param skillPaths - Array of paths to skills directories
|
|
2985
|
+
* @param options - Optional configuration
|
|
2986
|
+
* @param options.onCacheInvalidated - Callback invoked when cache is invalidated due to file changes
|
|
2987
|
+
*/
|
|
2988
|
+
constructor(cwd, skillPaths, options, logger = console) {
|
|
2989
|
+
this.cwd = cwd;
|
|
2990
|
+
this.skillPaths = skillPaths;
|
|
2991
|
+
this.onCacheInvalidated = options?.onCacheInvalidated;
|
|
2992
|
+
this.logger = logger;
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* Get all available skills from configured directories.
|
|
2996
|
+
* Results are cached after first load.
|
|
2997
|
+
*
|
|
2998
|
+
* Skills from earlier entries in the config take precedence over
|
|
2999
|
+
* skills with the same name from later entries.
|
|
3000
|
+
*
|
|
3001
|
+
* @returns Array of loaded skills
|
|
3002
|
+
* @throws SkillLoadError if a critical error occurs during loading
|
|
3003
|
+
*/
|
|
3004
|
+
async getSkills() {
|
|
3005
|
+
if (this.cachedSkills !== null) return this.cachedSkills;
|
|
3006
|
+
const skills = [];
|
|
3007
|
+
const loadedSkillNames = /* @__PURE__ */ new Set();
|
|
3008
|
+
const allDirSkills = await Promise.all(this.skillPaths.map(async (skillPath) => {
|
|
3009
|
+
const skillsDir = isAbsolute(skillPath) ? skillPath : join(this.cwd, skillPath);
|
|
3010
|
+
return this.loadSkillsFromDirectory(skillsDir, "project");
|
|
3011
|
+
}));
|
|
3012
|
+
for (const dirSkills of allDirSkills) for (const skill of dirSkills) if (!loadedSkillNames.has(skill.name)) {
|
|
3013
|
+
skills.push(skill);
|
|
3014
|
+
loadedSkillNames.add(skill.name);
|
|
3015
|
+
}
|
|
3016
|
+
this.cachedSkills = skills;
|
|
3017
|
+
this.skillsByName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
3018
|
+
return skills;
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Get a specific skill by name with O(1) lookup from cache.
|
|
3022
|
+
* @param name - The skill name (without skill__ prefix)
|
|
3023
|
+
* @returns The skill if found, undefined otherwise
|
|
3024
|
+
*/
|
|
3025
|
+
async getSkill(name) {
|
|
3026
|
+
if (this.skillsByName === null) await this.getSkills();
|
|
3027
|
+
return this.skillsByName?.get(name);
|
|
3028
|
+
}
|
|
3029
|
+
/**
|
|
3030
|
+
* Clears the cached skills to force a fresh reload on the next getSkills() or getSkill() call.
|
|
3031
|
+
* Use this when skill files have been modified on disk.
|
|
3032
|
+
*/
|
|
3033
|
+
clearCache() {
|
|
3034
|
+
this.cachedSkills = null;
|
|
3035
|
+
this.skillsByName = null;
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* Starts watching skill directories for changes to SKILL.md files.
|
|
3039
|
+
* When changes are detected, the cache is automatically invalidated.
|
|
3040
|
+
*
|
|
3041
|
+
* Uses Node.js fs.watch with recursive option for efficient directory monitoring.
|
|
3042
|
+
* Only invalidates cache when SKILL.md files are modified.
|
|
3043
|
+
*
|
|
3044
|
+
* @example
|
|
3045
|
+
* const skillService = new SkillService(cwd, skillPaths, {
|
|
3046
|
+
* onCacheInvalidated: () => console.log('Skills cache invalidated')
|
|
3047
|
+
* });
|
|
3048
|
+
* await skillService.startWatching();
|
|
3049
|
+
*/
|
|
3050
|
+
async startWatching() {
|
|
3051
|
+
this.stopWatching();
|
|
3052
|
+
const existenceChecks = await Promise.all(this.skillPaths.map(async (skillPath) => {
|
|
3053
|
+
const skillsDir = isAbsolute(skillPath) ? skillPath : join(this.cwd, skillPath);
|
|
3054
|
+
return {
|
|
3055
|
+
skillsDir,
|
|
3056
|
+
exists: await pathExists(skillsDir)
|
|
3057
|
+
};
|
|
3058
|
+
}));
|
|
3059
|
+
for (const { skillsDir, exists } of existenceChecks) {
|
|
3060
|
+
if (!exists) continue;
|
|
3061
|
+
const abortController = new AbortController();
|
|
3062
|
+
this.watchers.push(abortController);
|
|
3063
|
+
this.watchDirectory(skillsDir, abortController.signal).catch((error) => {
|
|
3064
|
+
if (error?.name !== "AbortError") {
|
|
3065
|
+
if (this.isWatchResourceLimitError(error)) {
|
|
3066
|
+
this.startPollingDirectory(skillsDir, abortController.signal);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
this.logger.warn(`[skill-watcher] Error watching ${skillsDir}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3070
|
+
}
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Stops all active file watchers.
|
|
3076
|
+
* Should be called when the service is being disposed.
|
|
3077
|
+
*/
|
|
3078
|
+
stopWatching() {
|
|
3079
|
+
for (const controller of this.watchers) controller.abort();
|
|
3080
|
+
this.watchers = [];
|
|
3081
|
+
for (const timer of this.pollingTimers) clearInterval(timer);
|
|
3082
|
+
this.pollingTimers = [];
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Watches a directory for changes to SKILL.md files.
|
|
3086
|
+
* @param dirPath - Directory path to watch
|
|
3087
|
+
* @param signal - AbortSignal to stop watching
|
|
3088
|
+
*/
|
|
3089
|
+
async watchDirectory(dirPath, signal) {
|
|
3090
|
+
const watcher = watch(dirPath, {
|
|
3091
|
+
recursive: true,
|
|
3092
|
+
signal
|
|
3093
|
+
});
|
|
3094
|
+
for await (const event of watcher) if (event.filename?.endsWith("SKILL.md")) this.invalidateCache();
|
|
3095
|
+
}
|
|
3096
|
+
invalidateCache() {
|
|
3097
|
+
this.clearCache();
|
|
3098
|
+
this.onCacheInvalidated?.();
|
|
3099
|
+
}
|
|
3100
|
+
isWatchResourceLimitError(error) {
|
|
3101
|
+
return error instanceof Error && "code" in error && (error.code === "EMFILE" || error.code === "ENOSPC");
|
|
3102
|
+
}
|
|
3103
|
+
startPollingDirectory(dirPath, signal) {
|
|
3104
|
+
this.createSkillSnapshot(dirPath).then((initialSnapshot) => {
|
|
3105
|
+
let previousSnapshot = initialSnapshot;
|
|
3106
|
+
const timer = setInterval(() => {
|
|
3107
|
+
if (signal.aborted) {
|
|
3108
|
+
clearInterval(timer);
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
this.createSkillSnapshot(dirPath).then((nextSnapshot) => {
|
|
3112
|
+
if (!this.snapshotsEqual(previousSnapshot, nextSnapshot)) {
|
|
3113
|
+
previousSnapshot = nextSnapshot;
|
|
3114
|
+
this.invalidateCache();
|
|
3115
|
+
}
|
|
3116
|
+
}).catch((error) => {
|
|
3117
|
+
this.logger.warn(`[skill-watcher] Polling failed for ${dirPath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3118
|
+
});
|
|
3119
|
+
}, 100);
|
|
3120
|
+
this.pollingTimers.push(timer);
|
|
3121
|
+
signal.addEventListener("abort", () => {
|
|
3122
|
+
clearInterval(timer);
|
|
3123
|
+
this.pollingTimers = this.pollingTimers.filter((activeTimer) => activeTimer !== timer);
|
|
3124
|
+
}, { once: true });
|
|
3125
|
+
});
|
|
3126
|
+
}
|
|
3127
|
+
async createSkillSnapshot(dirPath) {
|
|
3128
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
3129
|
+
await this.collectSkillSnapshots(dirPath, snapshot);
|
|
3130
|
+
return snapshot;
|
|
3131
|
+
}
|
|
3132
|
+
async collectSkillSnapshots(dirPath, snapshot) {
|
|
3133
|
+
let entries;
|
|
3134
|
+
try {
|
|
3135
|
+
entries = await readdir(dirPath);
|
|
3136
|
+
} catch (error) {
|
|
3137
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
|
3138
|
+
throw error;
|
|
3139
|
+
}
|
|
3140
|
+
await Promise.all(entries.map(async (entry) => {
|
|
3141
|
+
const entryPath = join(dirPath, entry);
|
|
3142
|
+
const entryStat = await stat(entryPath);
|
|
3143
|
+
if (entryStat.isDirectory()) {
|
|
3144
|
+
await this.collectSkillSnapshots(entryPath, snapshot);
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
if (entry === "SKILL.md") snapshot.set(entryPath, entryStat.mtimeMs);
|
|
3148
|
+
}));
|
|
3149
|
+
}
|
|
3150
|
+
snapshotsEqual(left, right) {
|
|
3151
|
+
if (left.size !== right.size) return false;
|
|
3152
|
+
for (const [filePath, mtimeMs] of left) if (right.get(filePath) !== mtimeMs) return false;
|
|
3153
|
+
return true;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Load skills from a directory.
|
|
3157
|
+
* Supports both flat structure (SKILL.md) and nested structure (name/SKILL.md).
|
|
3158
|
+
*
|
|
3159
|
+
* @param dirPath - Path to the skills directory
|
|
3160
|
+
* @param location - Whether this is a 'project' or 'user' skill directory
|
|
3161
|
+
* @returns Array of successfully loaded skills (skips invalid skills)
|
|
3162
|
+
* @throws SkillLoadError if there's a critical I/O error
|
|
3163
|
+
*
|
|
3164
|
+
* @example
|
|
3165
|
+
* // Load skills from project directory
|
|
3166
|
+
* const skills = await this.loadSkillsFromDirectory('/path/to/.claude/skills', 'project');
|
|
3167
|
+
* // Returns: [{ name: 'pdf', description: '...', location: 'project', ... }]
|
|
3168
|
+
*/
|
|
3169
|
+
async loadSkillsFromDirectory(dirPath, location) {
|
|
3170
|
+
const skills = [];
|
|
3171
|
+
try {
|
|
3172
|
+
if (!await pathExists(dirPath)) return skills;
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
throw new SkillLoadError(`Cannot access skills directory: ${error instanceof Error ? error.message : "Unknown error"}`, dirPath, error instanceof Error ? error : void 0);
|
|
3175
|
+
}
|
|
3176
|
+
let entries;
|
|
3177
|
+
try {
|
|
3178
|
+
entries = await readdir(dirPath);
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
throw new SkillLoadError(`Failed to read skills directory: ${error instanceof Error ? error.message : "Unknown error"}`, dirPath, error instanceof Error ? error : void 0);
|
|
3181
|
+
}
|
|
3182
|
+
const entryStats = await Promise.all(entries.map(async (entry) => {
|
|
3183
|
+
const entryPath = join(dirPath, entry);
|
|
3184
|
+
try {
|
|
3185
|
+
return {
|
|
3186
|
+
entry,
|
|
3187
|
+
entryPath,
|
|
3188
|
+
stat: await stat(entryPath),
|
|
3189
|
+
error: null
|
|
3190
|
+
};
|
|
3191
|
+
} catch (error) {
|
|
3192
|
+
this.logger.warn(`Skipping entry ${entryPath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3193
|
+
return {
|
|
3194
|
+
entry,
|
|
3195
|
+
entryPath,
|
|
3196
|
+
stat: null,
|
|
3197
|
+
error
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
}));
|
|
3201
|
+
const skillFilesToLoad = [];
|
|
3202
|
+
for (const { entry, entryPath, stat: entryStat } of entryStats) {
|
|
3203
|
+
if (!entryStat) continue;
|
|
3204
|
+
if (entryStat.isDirectory()) {
|
|
3205
|
+
const skillFilePath = join(entryPath, "SKILL.md");
|
|
3206
|
+
skillFilesToLoad.push({
|
|
3207
|
+
filePath: skillFilePath,
|
|
3208
|
+
isRootLevel: false
|
|
3209
|
+
});
|
|
3210
|
+
} else if (entry === "SKILL.md") skillFilesToLoad.push({
|
|
3211
|
+
filePath: entryPath,
|
|
3212
|
+
isRootLevel: true
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
const loadResults = await Promise.all(skillFilesToLoad.map(async ({ filePath, isRootLevel }) => {
|
|
3216
|
+
try {
|
|
3217
|
+
if (!isRootLevel && !await pathExists(filePath)) return null;
|
|
3218
|
+
return await this.loadSkillFile(filePath, location);
|
|
3219
|
+
} catch (error) {
|
|
3220
|
+
this.logger.warn(`Skipping skill at ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3221
|
+
return null;
|
|
3222
|
+
}
|
|
3223
|
+
}));
|
|
3224
|
+
for (const skill of loadResults) if (skill) skills.push(skill);
|
|
3225
|
+
return skills;
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* Load a single skill file and parse its frontmatter.
|
|
3229
|
+
* Supports multi-line YAML values using literal (|) and folded (>) block scalars.
|
|
3230
|
+
*
|
|
3231
|
+
* @param filePath - Path to the SKILL.md file
|
|
3232
|
+
* @param location - Whether this is a 'project' or 'user' skill
|
|
3233
|
+
* @returns The loaded skill, or null if the file is invalid (missing required frontmatter)
|
|
3234
|
+
* @throws SkillLoadError if there's an I/O error reading the file
|
|
3235
|
+
*
|
|
3236
|
+
* @example
|
|
3237
|
+
* // Load a skill from a file
|
|
3238
|
+
* const skill = await this.loadSkillFile('/path/to/pdf/SKILL.md', 'project');
|
|
3239
|
+
* // Returns: { name: 'pdf', description: 'PDF skill', location: 'project', content: '...', basePath: '/path/to/pdf' }
|
|
3240
|
+
* // Returns null if frontmatter is missing name or description
|
|
3241
|
+
*/
|
|
3242
|
+
async loadSkillFile(filePath, location) {
|
|
3243
|
+
let fileContent;
|
|
3244
|
+
try {
|
|
3245
|
+
fileContent = await readFile(filePath, "utf-8");
|
|
3246
|
+
} catch (error) {
|
|
3247
|
+
throw new SkillLoadError(`Failed to read skill file: ${error instanceof Error ? error.message : "Unknown error"}`, filePath, error instanceof Error ? error : void 0);
|
|
3248
|
+
}
|
|
3249
|
+
const { frontMatter, content } = parseFrontMatter(fileContent);
|
|
3250
|
+
if (!frontMatter || !frontMatter.name || !frontMatter.description) return null;
|
|
3251
|
+
return {
|
|
3252
|
+
name: frontMatter.name,
|
|
3253
|
+
description: frontMatter.description,
|
|
3254
|
+
location,
|
|
3255
|
+
content,
|
|
3256
|
+
basePath: dirname(filePath)
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
};
|
|
3260
|
+
|
|
3261
|
+
//#endregion
|
|
3262
|
+
//#region src/services/PrefetchService/constants.ts
|
|
3263
|
+
/**
|
|
3264
|
+
* PrefetchService Constants
|
|
3265
|
+
*
|
|
3266
|
+
* Constants for package manager commands and process configuration.
|
|
3267
|
+
*/
|
|
3268
|
+
/** Transport type for stdio-based MCP servers */
|
|
3269
|
+
const TRANSPORT_STDIO = "stdio";
|
|
3270
|
+
/** npx command name */
|
|
3271
|
+
const COMMAND_NPX = "npx";
|
|
3272
|
+
/** npm command name */
|
|
3273
|
+
const COMMAND_NPM = "npm";
|
|
3274
|
+
/** pnpx command name (pnpm's npx equivalent) */
|
|
3275
|
+
const COMMAND_PNPX = "pnpx";
|
|
3276
|
+
/** pnpm command name */
|
|
3277
|
+
const COMMAND_PNPM = "pnpm";
|
|
3278
|
+
/** uvx command name */
|
|
3279
|
+
const COMMAND_UVX = "uvx";
|
|
3280
|
+
/** uv command name */
|
|
3281
|
+
const COMMAND_UV = "uv";
|
|
3282
|
+
/** Path suffix for npx command */
|
|
3283
|
+
const COMMAND_NPX_SUFFIX = "/npx";
|
|
3284
|
+
/** Path suffix for pnpx command */
|
|
3285
|
+
const COMMAND_PNPX_SUFFIX = "/pnpx";
|
|
3286
|
+
/** Path suffix for uvx command */
|
|
3287
|
+
const COMMAND_UVX_SUFFIX = "/uvx";
|
|
3288
|
+
/** Path suffix for uv command */
|
|
3289
|
+
const COMMAND_UV_SUFFIX = "/uv";
|
|
3290
|
+
/** Run subcommand for uv */
|
|
3291
|
+
const ARG_RUN = "run";
|
|
3292
|
+
/** Tool subcommand for uv */
|
|
3293
|
+
const ARG_TOOL = "tool";
|
|
3294
|
+
/** Install subcommand for uv tool and npm/pnpm */
|
|
3295
|
+
const ARG_INSTALL = "install";
|
|
3296
|
+
/** Add subcommand for pnpm */
|
|
3297
|
+
const ARG_ADD = "add";
|
|
3298
|
+
/** Global flag for npm/pnpm install */
|
|
3299
|
+
const ARG_GLOBAL = "-g";
|
|
3300
|
+
/** Flag prefix for command arguments */
|
|
3301
|
+
const FLAG_PREFIX = "-";
|
|
3302
|
+
/** npx --package flag (long form) */
|
|
3303
|
+
const FLAG_PACKAGE_LONG = "--package";
|
|
3304
|
+
/** npx -p flag (short form) */
|
|
3305
|
+
const FLAG_PACKAGE_SHORT = "-p";
|
|
3306
|
+
/** Equals delimiter used in flag=value patterns */
|
|
3307
|
+
const EQUALS_DELIMITER = "=";
|
|
3308
|
+
/**
|
|
3309
|
+
* Regex pattern for valid package names (npm, pnpm, uvx, uv)
|
|
3310
|
+
* Allows: @scope/package-name@version, package-name, package_name
|
|
3311
|
+
* Prevents shell metacharacters that could enable command injection
|
|
3312
|
+
* @example
|
|
3313
|
+
* // Valid: '@scope/package@1.0.0', 'my-package', 'my_package', '@org/pkg'
|
|
3314
|
+
* // Invalid: 'pkg; rm -rf /', 'pkg$(cmd)', 'pkg`whoami`', 'pkg|cat /etc/passwd'
|
|
3315
|
+
*/
|
|
3316
|
+
const VALID_PACKAGE_NAME_PATTERN = /^(@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
3317
|
+
/** Windows platform identifier */
|
|
3318
|
+
const PLATFORM_WIN32 = "win32";
|
|
3319
|
+
/** Success exit code */
|
|
3320
|
+
const EXIT_CODE_SUCCESS = 0;
|
|
3321
|
+
/** Stdio option to ignore stream */
|
|
3322
|
+
const STDIO_IGNORE = "ignore";
|
|
3323
|
+
/** Stdio option to pipe stream */
|
|
3324
|
+
const STDIO_PIPE = "pipe";
|
|
3325
|
+
|
|
3326
|
+
//#endregion
|
|
3327
|
+
//#region src/services/PrefetchService/PrefetchService.ts
|
|
3328
|
+
/**
|
|
3329
|
+
* PrefetchService
|
|
3330
|
+
*
|
|
3331
|
+
* DESIGN PATTERNS:
|
|
3332
|
+
* - Service pattern for business logic encapsulation
|
|
3333
|
+
* - Single responsibility principle
|
|
3334
|
+
*
|
|
3335
|
+
* CODING STANDARDS:
|
|
3336
|
+
* - Use async/await for asynchronous operations
|
|
3337
|
+
* - Throw descriptive errors for error cases
|
|
3338
|
+
* - Keep methods focused and well-named
|
|
3339
|
+
* - Document complex logic with comments
|
|
3340
|
+
*
|
|
3341
|
+
* AVOID:
|
|
3342
|
+
* - Mixing concerns (keep focused on single domain)
|
|
3343
|
+
* - Direct tool implementation (services should be tool-agnostic)
|
|
3344
|
+
*/
|
|
3345
|
+
/**
|
|
3346
|
+
* Type guard to check if a config object is an McpStdioConfig
|
|
3347
|
+
* @param config - Config object to check
|
|
3348
|
+
* @returns True if config has required McpStdioConfig properties
|
|
3349
|
+
*/
|
|
3350
|
+
function isMcpStdioConfig(config) {
|
|
3351
|
+
return typeof config === "object" && config !== null && "command" in config;
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* PrefetchService handles pre-downloading packages used by MCP servers.
|
|
3355
|
+
* Supports npx (Node.js), uvx (Python/uv), and uv run commands.
|
|
3356
|
+
*
|
|
3357
|
+
* @example
|
|
3358
|
+
* ```typescript
|
|
3359
|
+
* const service = new PrefetchService({
|
|
3360
|
+
* mcpConfig: await configFetcher.fetchConfiguration(),
|
|
3361
|
+
* parallel: true,
|
|
3362
|
+
* });
|
|
3363
|
+
* const packages = service.extractPackages();
|
|
3364
|
+
* const summary = await service.prefetch();
|
|
3365
|
+
* ```
|
|
3366
|
+
*/
|
|
3367
|
+
var PrefetchService = class {
|
|
3368
|
+
config;
|
|
3369
|
+
/**
|
|
3370
|
+
* Creates a new PrefetchService instance
|
|
3371
|
+
* @param config - Service configuration options
|
|
3372
|
+
*/
|
|
3373
|
+
constructor(config) {
|
|
3374
|
+
this.config = config;
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* Extract all prefetchable packages from the MCP configuration
|
|
3378
|
+
* @returns Array of package info objects
|
|
3379
|
+
*/
|
|
3380
|
+
extractPackages() {
|
|
3381
|
+
const packages = [];
|
|
3382
|
+
const { mcpConfig, filter } = this.config;
|
|
3383
|
+
for (const [serverName, serverConfig] of Object.entries(mcpConfig.mcpServers)) {
|
|
3384
|
+
if (serverConfig.disabled) continue;
|
|
3385
|
+
if (serverConfig.transport !== TRANSPORT_STDIO) continue;
|
|
3386
|
+
if (!isMcpStdioConfig(serverConfig.config)) continue;
|
|
3387
|
+
const packageInfo = this.extractPackageInfo(serverName, serverConfig.config);
|
|
3388
|
+
if (packageInfo) {
|
|
3389
|
+
if (filter && packageInfo.packageManager !== filter) continue;
|
|
3390
|
+
packages.push(packageInfo);
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
return packages;
|
|
3394
|
+
}
|
|
3395
|
+
/**
|
|
3396
|
+
* Prefetch all packages from the configuration
|
|
3397
|
+
* @returns Summary of prefetch results
|
|
3398
|
+
* @throws Error if prefetch operation fails unexpectedly
|
|
3399
|
+
*/
|
|
3400
|
+
async prefetch() {
|
|
3401
|
+
try {
|
|
3402
|
+
const packages = this.extractPackages();
|
|
3403
|
+
const results = [];
|
|
3404
|
+
if (packages.length === 0) return {
|
|
3405
|
+
totalPackages: 0,
|
|
3406
|
+
successful: 0,
|
|
3407
|
+
failed: 0,
|
|
3408
|
+
results: []
|
|
3409
|
+
};
|
|
3410
|
+
if (this.config.parallel) {
|
|
3411
|
+
const promises = packages.map(async (pkg) => this.prefetchPackage(pkg));
|
|
3412
|
+
results.push(...await Promise.all(promises));
|
|
3413
|
+
} else for (const pkg of packages) {
|
|
3414
|
+
const result = await this.prefetchPackage(pkg);
|
|
3415
|
+
results.push(result);
|
|
3416
|
+
}
|
|
3417
|
+
const successful = results.filter((r) => r.success).length;
|
|
3418
|
+
const failed = results.filter((r) => !r.success).length;
|
|
3419
|
+
return {
|
|
3420
|
+
totalPackages: packages.length,
|
|
3421
|
+
successful,
|
|
3422
|
+
failed,
|
|
3423
|
+
results
|
|
3424
|
+
};
|
|
3425
|
+
} catch (error) {
|
|
3426
|
+
throw new Error(`Failed to prefetch packages: ${error instanceof Error ? error.message : String(error)}`);
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
/**
|
|
3430
|
+
* Prefetch a single package
|
|
3431
|
+
* @param pkg - Package info to prefetch
|
|
3432
|
+
* @returns Result of the prefetch operation
|
|
3433
|
+
*/
|
|
3434
|
+
async prefetchPackage(pkg) {
|
|
3435
|
+
try {
|
|
3436
|
+
const [command, ...args] = pkg.fullCommand;
|
|
3437
|
+
const result = await this.runCommand(command, args);
|
|
3438
|
+
return {
|
|
3439
|
+
package: pkg,
|
|
3440
|
+
success: result.success,
|
|
3441
|
+
output: result.output
|
|
3442
|
+
};
|
|
3443
|
+
} catch (error) {
|
|
3444
|
+
return {
|
|
3445
|
+
package: pkg,
|
|
3446
|
+
success: false,
|
|
3447
|
+
output: error instanceof Error ? error.message : String(error)
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
/**
|
|
3452
|
+
* Validate package name to prevent command injection
|
|
3453
|
+
* @param packageName - Package name to validate
|
|
3454
|
+
* @returns True if package name is safe, false otherwise
|
|
3455
|
+
* @remarks Rejects package names containing shell metacharacters
|
|
3456
|
+
* @example
|
|
3457
|
+
* isValidPackageName('@scope/package') // true
|
|
3458
|
+
* isValidPackageName('my-package@1.0.0') // true
|
|
3459
|
+
* isValidPackageName('pkg; rm -rf /') // false (shell injection)
|
|
3460
|
+
* isValidPackageName('pkg$(whoami)') // false (command substitution)
|
|
3461
|
+
*/
|
|
3462
|
+
isValidPackageName(packageName) {
|
|
3463
|
+
return VALID_PACKAGE_NAME_PATTERN.test(packageName);
|
|
3464
|
+
}
|
|
3465
|
+
/**
|
|
3466
|
+
* Extract package info from a server's stdio config
|
|
3467
|
+
* @param serverName - Name of the MCP server
|
|
3468
|
+
* @param config - Stdio configuration for the server
|
|
3469
|
+
* @returns Package info if extractable, null otherwise
|
|
3470
|
+
*/
|
|
3471
|
+
extractPackageInfo(serverName, config) {
|
|
3472
|
+
const command = config.command.toLowerCase();
|
|
3473
|
+
const args = config.args || [];
|
|
3474
|
+
if (command === COMMAND_NPX || command.endsWith(COMMAND_NPX_SUFFIX)) {
|
|
3475
|
+
const packageName = this.extractNpxPackage(args);
|
|
3476
|
+
if (packageName && this.isValidPackageName(packageName)) return {
|
|
3477
|
+
serverName,
|
|
3478
|
+
packageManager: COMMAND_NPX,
|
|
3479
|
+
packageName,
|
|
3480
|
+
fullCommand: [
|
|
3481
|
+
COMMAND_NPM,
|
|
3482
|
+
ARG_INSTALL,
|
|
3483
|
+
ARG_GLOBAL,
|
|
3484
|
+
packageName
|
|
3485
|
+
]
|
|
3486
|
+
};
|
|
3487
|
+
}
|
|
3488
|
+
if (command === COMMAND_PNPX || command.endsWith(COMMAND_PNPX_SUFFIX)) {
|
|
3489
|
+
const packageName = this.extractNpxPackage(args);
|
|
3490
|
+
if (packageName && this.isValidPackageName(packageName)) return {
|
|
3491
|
+
serverName,
|
|
3492
|
+
packageManager: COMMAND_PNPX,
|
|
3493
|
+
packageName,
|
|
3494
|
+
fullCommand: [
|
|
3495
|
+
COMMAND_PNPM,
|
|
3496
|
+
ARG_ADD,
|
|
3497
|
+
ARG_GLOBAL,
|
|
3498
|
+
packageName
|
|
3499
|
+
]
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
if (command === COMMAND_UVX || command.endsWith(COMMAND_UVX_SUFFIX)) {
|
|
3503
|
+
const packageName = this.extractUvxPackage(args);
|
|
3504
|
+
if (packageName && this.isValidPackageName(packageName)) return {
|
|
3505
|
+
serverName,
|
|
3506
|
+
packageManager: COMMAND_UVX,
|
|
3507
|
+
packageName,
|
|
3508
|
+
fullCommand: [COMMAND_UVX, packageName]
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
if ((command === COMMAND_UV || command.endsWith(COMMAND_UV_SUFFIX)) && args.includes(ARG_RUN)) {
|
|
3512
|
+
const packageName = this.extractUvRunPackage(args);
|
|
3513
|
+
if (packageName && this.isValidPackageName(packageName)) return {
|
|
3514
|
+
serverName,
|
|
3515
|
+
packageManager: COMMAND_UV,
|
|
3516
|
+
packageName,
|
|
3517
|
+
fullCommand: [
|
|
3518
|
+
COMMAND_UV,
|
|
3519
|
+
ARG_TOOL,
|
|
3520
|
+
ARG_INSTALL,
|
|
3521
|
+
packageName
|
|
3522
|
+
]
|
|
3523
|
+
};
|
|
3524
|
+
}
|
|
3525
|
+
return null;
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* Extract package name from npx command args
|
|
3529
|
+
* @param args - Command arguments
|
|
3530
|
+
* @returns Package name or null
|
|
3531
|
+
* @remarks Handles --package=value, --package value, -p value patterns.
|
|
3532
|
+
* Falls back to first non-flag argument if no --package/-p flag found.
|
|
3533
|
+
* Returns null if flag has no value or is followed by another flag.
|
|
3534
|
+
* When multiple --package flags exist, returns the first valid one.
|
|
3535
|
+
* @example
|
|
3536
|
+
* extractNpxPackage(['--package=@scope/pkg']) // returns '@scope/pkg'
|
|
3537
|
+
* extractNpxPackage(['--package', 'pkg-name']) // returns 'pkg-name'
|
|
3538
|
+
* extractNpxPackage(['-p', 'pkg']) // returns 'pkg'
|
|
3539
|
+
* extractNpxPackage(['-y', 'pkg-name', '--flag']) // returns 'pkg-name' (fallback)
|
|
3540
|
+
* extractNpxPackage(['--package=']) // returns null (empty value)
|
|
3541
|
+
*/
|
|
3542
|
+
extractNpxPackage(args) {
|
|
3543
|
+
for (let i = 0; i < args.length; i++) {
|
|
3544
|
+
const arg = args[i];
|
|
3545
|
+
if (arg.startsWith(FLAG_PACKAGE_LONG + EQUALS_DELIMITER)) return arg.slice(FLAG_PACKAGE_LONG.length + EQUALS_DELIMITER.length) || null;
|
|
3546
|
+
if (arg === FLAG_PACKAGE_LONG && i + 1 < args.length) {
|
|
3547
|
+
const nextArg = args[i + 1];
|
|
3548
|
+
if (!nextArg.startsWith(FLAG_PREFIX)) return nextArg;
|
|
3549
|
+
}
|
|
3550
|
+
if (arg === FLAG_PACKAGE_SHORT && i + 1 < args.length) {
|
|
3551
|
+
const nextArg = args[i + 1];
|
|
3552
|
+
if (!nextArg.startsWith(FLAG_PREFIX)) return nextArg;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
for (const arg of args) {
|
|
3556
|
+
if (arg.startsWith(FLAG_PREFIX)) continue;
|
|
3557
|
+
return arg;
|
|
3558
|
+
}
|
|
3559
|
+
return null;
|
|
3560
|
+
}
|
|
3561
|
+
/**
|
|
3562
|
+
* Extract package name from uvx command args
|
|
3563
|
+
* @param args - Command arguments
|
|
3564
|
+
* @returns Package name or null
|
|
3565
|
+
* @remarks Assumes the first non-flag argument is the package name.
|
|
3566
|
+
* Handles both single (-) and double (--) dash flags.
|
|
3567
|
+
* @example
|
|
3568
|
+
* extractUvxPackage(['mcp-server-fetch']) // returns 'mcp-server-fetch'
|
|
3569
|
+
* extractUvxPackage(['--quiet', 'pkg-name']) // returns 'pkg-name'
|
|
3570
|
+
*/
|
|
3571
|
+
extractUvxPackage(args) {
|
|
3572
|
+
for (const arg of args) {
|
|
3573
|
+
if (arg.startsWith(FLAG_PREFIX)) continue;
|
|
3574
|
+
return arg;
|
|
3575
|
+
}
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
/**
|
|
3579
|
+
* Extract package name from uv run command args
|
|
3580
|
+
* @param args - Command arguments
|
|
3581
|
+
* @returns Package name or null
|
|
3582
|
+
* @remarks Looks for the first non-flag argument after the 'run' subcommand.
|
|
3583
|
+
* Returns null if 'run' is not found in args.
|
|
3584
|
+
* @example
|
|
3585
|
+
* extractUvRunPackage(['run', 'mcp-server']) // returns 'mcp-server'
|
|
3586
|
+
* extractUvRunPackage(['run', '--verbose', 'pkg']) // returns 'pkg'
|
|
3587
|
+
* extractUvRunPackage(['install', 'pkg']) // returns null (no 'run')
|
|
3588
|
+
*/
|
|
3589
|
+
extractUvRunPackage(args) {
|
|
3590
|
+
const runIndex = args.indexOf(ARG_RUN);
|
|
3591
|
+
if (runIndex === -1) return null;
|
|
3592
|
+
for (let i = runIndex + 1; i < args.length; i++) {
|
|
3593
|
+
const arg = args[i];
|
|
3594
|
+
if (arg.startsWith(FLAG_PREFIX)) continue;
|
|
3595
|
+
return arg;
|
|
3596
|
+
}
|
|
3597
|
+
return null;
|
|
3598
|
+
}
|
|
3599
|
+
/**
|
|
3600
|
+
* Run a shell command and capture output
|
|
3601
|
+
* @param command - Command to run
|
|
3602
|
+
* @param args - Command arguments
|
|
3603
|
+
* @returns Promise with success status and output
|
|
3604
|
+
*/
|
|
3605
|
+
runCommand(command, args) {
|
|
3606
|
+
return new Promise((resolve$1) => {
|
|
3607
|
+
const proc = spawn(command, args, {
|
|
3608
|
+
stdio: [
|
|
3609
|
+
STDIO_IGNORE,
|
|
3610
|
+
STDIO_PIPE,
|
|
3611
|
+
STDIO_PIPE
|
|
3612
|
+
],
|
|
3613
|
+
shell: process.platform === PLATFORM_WIN32
|
|
3614
|
+
});
|
|
3615
|
+
let stdout = "";
|
|
3616
|
+
let stderr = "";
|
|
3617
|
+
proc.stdout?.on("data", (data) => {
|
|
3618
|
+
stdout += data.toString();
|
|
3619
|
+
});
|
|
3620
|
+
proc.stderr?.on("data", (data) => {
|
|
3621
|
+
stderr += data.toString();
|
|
3622
|
+
});
|
|
3623
|
+
proc.on("close", (code) => {
|
|
3624
|
+
resolve$1({
|
|
3625
|
+
success: code === EXIT_CODE_SUCCESS,
|
|
3626
|
+
output: stdout || stderr
|
|
3627
|
+
});
|
|
3628
|
+
});
|
|
3629
|
+
proc.on("error", (error) => {
|
|
3630
|
+
resolve$1({
|
|
3631
|
+
success: false,
|
|
3632
|
+
output: error.message
|
|
3633
|
+
});
|
|
3634
|
+
});
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
};
|
|
3638
|
+
|
|
3639
|
+
//#endregion
|
|
3640
|
+
//#region src/transports/http.ts
|
|
3641
|
+
/**
|
|
3642
|
+
* HTTP Transport Handler
|
|
3643
|
+
*
|
|
3644
|
+
* DESIGN PATTERNS:
|
|
3645
|
+
* - Transport handler pattern implementing TransportHandler interface
|
|
3646
|
+
* - Session management for stateful connections
|
|
3647
|
+
* - Streamable HTTP protocol (2025-03-26) with resumability support
|
|
3648
|
+
* - Factory pattern for creating MCP server instances per session
|
|
3649
|
+
*
|
|
3650
|
+
* CODING STANDARDS:
|
|
3651
|
+
* - Use async/await for all asynchronous operations
|
|
3652
|
+
* - Implement proper session lifecycle management
|
|
3653
|
+
* - Handle errors gracefully with appropriate HTTP status codes
|
|
3654
|
+
* - Provide health check endpoint for monitoring
|
|
3655
|
+
* - Clean up resources on shutdown
|
|
3656
|
+
*
|
|
3657
|
+
* AVOID:
|
|
3658
|
+
* - Sharing MCP server instances across sessions (use factory pattern)
|
|
3659
|
+
* - Forgetting to clean up sessions on disconnect
|
|
3660
|
+
* - Missing error handling for request processing
|
|
3661
|
+
* - Hardcoded configuration (use TransportConfig)
|
|
3662
|
+
*/
|
|
3663
|
+
/**
|
|
3664
|
+
* HTTP session manager
|
|
3665
|
+
*/
|
|
3666
|
+
var HttpFullSessionManager = class {
|
|
3667
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3668
|
+
closingSessions = /* @__PURE__ */ new Set();
|
|
3669
|
+
getSession(sessionId) {
|
|
3670
|
+
return this.sessions.get(sessionId);
|
|
3671
|
+
}
|
|
3672
|
+
setSession(sessionId, transport, server) {
|
|
3673
|
+
this.sessions.set(sessionId, {
|
|
3674
|
+
transport,
|
|
3675
|
+
server
|
|
3676
|
+
});
|
|
3677
|
+
}
|
|
3678
|
+
removeSession(sessionId) {
|
|
3679
|
+
this.sessions.delete(sessionId);
|
|
3680
|
+
}
|
|
3681
|
+
isClosing(sessionId) {
|
|
3682
|
+
return this.closingSessions.has(sessionId);
|
|
3683
|
+
}
|
|
3684
|
+
async closeSession(sessionId) {
|
|
3685
|
+
const session = this.sessions.get(sessionId);
|
|
3686
|
+
if (session) {
|
|
3687
|
+
this.closingSessions.add(sessionId);
|
|
3688
|
+
try {
|
|
3689
|
+
await session.server.close();
|
|
3690
|
+
} catch (error) {
|
|
3691
|
+
throw new Error(`Failed to close MCP server for session '${sessionId}': ${toErrorMessage(error)}`);
|
|
3692
|
+
} finally {
|
|
3693
|
+
this.sessions.delete(sessionId);
|
|
3694
|
+
this.closingSessions.delete(sessionId);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
hasSession(sessionId) {
|
|
3699
|
+
return this.sessions.has(sessionId);
|
|
3700
|
+
}
|
|
3701
|
+
async clear() {
|
|
3702
|
+
try {
|
|
3703
|
+
await Promise.all(Array.from(this.sessions.keys()).map(async (sessionId) => this.closeSession(sessionId)));
|
|
3704
|
+
} catch (error) {
|
|
3705
|
+
throw new Error(`Failed to clear sessions: ${toErrorMessage(error)}`);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
};
|
|
3709
|
+
function toErrorMessage(error) {
|
|
3710
|
+
return error instanceof Error ? error.message : String(error);
|
|
3711
|
+
}
|
|
3712
|
+
const ADMIN_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
3713
|
+
const ADMIN_RATE_LIMIT_MAX_REQUESTS = 5;
|
|
3714
|
+
/**
|
|
3715
|
+
* Simple in-memory rate limiter for the admin shutdown endpoint.
|
|
3716
|
+
* Tracks request timestamps per IP within a sliding window.
|
|
3717
|
+
*/
|
|
3718
|
+
var AdminRateLimiter = class {
|
|
3719
|
+
requests = /* @__PURE__ */ new Map();
|
|
3720
|
+
isAllowed(ip) {
|
|
3721
|
+
const now = Date.now();
|
|
3722
|
+
const windowStart = now - ADMIN_RATE_LIMIT_WINDOW_MS;
|
|
3723
|
+
const timestamps = (this.requests.get(ip) ?? []).filter((t) => t > windowStart);
|
|
3724
|
+
if (timestamps.length >= ADMIN_RATE_LIMIT_MAX_REQUESTS) {
|
|
3725
|
+
this.requests.set(ip, timestamps);
|
|
3726
|
+
return false;
|
|
3727
|
+
}
|
|
3728
|
+
timestamps.push(now);
|
|
3729
|
+
this.requests.set(ip, timestamps);
|
|
3730
|
+
return true;
|
|
3731
|
+
}
|
|
3732
|
+
};
|
|
3733
|
+
/**
|
|
3734
|
+
* HTTP transport handler using Streamable HTTP (protocol version 2025-03-26)
|
|
3735
|
+
* Provides stateful session management with resumability support
|
|
3736
|
+
*/
|
|
3737
|
+
var HttpTransportHandler = class {
|
|
3738
|
+
serverFactory;
|
|
3739
|
+
app;
|
|
3740
|
+
server = null;
|
|
3741
|
+
sessionManager;
|
|
3742
|
+
config;
|
|
3743
|
+
adminOptions;
|
|
3744
|
+
adminRateLimiter = new AdminRateLimiter();
|
|
3745
|
+
logger;
|
|
3746
|
+
constructor(serverFactory, config, adminOptions, logger = console) {
|
|
3747
|
+
this.serverFactory = serverFactory;
|
|
3748
|
+
this.app = express();
|
|
3749
|
+
this.sessionManager = new HttpFullSessionManager();
|
|
3750
|
+
this.config = {
|
|
3751
|
+
mode: config.mode,
|
|
3752
|
+
port: config.port ?? 3e3,
|
|
3753
|
+
host: config.host ?? "localhost"
|
|
3754
|
+
};
|
|
3755
|
+
this.adminOptions = adminOptions;
|
|
3756
|
+
this.logger = logger;
|
|
3757
|
+
this.setupMiddleware();
|
|
3758
|
+
this.setupRoutes();
|
|
3759
|
+
}
|
|
3760
|
+
setupMiddleware() {
|
|
3761
|
+
this.app.use(express.json());
|
|
3762
|
+
}
|
|
3763
|
+
setupRoutes() {
|
|
3764
|
+
this.app.post("/mcp", async (req, res) => {
|
|
3765
|
+
try {
|
|
3766
|
+
await this.handlePostRequest(req, res);
|
|
3767
|
+
} catch (error) {
|
|
3768
|
+
this.logger.error(`Failed to handle MCP POST request: ${toErrorMessage(error)}`);
|
|
3769
|
+
res.status(500).json({
|
|
3770
|
+
jsonrpc: "2.0",
|
|
3771
|
+
error: {
|
|
3772
|
+
code: -32603,
|
|
3773
|
+
message: "Failed to handle MCP POST request."
|
|
3774
|
+
},
|
|
3775
|
+
id: null
|
|
3776
|
+
});
|
|
3777
|
+
}
|
|
3778
|
+
});
|
|
3779
|
+
this.app.get("/mcp", async (req, res) => {
|
|
3780
|
+
try {
|
|
3781
|
+
await this.handleGetRequest(req, res);
|
|
3782
|
+
} catch (error) {
|
|
3783
|
+
this.logger.error(`Failed to handle MCP GET request: ${toErrorMessage(error)}`);
|
|
3784
|
+
res.status(500).send("Failed to handle MCP GET request.");
|
|
3785
|
+
}
|
|
3786
|
+
});
|
|
3787
|
+
this.app.delete("/mcp", async (req, res) => {
|
|
3788
|
+
try {
|
|
3789
|
+
await this.handleDeleteRequest(req, res);
|
|
3790
|
+
} catch (error) {
|
|
3791
|
+
this.logger.error(`Failed to handle MCP DELETE request: ${toErrorMessage(error)}`);
|
|
3792
|
+
res.status(500).send("Failed to handle MCP DELETE request.");
|
|
3793
|
+
}
|
|
3794
|
+
});
|
|
3795
|
+
this.app.get("/health", (_req, res) => {
|
|
3796
|
+
const payload = {
|
|
3797
|
+
status: "ok",
|
|
3798
|
+
transport: "http",
|
|
3799
|
+
serverId: this.adminOptions?.serverId
|
|
3800
|
+
};
|
|
3801
|
+
res.json(payload);
|
|
3802
|
+
});
|
|
3803
|
+
this.app.post("/admin/shutdown", async (req, res) => {
|
|
3804
|
+
try {
|
|
3805
|
+
const clientIp = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
3806
|
+
if (!this.adminRateLimiter.isAllowed(clientIp)) {
|
|
3807
|
+
const payload = {
|
|
3808
|
+
ok: false,
|
|
3809
|
+
message: "Too many shutdown requests. Try again later.",
|
|
3810
|
+
serverId: this.adminOptions?.serverId
|
|
3811
|
+
};
|
|
3812
|
+
res.status(429).json(payload);
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3815
|
+
await this.handleAdminShutdownRequest(req, res);
|
|
3816
|
+
} catch (error) {
|
|
3817
|
+
this.logger.error(`Failed to process shutdown request: ${toErrorMessage(error)}`);
|
|
3818
|
+
const payload = {
|
|
3819
|
+
ok: false,
|
|
3820
|
+
message: "Failed to process shutdown request.",
|
|
3821
|
+
serverId: this.adminOptions?.serverId
|
|
3822
|
+
};
|
|
3823
|
+
res.status(500).json(payload);
|
|
3824
|
+
}
|
|
3825
|
+
});
|
|
3826
|
+
}
|
|
3827
|
+
isAuthorizedShutdownRequest(req) {
|
|
3828
|
+
const expectedToken = this.adminOptions?.shutdownToken;
|
|
3829
|
+
if (!expectedToken) return false;
|
|
3830
|
+
const authHeader = req.headers.authorization;
|
|
3831
|
+
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) return authHeader.slice(7) === expectedToken;
|
|
3832
|
+
const tokenHeader = req.headers["x-mcp-proxy-shutdown-token"];
|
|
3833
|
+
return typeof tokenHeader === "string" && tokenHeader === expectedToken;
|
|
3834
|
+
}
|
|
3835
|
+
async handleAdminShutdownRequest(req, res) {
|
|
3836
|
+
try {
|
|
3837
|
+
if (!this.adminOptions?.onShutdownRequested) {
|
|
3838
|
+
const payload$1 = {
|
|
3839
|
+
ok: false,
|
|
3840
|
+
message: "Shutdown endpoint is not enabled for this server instance.",
|
|
3841
|
+
serverId: this.adminOptions?.serverId
|
|
3842
|
+
};
|
|
3843
|
+
res.status(404).json(payload$1);
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
if (!this.isAuthorizedShutdownRequest(req)) {
|
|
3847
|
+
const payload$1 = {
|
|
3848
|
+
ok: false,
|
|
3849
|
+
message: "Unauthorized shutdown request: invalid or missing shutdown token.",
|
|
3850
|
+
serverId: this.adminOptions?.serverId
|
|
3851
|
+
};
|
|
3852
|
+
res.status(401).json(payload$1);
|
|
3853
|
+
return;
|
|
3854
|
+
}
|
|
3855
|
+
const payload = {
|
|
3856
|
+
ok: true,
|
|
3857
|
+
message: "Shutdown request accepted. Stopping server gracefully.",
|
|
3858
|
+
serverId: this.adminOptions?.serverId
|
|
3859
|
+
};
|
|
3860
|
+
res.json(payload);
|
|
3861
|
+
await this.adminOptions.onShutdownRequested();
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
throw new Error(`Failed to handle admin shutdown request: ${toErrorMessage(error)}`);
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
async handlePostRequest(req, res) {
|
|
3867
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
3868
|
+
let transport;
|
|
3869
|
+
if (sessionId && this.sessionManager.hasSession(sessionId)) transport = this.sessionManager.getSession(sessionId).transport;
|
|
3870
|
+
else if (!sessionId && isInitializeRequest(req.body)) {
|
|
3871
|
+
const mcpServer = await this.serverFactory();
|
|
3872
|
+
transport = new StreamableHTTPServerTransport({
|
|
3873
|
+
sessionIdGenerator: () => randomUUID(),
|
|
3874
|
+
enableJsonResponse: true,
|
|
3875
|
+
onsessioninitialized: (initializedSessionId) => {
|
|
3876
|
+
this.sessionManager.setSession(initializedSessionId, transport, mcpServer);
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
transport.onclose = async () => {
|
|
3880
|
+
if (transport.sessionId) try {
|
|
3881
|
+
if (!this.sessionManager.isClosing(transport.sessionId)) this.sessionManager.removeSession(transport.sessionId);
|
|
3882
|
+
} catch (error) {
|
|
3883
|
+
this.logger.warn(`Failed to clean up session '${transport.sessionId}': ${toErrorMessage(error)}`);
|
|
3884
|
+
}
|
|
3885
|
+
};
|
|
3886
|
+
try {
|
|
3887
|
+
await mcpServer.connect(transport);
|
|
3888
|
+
} catch (error) {
|
|
3889
|
+
throw new Error(`Failed to connect MCP server transport for initialization request: ${toErrorMessage(error)}`);
|
|
3890
|
+
}
|
|
3891
|
+
} else {
|
|
3892
|
+
res.status(400).json({
|
|
3893
|
+
jsonrpc: "2.0",
|
|
3894
|
+
error: {
|
|
3895
|
+
code: -32e3,
|
|
3896
|
+
message: sessionId === void 0 ? "Bad Request: missing session ID and request body is not an initialize request." : `Bad Request: unknown session ID '${sessionId}'.`
|
|
3897
|
+
},
|
|
3898
|
+
id: null
|
|
3899
|
+
});
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
try {
|
|
3903
|
+
await transport.handleRequest(req, res, req.body);
|
|
3904
|
+
} catch (error) {
|
|
3905
|
+
throw new Error(`Failed handling MCP transport request: ${toErrorMessage(error)}`);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
async handleGetRequest(req, res) {
|
|
3909
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
3910
|
+
if (!sessionId || !this.sessionManager.hasSession(sessionId)) {
|
|
3911
|
+
res.status(400).send("Invalid or missing session ID");
|
|
3912
|
+
return;
|
|
3913
|
+
}
|
|
3914
|
+
const session = this.sessionManager.getSession(sessionId);
|
|
3915
|
+
try {
|
|
3916
|
+
await session.transport.handleRequest(req, res);
|
|
3917
|
+
} catch (error) {
|
|
3918
|
+
throw new Error(`Failed handling MCP GET request for session '${sessionId}': ${toErrorMessage(error)}`);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
async handleDeleteRequest(req, res) {
|
|
3922
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
3923
|
+
if (!sessionId || !this.sessionManager.hasSession(sessionId)) {
|
|
3924
|
+
res.status(400).send("Invalid or missing session ID");
|
|
3925
|
+
return;
|
|
3926
|
+
}
|
|
3927
|
+
const session = this.sessionManager.getSession(sessionId);
|
|
3928
|
+
try {
|
|
3929
|
+
await session.transport.handleRequest(req, res);
|
|
3930
|
+
} catch (error) {
|
|
3931
|
+
throw new Error(`Failed handling MCP DELETE request for session '${sessionId}': ${toErrorMessage(error)}`);
|
|
3932
|
+
}
|
|
3933
|
+
this.sessionManager.removeSession(sessionId);
|
|
3934
|
+
}
|
|
3935
|
+
async start() {
|
|
3936
|
+
try {
|
|
3937
|
+
const server = this.app.listen(this.config.port, this.config.host);
|
|
3938
|
+
this.server = server;
|
|
3939
|
+
const listeningPromise = (async () => {
|
|
3940
|
+
await once(server, "listening");
|
|
3941
|
+
})();
|
|
3942
|
+
const errorPromise = (async () => {
|
|
3943
|
+
const [error] = await once(server, "error");
|
|
3944
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3945
|
+
})();
|
|
3946
|
+
await Promise.race([listeningPromise, errorPromise]);
|
|
3947
|
+
this.logger.info(`@agimon-ai/mcp-proxy MCP server started on http://${this.config.host}:${this.config.port}/mcp`);
|
|
3948
|
+
this.logger.info(`Health check: http://${this.config.host}:${this.config.port}/health`);
|
|
3949
|
+
} catch (error) {
|
|
3950
|
+
this.server = null;
|
|
3951
|
+
throw new Error(`Failed to start HTTP transport: ${toErrorMessage(error)}`);
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
async stop() {
|
|
3955
|
+
if (!this.server) return;
|
|
3956
|
+
try {
|
|
3957
|
+
await this.sessionManager.clear();
|
|
3958
|
+
} catch (error) {
|
|
3959
|
+
throw new Error(`Failed to clear sessions during HTTP transport stop: ${toErrorMessage(error)}`);
|
|
3960
|
+
}
|
|
3961
|
+
const closeServer = promisify(this.server.close.bind(this.server));
|
|
3962
|
+
try {
|
|
3963
|
+
await closeServer();
|
|
3964
|
+
this.server = null;
|
|
3965
|
+
} catch (error) {
|
|
3966
|
+
throw new Error(`Failed to stop HTTP transport: ${toErrorMessage(error)}`);
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
getPort() {
|
|
3970
|
+
return this.config.port;
|
|
3971
|
+
}
|
|
3972
|
+
getHost() {
|
|
3973
|
+
return this.config.host;
|
|
3974
|
+
}
|
|
3975
|
+
};
|
|
3976
|
+
|
|
3977
|
+
//#endregion
|
|
3978
|
+
//#region src/transports/sse.ts
|
|
3979
|
+
/**
|
|
3980
|
+
* Session manager for SSE transports
|
|
3981
|
+
*/
|
|
3982
|
+
var SseSessionManager = class {
|
|
3983
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3984
|
+
closingSessions = /* @__PURE__ */ new Set();
|
|
3985
|
+
getSession(sessionId) {
|
|
3986
|
+
return this.sessions.get(sessionId)?.transport;
|
|
3987
|
+
}
|
|
3988
|
+
setSession(sessionId, transport, server) {
|
|
3989
|
+
this.sessions.set(sessionId, {
|
|
3990
|
+
transport,
|
|
3991
|
+
server
|
|
3992
|
+
});
|
|
3993
|
+
}
|
|
3994
|
+
removeSession(sessionId) {
|
|
3995
|
+
this.sessions.delete(sessionId);
|
|
3996
|
+
}
|
|
3997
|
+
isClosing(sessionId) {
|
|
3998
|
+
return this.closingSessions.has(sessionId);
|
|
3999
|
+
}
|
|
4000
|
+
async closeSession(sessionId) {
|
|
4001
|
+
const session = this.sessions.get(sessionId);
|
|
4002
|
+
if (session) {
|
|
4003
|
+
this.closingSessions.add(sessionId);
|
|
4004
|
+
try {
|
|
4005
|
+
await session.server.close();
|
|
4006
|
+
} finally {
|
|
4007
|
+
this.sessions.delete(sessionId);
|
|
4008
|
+
this.closingSessions.delete(sessionId);
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
async clear() {
|
|
4013
|
+
await Promise.all(Array.from(this.sessions.keys()).map(async (sessionId) => this.closeSession(sessionId)));
|
|
4014
|
+
}
|
|
4015
|
+
hasSession(sessionId) {
|
|
4016
|
+
return this.sessions.has(sessionId);
|
|
4017
|
+
}
|
|
4018
|
+
};
|
|
4019
|
+
/**
|
|
4020
|
+
* SSE (Server-Sent Events) transport handler
|
|
4021
|
+
* Legacy transport for backwards compatibility (protocol version 2024-11-05)
|
|
4022
|
+
* Uses separate endpoints: /sse for SSE stream (GET) and /messages for client messages (POST)
|
|
4023
|
+
*/
|
|
4024
|
+
var SseTransportHandler = class {
|
|
4025
|
+
serverFactory;
|
|
4026
|
+
app;
|
|
4027
|
+
server = null;
|
|
4028
|
+
sessionManager;
|
|
4029
|
+
config;
|
|
4030
|
+
logger;
|
|
4031
|
+
constructor(serverFactory, config, logger = console) {
|
|
4032
|
+
this.serverFactory = typeof serverFactory === "function" ? serverFactory : () => serverFactory;
|
|
4033
|
+
this.app = express();
|
|
4034
|
+
this.sessionManager = new SseSessionManager();
|
|
4035
|
+
this.config = {
|
|
4036
|
+
mode: config.mode,
|
|
4037
|
+
port: config.port ?? 3e3,
|
|
4038
|
+
host: config.host ?? "localhost"
|
|
4039
|
+
};
|
|
4040
|
+
this.logger = logger;
|
|
4041
|
+
this.setupMiddleware();
|
|
4042
|
+
this.setupRoutes();
|
|
4043
|
+
}
|
|
4044
|
+
setupMiddleware() {
|
|
4045
|
+
this.app.use(express.json());
|
|
4046
|
+
}
|
|
4047
|
+
setupRoutes() {
|
|
4048
|
+
this.app.get("/sse", async (req, res) => {
|
|
4049
|
+
await this.handleSseConnection(req, res);
|
|
4050
|
+
});
|
|
4051
|
+
this.app.post("/messages", async (req, res) => {
|
|
4052
|
+
await this.handlePostMessage(req, res);
|
|
4053
|
+
});
|
|
4054
|
+
this.app.get("/health", (_req, res) => {
|
|
4055
|
+
res.json({
|
|
4056
|
+
status: "ok",
|
|
4057
|
+
transport: "sse"
|
|
4058
|
+
});
|
|
4059
|
+
});
|
|
4060
|
+
}
|
|
4061
|
+
async handleSseConnection(_req, res) {
|
|
4062
|
+
try {
|
|
4063
|
+
const mcpServer = this.serverFactory();
|
|
4064
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
4065
|
+
this.sessionManager.setSession(transport.sessionId, transport, mcpServer);
|
|
4066
|
+
res.on("close", () => {
|
|
4067
|
+
const sessionId = transport.sessionId;
|
|
4068
|
+
if (sessionId && !this.sessionManager.isClosing(sessionId)) this.sessionManager.removeSession(sessionId);
|
|
4069
|
+
});
|
|
4070
|
+
await mcpServer.connect(transport);
|
|
4071
|
+
this.logger.info(`SSE session established: ${transport.sessionId}`);
|
|
4072
|
+
} catch (error) {
|
|
4073
|
+
this.logger.error("Error handling SSE connection", error);
|
|
4074
|
+
if (!res.headersSent) res.status(500).send("Internal Server Error");
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
async handlePostMessage(req, res) {
|
|
4078
|
+
const sessionId = req.query.sessionId;
|
|
4079
|
+
if (!sessionId) {
|
|
4080
|
+
res.status(400).send("Missing sessionId query parameter");
|
|
4081
|
+
return;
|
|
4082
|
+
}
|
|
4083
|
+
const transport = this.sessionManager.getSession(sessionId);
|
|
4084
|
+
if (!transport) {
|
|
4085
|
+
res.status(404).send("No transport found for sessionId");
|
|
4086
|
+
return;
|
|
4087
|
+
}
|
|
4088
|
+
try {
|
|
4089
|
+
await transport.handlePostMessage(req, res, req.body);
|
|
4090
|
+
} catch (error) {
|
|
4091
|
+
this.logger.error("Error handling post message", error);
|
|
4092
|
+
if (!res.headersSent) res.status(500).send("Internal Server Error");
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
async start() {
|
|
4096
|
+
return new Promise((resolve$1, reject) => {
|
|
4097
|
+
try {
|
|
4098
|
+
this.server = this.app.listen(this.config.port, this.config.host, () => {
|
|
4099
|
+
this.logger.info(`@agimon-ai/mcp-proxy MCP server started with SSE transport on http://${this.config.host}:${this.config.port}`);
|
|
4100
|
+
this.logger.info(`SSE endpoint: http://${this.config.host}:${this.config.port}/sse`);
|
|
4101
|
+
this.logger.info(`Messages endpoint: http://${this.config.host}:${this.config.port}/messages`);
|
|
4102
|
+
this.logger.info(`Health check: http://${this.config.host}:${this.config.port}/health`);
|
|
4103
|
+
resolve$1();
|
|
4104
|
+
});
|
|
4105
|
+
this.server.on("error", (error) => {
|
|
4106
|
+
reject(error);
|
|
4107
|
+
});
|
|
4108
|
+
} catch (error) {
|
|
4109
|
+
reject(error);
|
|
4110
|
+
}
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
4113
|
+
async stop() {
|
|
4114
|
+
return new Promise((resolve$1, reject) => {
|
|
4115
|
+
if (this.server) {
|
|
4116
|
+
const server = this.server;
|
|
4117
|
+
(async () => {
|
|
4118
|
+
try {
|
|
4119
|
+
await this.sessionManager.clear();
|
|
4120
|
+
server.close((err) => {
|
|
4121
|
+
if (err) reject(err);
|
|
4122
|
+
else {
|
|
4123
|
+
this.server = null;
|
|
4124
|
+
resolve$1();
|
|
4125
|
+
}
|
|
4126
|
+
});
|
|
4127
|
+
} catch (error) {
|
|
4128
|
+
reject(error);
|
|
4129
|
+
}
|
|
4130
|
+
})();
|
|
4131
|
+
} else resolve$1();
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
getPort() {
|
|
4135
|
+
return this.config.port;
|
|
4136
|
+
}
|
|
4137
|
+
getHost() {
|
|
4138
|
+
return this.config.host;
|
|
4139
|
+
}
|
|
4140
|
+
};
|
|
4141
|
+
|
|
4142
|
+
//#endregion
|
|
4143
|
+
//#region src/transports/stdio.ts
|
|
4144
|
+
/**
|
|
4145
|
+
* Stdio transport handler for MCP server
|
|
4146
|
+
* Used for command-line and direct integrations
|
|
4147
|
+
*/
|
|
4148
|
+
var StdioTransportHandler = class {
|
|
4149
|
+
server;
|
|
4150
|
+
transport = null;
|
|
4151
|
+
logger;
|
|
4152
|
+
constructor(server, logger = console) {
|
|
4153
|
+
this.server = server;
|
|
4154
|
+
this.logger = logger;
|
|
4155
|
+
}
|
|
4156
|
+
async start() {
|
|
4157
|
+
this.transport = new StdioServerTransport();
|
|
4158
|
+
await this.server.connect(this.transport);
|
|
4159
|
+
this.logger.info("@agimon-ai/mcp-proxy MCP server started on stdio");
|
|
4160
|
+
}
|
|
4161
|
+
async stop() {
|
|
4162
|
+
if (this.transport) {
|
|
4163
|
+
await this.transport.close();
|
|
4164
|
+
this.transport = null;
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
};
|
|
4168
|
+
|
|
4169
|
+
//#endregion
|
|
4170
|
+
//#region src/transports/stdio-http.ts
|
|
4171
|
+
/**
|
|
4172
|
+
* STDIO-HTTP Proxy Transport
|
|
4173
|
+
*
|
|
4174
|
+
* DESIGN PATTERNS:
|
|
4175
|
+
* - Transport handler pattern implementing TransportHandler interface
|
|
4176
|
+
* - STDIO transport with MCP request forwarding to HTTP backend
|
|
4177
|
+
* - Graceful cleanup with error isolation
|
|
4178
|
+
*
|
|
4179
|
+
* CODING STANDARDS:
|
|
4180
|
+
* - Use StdioServerTransport for stdio communication
|
|
4181
|
+
* - Reuse a single StreamableHTTP client connection
|
|
4182
|
+
* - Wrap async operations with try-catch and descriptive errors
|
|
4183
|
+
*
|
|
4184
|
+
* AVOID:
|
|
4185
|
+
* - Starting HTTP server lifecycle in this transport entry point
|
|
4186
|
+
* - Recreating HTTP client per request
|
|
4187
|
+
* - Swallowing cleanup failures silently
|
|
4188
|
+
*/
|
|
4189
|
+
/**
|
|
4190
|
+
* Transport that serves MCP over stdio and forwards MCP requests to an HTTP endpoint.
|
|
4191
|
+
*/
|
|
4192
|
+
var StdioHttpTransportHandler = class {
|
|
4193
|
+
endpoint;
|
|
4194
|
+
stdioProxyServer = null;
|
|
4195
|
+
stdioTransport = null;
|
|
4196
|
+
httpClient = null;
|
|
4197
|
+
logger;
|
|
4198
|
+
constructor(config, logger = console) {
|
|
4199
|
+
this.endpoint = config.endpoint;
|
|
4200
|
+
this.logger = logger;
|
|
4201
|
+
}
|
|
4202
|
+
async start() {
|
|
4203
|
+
try {
|
|
4204
|
+
const httpClientTransport = new StreamableHTTPClientTransport(this.endpoint);
|
|
4205
|
+
const client = new Client({
|
|
4206
|
+
name: "@agimon-ai/mcp-proxy-stdio-http-proxy",
|
|
4207
|
+
version: "0.1.0"
|
|
4208
|
+
}, { capabilities: {} });
|
|
4209
|
+
await client.connect(httpClientTransport);
|
|
4210
|
+
this.httpClient = client;
|
|
4211
|
+
this.stdioProxyServer = this.createProxyServer(client);
|
|
4212
|
+
this.stdioTransport = new StdioServerTransport();
|
|
4213
|
+
await this.stdioProxyServer.connect(this.stdioTransport);
|
|
4214
|
+
this.logger.info(`@agimon-ai/mcp-proxy MCP stdio proxy connected to ${this.endpoint.toString()}`);
|
|
4215
|
+
} catch (error) {
|
|
4216
|
+
await this.stop();
|
|
4217
|
+
throw new Error(`Failed to start stdio-http proxy transport: ${error instanceof Error ? error.message : String(error)}`);
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
async stop() {
|
|
4221
|
+
const stdioTransport = this.stdioTransport;
|
|
4222
|
+
const stdioProxyServer = this.stdioProxyServer;
|
|
4223
|
+
const httpClient = this.httpClient;
|
|
4224
|
+
this.stdioTransport = null;
|
|
4225
|
+
this.stdioProxyServer = null;
|
|
4226
|
+
this.httpClient = null;
|
|
4227
|
+
const cleanupErrors = [];
|
|
4228
|
+
await Promise.all([
|
|
4229
|
+
(async () => {
|
|
4230
|
+
try {
|
|
4231
|
+
if (stdioTransport) await stdioTransport.close();
|
|
4232
|
+
} catch (error) {
|
|
4233
|
+
cleanupErrors.push(`failed closing stdio transport: ${error instanceof Error ? error.message : String(error)}`);
|
|
4234
|
+
}
|
|
4235
|
+
})(),
|
|
4236
|
+
(async () => {
|
|
4237
|
+
try {
|
|
4238
|
+
if (stdioProxyServer) await stdioProxyServer.close();
|
|
4239
|
+
} catch (error) {
|
|
4240
|
+
cleanupErrors.push(`failed closing stdio proxy server: ${error instanceof Error ? error.message : String(error)}`);
|
|
4241
|
+
}
|
|
4242
|
+
})(),
|
|
4243
|
+
(async () => {
|
|
4244
|
+
try {
|
|
4245
|
+
if (httpClient) await httpClient.close();
|
|
4246
|
+
} catch (error) {
|
|
4247
|
+
cleanupErrors.push(`failed closing http client: ${error instanceof Error ? error.message : String(error)}`);
|
|
4248
|
+
}
|
|
4249
|
+
})()
|
|
4250
|
+
]);
|
|
4251
|
+
if (cleanupErrors.length > 0) throw new Error(`Failed to stop stdio-http proxy transport: ${cleanupErrors.join("; ")}`);
|
|
4252
|
+
}
|
|
4253
|
+
createProxyServer(client) {
|
|
4254
|
+
const proxyServer = new Server({
|
|
4255
|
+
name: "@agimon-ai/mcp-proxy-stdio-http-proxy",
|
|
4256
|
+
version: "0.1.0"
|
|
4257
|
+
}, { capabilities: {
|
|
4258
|
+
tools: {},
|
|
4259
|
+
resources: {},
|
|
4260
|
+
prompts: {}
|
|
4261
|
+
} });
|
|
4262
|
+
proxyServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
4263
|
+
try {
|
|
4264
|
+
return await client.listTools();
|
|
4265
|
+
} catch (error) {
|
|
4266
|
+
throw new Error(`Failed forwarding tools/list to HTTP backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
4267
|
+
}
|
|
4268
|
+
});
|
|
4269
|
+
proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4270
|
+
try {
|
|
4271
|
+
return await client.callTool({
|
|
4272
|
+
name: request.params.name,
|
|
4273
|
+
arguments: request.params.arguments
|
|
4274
|
+
});
|
|
4275
|
+
} catch (error) {
|
|
4276
|
+
throw new Error(`Failed forwarding tools/call (${request.params.name}) to HTTP backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
4277
|
+
}
|
|
4278
|
+
});
|
|
4279
|
+
proxyServer.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
4280
|
+
try {
|
|
4281
|
+
return await client.listResources();
|
|
4282
|
+
} catch (error) {
|
|
4283
|
+
throw new Error(`Failed forwarding resources/list to HTTP backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
4284
|
+
}
|
|
4285
|
+
});
|
|
4286
|
+
proxyServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
4287
|
+
try {
|
|
4288
|
+
return await client.readResource({ uri: request.params.uri });
|
|
4289
|
+
} catch (error) {
|
|
4290
|
+
throw new Error(`Failed forwarding resources/read (${request.params.uri}) to HTTP backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
4291
|
+
}
|
|
4292
|
+
});
|
|
4293
|
+
proxyServer.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
4294
|
+
try {
|
|
4295
|
+
return await client.listPrompts();
|
|
4296
|
+
} catch (error) {
|
|
4297
|
+
throw new Error(`Failed forwarding prompts/list to HTTP backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
4298
|
+
}
|
|
4299
|
+
});
|
|
4300
|
+
proxyServer.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
4301
|
+
try {
|
|
4302
|
+
return await client.getPrompt({
|
|
4303
|
+
name: request.params.name,
|
|
4304
|
+
arguments: request.params.arguments
|
|
4305
|
+
});
|
|
4306
|
+
} catch (error) {
|
|
4307
|
+
throw new Error(`Failed forwarding prompts/get (${request.params.name}) to HTTP backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
4308
|
+
}
|
|
4309
|
+
});
|
|
4310
|
+
return proxyServer;
|
|
4311
|
+
}
|
|
4312
|
+
};
|
|
4313
|
+
|
|
4314
|
+
//#endregion
|
|
4315
|
+
//#region package.json
|
|
4316
|
+
var version = "0.3.19";
|
|
4317
|
+
|
|
4318
|
+
//#endregion
|
|
4319
|
+
//#region src/container/index.ts
|
|
4320
|
+
/**
|
|
4321
|
+
* Proxy Container
|
|
4322
|
+
*
|
|
4323
|
+
* DESIGN PATTERNS:
|
|
4324
|
+
* - Composition root pattern for wiring proxy services
|
|
4325
|
+
* - Factory-based service construction
|
|
4326
|
+
* - Single place for runtime service initialization
|
|
4327
|
+
*
|
|
4328
|
+
* CODING STANDARDS:
|
|
4329
|
+
* - Keep service wiring centralized here
|
|
4330
|
+
* - Prefer lazy initialization for cache-heavy services
|
|
4331
|
+
* - Return a dispose function for owned resources
|
|
4332
|
+
*
|
|
4333
|
+
* AVOID:
|
|
4334
|
+
* - Instantiating proxy services directly in transport or CLI layers
|
|
4335
|
+
* - Spreading construction logic across commands
|
|
4336
|
+
*/
|
|
4337
|
+
function createProxyIoCContainer(logger = console) {
|
|
4338
|
+
return {
|
|
4339
|
+
createConfigFetcherService: (options) => new ConfigFetcherService(options, logger),
|
|
4340
|
+
createClientManagerService: () => new McpClientManagerService(logger),
|
|
4341
|
+
createRuntimeStateService: (runtimeDir) => new RuntimeStateService(runtimeDir, logger),
|
|
4342
|
+
createStopServerService: (runtimeStateService) => new StopServerService(runtimeStateService, logger),
|
|
4343
|
+
createSkillService: (cwd, skillPaths, options) => new SkillService(cwd, skillPaths, options, logger),
|
|
4344
|
+
createDefinitionsCacheService: (clientManager, skillService, options) => new DefinitionsCacheService(clientManager, skillService, options, logger),
|
|
4345
|
+
createPrefetchService: (config) => new PrefetchService(config),
|
|
4346
|
+
createDescribeToolsTool: (clientManager, skillService, serverId, definitionsCacheService) => new DescribeToolsTool(clientManager, skillService, serverId, definitionsCacheService),
|
|
4347
|
+
createUseToolTool: (clientManager, skillService, serverId, definitionsCacheService) => new UseToolTool(clientManager, skillService, serverId, definitionsCacheService),
|
|
4348
|
+
createSearchListToolsTool: (clientManager, definitionsCacheService) => new SearchListToolsTool(clientManager, definitionsCacheService),
|
|
4349
|
+
createStdioTransportHandler: async (createServer$1) => {
|
|
4350
|
+
return new StdioTransportHandler(await createServer$1(), logger);
|
|
4351
|
+
},
|
|
4352
|
+
createSseTransportHandler: async (createServer$1, config) => {
|
|
4353
|
+
return new SseTransportHandler(await createServer$1(), config, logger);
|
|
4354
|
+
},
|
|
4355
|
+
createHttpTransportHandler: (createServer$1, config, adminOptions) => new HttpTransportHandler(createServer$1, config, adminOptions, logger),
|
|
4356
|
+
createStdioHttpTransportHandler: (endpoint) => new StdioHttpTransportHandler({ endpoint }, logger)
|
|
4357
|
+
};
|
|
4358
|
+
}
|
|
4359
|
+
/**
|
|
4360
|
+
* Create the shared proxy container for a session or server startup.
|
|
4361
|
+
*
|
|
4362
|
+
* This is the composition root for the package: it owns service wiring,
|
|
4363
|
+
* cache warmup, and cleanup for all proxy-specific resources.
|
|
4364
|
+
*/
|
|
4365
|
+
async function createProxyContainer(options) {
|
|
4366
|
+
const telemetry = await createProxyLogger({
|
|
4367
|
+
workspaceRoot: process.cwd(),
|
|
4368
|
+
serviceName: "@agimon-ai/mcp-proxy"
|
|
4369
|
+
});
|
|
4370
|
+
const logger = telemetry;
|
|
4371
|
+
const container = createProxyIoCContainer(logger);
|
|
4372
|
+
const clientManager = container.createClientManagerService();
|
|
4373
|
+
try {
|
|
4374
|
+
let configSkills;
|
|
4375
|
+
let configId;
|
|
4376
|
+
let configHash;
|
|
4377
|
+
let effectiveDefinitionsCachePath;
|
|
4378
|
+
let shouldStartFromCache = false;
|
|
4379
|
+
if (options?.configFilePath) {
|
|
4380
|
+
let config;
|
|
4381
|
+
try {
|
|
4382
|
+
config = await container.createConfigFetcherService({
|
|
4383
|
+
configFilePath: options.configFilePath,
|
|
4384
|
+
useCache: !options.noCache
|
|
4385
|
+
}).fetchConfiguration(options.noCache || false);
|
|
4386
|
+
} catch (error) {
|
|
4387
|
+
throw new Error(`Failed to load MCP configuration from '${options.configFilePath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
4388
|
+
}
|
|
4389
|
+
configSkills = config.skills;
|
|
4390
|
+
configId = config.id;
|
|
4391
|
+
configHash = DefinitionsCacheService.generateConfigHash(config);
|
|
4392
|
+
effectiveDefinitionsCachePath = options.definitionsCachePath || DefinitionsCacheService.getDefaultCachePath(options.configFilePath);
|
|
4393
|
+
clientManager.registerServerConfigs(config.mcpServers);
|
|
4394
|
+
if (options.clearDefinitionsCache && effectiveDefinitionsCachePath) {
|
|
4395
|
+
await DefinitionsCacheService.clearFile(effectiveDefinitionsCachePath);
|
|
4396
|
+
logger.info(`[definitions-cache] Cleared ${effectiveDefinitionsCachePath}`);
|
|
4397
|
+
}
|
|
4398
|
+
if (effectiveDefinitionsCachePath) try {
|
|
4399
|
+
const cacheData = await DefinitionsCacheService.readFromFile(effectiveDefinitionsCachePath);
|
|
4400
|
+
if (DefinitionsCacheService.isCacheValid(cacheData, {
|
|
4401
|
+
configHash,
|
|
4402
|
+
oneMcpVersion: version
|
|
4403
|
+
})) shouldStartFromCache = true;
|
|
4404
|
+
} catch (error) {
|
|
4405
|
+
logger.warn(`[definitions-cache] Failed to inspect ${effectiveDefinitionsCachePath}; falling back to live discovery`, error);
|
|
4406
|
+
}
|
|
4407
|
+
if (!shouldStartFromCache) {
|
|
4408
|
+
const failedConnections = [];
|
|
4409
|
+
const connectionPromises = Object.entries(config.mcpServers).map(async ([serverName, serverConfig]) => {
|
|
4410
|
+
try {
|
|
4411
|
+
await clientManager.connectToServer(serverName, serverConfig);
|
|
4412
|
+
logger.info(`Connected to MCP server: ${serverName}`);
|
|
4413
|
+
} catch (error) {
|
|
4414
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
4415
|
+
failedConnections.push({
|
|
4416
|
+
serverName,
|
|
4417
|
+
error: err
|
|
4418
|
+
});
|
|
4419
|
+
logger.warn(`Failed to connect to ${serverName}`, error);
|
|
4420
|
+
}
|
|
4421
|
+
});
|
|
4422
|
+
await Promise.all(connectionPromises);
|
|
4423
|
+
if (failedConnections.length > 0 && failedConnections.length < Object.keys(config.mcpServers).length) logger.warn(`Warning: Some MCP server connections failed: ${failedConnections.map((f) => f.serverName).join(", ")}`);
|
|
4424
|
+
if (failedConnections.length > 0 && failedConnections.length === Object.keys(config.mcpServers).length) throw new Error(`All MCP server connections failed: ${failedConnections.map((f) => `${f.serverName}: ${f.error.message}`).join(", ")}`);
|
|
4425
|
+
} else logger.info(`[definitions-cache] Using cached definitions from ${effectiveDefinitionsCachePath}`);
|
|
4426
|
+
}
|
|
4427
|
+
const serverId = options?.serverId || configId || generateServerId();
|
|
4428
|
+
logger.info(`[mcp-proxy] Server ID: ${serverId}`);
|
|
4429
|
+
const skillPaths = (options?.skills || configSkills)?.paths ?? [];
|
|
4430
|
+
const toolsRef = { describeTools: null };
|
|
4431
|
+
const skillService = skillPaths.length > 0 ? container.createSkillService(process.cwd(), skillPaths, { onCacheInvalidated: () => {
|
|
4432
|
+
toolsRef.describeTools?.clearAutoDetectedSkillsCache();
|
|
4433
|
+
} }) : void 0;
|
|
4434
|
+
let definitionsCacheService;
|
|
4435
|
+
if (effectiveDefinitionsCachePath) try {
|
|
4436
|
+
const cacheData = await DefinitionsCacheService.readFromFile(effectiveDefinitionsCachePath);
|
|
4437
|
+
if (DefinitionsCacheService.isCacheValid(cacheData, {
|
|
4438
|
+
configHash,
|
|
4439
|
+
oneMcpVersion: version
|
|
4440
|
+
})) definitionsCacheService = container.createDefinitionsCacheService(clientManager, skillService, { cacheData });
|
|
4441
|
+
else definitionsCacheService = container.createDefinitionsCacheService(clientManager, skillService);
|
|
4442
|
+
} catch (error) {
|
|
4443
|
+
logger.warn(`[definitions-cache] Failed to load ${effectiveDefinitionsCachePath}, falling back to live discovery: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4444
|
+
definitionsCacheService = container.createDefinitionsCacheService(clientManager, skillService);
|
|
4445
|
+
}
|
|
4446
|
+
else definitionsCacheService = container.createDefinitionsCacheService(clientManager, skillService);
|
|
4447
|
+
const proxyMode = options?.proxyMode || "meta";
|
|
4448
|
+
const describeTools = container.createDescribeToolsTool(clientManager, skillService, serverId, definitionsCacheService);
|
|
4449
|
+
const useTool = container.createUseToolTool(clientManager, skillService, serverId, definitionsCacheService);
|
|
4450
|
+
const searchListTools = container.createSearchListToolsTool(clientManager, definitionsCacheService);
|
|
4451
|
+
toolsRef.describeTools = describeTools;
|
|
4452
|
+
if (skillService) skillService.startWatching().catch((error) => {
|
|
4453
|
+
logger.warn(`[skill-watcher] File watcher failed (non-critical): ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4454
|
+
});
|
|
4455
|
+
if (!shouldStartFromCache && effectiveDefinitionsCachePath && options?.configFilePath) definitionsCacheService.collectForCache({
|
|
4456
|
+
configPath: options.configFilePath,
|
|
4457
|
+
configHash,
|
|
4458
|
+
oneMcpVersion: version,
|
|
4459
|
+
serverId
|
|
4460
|
+
}).then((definitionsCache) => DefinitionsCacheService.writeToFile(effectiveDefinitionsCachePath, definitionsCache)).then(() => {
|
|
4461
|
+
logger.info(`[definitions-cache] Wrote ${effectiveDefinitionsCachePath}`);
|
|
4462
|
+
}).catch((error) => {
|
|
4463
|
+
logger.warn(`[definitions-cache] Failed to persist ${effectiveDefinitionsCachePath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4464
|
+
});
|
|
4465
|
+
const dispose = async () => {
|
|
4466
|
+
try {
|
|
4467
|
+
await clientManager.disconnectAll();
|
|
4468
|
+
} finally {
|
|
4469
|
+
if (skillService) skillService.stopWatching();
|
|
4470
|
+
await telemetry.shutdown();
|
|
4471
|
+
}
|
|
4472
|
+
};
|
|
4473
|
+
return {
|
|
4474
|
+
clientManager,
|
|
4475
|
+
definitionsCacheService,
|
|
4476
|
+
skillService,
|
|
4477
|
+
describeTools,
|
|
4478
|
+
useTool,
|
|
4479
|
+
searchListTools,
|
|
4480
|
+
serverId,
|
|
4481
|
+
proxyMode,
|
|
4482
|
+
dispose
|
|
4483
|
+
};
|
|
4484
|
+
} catch (error) {
|
|
4485
|
+
await telemetry.shutdown();
|
|
4486
|
+
throw error;
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
/**
|
|
4490
|
+
* Create a sessionless stdio transport handler from the shared server factory.
|
|
4491
|
+
*/
|
|
4492
|
+
async function createStdioTransportHandler(createServer$1) {
|
|
4493
|
+
const server = await createServer$1();
|
|
4494
|
+
return createProxyIoCContainer().createStdioTransportHandler(() => Promise.resolve(server));
|
|
4495
|
+
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Create an SSE transport handler from the shared server factory.
|
|
4498
|
+
*/
|
|
4499
|
+
async function createSseTransportHandler(createServer$1, config) {
|
|
4500
|
+
const server = await createServer$1();
|
|
4501
|
+
return createProxyIoCContainer().createSseTransportHandler(() => Promise.resolve(server), config);
|
|
4502
|
+
}
|
|
4503
|
+
/**
|
|
4504
|
+
* Create an HTTP transport handler from shared services.
|
|
4505
|
+
*/
|
|
4506
|
+
function createHttpTransportHandler(createServer$1, config, adminOptions) {
|
|
4507
|
+
return createProxyIoCContainer().createHttpTransportHandler(createServer$1, config, adminOptions);
|
|
4508
|
+
}
|
|
4509
|
+
/**
|
|
4510
|
+
* Create a stdio-http transport handler from an endpoint URL.
|
|
4511
|
+
*/
|
|
4512
|
+
function createStdioHttpTransportHandler(endpoint) {
|
|
4513
|
+
return createProxyIoCContainer().createStdioHttpTransportHandler(endpoint);
|
|
4514
|
+
}
|
|
4515
|
+
/**
|
|
4516
|
+
* Backward-compatible alias for the shared-service composition root.
|
|
4517
|
+
*/
|
|
4518
|
+
async function initializeSharedServices(options) {
|
|
4519
|
+
return createProxyContainer(options);
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4522
|
+
//#endregion
|
|
4523
|
+
//#region src/server/index.ts
|
|
4524
|
+
/**
|
|
4525
|
+
* MCP Server Setup
|
|
4526
|
+
*
|
|
4527
|
+
* DESIGN PATTERNS:
|
|
4528
|
+
* - Factory pattern for server creation
|
|
4529
|
+
* - Tool registration pattern
|
|
4530
|
+
* - Dependency injection for services
|
|
4531
|
+
* - Shared services pattern for multi-session HTTP transport
|
|
4532
|
+
*
|
|
4533
|
+
* CODING STANDARDS:
|
|
4534
|
+
* - Register all tools, resources, and prompts here
|
|
4535
|
+
* - Keep server setup modular and extensible
|
|
4536
|
+
* - Import tools from ../tools/ and register them in the handlers
|
|
4537
|
+
*/
|
|
4538
|
+
function summarizeServerTools(serverDefinition) {
|
|
4539
|
+
const toolNames = serverDefinition.tools.map((tool) => tool.name);
|
|
4540
|
+
const capabilities = getUniqueSortedCapabilities(serverDefinition.tools);
|
|
4541
|
+
const capabilitySummary = capabilities.length > 0 ? `; capabilities: ${capabilities.join(", ")}` : "";
|
|
4542
|
+
if (toolNames.length === 0) return `${serverDefinition.serverName} (no tools cached${capabilitySummary})`;
|
|
4543
|
+
return `${serverDefinition.serverName} (${toolNames.join(", ")})${capabilitySummary}`;
|
|
4544
|
+
}
|
|
4545
|
+
function buildFlatToolDescription(serverDefinition, tool) {
|
|
4546
|
+
const parts = [`Proxied from server "${serverDefinition.serverName}" as tool "${tool.name}".`];
|
|
4547
|
+
if (serverDefinition.serverInstruction) parts.push(`Server summary: ${serverDefinition.serverInstruction}`);
|
|
4548
|
+
if (tool.description && !serverDefinition.omitToolDescription) parts.push(tool.description);
|
|
4549
|
+
const capabilities = getToolCapabilities(tool);
|
|
4550
|
+
if (capabilities.length > 0) parts.push(`Capabilities: ${capabilities.join(", ")}`);
|
|
4551
|
+
return parts.join("\n\n");
|
|
4552
|
+
}
|
|
4553
|
+
function buildFlatToolDefinitions(serverDefinitions) {
|
|
4554
|
+
const toolToServers = /* @__PURE__ */ new Map();
|
|
4555
|
+
for (const serverDefinition of serverDefinitions) for (const tool of serverDefinition.tools) {
|
|
4556
|
+
if (!toolToServers.has(tool.name)) toolToServers.set(tool.name, []);
|
|
4557
|
+
toolToServers.get(tool.name)?.push(serverDefinition.serverName);
|
|
4558
|
+
}
|
|
4559
|
+
const definitions = [];
|
|
4560
|
+
for (const serverDefinition of serverDefinitions) for (const tool of serverDefinition.tools) {
|
|
4561
|
+
const hasClash = (toolToServers.get(tool.name) || []).length > 1;
|
|
4562
|
+
definitions.push({
|
|
4563
|
+
name: hasClash ? `${serverDefinition.serverName}__${tool.name}` : tool.name,
|
|
4564
|
+
description: buildFlatToolDescription(serverDefinition, tool),
|
|
4565
|
+
inputSchema: tool.inputSchema,
|
|
4566
|
+
_meta: tool._meta
|
|
4567
|
+
});
|
|
4568
|
+
}
|
|
4569
|
+
return definitions;
|
|
4570
|
+
}
|
|
4571
|
+
async function hasAnySkills(definitionsCacheService, skillService) {
|
|
4572
|
+
const [fileSkills, serverDefinitions] = await Promise.all([skillService ? skillService.getSkills() : definitionsCacheService.getCachedFileSkills(), definitionsCacheService.getServerDefinitions()]);
|
|
4573
|
+
return fileSkills.length > 0 || serverDefinitions.some((server) => server.promptSkills.length > 0);
|
|
4574
|
+
}
|
|
4575
|
+
function buildSkillsDescribeDefinition(serverDefinitions, serverId) {
|
|
4576
|
+
const proxySummary = serverDefinitions.length > 0 ? serverDefinitions.map(summarizeServerTools).join("; ") : "No proxied servers available.";
|
|
4577
|
+
return {
|
|
4578
|
+
name: DescribeToolsTool.TOOL_NAME,
|
|
4579
|
+
description: `Get detailed skill instructions for file-based skills and prompt-based skills proxied by mcp-proxy.\n\nProxy summary: ${proxySummary}\n\nUse this when you need the full instructions for a skill. For MCP tools, call the flat tool names directly. Only use skills discovered from describe_tools with id="${serverId}".`,
|
|
4580
|
+
inputSchema: {
|
|
4581
|
+
type: "object",
|
|
4582
|
+
properties: { toolNames: {
|
|
4583
|
+
type: "array",
|
|
4584
|
+
items: {
|
|
4585
|
+
type: "string",
|
|
4586
|
+
minLength: 1
|
|
4587
|
+
},
|
|
4588
|
+
description: "List of skill names to get detailed information about",
|
|
4589
|
+
minItems: 1
|
|
4590
|
+
} },
|
|
4591
|
+
required: ["toolNames"],
|
|
4592
|
+
additionalProperties: false
|
|
4593
|
+
}
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
function buildSearchDescribeDefinition(serverDefinitions, serverId) {
|
|
4597
|
+
const summary = serverDefinitions.length > 0 ? serverDefinitions.map(summarizeServerTools).join("; ") : "No proxied servers available.";
|
|
4598
|
+
return {
|
|
4599
|
+
name: DescribeToolsTool.TOOL_NAME,
|
|
4600
|
+
description: `Get detailed schemas and skill instructions for proxied MCP capabilities.\n\nProxy summary: ${summary}\n\nUse list_tools first to search capability summaries and discover tool names. Then use describe_tools to fetch full schemas or skill instructions. Only use capabilities discovered from mcp-proxy id="${serverId}".`,
|
|
4601
|
+
inputSchema: {
|
|
4602
|
+
type: "object",
|
|
4603
|
+
properties: { toolNames: {
|
|
4604
|
+
type: "array",
|
|
4605
|
+
items: {
|
|
4606
|
+
type: "string",
|
|
4607
|
+
minLength: 1
|
|
4608
|
+
},
|
|
4609
|
+
description: "List of tool or skill names to get detailed information about",
|
|
4610
|
+
minItems: 1
|
|
4611
|
+
} },
|
|
4612
|
+
required: ["toolNames"],
|
|
4613
|
+
additionalProperties: false
|
|
4614
|
+
}
|
|
4615
|
+
};
|
|
4616
|
+
}
|
|
4617
|
+
function buildProxyInstructions(serverDefinitions, mode, includeSkillsTool) {
|
|
4618
|
+
const summary = serverDefinitions.length > 0 ? serverDefinitions.map(summarizeServerTools).join("; ") : "No proxied servers available.";
|
|
4619
|
+
if (mode === "flat") return [
|
|
4620
|
+
"mcp-proxy proxies downstream MCP servers and exposes their tools and resources directly.",
|
|
4621
|
+
`Proxied servers and tools: ${summary}`,
|
|
4622
|
+
includeSkillsTool ? "Skills are still exposed through describe_tools when file-based skills or prompt-backed skills are configured." : "No skills are currently exposed through describe_tools."
|
|
4623
|
+
].join("\n\n");
|
|
4624
|
+
if (mode === "search") return [
|
|
4625
|
+
"mcp-proxy proxies downstream MCP servers in search mode.",
|
|
4626
|
+
`Proxied servers and tools: ${summary}`,
|
|
4627
|
+
"Use list_tools to search capability summaries and discover tool names, describe_tools to fetch schemas or skill instructions, and use_tool to execute tools."
|
|
4628
|
+
].join("\n\n");
|
|
4629
|
+
return [
|
|
4630
|
+
"mcp-proxy proxies downstream MCP servers in meta mode.",
|
|
4631
|
+
`Proxied servers and tools: ${summary}`,
|
|
4632
|
+
"Use describe_tools to inspect capabilities and use_tool to execute them."
|
|
4633
|
+
].join("\n\n");
|
|
4634
|
+
}
|
|
4635
|
+
/**
|
|
4636
|
+
* Initialize shared services and tools once for use across multiple sessions.
|
|
4637
|
+
* Use with createSessionServer() for HTTP transport where multiple agents
|
|
4638
|
+
* connect concurrently. This avoids duplicating downstream connections,
|
|
4639
|
+
* file watchers, caches, and tool instances per session.
|
|
4640
|
+
*/
|
|
4641
|
+
/**
|
|
4642
|
+
* Create a lightweight per-session MCP Server instance that delegates
|
|
4643
|
+
* to shared services and tools. Use with createProxyContainer()
|
|
4644
|
+
* for multi-session HTTP transport.
|
|
4645
|
+
*/
|
|
4646
|
+
async function createSessionServer(shared) {
|
|
4647
|
+
const { clientManager, definitionsCacheService, skillService, describeTools, useTool: useToolWithCache, searchListTools, serverId, proxyMode } = shared;
|
|
4648
|
+
const server = new Server({
|
|
4649
|
+
name: "@agimon-ai/mcp-proxy",
|
|
4650
|
+
version: "0.1.0"
|
|
4651
|
+
}, {
|
|
4652
|
+
capabilities: {
|
|
4653
|
+
tools: {},
|
|
4654
|
+
resources: {},
|
|
4655
|
+
prompts: {}
|
|
4656
|
+
},
|
|
4657
|
+
instructions: buildProxyInstructions(await definitionsCacheService.getServerDefinitions(), proxyMode, await hasAnySkills(definitionsCacheService, skillService))
|
|
4658
|
+
});
|
|
4659
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: proxyMode === "flat" ? await (async () => {
|
|
4660
|
+
const currentServerDefinitions = await definitionsCacheService.getServerDefinitions();
|
|
4661
|
+
const shouldIncludeSkillsTool = await hasAnySkills(definitionsCacheService, skillService);
|
|
4662
|
+
return [...buildFlatToolDefinitions(currentServerDefinitions), ...shouldIncludeSkillsTool ? [buildSkillsDescribeDefinition(currentServerDefinitions, serverId)] : []];
|
|
4663
|
+
})() : proxyMode === "search" ? await (async () => {
|
|
4664
|
+
return [
|
|
4665
|
+
buildSearchDescribeDefinition(await definitionsCacheService.getServerDefinitions(), serverId),
|
|
4666
|
+
await searchListTools.getDefinition(),
|
|
4667
|
+
useToolWithCache.getDefinition()
|
|
4668
|
+
];
|
|
4669
|
+
})() : [await describeTools.getDefinition(), useToolWithCache.getDefinition()] }));
|
|
4670
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4671
|
+
const { name, arguments: args } = request.params;
|
|
4672
|
+
if (name === DescribeToolsTool.TOOL_NAME) try {
|
|
4673
|
+
return await describeTools.execute(args);
|
|
4674
|
+
} catch (error) {
|
|
4675
|
+
throw new Error(`Failed to execute ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4676
|
+
}
|
|
4677
|
+
if (name === UseToolTool.TOOL_NAME) try {
|
|
4678
|
+
return await useToolWithCache.execute(args);
|
|
4679
|
+
} catch (error) {
|
|
4680
|
+
throw new Error(`Failed to execute ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4681
|
+
}
|
|
4682
|
+
if (name === SearchListToolsTool.TOOL_NAME && proxyMode === "search") try {
|
|
4683
|
+
return await searchListTools.execute(args);
|
|
4684
|
+
} catch (error) {
|
|
4685
|
+
throw new Error(`Failed to execute ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4686
|
+
}
|
|
4687
|
+
if (proxyMode === "flat") return await useToolWithCache.execute({
|
|
4688
|
+
toolName: name,
|
|
4689
|
+
toolArgs: args || {}
|
|
4690
|
+
});
|
|
4691
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
4692
|
+
});
|
|
4693
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
4694
|
+
const currentServerDefinitions = await definitionsCacheService.getServerDefinitions();
|
|
4695
|
+
const resourceToServers = /* @__PURE__ */ new Map();
|
|
4696
|
+
for (const serverDefinition of currentServerDefinitions) for (const resource of serverDefinition.resources) {
|
|
4697
|
+
if (!resourceToServers.has(resource.uri)) resourceToServers.set(resource.uri, []);
|
|
4698
|
+
resourceToServers.get(resource.uri)?.push(serverDefinition.serverName);
|
|
4699
|
+
}
|
|
4700
|
+
const resources = [];
|
|
4701
|
+
for (const serverDefinition of currentServerDefinitions) for (const resource of serverDefinition.resources) {
|
|
4702
|
+
const hasClash = (resourceToServers.get(resource.uri) || []).length > 1;
|
|
4703
|
+
resources.push({
|
|
4704
|
+
...resource,
|
|
4705
|
+
uri: hasClash ? `${serverDefinition.serverName}__${resource.uri}` : resource.uri
|
|
4706
|
+
});
|
|
4707
|
+
}
|
|
4708
|
+
return { resources };
|
|
4709
|
+
});
|
|
4710
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
4711
|
+
const { uri } = request.params;
|
|
4712
|
+
const { serverName, actualToolName: actualUri } = parseToolName(uri);
|
|
4713
|
+
if (serverName) return await (await clientManager.ensureConnected(serverName)).readResource(actualUri);
|
|
4714
|
+
const matchingServers = await definitionsCacheService.getServersForResource(actualUri);
|
|
4715
|
+
if (matchingServers.length === 0) throw new Error(`Resource not found: ${uri}`);
|
|
4716
|
+
if (matchingServers.length > 1) throw new Error(`Resource "${actualUri}" exists on multiple servers: ${matchingServers.join(", ")}. Use the prefixed format (e.g., "${matchingServers[0]}__${actualUri}") to specify which server to use.`);
|
|
4717
|
+
return await (await clientManager.ensureConnected(matchingServers[0])).readResource(actualUri);
|
|
4718
|
+
});
|
|
4719
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
4720
|
+
const currentServerDefinitions = await definitionsCacheService.getServerDefinitions();
|
|
4721
|
+
const promptToServers = /* @__PURE__ */ new Map();
|
|
4722
|
+
const serverPromptsMap = /* @__PURE__ */ new Map();
|
|
4723
|
+
for (const serverDefinition of currentServerDefinitions) {
|
|
4724
|
+
serverPromptsMap.set(serverDefinition.serverName, serverDefinition.prompts);
|
|
4725
|
+
for (const prompt of serverDefinition.prompts) {
|
|
4726
|
+
if (!promptToServers.has(prompt.name)) promptToServers.set(prompt.name, []);
|
|
4727
|
+
promptToServers.get(prompt.name).push(serverDefinition.serverName);
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
const aggregatedPrompts = [];
|
|
4731
|
+
for (const serverDefinition of currentServerDefinitions) {
|
|
4732
|
+
const prompts = serverPromptsMap.get(serverDefinition.serverName) || [];
|
|
4733
|
+
for (const prompt of prompts) {
|
|
4734
|
+
const hasClash = (promptToServers.get(prompt.name) || []).length > 1;
|
|
4735
|
+
aggregatedPrompts.push({
|
|
4736
|
+
name: hasClash ? `${serverDefinition.serverName}__${prompt.name}` : prompt.name,
|
|
4737
|
+
description: prompt.description,
|
|
4738
|
+
arguments: prompt.arguments
|
|
4739
|
+
});
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
return { prompts: aggregatedPrompts };
|
|
4743
|
+
});
|
|
4744
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
4745
|
+
const { name, arguments: args } = request.params;
|
|
4746
|
+
const currentServerDefinitions = await definitionsCacheService.getServerDefinitions();
|
|
4747
|
+
const { serverName, actualToolName: actualPromptName } = parseToolName(name);
|
|
4748
|
+
if (serverName) return await (await clientManager.ensureConnected(serverName)).getPrompt(actualPromptName, args);
|
|
4749
|
+
const serversWithPrompt = [];
|
|
4750
|
+
for (const serverDefinition of currentServerDefinitions) if (serverDefinition.prompts.some((prompt) => prompt.name === name)) serversWithPrompt.push(serverDefinition.serverName);
|
|
4751
|
+
if (serversWithPrompt.length === 0) throw new Error(`Prompt not found: ${name}`);
|
|
4752
|
+
if (serversWithPrompt.length > 1) throw new Error(`Prompt "${name}" exists on multiple servers: ${serversWithPrompt.join(", ")}. Use the prefixed format (e.g., "${serversWithPrompt[0]}__${name}") to specify which server to use.`);
|
|
4753
|
+
const client = clientManager.getClient(serversWithPrompt[0]);
|
|
4754
|
+
if (!client) return await (await clientManager.ensureConnected(serversWithPrompt[0])).getPrompt(name, args);
|
|
4755
|
+
return await client.getPrompt(name, args);
|
|
4756
|
+
});
|
|
4757
|
+
return server;
|
|
4758
|
+
}
|
|
4759
|
+
/**
|
|
4760
|
+
* Create a single MCP server instance (backward-compatible wrapper).
|
|
4761
|
+
* For multi-session HTTP transport, use createProxyContainer() + createSessionServer() instead.
|
|
4762
|
+
*/
|
|
4763
|
+
async function createServer(options) {
|
|
4764
|
+
return createSessionServer(await createProxyContainer(options));
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
//#endregion
|
|
4768
|
+
//#region src/types/index.ts
|
|
4769
|
+
/**
|
|
4770
|
+
* Transport mode constants
|
|
4771
|
+
*/
|
|
4772
|
+
const TRANSPORT_MODE = {
|
|
4773
|
+
STDIO: "stdio",
|
|
4774
|
+
HTTP: "http",
|
|
4775
|
+
SSE: "sse"
|
|
4776
|
+
};
|
|
4777
|
+
|
|
4778
|
+
//#endregion
|
|
4779
|
+
export { SearchListToolsTool as C, findConfigFile as D, generateServerId as E, UseToolTool as S, DefinitionsCacheService as T, StopServerService as _, createProxyContainer as a, createProxyLogger as b, createStdioHttpTransportHandler as c, version as d, StdioHttpTransportHandler as f, SkillService as g, HttpTransportHandler as h, createHttpTransportHandler as i, createStdioTransportHandler as l, SseTransportHandler as m, createServer as n, createProxyIoCContainer as o, StdioTransportHandler as p, createSessionServer as r, createSseTransportHandler as s, TRANSPORT_MODE as t, initializeSharedServices as u, RuntimeStateService as v, DescribeToolsTool as w, ConfigFetcherService as x, McpClientManagerService as y };
|