@mcpspec/core 1.0.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 +21 -0
- package/dist/http-XUWKDMSR.js +9 -0
- package/dist/index.d.ts +526 -0
- package/dist/index.js +3305 -0
- package/dist/sse-NXEF5JDZ.js +9 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3305 @@
|
|
|
1
|
+
// src/errors/error-codes.ts
|
|
2
|
+
import { EXIT_CODES } from "@mcpspec/shared";
|
|
3
|
+
var ERROR_CODE_MAP = {
|
|
4
|
+
CONNECTION_TIMEOUT: EXIT_CODES.CONNECTION_ERROR,
|
|
5
|
+
CONNECTION_REFUSED: EXIT_CODES.CONNECTION_ERROR,
|
|
6
|
+
CONNECTION_LOST: EXIT_CODES.CONNECTION_ERROR,
|
|
7
|
+
PROCESS_SPAWN_FAILED: EXIT_CODES.ERROR,
|
|
8
|
+
PROCESS_CRASHED: EXIT_CODES.ERROR,
|
|
9
|
+
PROCESS_TIMEOUT: EXIT_CODES.TIMEOUT,
|
|
10
|
+
TOOL_NOT_FOUND: EXIT_CODES.ERROR,
|
|
11
|
+
TOOL_CALL_FAILED: EXIT_CODES.ERROR,
|
|
12
|
+
INVALID_RESPONSE: EXIT_CODES.ERROR,
|
|
13
|
+
SCHEMA_VALIDATION_FAILED: EXIT_CODES.VALIDATION_ERROR,
|
|
14
|
+
ASSERTION_FAILED: EXIT_CODES.TEST_FAILURE,
|
|
15
|
+
COLLECTION_PARSE_ERROR: EXIT_CODES.CONFIG_ERROR,
|
|
16
|
+
COLLECTION_VALIDATION_ERROR: EXIT_CODES.CONFIG_ERROR,
|
|
17
|
+
YAML_PARSE_ERROR: EXIT_CODES.CONFIG_ERROR,
|
|
18
|
+
YAML_TOO_LARGE: EXIT_CODES.CONFIG_ERROR,
|
|
19
|
+
YAML_TOO_DEEP: EXIT_CODES.CONFIG_ERROR,
|
|
20
|
+
TIMEOUT: EXIT_CODES.TIMEOUT,
|
|
21
|
+
RATE_LIMITED: EXIT_CODES.ERROR,
|
|
22
|
+
CONFIG_ERROR: EXIT_CODES.CONFIG_ERROR,
|
|
23
|
+
SECURITY_SCAN_ERROR: EXIT_CODES.SECURITY_FINDINGS,
|
|
24
|
+
NOT_IMPLEMENTED: EXIT_CODES.ERROR,
|
|
25
|
+
UNKNOWN_ERROR: EXIT_CODES.ERROR
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/errors/mcpspec-error.ts
|
|
29
|
+
var MCPSpecError = class extends Error {
|
|
30
|
+
code;
|
|
31
|
+
exitCode;
|
|
32
|
+
context;
|
|
33
|
+
constructor(code, message, context = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "MCPSpecError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.exitCode = ERROR_CODE_MAP[code];
|
|
38
|
+
this.context = context;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var NotImplementedError = class extends MCPSpecError {
|
|
42
|
+
constructor(feature) {
|
|
43
|
+
super("NOT_IMPLEMENTED", `${feature} is not yet implemented. Coming in a future release.`, {
|
|
44
|
+
feature
|
|
45
|
+
});
|
|
46
|
+
this.name = "NotImplementedError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/errors/error-messages.ts
|
|
51
|
+
var ERROR_TEMPLATES = {
|
|
52
|
+
CONNECTION_TIMEOUT: {
|
|
53
|
+
title: "Connection Timed Out",
|
|
54
|
+
description: "Could not connect to the MCP server within {{timeout}}ms.",
|
|
55
|
+
suggestions: [
|
|
56
|
+
"Verify the server is running",
|
|
57
|
+
"Check the command/URL is correct",
|
|
58
|
+
"Increase timeout with --timeout flag"
|
|
59
|
+
],
|
|
60
|
+
docs: "https://mcpspec.dev/docs/troubleshooting#connection-timeout"
|
|
61
|
+
},
|
|
62
|
+
CONNECTION_REFUSED: {
|
|
63
|
+
title: "Connection Refused",
|
|
64
|
+
description: "The MCP server at {{target}} refused the connection.",
|
|
65
|
+
suggestions: [
|
|
66
|
+
"Verify the server is running and accepting connections",
|
|
67
|
+
"Check the port number is correct",
|
|
68
|
+
"Ensure no firewall is blocking the connection"
|
|
69
|
+
],
|
|
70
|
+
docs: "https://mcpspec.dev/docs/troubleshooting#connection-refused"
|
|
71
|
+
},
|
|
72
|
+
PROCESS_SPAWN_FAILED: {
|
|
73
|
+
title: "Failed to Start Server",
|
|
74
|
+
description: "Could not spawn process: {{command}}",
|
|
75
|
+
suggestions: [
|
|
76
|
+
"Verify the command exists and is in PATH",
|
|
77
|
+
"Check that all required dependencies are installed",
|
|
78
|
+
"Try running the command directly in your terminal"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
PROCESS_CRASHED: {
|
|
82
|
+
title: "Server Process Crashed",
|
|
83
|
+
description: "The MCP server process exited unexpectedly with code {{exitCode}}.",
|
|
84
|
+
suggestions: [
|
|
85
|
+
"Check the server logs for errors",
|
|
86
|
+
"Ensure the server has the required environment variables",
|
|
87
|
+
"Try running the server command directly to see errors"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
TOOL_NOT_FOUND: {
|
|
91
|
+
title: "Tool Not Found",
|
|
92
|
+
description: 'The tool "{{toolName}}" does not exist on this server.',
|
|
93
|
+
suggestions: [
|
|
94
|
+
"Available tools: {{availableTools}}",
|
|
95
|
+
"Run `mcpspec inspect` to see all available tools",
|
|
96
|
+
"Check for typos in the tool name"
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
COLLECTION_PARSE_ERROR: {
|
|
100
|
+
title: "Collection Parse Error",
|
|
101
|
+
description: "Failed to parse collection file: {{filePath}}",
|
|
102
|
+
suggestions: [
|
|
103
|
+
"Check YAML syntax is valid",
|
|
104
|
+
"Ensure the file is UTF-8 encoded",
|
|
105
|
+
"Validate against the collection schema"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
COLLECTION_VALIDATION_ERROR: {
|
|
109
|
+
title: "Collection Validation Error",
|
|
110
|
+
description: "Collection file has invalid structure: {{details}}",
|
|
111
|
+
suggestions: [
|
|
112
|
+
"Check required fields: name, server, tests",
|
|
113
|
+
"Ensure each test has a name and a tool/call",
|
|
114
|
+
"See collection docs for the correct format"
|
|
115
|
+
],
|
|
116
|
+
docs: "https://mcpspec.dev/docs/collections"
|
|
117
|
+
},
|
|
118
|
+
YAML_TOO_LARGE: {
|
|
119
|
+
title: "YAML File Too Large",
|
|
120
|
+
description: "The YAML file exceeds the maximum size of {{maxSize}} bytes.",
|
|
121
|
+
suggestions: [
|
|
122
|
+
"Split large collections into multiple files",
|
|
123
|
+
"Remove unused tests or data"
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
TIMEOUT: {
|
|
127
|
+
title: "Operation Timed Out",
|
|
128
|
+
description: "The operation did not complete within {{timeout}}ms.",
|
|
129
|
+
suggestions: [
|
|
130
|
+
"Increase the timeout in your collection or CLI flags",
|
|
131
|
+
"Check if the server is responding slowly",
|
|
132
|
+
"Reduce the complexity of the test"
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// src/errors/error-formatter.ts
|
|
138
|
+
function formatError(error) {
|
|
139
|
+
if (error instanceof MCPSpecError) {
|
|
140
|
+
const template = ERROR_TEMPLATES[error.code];
|
|
141
|
+
if (template) {
|
|
142
|
+
return {
|
|
143
|
+
title: interpolate(template.title, error.context),
|
|
144
|
+
description: interpolate(template.description, error.context),
|
|
145
|
+
suggestions: template.suggestions.map((s) => interpolate(s, error.context)),
|
|
146
|
+
docs: template.docs,
|
|
147
|
+
code: error.code,
|
|
148
|
+
exitCode: error.exitCode
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
title: error.code,
|
|
153
|
+
description: error.message,
|
|
154
|
+
suggestions: [],
|
|
155
|
+
code: error.code,
|
|
156
|
+
exitCode: error.exitCode
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (error instanceof Error) {
|
|
160
|
+
return {
|
|
161
|
+
title: "Unexpected Error",
|
|
162
|
+
description: error.message,
|
|
163
|
+
suggestions: ["This may be a bug. Please report it at https://github.com/mcpspec/mcpspec/issues"],
|
|
164
|
+
code: "UNKNOWN_ERROR",
|
|
165
|
+
exitCode: 2
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
title: "Unknown Error",
|
|
170
|
+
description: String(error),
|
|
171
|
+
suggestions: [],
|
|
172
|
+
code: "UNKNOWN_ERROR",
|
|
173
|
+
exitCode: 2
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function interpolate(template, context) {
|
|
177
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
178
|
+
const value = context[key];
|
|
179
|
+
if (value === void 0) return `{{${key}}}`;
|
|
180
|
+
return String(value);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/utils/yaml-loader.ts
|
|
185
|
+
import yaml from "js-yaml";
|
|
186
|
+
var YAML_LIMITS = {
|
|
187
|
+
maxFileSize: 1024 * 1024,
|
|
188
|
+
// 1MB
|
|
189
|
+
maxNestingDepth: 10,
|
|
190
|
+
maxTests: 1e3
|
|
191
|
+
};
|
|
192
|
+
function loadYamlSafely(content) {
|
|
193
|
+
if (content.length > YAML_LIMITS.maxFileSize) {
|
|
194
|
+
throw new MCPSpecError("YAML_TOO_LARGE", `YAML content exceeds maximum size of ${YAML_LIMITS.maxFileSize} bytes`, {
|
|
195
|
+
maxSize: YAML_LIMITS.maxFileSize,
|
|
196
|
+
actualSize: content.length
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = yaml.load(content, {
|
|
202
|
+
schema: yaml.FAILSAFE_SCHEMA
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
206
|
+
throw new MCPSpecError("YAML_PARSE_ERROR", `Failed to parse YAML: ${message}`, {
|
|
207
|
+
parseError: message
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
validateNestingDepth(parsed, 0);
|
|
211
|
+
return parsed;
|
|
212
|
+
}
|
|
213
|
+
function validateNestingDepth(value, depth) {
|
|
214
|
+
if (depth > YAML_LIMITS.maxNestingDepth) {
|
|
215
|
+
throw new MCPSpecError("YAML_TOO_DEEP", `YAML nesting exceeds maximum depth of ${YAML_LIMITS.maxNestingDepth}`, {
|
|
216
|
+
maxDepth: YAML_LIMITS.maxNestingDepth
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(value)) {
|
|
220
|
+
for (const item of value) {
|
|
221
|
+
validateNestingDepth(item, depth + 1);
|
|
222
|
+
}
|
|
223
|
+
} else if (value !== null && typeof value === "object") {
|
|
224
|
+
for (const val of Object.values(value)) {
|
|
225
|
+
validateNestingDepth(val, depth + 1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/utils/secret-masker.ts
|
|
231
|
+
var SecretMasker = class _SecretMasker {
|
|
232
|
+
secrets = /* @__PURE__ */ new Set();
|
|
233
|
+
static REDACTED = "***REDACTED***";
|
|
234
|
+
static SECRET_PATTERNS = [
|
|
235
|
+
/api[_-]?key/i,
|
|
236
|
+
/password/i,
|
|
237
|
+
/secret/i,
|
|
238
|
+
/token/i,
|
|
239
|
+
/credential/i,
|
|
240
|
+
/auth/i,
|
|
241
|
+
/private[_-]?key/i
|
|
242
|
+
];
|
|
243
|
+
static MIN_SECRET_LENGTH = 4;
|
|
244
|
+
register(secret) {
|
|
245
|
+
if (secret.length >= _SecretMasker.MIN_SECRET_LENGTH) {
|
|
246
|
+
this.secrets.add(secret);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
registerFromEnv(env) {
|
|
250
|
+
for (const [key, value] of Object.entries(env)) {
|
|
251
|
+
if (_SecretMasker.SECRET_PATTERNS.some((p) => p.test(key)) && value.length >= _SecretMasker.MIN_SECRET_LENGTH) {
|
|
252
|
+
this.secrets.add(value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
mask(text) {
|
|
257
|
+
let masked = text;
|
|
258
|
+
for (const secret of this.secrets) {
|
|
259
|
+
masked = masked.replaceAll(secret, _SecretMasker.REDACTED);
|
|
260
|
+
}
|
|
261
|
+
return masked;
|
|
262
|
+
}
|
|
263
|
+
clear() {
|
|
264
|
+
this.secrets.clear();
|
|
265
|
+
}
|
|
266
|
+
get size() {
|
|
267
|
+
return this.secrets.size;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// src/utils/variable-resolver.ts
|
|
272
|
+
var VARIABLE_PATTERN = /\{\{(\w+(?:\.\w+)*)\}\}/g;
|
|
273
|
+
function resolveVariables(template, variables) {
|
|
274
|
+
return template.replace(VARIABLE_PATTERN, (match, path) => {
|
|
275
|
+
const value = getNestedValue(variables, path);
|
|
276
|
+
if (value === void 0) {
|
|
277
|
+
return match;
|
|
278
|
+
}
|
|
279
|
+
return String(value);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function resolveObjectVariables(obj, variables) {
|
|
283
|
+
if (typeof obj === "string") {
|
|
284
|
+
return resolveVariables(obj, variables);
|
|
285
|
+
}
|
|
286
|
+
if (Array.isArray(obj)) {
|
|
287
|
+
return obj.map((item) => resolveObjectVariables(item, variables));
|
|
288
|
+
}
|
|
289
|
+
if (obj !== null && typeof obj === "object") {
|
|
290
|
+
const result = {};
|
|
291
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
292
|
+
result[key] = resolveObjectVariables(value, variables);
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
return obj;
|
|
297
|
+
}
|
|
298
|
+
function getNestedValue(obj, path) {
|
|
299
|
+
const parts = path.split(".");
|
|
300
|
+
let current = obj;
|
|
301
|
+
for (const part of parts) {
|
|
302
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
303
|
+
return void 0;
|
|
304
|
+
}
|
|
305
|
+
current = current[part];
|
|
306
|
+
}
|
|
307
|
+
return current;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/utils/jsonpath.ts
|
|
311
|
+
function queryJsonPath(data, path) {
|
|
312
|
+
if (!path.startsWith("$")) {
|
|
313
|
+
throw new Error(`Invalid JSONPath: must start with $. Got: ${path}`);
|
|
314
|
+
}
|
|
315
|
+
const remaining = path.slice(1);
|
|
316
|
+
if (remaining === "" || remaining === ".") {
|
|
317
|
+
return data;
|
|
318
|
+
}
|
|
319
|
+
const normalizedPath = remaining.startsWith(".") ? remaining.slice(1) : remaining;
|
|
320
|
+
const segments = parseSegments(normalizedPath);
|
|
321
|
+
let current = data;
|
|
322
|
+
for (const segment of segments) {
|
|
323
|
+
if (current === null || current === void 0) {
|
|
324
|
+
return void 0;
|
|
325
|
+
}
|
|
326
|
+
if (segment.type === "property") {
|
|
327
|
+
if (typeof current !== "object") {
|
|
328
|
+
return void 0;
|
|
329
|
+
}
|
|
330
|
+
current = current[segment.key];
|
|
331
|
+
} else if (segment.type === "index") {
|
|
332
|
+
if (!Array.isArray(current)) {
|
|
333
|
+
return void 0;
|
|
334
|
+
}
|
|
335
|
+
current = current[segment.index];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return current;
|
|
339
|
+
}
|
|
340
|
+
function parseSegments(path) {
|
|
341
|
+
const segments = [];
|
|
342
|
+
let i = 0;
|
|
343
|
+
while (i < path.length) {
|
|
344
|
+
if (path[i] === "[") {
|
|
345
|
+
const end = path.indexOf("]", i);
|
|
346
|
+
if (end === -1) {
|
|
347
|
+
throw new Error(`Invalid JSONPath: unclosed bracket at position ${i}`);
|
|
348
|
+
}
|
|
349
|
+
const indexStr = path.slice(i + 1, end);
|
|
350
|
+
const index = parseInt(indexStr, 10);
|
|
351
|
+
if (isNaN(index)) {
|
|
352
|
+
throw new Error(`Invalid JSONPath: non-numeric array index "${indexStr}"`);
|
|
353
|
+
}
|
|
354
|
+
segments.push({ type: "index", index });
|
|
355
|
+
i = end + 1;
|
|
356
|
+
if (i < path.length && path[i] === ".") {
|
|
357
|
+
i++;
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
let end = i;
|
|
361
|
+
while (end < path.length && path[end] !== "." && path[end] !== "[") {
|
|
362
|
+
end++;
|
|
363
|
+
}
|
|
364
|
+
const key = path.slice(i, end);
|
|
365
|
+
if (key.length > 0) {
|
|
366
|
+
segments.push({ type: "property", key });
|
|
367
|
+
}
|
|
368
|
+
i = end;
|
|
369
|
+
if (i < path.length && path[i] === ".") {
|
|
370
|
+
i++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return segments;
|
|
375
|
+
}
|
|
376
|
+
function jsonPathExists(data, path) {
|
|
377
|
+
return queryJsonPath(data, path) !== void 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/utils/platform.ts
|
|
381
|
+
import { platform, arch, release, tmpdir, homedir } from "os";
|
|
382
|
+
import { join } from "path";
|
|
383
|
+
function getPlatformInfo() {
|
|
384
|
+
const os = platform();
|
|
385
|
+
return {
|
|
386
|
+
os,
|
|
387
|
+
arch: arch(),
|
|
388
|
+
release: release(),
|
|
389
|
+
nodeVersion: process.version,
|
|
390
|
+
isWindows: os === "win32",
|
|
391
|
+
isMacOS: os === "darwin",
|
|
392
|
+
isLinux: os === "linux",
|
|
393
|
+
tmpDir: tmpdir(),
|
|
394
|
+
homeDir: homedir(),
|
|
395
|
+
dataDir: getDataDir(os)
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function getDataDir(os) {
|
|
399
|
+
if (os === "win32") {
|
|
400
|
+
return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), "mcpspec");
|
|
401
|
+
}
|
|
402
|
+
if (os === "darwin") {
|
|
403
|
+
return join(homedir(), "Library", "Application Support", "mcpspec");
|
|
404
|
+
}
|
|
405
|
+
return join(process.env["XDG_DATA_HOME"] ?? join(homedir(), ".local", "share"), "mcpspec");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/process/process-manager.ts
|
|
409
|
+
import { execaCommand } from "execa";
|
|
410
|
+
import { randomUUID } from "crypto";
|
|
411
|
+
|
|
412
|
+
// src/process/process-registry.ts
|
|
413
|
+
var ProcessRegistry = class {
|
|
414
|
+
processes = /* @__PURE__ */ new Map();
|
|
415
|
+
register(process2) {
|
|
416
|
+
this.processes.set(process2.id, process2);
|
|
417
|
+
}
|
|
418
|
+
unregister(id) {
|
|
419
|
+
this.processes.delete(id);
|
|
420
|
+
}
|
|
421
|
+
get(id) {
|
|
422
|
+
return this.processes.get(id);
|
|
423
|
+
}
|
|
424
|
+
getAll() {
|
|
425
|
+
return Array.from(this.processes.values());
|
|
426
|
+
}
|
|
427
|
+
has(id) {
|
|
428
|
+
return this.processes.has(id);
|
|
429
|
+
}
|
|
430
|
+
get size() {
|
|
431
|
+
return this.processes.size;
|
|
432
|
+
}
|
|
433
|
+
clear() {
|
|
434
|
+
this.processes.clear();
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// src/process/process-manager.ts
|
|
439
|
+
var ProcessManagerImpl = class {
|
|
440
|
+
registry;
|
|
441
|
+
defaultGracePeriod = 5e3;
|
|
442
|
+
constructor(registry) {
|
|
443
|
+
this.registry = registry ?? new ProcessRegistry();
|
|
444
|
+
}
|
|
445
|
+
async spawn(config) {
|
|
446
|
+
const id = randomUUID();
|
|
447
|
+
const fullCommand = [config.command, ...config.args].join(" ");
|
|
448
|
+
try {
|
|
449
|
+
const child = execaCommand(fullCommand, {
|
|
450
|
+
cwd: config.cwd,
|
|
451
|
+
env: { ...process.env, ...config.env },
|
|
452
|
+
stdin: "pipe",
|
|
453
|
+
stdout: "pipe",
|
|
454
|
+
stderr: "pipe",
|
|
455
|
+
buffer: false,
|
|
456
|
+
timeout: config.timeout
|
|
457
|
+
});
|
|
458
|
+
if (!child.pid || !child.stdin || !child.stdout || !child.stderr) {
|
|
459
|
+
throw new MCPSpecError("PROCESS_SPAWN_FAILED", `Failed to spawn process: ${fullCommand}`, {
|
|
460
|
+
command: fullCommand
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const managed = {
|
|
464
|
+
id,
|
|
465
|
+
pid: child.pid,
|
|
466
|
+
command: config.command,
|
|
467
|
+
args: config.args,
|
|
468
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
469
|
+
stdin: child.stdin,
|
|
470
|
+
stdout: child.stdout,
|
|
471
|
+
stderr: child.stderr,
|
|
472
|
+
childProcess: child
|
|
473
|
+
};
|
|
474
|
+
this.registry.register(managed);
|
|
475
|
+
child.then(() => {
|
|
476
|
+
this.registry.unregister(id);
|
|
477
|
+
}).catch(() => {
|
|
478
|
+
this.registry.unregister(id);
|
|
479
|
+
});
|
|
480
|
+
return managed;
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (err instanceof MCPSpecError) throw err;
|
|
483
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
484
|
+
throw new MCPSpecError("PROCESS_SPAWN_FAILED", `Failed to spawn process: ${message}`, {
|
|
485
|
+
command: fullCommand,
|
|
486
|
+
error: message
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async shutdown(processId, gracePeriodMs) {
|
|
491
|
+
const managed = this.registry.get(processId);
|
|
492
|
+
if (!managed) return;
|
|
493
|
+
const grace = gracePeriodMs ?? this.defaultGracePeriod;
|
|
494
|
+
try {
|
|
495
|
+
managed.childProcess.kill("SIGTERM");
|
|
496
|
+
await Promise.race([
|
|
497
|
+
new Promise((resolve) => {
|
|
498
|
+
managed.childProcess.on("exit", () => resolve());
|
|
499
|
+
}),
|
|
500
|
+
new Promise(
|
|
501
|
+
(_, reject) => setTimeout(() => reject(new Error("Grace period exceeded")), grace)
|
|
502
|
+
)
|
|
503
|
+
]);
|
|
504
|
+
} catch {
|
|
505
|
+
try {
|
|
506
|
+
managed.childProcess.kill("SIGKILL");
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
509
|
+
} finally {
|
|
510
|
+
this.registry.unregister(processId);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async shutdownAll() {
|
|
514
|
+
const processes = this.registry.getAll();
|
|
515
|
+
await Promise.allSettled(processes.map((p) => this.shutdown(p.id)));
|
|
516
|
+
}
|
|
517
|
+
isAlive(processId) {
|
|
518
|
+
const managed = this.registry.get(processId);
|
|
519
|
+
if (!managed) return false;
|
|
520
|
+
try {
|
|
521
|
+
process.kill(managed.pid, 0);
|
|
522
|
+
return true;
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
getRegistry() {
|
|
528
|
+
return this.registry;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// src/process/cleanup-handler.ts
|
|
533
|
+
var registered = false;
|
|
534
|
+
function registerCleanupHandlers(manager) {
|
|
535
|
+
if (registered) return;
|
|
536
|
+
registered = true;
|
|
537
|
+
const cleanup = async (signal) => {
|
|
538
|
+
process.stderr.write(`
|
|
539
|
+
Received ${signal}, cleaning up processes...
|
|
540
|
+
`);
|
|
541
|
+
await manager.shutdownAll();
|
|
542
|
+
process.exit(signal === "SIGINT" ? 130 : 0);
|
|
543
|
+
};
|
|
544
|
+
process.on("SIGINT", () => {
|
|
545
|
+
void cleanup("SIGINT");
|
|
546
|
+
});
|
|
547
|
+
process.on("SIGTERM", () => {
|
|
548
|
+
void cleanup("SIGTERM");
|
|
549
|
+
});
|
|
550
|
+
process.on("uncaughtException", (err) => {
|
|
551
|
+
process.stderr.write(`Uncaught exception: ${err.message}
|
|
552
|
+
`);
|
|
553
|
+
void manager.shutdownAll().finally(() => process.exit(1));
|
|
554
|
+
});
|
|
555
|
+
process.on("unhandledRejection", (reason) => {
|
|
556
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
557
|
+
process.stderr.write(`Unhandled rejection: ${message}
|
|
558
|
+
`);
|
|
559
|
+
});
|
|
560
|
+
process.on("exit", () => {
|
|
561
|
+
void manager.shutdownAll();
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/client/mcp-client.ts
|
|
566
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
567
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
568
|
+
|
|
569
|
+
// src/client/connection-manager.ts
|
|
570
|
+
var VALID_TRANSITIONS = {
|
|
571
|
+
disconnected: ["connecting"],
|
|
572
|
+
connecting: ["connected", "error", "disconnected"],
|
|
573
|
+
connected: ["disconnecting", "reconnecting", "error"],
|
|
574
|
+
reconnecting: ["connected", "error", "disconnected"],
|
|
575
|
+
disconnecting: ["disconnected"],
|
|
576
|
+
error: ["connecting", "disconnected"]
|
|
577
|
+
};
|
|
578
|
+
var DEFAULT_CONNECTION_CONFIG = {
|
|
579
|
+
maxReconnectAttempts: 3,
|
|
580
|
+
reconnectBackoff: "exponential",
|
|
581
|
+
initialReconnectDelay: 1e3,
|
|
582
|
+
maxReconnectDelay: 3e4
|
|
583
|
+
};
|
|
584
|
+
var ConnectionManager = class {
|
|
585
|
+
state = "disconnected";
|
|
586
|
+
reconnectAttempts = 0;
|
|
587
|
+
config;
|
|
588
|
+
listeners = [];
|
|
589
|
+
constructor(config) {
|
|
590
|
+
this.config = { ...DEFAULT_CONNECTION_CONFIG, ...config };
|
|
591
|
+
}
|
|
592
|
+
getState() {
|
|
593
|
+
return this.state;
|
|
594
|
+
}
|
|
595
|
+
canTransition(to) {
|
|
596
|
+
const allowed = VALID_TRANSITIONS[this.state];
|
|
597
|
+
return allowed !== void 0 && allowed.includes(to);
|
|
598
|
+
}
|
|
599
|
+
transition(to) {
|
|
600
|
+
if (!this.canTransition(to)) {
|
|
601
|
+
throw new MCPSpecError(
|
|
602
|
+
"CONNECTION_LOST",
|
|
603
|
+
`Invalid state transition: ${this.state} -> ${to}`,
|
|
604
|
+
{ from: this.state, to }
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const from = this.state;
|
|
608
|
+
this.state = to;
|
|
609
|
+
if (to === "connected") {
|
|
610
|
+
this.reconnectAttempts = 0;
|
|
611
|
+
}
|
|
612
|
+
for (const listener of this.listeners) {
|
|
613
|
+
listener(from, to);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
onTransition(listener) {
|
|
617
|
+
this.listeners.push(listener);
|
|
618
|
+
return () => {
|
|
619
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
shouldReconnect() {
|
|
623
|
+
return this.reconnectAttempts < this.config.maxReconnectAttempts;
|
|
624
|
+
}
|
|
625
|
+
getReconnectDelay() {
|
|
626
|
+
const delay = this.config.initialReconnectDelay * Math.pow(2, this.reconnectAttempts);
|
|
627
|
+
this.reconnectAttempts++;
|
|
628
|
+
return Math.min(delay, this.config.maxReconnectDelay);
|
|
629
|
+
}
|
|
630
|
+
resetReconnectAttempts() {
|
|
631
|
+
this.reconnectAttempts = 0;
|
|
632
|
+
}
|
|
633
|
+
getConfig() {
|
|
634
|
+
return { ...this.config };
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// src/rate-limiting/backoff.ts
|
|
639
|
+
var DEFAULT_BACKOFF = {
|
|
640
|
+
initial: 1e3,
|
|
641
|
+
multiplier: 2,
|
|
642
|
+
max: 3e4
|
|
643
|
+
};
|
|
644
|
+
function calculateBackoff(attempt, config = DEFAULT_BACKOFF) {
|
|
645
|
+
const delay = config.initial * Math.pow(config.multiplier, attempt);
|
|
646
|
+
return Math.min(delay, config.max);
|
|
647
|
+
}
|
|
648
|
+
function sleep(ms) {
|
|
649
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/client/logging-transport.ts
|
|
653
|
+
var LoggingTransport = class {
|
|
654
|
+
inner;
|
|
655
|
+
callback;
|
|
656
|
+
constructor(inner, callback) {
|
|
657
|
+
this.inner = inner;
|
|
658
|
+
this.callback = callback;
|
|
659
|
+
}
|
|
660
|
+
async start() {
|
|
661
|
+
return this.inner.start();
|
|
662
|
+
}
|
|
663
|
+
async send(message, options) {
|
|
664
|
+
this.callback("outgoing", message);
|
|
665
|
+
return this.inner.send(message, options);
|
|
666
|
+
}
|
|
667
|
+
async close() {
|
|
668
|
+
return this.inner.close();
|
|
669
|
+
}
|
|
670
|
+
get onclose() {
|
|
671
|
+
return this.inner.onclose;
|
|
672
|
+
}
|
|
673
|
+
set onclose(handler) {
|
|
674
|
+
this.inner.onclose = handler;
|
|
675
|
+
}
|
|
676
|
+
get onerror() {
|
|
677
|
+
return this.inner.onerror;
|
|
678
|
+
}
|
|
679
|
+
set onerror(handler) {
|
|
680
|
+
this.inner.onerror = handler;
|
|
681
|
+
}
|
|
682
|
+
get onmessage() {
|
|
683
|
+
return this.inner.onmessage;
|
|
684
|
+
}
|
|
685
|
+
set onmessage(handler) {
|
|
686
|
+
if (!handler) {
|
|
687
|
+
this.inner.onmessage = void 0;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const cb = this.callback;
|
|
691
|
+
this.inner.onmessage = (message, extra) => {
|
|
692
|
+
cb("incoming", message);
|
|
693
|
+
handler(message, extra);
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
get sessionId() {
|
|
697
|
+
return this.inner.sessionId;
|
|
698
|
+
}
|
|
699
|
+
set sessionId(value) {
|
|
700
|
+
this.inner.sessionId = value;
|
|
701
|
+
}
|
|
702
|
+
get setProtocolVersion() {
|
|
703
|
+
return this.inner.setProtocolVersion;
|
|
704
|
+
}
|
|
705
|
+
set setProtocolVersion(handler) {
|
|
706
|
+
this.inner.setProtocolVersion = handler;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/client/mcp-client.ts
|
|
711
|
+
var MCPClient = class {
|
|
712
|
+
client = null;
|
|
713
|
+
transport = null;
|
|
714
|
+
connectionManager;
|
|
715
|
+
processManager;
|
|
716
|
+
serverConfig;
|
|
717
|
+
serverInfo;
|
|
718
|
+
onProtocolMessage;
|
|
719
|
+
constructor(options) {
|
|
720
|
+
this.connectionManager = new ConnectionManager();
|
|
721
|
+
this.processManager = options.processManager ?? new ProcessManagerImpl();
|
|
722
|
+
this.serverConfig = this.normalizeConfig(options.serverConfig);
|
|
723
|
+
this.onProtocolMessage = options.onProtocolMessage;
|
|
724
|
+
}
|
|
725
|
+
normalizeConfig(config) {
|
|
726
|
+
if (typeof config === "string") {
|
|
727
|
+
const parts = config.split(/\s+/);
|
|
728
|
+
const command = parts[0];
|
|
729
|
+
const args = parts.slice(1);
|
|
730
|
+
if (!command) {
|
|
731
|
+
throw new MCPSpecError("CONFIG_ERROR", "Empty server command", { config });
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
transport: "stdio",
|
|
735
|
+
command,
|
|
736
|
+
args
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
return config;
|
|
740
|
+
}
|
|
741
|
+
async connect() {
|
|
742
|
+
if (this.connectionManager.getState() === "connected") return;
|
|
743
|
+
this.connectionManager.transition("connecting");
|
|
744
|
+
try {
|
|
745
|
+
let transport = await this.createTransport();
|
|
746
|
+
if (this.onProtocolMessage) {
|
|
747
|
+
transport = new LoggingTransport(transport, this.onProtocolMessage);
|
|
748
|
+
}
|
|
749
|
+
this.transport = transport;
|
|
750
|
+
this.client = new Client(
|
|
751
|
+
{ name: "mcpspec", version: "0.2.0" },
|
|
752
|
+
{ capabilities: {} }
|
|
753
|
+
);
|
|
754
|
+
await this.client.connect(this.transport);
|
|
755
|
+
this.serverInfo = this.client.getServerVersion();
|
|
756
|
+
this.connectionManager.transition("connected");
|
|
757
|
+
} catch (err) {
|
|
758
|
+
this.connectionManager.transition("error");
|
|
759
|
+
if (this.connectionManager.shouldReconnect()) {
|
|
760
|
+
return this.reconnect();
|
|
761
|
+
}
|
|
762
|
+
if (err instanceof MCPSpecError) throw err;
|
|
763
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
764
|
+
throw new MCPSpecError("CONNECTION_TIMEOUT", `Failed to connect to MCP server: ${message}`, {
|
|
765
|
+
command: this.serverConfig.command,
|
|
766
|
+
url: this.serverConfig.url,
|
|
767
|
+
error: message
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
async reconnect() {
|
|
772
|
+
this.connectionManager.transition("connecting");
|
|
773
|
+
const delay = this.connectionManager.getReconnectDelay();
|
|
774
|
+
await sleep(delay);
|
|
775
|
+
return this.connect();
|
|
776
|
+
}
|
|
777
|
+
async createTransport() {
|
|
778
|
+
const transport = this.serverConfig.transport ?? "stdio";
|
|
779
|
+
switch (transport) {
|
|
780
|
+
case "stdio": {
|
|
781
|
+
const { command, args } = this.serverConfig;
|
|
782
|
+
if (!command) {
|
|
783
|
+
throw new MCPSpecError("CONFIG_ERROR", "Server command is required for stdio transport", {
|
|
784
|
+
config: this.serverConfig
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
return new StdioClientTransport({
|
|
788
|
+
command,
|
|
789
|
+
args: args ?? [],
|
|
790
|
+
env: this.serverConfig.env
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
case "sse": {
|
|
794
|
+
const { url } = this.serverConfig;
|
|
795
|
+
if (!url) {
|
|
796
|
+
throw new MCPSpecError("CONFIG_ERROR", "Server URL is required for SSE transport", {
|
|
797
|
+
config: this.serverConfig
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const { createSSETransport } = await import("./sse-NXEF5JDZ.js");
|
|
801
|
+
return createSSETransport(url);
|
|
802
|
+
}
|
|
803
|
+
case "streamable-http": {
|
|
804
|
+
const { url } = this.serverConfig;
|
|
805
|
+
if (!url) {
|
|
806
|
+
throw new MCPSpecError("CONFIG_ERROR", "Server URL is required for streamable-http transport", {
|
|
807
|
+
config: this.serverConfig
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
const { createStreamableHTTPTransport } = await import("./http-XUWKDMSR.js");
|
|
811
|
+
return createStreamableHTTPTransport(url);
|
|
812
|
+
}
|
|
813
|
+
default:
|
|
814
|
+
throw new MCPSpecError("CONFIG_ERROR", `Unknown transport type: ${transport}`, {
|
|
815
|
+
transport
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async disconnect() {
|
|
820
|
+
if (this.connectionManager.getState() === "disconnected") return;
|
|
821
|
+
if (this.connectionManager.canTransition("disconnecting")) {
|
|
822
|
+
this.connectionManager.transition("disconnecting");
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
if (this.transport) {
|
|
826
|
+
await this.transport.close();
|
|
827
|
+
}
|
|
828
|
+
} catch {
|
|
829
|
+
} finally {
|
|
830
|
+
this.client = null;
|
|
831
|
+
this.transport = null;
|
|
832
|
+
if (this.connectionManager.canTransition("disconnected")) {
|
|
833
|
+
this.connectionManager.transition("disconnected");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
isConnected() {
|
|
838
|
+
return this.connectionManager.getState() === "connected";
|
|
839
|
+
}
|
|
840
|
+
async listTools() {
|
|
841
|
+
this.ensureConnected();
|
|
842
|
+
try {
|
|
843
|
+
const result = await this.client.listTools();
|
|
844
|
+
return (result.tools ?? []).map((t) => ({
|
|
845
|
+
name: t.name,
|
|
846
|
+
description: t.description,
|
|
847
|
+
inputSchema: t.inputSchema
|
|
848
|
+
}));
|
|
849
|
+
} catch (err) {
|
|
850
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
851
|
+
throw new MCPSpecError("TOOL_CALL_FAILED", `Failed to list tools: ${message}`, {
|
|
852
|
+
error: message
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
async listResources() {
|
|
857
|
+
this.ensureConnected();
|
|
858
|
+
try {
|
|
859
|
+
const result = await this.client.listResources();
|
|
860
|
+
return (result.resources ?? []).map((r) => ({
|
|
861
|
+
uri: r.uri,
|
|
862
|
+
name: r.name,
|
|
863
|
+
description: r.description,
|
|
864
|
+
mimeType: r.mimeType
|
|
865
|
+
}));
|
|
866
|
+
} catch (err) {
|
|
867
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
868
|
+
throw new MCPSpecError("TOOL_CALL_FAILED", `Failed to list resources: ${message}`, {
|
|
869
|
+
error: message
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async callTool(name, args) {
|
|
874
|
+
this.ensureConnected();
|
|
875
|
+
try {
|
|
876
|
+
const result = await this.client.callTool({ name, arguments: args });
|
|
877
|
+
return {
|
|
878
|
+
content: result.content,
|
|
879
|
+
isError: result.isError === true ? true : void 0
|
|
880
|
+
};
|
|
881
|
+
} catch (err) {
|
|
882
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
883
|
+
throw new MCPSpecError("TOOL_CALL_FAILED", `Tool call "${name}" failed: ${message}`, {
|
|
884
|
+
toolName: name,
|
|
885
|
+
error: message
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
async readResource(uri) {
|
|
890
|
+
this.ensureConnected();
|
|
891
|
+
try {
|
|
892
|
+
const result = await this.client.readResource({ uri });
|
|
893
|
+
return { contents: result.contents };
|
|
894
|
+
} catch (err) {
|
|
895
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
896
|
+
throw new MCPSpecError("TOOL_CALL_FAILED", `Failed to read resource "${uri}": ${message}`, {
|
|
897
|
+
uri,
|
|
898
|
+
error: message
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
getServerInfo() {
|
|
903
|
+
return this.serverInfo;
|
|
904
|
+
}
|
|
905
|
+
getConnectionState() {
|
|
906
|
+
return this.connectionManager.getState();
|
|
907
|
+
}
|
|
908
|
+
ensureConnected() {
|
|
909
|
+
if (!this.isConnected() || !this.client) {
|
|
910
|
+
throw new MCPSpecError("CONNECTION_LOST", "Not connected to MCP server. Call connect() first.", {});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// src/testing/test-executor.ts
|
|
916
|
+
import { DEFAULT_TIMEOUTS } from "@mcpspec/shared";
|
|
917
|
+
|
|
918
|
+
// src/testing/assertions/schema-assertion.ts
|
|
919
|
+
function assertSchema(response, inputSchema) {
|
|
920
|
+
if (response === void 0 || response === null) {
|
|
921
|
+
return {
|
|
922
|
+
type: "schema",
|
|
923
|
+
passed: false,
|
|
924
|
+
message: "Response is null or undefined",
|
|
925
|
+
actual: typeof response
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (typeof response !== "object") {
|
|
929
|
+
return {
|
|
930
|
+
type: "schema",
|
|
931
|
+
passed: false,
|
|
932
|
+
message: `Response is not an object or array, got ${typeof response}`,
|
|
933
|
+
actual: typeof response
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
if (inputSchema && typeof inputSchema["properties"] === "object" && inputSchema["properties"] !== null) {
|
|
937
|
+
const properties = inputSchema["properties"];
|
|
938
|
+
const required = Array.isArray(inputSchema["required"]) ? inputSchema["required"] : [];
|
|
939
|
+
const missingFields = [];
|
|
940
|
+
for (const field of required) {
|
|
941
|
+
if (!jsonPathExists(response, `$.${field}`)) {
|
|
942
|
+
missingFields.push(field);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (missingFields.length > 0) {
|
|
946
|
+
return {
|
|
947
|
+
type: "schema",
|
|
948
|
+
passed: false,
|
|
949
|
+
message: `Missing required fields: ${missingFields.join(", ")}`,
|
|
950
|
+
expected: Object.keys(properties),
|
|
951
|
+
actual: Object.keys(response)
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
type: "schema",
|
|
957
|
+
passed: true,
|
|
958
|
+
message: "Response has valid structure",
|
|
959
|
+
actual: typeof response
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/testing/assertions/equals-assertion.ts
|
|
964
|
+
function assertEqual(response, path, expected) {
|
|
965
|
+
const actual = queryJsonPath(response, path);
|
|
966
|
+
const passed = JSON.stringify(actual) === JSON.stringify(expected);
|
|
967
|
+
return {
|
|
968
|
+
type: "equals",
|
|
969
|
+
passed,
|
|
970
|
+
message: passed ? `${path} equals expected value` : `${path} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
|
971
|
+
expected,
|
|
972
|
+
actual
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/testing/assertions/contains-assertion.ts
|
|
977
|
+
function assertContains(response, path, value) {
|
|
978
|
+
const actual = queryJsonPath(response, path);
|
|
979
|
+
let passed = false;
|
|
980
|
+
if (Array.isArray(actual)) {
|
|
981
|
+
passed = actual.some((item) => JSON.stringify(item) === JSON.stringify(value));
|
|
982
|
+
} else if (typeof actual === "string" && typeof value === "string") {
|
|
983
|
+
passed = actual.includes(value);
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
type: "contains",
|
|
987
|
+
passed,
|
|
988
|
+
message: passed ? `${path} contains ${JSON.stringify(value)}` : `${path} does not contain ${JSON.stringify(value)}`,
|
|
989
|
+
expected: value,
|
|
990
|
+
actual
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// src/testing/assertions/exists-assertion.ts
|
|
995
|
+
function assertExists(response, path) {
|
|
996
|
+
const passed = jsonPathExists(response, path);
|
|
997
|
+
return {
|
|
998
|
+
type: "exists",
|
|
999
|
+
passed,
|
|
1000
|
+
message: passed ? `${path} exists` : `${path} does not exist`
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/testing/assertions/regex-assertion.ts
|
|
1005
|
+
function assertMatches(response, path, pattern) {
|
|
1006
|
+
const actual = queryJsonPath(response, path);
|
|
1007
|
+
const actualStr = typeof actual === "string" ? actual : JSON.stringify(actual);
|
|
1008
|
+
let passed = false;
|
|
1009
|
+
try {
|
|
1010
|
+
const regex = new RegExp(pattern);
|
|
1011
|
+
passed = regex.test(actualStr ?? "");
|
|
1012
|
+
} catch {
|
|
1013
|
+
return {
|
|
1014
|
+
type: "matches",
|
|
1015
|
+
passed: false,
|
|
1016
|
+
message: `Invalid regex pattern: ${pattern}`,
|
|
1017
|
+
expected: pattern,
|
|
1018
|
+
actual: actualStr
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
type: "matches",
|
|
1023
|
+
passed,
|
|
1024
|
+
message: passed ? `${path} matches pattern /${pattern}/` : `${path} does not match pattern /${pattern}/`,
|
|
1025
|
+
expected: pattern,
|
|
1026
|
+
actual: actualStr
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/testing/assertions/type-assertion.ts
|
|
1031
|
+
function assertType(response, path, expected) {
|
|
1032
|
+
const value = queryJsonPath(response, path);
|
|
1033
|
+
let actualType;
|
|
1034
|
+
if (value === null) {
|
|
1035
|
+
actualType = "null";
|
|
1036
|
+
} else if (Array.isArray(value)) {
|
|
1037
|
+
actualType = "array";
|
|
1038
|
+
} else {
|
|
1039
|
+
actualType = typeof value;
|
|
1040
|
+
}
|
|
1041
|
+
if (value === void 0) {
|
|
1042
|
+
return {
|
|
1043
|
+
type: "type",
|
|
1044
|
+
passed: false,
|
|
1045
|
+
message: `Path "${path}" does not exist`,
|
|
1046
|
+
expected,
|
|
1047
|
+
actual: "undefined"
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const passed = actualType === expected;
|
|
1051
|
+
return {
|
|
1052
|
+
type: "type",
|
|
1053
|
+
passed,
|
|
1054
|
+
message: passed ? `Value at "${path}" is type "${expected}"` : `Expected type "${expected}" at "${path}", got "${actualType}"`,
|
|
1055
|
+
expected,
|
|
1056
|
+
actual: actualType
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/testing/assertions/expression-assertion.ts
|
|
1061
|
+
import { Parser } from "expr-eval";
|
|
1062
|
+
function assertExpression(response, expr) {
|
|
1063
|
+
try {
|
|
1064
|
+
const parser = new Parser();
|
|
1065
|
+
const result = parser.evaluate(expr, { response });
|
|
1066
|
+
const passed = Boolean(result);
|
|
1067
|
+
return {
|
|
1068
|
+
type: "expression",
|
|
1069
|
+
passed,
|
|
1070
|
+
message: passed ? `Expression "${expr}" evaluated to true` : `Expression "${expr}" evaluated to false`,
|
|
1071
|
+
expected: true,
|
|
1072
|
+
actual: result
|
|
1073
|
+
};
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1076
|
+
return {
|
|
1077
|
+
type: "expression",
|
|
1078
|
+
passed: false,
|
|
1079
|
+
message: `Expression evaluation error: ${message}`,
|
|
1080
|
+
expected: true,
|
|
1081
|
+
actual: message
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// src/testing/assertions/binary-assertion.ts
|
|
1087
|
+
function assertBinary(response, expected) {
|
|
1088
|
+
let actual;
|
|
1089
|
+
if (response && typeof response === "object") {
|
|
1090
|
+
const obj = response;
|
|
1091
|
+
if (typeof obj["mimeType"] === "string") {
|
|
1092
|
+
actual = obj["mimeType"];
|
|
1093
|
+
} else if (Array.isArray(obj["content"])) {
|
|
1094
|
+
for (const item of obj["content"]) {
|
|
1095
|
+
if (item && typeof item === "object" && typeof item["mimeType"] === "string") {
|
|
1096
|
+
actual = item["mimeType"];
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (actual === void 0) {
|
|
1103
|
+
return {
|
|
1104
|
+
type: "mimeType",
|
|
1105
|
+
passed: false,
|
|
1106
|
+
message: "No mimeType found in response",
|
|
1107
|
+
expected,
|
|
1108
|
+
actual: "undefined"
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
const passed = actual === expected;
|
|
1112
|
+
return {
|
|
1113
|
+
type: "mimeType",
|
|
1114
|
+
passed,
|
|
1115
|
+
message: passed ? `MIME type matches "${expected}"` : `Expected MIME type "${expected}", got "${actual}"`,
|
|
1116
|
+
expected,
|
|
1117
|
+
actual
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/testing/test-executor.ts
|
|
1122
|
+
var TestExecutor = class {
|
|
1123
|
+
variables = {};
|
|
1124
|
+
rateLimiter;
|
|
1125
|
+
constructor(initialVariables, rateLimiter) {
|
|
1126
|
+
if (initialVariables) {
|
|
1127
|
+
this.variables = { ...initialVariables };
|
|
1128
|
+
}
|
|
1129
|
+
this.rateLimiter = rateLimiter;
|
|
1130
|
+
}
|
|
1131
|
+
async execute(test, client) {
|
|
1132
|
+
const timeout = test.timeout ?? DEFAULT_TIMEOUTS.test;
|
|
1133
|
+
const retries = test.retries ?? 0;
|
|
1134
|
+
let lastError;
|
|
1135
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1136
|
+
if (attempt > 0) {
|
|
1137
|
+
const delay = calculateBackoff(attempt - 1);
|
|
1138
|
+
await sleep(delay);
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
return await this.executeWithTimeout(test, client, timeout);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1144
|
+
if (attempt < retries) {
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
testId: test.id ?? test.name,
|
|
1151
|
+
testName: test.name,
|
|
1152
|
+
status: "error",
|
|
1153
|
+
duration: 0,
|
|
1154
|
+
assertions: [],
|
|
1155
|
+
error: lastError?.message ?? "Unknown error"
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
executeWithTimeout(test, client, timeoutMs) {
|
|
1159
|
+
return Promise.race([
|
|
1160
|
+
this.executeInternal(test, client),
|
|
1161
|
+
new Promise(
|
|
1162
|
+
(_, reject) => setTimeout(() => reject(new MCPSpecError("TIMEOUT", `Test "${test.name}" timed out after ${timeoutMs}ms`, {
|
|
1163
|
+
testName: test.name,
|
|
1164
|
+
timeout: timeoutMs
|
|
1165
|
+
})), timeoutMs)
|
|
1166
|
+
)
|
|
1167
|
+
]);
|
|
1168
|
+
}
|
|
1169
|
+
async executeInternal(test, client) {
|
|
1170
|
+
const startTime = Date.now();
|
|
1171
|
+
const testId = test.id ?? test.name;
|
|
1172
|
+
const assertionResults = [];
|
|
1173
|
+
try {
|
|
1174
|
+
const toolName = test.call ?? test.tool;
|
|
1175
|
+
if (!toolName) {
|
|
1176
|
+
throw new MCPSpecError("CONFIG_ERROR", `Test "${test.name}" has no tool/call defined`, {
|
|
1177
|
+
testName: test.name
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
const input = test.with ?? test.input ?? {};
|
|
1181
|
+
const resolvedInput = resolveObjectVariables(input, this.variables);
|
|
1182
|
+
let result;
|
|
1183
|
+
try {
|
|
1184
|
+
const callFn = () => client.callTool(toolName, resolvedInput);
|
|
1185
|
+
result = this.rateLimiter ? await this.rateLimiter.schedule(callFn) : await callFn();
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
if (test.expectError) {
|
|
1188
|
+
return {
|
|
1189
|
+
testId,
|
|
1190
|
+
testName: test.name,
|
|
1191
|
+
status: "passed",
|
|
1192
|
+
duration: Date.now() - startTime,
|
|
1193
|
+
assertions: [
|
|
1194
|
+
{
|
|
1195
|
+
type: "schema",
|
|
1196
|
+
passed: true,
|
|
1197
|
+
message: "Expected error occurred"
|
|
1198
|
+
}
|
|
1199
|
+
]
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
throw err;
|
|
1203
|
+
}
|
|
1204
|
+
if (test.expectError) {
|
|
1205
|
+
if (result.isError) {
|
|
1206
|
+
return {
|
|
1207
|
+
testId,
|
|
1208
|
+
testName: test.name,
|
|
1209
|
+
status: "passed",
|
|
1210
|
+
duration: Date.now() - startTime,
|
|
1211
|
+
assertions: [
|
|
1212
|
+
{
|
|
1213
|
+
type: "schema",
|
|
1214
|
+
passed: true,
|
|
1215
|
+
message: "Expected error response received"
|
|
1216
|
+
}
|
|
1217
|
+
]
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
assertionResults.push({
|
|
1221
|
+
type: "schema",
|
|
1222
|
+
passed: false,
|
|
1223
|
+
message: "Expected error but got success"
|
|
1224
|
+
});
|
|
1225
|
+
return {
|
|
1226
|
+
testId,
|
|
1227
|
+
testName: test.name,
|
|
1228
|
+
status: "failed",
|
|
1229
|
+
duration: Date.now() - startTime,
|
|
1230
|
+
assertions: assertionResults
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const response = this.buildResponse(result);
|
|
1234
|
+
if (test.assertions) {
|
|
1235
|
+
for (const assertion of test.assertions) {
|
|
1236
|
+
assertionResults.push(this.runAssertion(assertion, response, Date.now() - startTime));
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (test.expect) {
|
|
1240
|
+
for (const expectation of test.expect) {
|
|
1241
|
+
assertionResults.push(this.runSimpleExpectation(expectation, response));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (!test.assertions && !test.expect) {
|
|
1245
|
+
assertionResults.push(assertSchema(response));
|
|
1246
|
+
}
|
|
1247
|
+
const extractedVariables = {};
|
|
1248
|
+
if (test.extract) {
|
|
1249
|
+
for (const extraction of test.extract) {
|
|
1250
|
+
const value = queryJsonPath(response, extraction.path);
|
|
1251
|
+
extractedVariables[extraction.name] = value;
|
|
1252
|
+
this.variables[extraction.name] = value;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
const allPassed = assertionResults.every((r) => r.passed);
|
|
1256
|
+
return {
|
|
1257
|
+
testId,
|
|
1258
|
+
testName: test.name,
|
|
1259
|
+
status: allPassed ? "passed" : "failed",
|
|
1260
|
+
duration: Date.now() - startTime,
|
|
1261
|
+
assertions: assertionResults,
|
|
1262
|
+
extractedVariables: Object.keys(extractedVariables).length > 0 ? extractedVariables : void 0
|
|
1263
|
+
};
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1266
|
+
return {
|
|
1267
|
+
testId,
|
|
1268
|
+
testName: test.name,
|
|
1269
|
+
status: "error",
|
|
1270
|
+
duration: Date.now() - startTime,
|
|
1271
|
+
assertions: assertionResults,
|
|
1272
|
+
error: message
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
buildResponse(result) {
|
|
1277
|
+
const contents = result.content;
|
|
1278
|
+
if (!Array.isArray(contents) || contents.length === 0) {
|
|
1279
|
+
return {};
|
|
1280
|
+
}
|
|
1281
|
+
if (contents.length === 1) {
|
|
1282
|
+
const item = contents[0];
|
|
1283
|
+
if (item["type"] === "text" && typeof item["text"] === "string") {
|
|
1284
|
+
try {
|
|
1285
|
+
return JSON.parse(item["text"]);
|
|
1286
|
+
} catch {
|
|
1287
|
+
return { content: item["text"], text: item["text"] };
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return item;
|
|
1291
|
+
}
|
|
1292
|
+
return { content: contents };
|
|
1293
|
+
}
|
|
1294
|
+
runAssertion(assertion, response, durationMs) {
|
|
1295
|
+
switch (assertion.type) {
|
|
1296
|
+
case "schema":
|
|
1297
|
+
return assertSchema(response);
|
|
1298
|
+
case "equals":
|
|
1299
|
+
return assertEqual(response, assertion.path ?? "$", assertion.value);
|
|
1300
|
+
case "contains":
|
|
1301
|
+
return assertContains(response, assertion.path ?? "$", assertion.value);
|
|
1302
|
+
case "exists":
|
|
1303
|
+
return assertExists(response, assertion.path ?? "$");
|
|
1304
|
+
case "matches":
|
|
1305
|
+
return assertMatches(response, assertion.path ?? "$", assertion.pattern ?? "");
|
|
1306
|
+
case "type":
|
|
1307
|
+
return assertType(response, assertion.path ?? "$", assertion.expected ?? "object");
|
|
1308
|
+
case "expression":
|
|
1309
|
+
return assertExpression(response, assertion.expr ?? "");
|
|
1310
|
+
case "mimeType":
|
|
1311
|
+
return assertBinary(response, assertion.expected ?? "");
|
|
1312
|
+
case "length": {
|
|
1313
|
+
const value = queryJsonPath(response, assertion.path ?? "$");
|
|
1314
|
+
const len = Array.isArray(value) ? value.length : typeof value === "string" ? value.length : -1;
|
|
1315
|
+
if (len === -1) {
|
|
1316
|
+
return {
|
|
1317
|
+
type: "length",
|
|
1318
|
+
passed: false,
|
|
1319
|
+
message: `Value at "${assertion.path}" is not an array or string`,
|
|
1320
|
+
actual: typeof value
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
const op = assertion.operator ?? "eq";
|
|
1324
|
+
const target = typeof assertion.value === "number" ? assertion.value : Number(assertion.value);
|
|
1325
|
+
let passed = false;
|
|
1326
|
+
switch (op) {
|
|
1327
|
+
case "eq":
|
|
1328
|
+
passed = len === target;
|
|
1329
|
+
break;
|
|
1330
|
+
case "gt":
|
|
1331
|
+
passed = len > target;
|
|
1332
|
+
break;
|
|
1333
|
+
case "gte":
|
|
1334
|
+
passed = len >= target;
|
|
1335
|
+
break;
|
|
1336
|
+
case "lt":
|
|
1337
|
+
passed = len < target;
|
|
1338
|
+
break;
|
|
1339
|
+
case "lte":
|
|
1340
|
+
passed = len <= target;
|
|
1341
|
+
break;
|
|
1342
|
+
default:
|
|
1343
|
+
passed = len === target;
|
|
1344
|
+
}
|
|
1345
|
+
return {
|
|
1346
|
+
type: "length",
|
|
1347
|
+
passed,
|
|
1348
|
+
message: passed ? `Length ${len} satisfies ${op} ${target}` : `Length ${len} does not satisfy ${op} ${target}`,
|
|
1349
|
+
expected: target,
|
|
1350
|
+
actual: len
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
case "latency":
|
|
1354
|
+
return {
|
|
1355
|
+
type: "latency",
|
|
1356
|
+
passed: durationMs <= (assertion.maxMs ?? 1e3),
|
|
1357
|
+
message: durationMs <= (assertion.maxMs ?? 1e3) ? `Response time ${durationMs}ms within ${assertion.maxMs ?? 1e3}ms limit` : `Response time ${durationMs}ms exceeds ${assertion.maxMs ?? 1e3}ms limit`,
|
|
1358
|
+
expected: assertion.maxMs,
|
|
1359
|
+
actual: durationMs
|
|
1360
|
+
};
|
|
1361
|
+
default:
|
|
1362
|
+
return {
|
|
1363
|
+
type: assertion.type,
|
|
1364
|
+
passed: false,
|
|
1365
|
+
message: `Assertion type "${assertion.type}" not yet implemented`
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
runSimpleExpectation(expectation, response) {
|
|
1370
|
+
if ("exists" in expectation) {
|
|
1371
|
+
return assertExists(response, expectation.exists);
|
|
1372
|
+
}
|
|
1373
|
+
if ("equals" in expectation) {
|
|
1374
|
+
const [path, value] = expectation.equals;
|
|
1375
|
+
return assertEqual(response, path, value);
|
|
1376
|
+
}
|
|
1377
|
+
if ("contains" in expectation) {
|
|
1378
|
+
const [path, value] = expectation.contains;
|
|
1379
|
+
return assertContains(response, path, value);
|
|
1380
|
+
}
|
|
1381
|
+
if ("matches" in expectation) {
|
|
1382
|
+
const [path, pattern] = expectation.matches;
|
|
1383
|
+
return assertMatches(response, path, pattern);
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
type: "schema",
|
|
1387
|
+
passed: false,
|
|
1388
|
+
message: "Unknown expectation type"
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
getVariables() {
|
|
1392
|
+
return { ...this.variables };
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// src/testing/test-scheduler.ts
|
|
1397
|
+
function normalizeTags(tags) {
|
|
1398
|
+
return tags.map((t) => t.startsWith("@") ? t.slice(1) : t);
|
|
1399
|
+
}
|
|
1400
|
+
function matchesTags(test, filterTags) {
|
|
1401
|
+
if (filterTags.length === 0) return true;
|
|
1402
|
+
if (!test.tags || test.tags.length === 0) return false;
|
|
1403
|
+
const normalized = normalizeTags(test.tags);
|
|
1404
|
+
const normalizedFilter = normalizeTags(filterTags);
|
|
1405
|
+
return normalizedFilter.some((ft) => normalized.includes(ft));
|
|
1406
|
+
}
|
|
1407
|
+
var TestScheduler = class {
|
|
1408
|
+
async schedule(tests, client, options) {
|
|
1409
|
+
const { parallelism, tags, reporter, rateLimiter, initialVariables } = options;
|
|
1410
|
+
const filteredTests = tags && tags.length > 0 ? tests.filter((t) => matchesTags(t, tags)) : tests;
|
|
1411
|
+
const skippedTests = tags && tags.length > 0 ? tests.filter((t) => !matchesTags(t, tags)) : [];
|
|
1412
|
+
const skippedResults = skippedTests.map((t) => ({
|
|
1413
|
+
testId: t.id ?? t.name,
|
|
1414
|
+
testName: t.name,
|
|
1415
|
+
status: "skipped",
|
|
1416
|
+
duration: 0,
|
|
1417
|
+
assertions: []
|
|
1418
|
+
}));
|
|
1419
|
+
if (filteredTests.length === 0) {
|
|
1420
|
+
return skippedResults;
|
|
1421
|
+
}
|
|
1422
|
+
if (parallelism <= 1) {
|
|
1423
|
+
const executor2 = new TestExecutor(initialVariables, rateLimiter);
|
|
1424
|
+
const results2 = [];
|
|
1425
|
+
for (const test of filteredTests) {
|
|
1426
|
+
reporter?.onTestStart(test.name);
|
|
1427
|
+
const result = await executor2.execute(test, client);
|
|
1428
|
+
results2.push(result);
|
|
1429
|
+
reporter?.onTestComplete(result);
|
|
1430
|
+
}
|
|
1431
|
+
return [...results2, ...skippedResults];
|
|
1432
|
+
}
|
|
1433
|
+
const executor = new TestExecutor(initialVariables, rateLimiter);
|
|
1434
|
+
let running = 0;
|
|
1435
|
+
const results = new Array(filteredTests.length);
|
|
1436
|
+
const waitQueue = [];
|
|
1437
|
+
function acquire() {
|
|
1438
|
+
if (running < parallelism) {
|
|
1439
|
+
running++;
|
|
1440
|
+
return Promise.resolve();
|
|
1441
|
+
}
|
|
1442
|
+
return new Promise((resolve) => {
|
|
1443
|
+
waitQueue.push(resolve);
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
function release2() {
|
|
1447
|
+
running--;
|
|
1448
|
+
const next = waitQueue.shift();
|
|
1449
|
+
if (next) {
|
|
1450
|
+
running++;
|
|
1451
|
+
next();
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const tasks = filteredTests.map((test, i) => {
|
|
1455
|
+
return (async () => {
|
|
1456
|
+
await acquire();
|
|
1457
|
+
try {
|
|
1458
|
+
reporter?.onTestStart(test.name);
|
|
1459
|
+
const result = await executor.execute(test, client);
|
|
1460
|
+
results[i] = result;
|
|
1461
|
+
reporter?.onTestComplete(result);
|
|
1462
|
+
} finally {
|
|
1463
|
+
release2();
|
|
1464
|
+
}
|
|
1465
|
+
})();
|
|
1466
|
+
});
|
|
1467
|
+
await Promise.allSettled(tasks);
|
|
1468
|
+
for (let i = 0; i < results.length; i++) {
|
|
1469
|
+
if (!results[i]) {
|
|
1470
|
+
results[i] = {
|
|
1471
|
+
testId: filteredTests[i].id ?? filteredTests[i].name,
|
|
1472
|
+
testName: filteredTests[i].name,
|
|
1473
|
+
status: "error",
|
|
1474
|
+
duration: 0,
|
|
1475
|
+
assertions: [],
|
|
1476
|
+
error: "Test execution failed unexpectedly"
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return [...results, ...skippedResults];
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
// src/rate-limiting/rate-limiter.ts
|
|
1485
|
+
import Bottleneck from "bottleneck";
|
|
1486
|
+
import { DEFAULT_RATE_LIMIT } from "@mcpspec/shared";
|
|
1487
|
+
var RateLimiter = class {
|
|
1488
|
+
limiter;
|
|
1489
|
+
config;
|
|
1490
|
+
constructor(config) {
|
|
1491
|
+
this.config = { ...DEFAULT_RATE_LIMIT, ...config };
|
|
1492
|
+
this.limiter = new Bottleneck({
|
|
1493
|
+
maxConcurrent: this.config.maxConcurrent,
|
|
1494
|
+
minTime: Math.ceil(1e3 / this.config.maxCallsPerSecond)
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
async schedule(fn) {
|
|
1498
|
+
return this.limiter.schedule(fn);
|
|
1499
|
+
}
|
|
1500
|
+
getConfig() {
|
|
1501
|
+
return { ...this.config };
|
|
1502
|
+
}
|
|
1503
|
+
async stop() {
|
|
1504
|
+
await this.limiter.stop();
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
// src/testing/test-runner.ts
|
|
1509
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1510
|
+
var TestRunner = class {
|
|
1511
|
+
processManager;
|
|
1512
|
+
constructor() {
|
|
1513
|
+
this.processManager = new ProcessManagerImpl();
|
|
1514
|
+
registerCleanupHandlers(this.processManager);
|
|
1515
|
+
}
|
|
1516
|
+
async run(collection, options) {
|
|
1517
|
+
const runId = randomUUID2();
|
|
1518
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
1519
|
+
const reporter = options?.reporter;
|
|
1520
|
+
const parallelism = options?.parallelism ?? 1;
|
|
1521
|
+
const tags = options?.tags;
|
|
1522
|
+
const secretMasker = new SecretMasker();
|
|
1523
|
+
const serverConfig = this.resolveServerConfig(collection.server);
|
|
1524
|
+
if (serverConfig.env) {
|
|
1525
|
+
secretMasker.registerFromEnv(serverConfig.env);
|
|
1526
|
+
}
|
|
1527
|
+
if (reporter && typeof reporter.setSecretMasker === "function") {
|
|
1528
|
+
reporter.setSecretMasker(secretMasker);
|
|
1529
|
+
}
|
|
1530
|
+
reporter?.onRunStart(collection.name, collection.tests.length);
|
|
1531
|
+
let envVariables = {};
|
|
1532
|
+
if (options?.environment && collection.environments) {
|
|
1533
|
+
const env = collection.environments[options.environment];
|
|
1534
|
+
if (!env) {
|
|
1535
|
+
throw new MCPSpecError("CONFIG_ERROR", `Environment "${options.environment}" not found`, {
|
|
1536
|
+
available: Object.keys(collection.environments)
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
envVariables = env.variables;
|
|
1540
|
+
} else if (collection.defaultEnvironment && collection.environments) {
|
|
1541
|
+
const env = collection.environments[collection.defaultEnvironment];
|
|
1542
|
+
if (env) {
|
|
1543
|
+
envVariables = env.variables;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const client = new MCPClient({
|
|
1547
|
+
serverConfig,
|
|
1548
|
+
processManager: this.processManager
|
|
1549
|
+
});
|
|
1550
|
+
let results;
|
|
1551
|
+
try {
|
|
1552
|
+
await client.connect();
|
|
1553
|
+
const rateLimiter = options?.rateLimitConfig ? new RateLimiter(options.rateLimitConfig) : void 0;
|
|
1554
|
+
const scheduler = new TestScheduler();
|
|
1555
|
+
results = await scheduler.schedule(collection.tests, client, {
|
|
1556
|
+
parallelism,
|
|
1557
|
+
tags,
|
|
1558
|
+
reporter,
|
|
1559
|
+
rateLimiter,
|
|
1560
|
+
initialVariables: envVariables
|
|
1561
|
+
});
|
|
1562
|
+
if (rateLimiter) {
|
|
1563
|
+
await rateLimiter.stop();
|
|
1564
|
+
}
|
|
1565
|
+
} finally {
|
|
1566
|
+
await client.disconnect();
|
|
1567
|
+
}
|
|
1568
|
+
const completedAt = /* @__PURE__ */ new Date();
|
|
1569
|
+
const summary = this.computeSummary(results, completedAt.getTime() - startedAt.getTime());
|
|
1570
|
+
const runResult = {
|
|
1571
|
+
id: runId,
|
|
1572
|
+
collectionName: collection.name,
|
|
1573
|
+
startedAt,
|
|
1574
|
+
completedAt,
|
|
1575
|
+
duration: completedAt.getTime() - startedAt.getTime(),
|
|
1576
|
+
results,
|
|
1577
|
+
summary
|
|
1578
|
+
};
|
|
1579
|
+
reporter?.onRunComplete(runResult);
|
|
1580
|
+
return runResult;
|
|
1581
|
+
}
|
|
1582
|
+
resolveServerConfig(server) {
|
|
1583
|
+
if (typeof server === "string") {
|
|
1584
|
+
const parts = server.split(/\s+/);
|
|
1585
|
+
const command = parts[0];
|
|
1586
|
+
if (!command) {
|
|
1587
|
+
throw new MCPSpecError("CONFIG_ERROR", "Empty server command", {});
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
transport: "stdio",
|
|
1591
|
+
command,
|
|
1592
|
+
args: parts.slice(1)
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
return server;
|
|
1596
|
+
}
|
|
1597
|
+
computeSummary(results, duration) {
|
|
1598
|
+
return {
|
|
1599
|
+
total: results.length,
|
|
1600
|
+
passed: results.filter((r) => r.status === "passed").length,
|
|
1601
|
+
failed: results.filter((r) => r.status === "failed").length,
|
|
1602
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
1603
|
+
errors: results.filter((r) => r.status === "error").length,
|
|
1604
|
+
duration
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
async cleanup() {
|
|
1608
|
+
await this.processManager.shutdownAll();
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
// src/testing/reporters/console-reporter.ts
|
|
1613
|
+
var COLORS = {
|
|
1614
|
+
reset: "\x1B[0m",
|
|
1615
|
+
green: "\x1B[32m",
|
|
1616
|
+
red: "\x1B[31m",
|
|
1617
|
+
yellow: "\x1B[33m",
|
|
1618
|
+
gray: "\x1B[90m",
|
|
1619
|
+
bold: "\x1B[1m",
|
|
1620
|
+
cyan: "\x1B[36m"
|
|
1621
|
+
};
|
|
1622
|
+
var ICONS = {
|
|
1623
|
+
pass: `${COLORS.green}\u2713${COLORS.reset}`,
|
|
1624
|
+
fail: `${COLORS.red}\u2717${COLORS.reset}`,
|
|
1625
|
+
error: `${COLORS.red}!${COLORS.reset}`,
|
|
1626
|
+
skip: `${COLORS.yellow}-${COLORS.reset}`
|
|
1627
|
+
};
|
|
1628
|
+
var ConsoleReporter = class {
|
|
1629
|
+
ci;
|
|
1630
|
+
constructor(options) {
|
|
1631
|
+
this.ci = options?.ci ?? false;
|
|
1632
|
+
}
|
|
1633
|
+
onRunStart(collectionName, testCount) {
|
|
1634
|
+
if (!this.ci) {
|
|
1635
|
+
console.log(
|
|
1636
|
+
`
|
|
1637
|
+
${COLORS.bold}${COLORS.cyan}MCPSpec${COLORS.reset} running ${COLORS.bold}${collectionName}${COLORS.reset} (${testCount} tests)
|
|
1638
|
+
`
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
onTestStart(_testName) {
|
|
1643
|
+
}
|
|
1644
|
+
onTestComplete(result) {
|
|
1645
|
+
const icon = this.getIcon(result.status);
|
|
1646
|
+
const duration = `${COLORS.gray}(${result.duration}ms)${COLORS.reset}`;
|
|
1647
|
+
console.log(` ${icon} ${result.testName} ${duration}`);
|
|
1648
|
+
if (result.status === "failed") {
|
|
1649
|
+
for (const assertion of result.assertions) {
|
|
1650
|
+
if (!assertion.passed) {
|
|
1651
|
+
console.log(` ${COLORS.red}${assertion.message}${COLORS.reset}`);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (result.status === "error" && result.error) {
|
|
1656
|
+
console.log(` ${COLORS.red}${result.error}${COLORS.reset}`);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
onRunComplete(result) {
|
|
1660
|
+
const { summary } = result;
|
|
1661
|
+
console.log("");
|
|
1662
|
+
const parts = [];
|
|
1663
|
+
if (summary.passed > 0) parts.push(`${COLORS.green}${summary.passed} passed${COLORS.reset}`);
|
|
1664
|
+
if (summary.failed > 0) parts.push(`${COLORS.red}${summary.failed} failed${COLORS.reset}`);
|
|
1665
|
+
if (summary.errors > 0) parts.push(`${COLORS.red}${summary.errors} errors${COLORS.reset}`);
|
|
1666
|
+
if (summary.skipped > 0) parts.push(`${COLORS.yellow}${summary.skipped} skipped${COLORS.reset}`);
|
|
1667
|
+
console.log(
|
|
1668
|
+
` ${COLORS.bold}Tests:${COLORS.reset} ${parts.join(", ")} (${summary.total} total)`
|
|
1669
|
+
);
|
|
1670
|
+
console.log(
|
|
1671
|
+
` ${COLORS.bold}Time:${COLORS.reset} ${(summary.duration / 1e3).toFixed(2)}s`
|
|
1672
|
+
);
|
|
1673
|
+
console.log("");
|
|
1674
|
+
}
|
|
1675
|
+
getIcon(status) {
|
|
1676
|
+
switch (status) {
|
|
1677
|
+
case "passed":
|
|
1678
|
+
return ICONS.pass;
|
|
1679
|
+
case "failed":
|
|
1680
|
+
return ICONS.fail;
|
|
1681
|
+
case "error":
|
|
1682
|
+
return ICONS.error;
|
|
1683
|
+
case "skipped":
|
|
1684
|
+
return ICONS.skip;
|
|
1685
|
+
default:
|
|
1686
|
+
return " ";
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
// src/testing/reporters/json-reporter.ts
|
|
1692
|
+
var JsonReporter = class {
|
|
1693
|
+
constructor(outputPath) {
|
|
1694
|
+
this.outputPath = outputPath;
|
|
1695
|
+
}
|
|
1696
|
+
output;
|
|
1697
|
+
onRunStart(_collectionName, _testCount) {
|
|
1698
|
+
}
|
|
1699
|
+
onTestStart(_testName) {
|
|
1700
|
+
}
|
|
1701
|
+
onTestComplete(_result) {
|
|
1702
|
+
}
|
|
1703
|
+
onRunComplete(result) {
|
|
1704
|
+
const json = JSON.stringify(
|
|
1705
|
+
{
|
|
1706
|
+
id: result.id,
|
|
1707
|
+
collectionName: result.collectionName,
|
|
1708
|
+
startedAt: result.startedAt.toISOString(),
|
|
1709
|
+
completedAt: result.completedAt.toISOString(),
|
|
1710
|
+
duration: result.duration,
|
|
1711
|
+
summary: result.summary,
|
|
1712
|
+
results: result.results
|
|
1713
|
+
},
|
|
1714
|
+
null,
|
|
1715
|
+
2
|
|
1716
|
+
);
|
|
1717
|
+
if (this.outputPath) {
|
|
1718
|
+
this.output = json;
|
|
1719
|
+
} else {
|
|
1720
|
+
console.log(json);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
getOutput() {
|
|
1724
|
+
return this.output;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
// src/testing/reporters/junit-reporter.ts
|
|
1729
|
+
function escapeXml(str) {
|
|
1730
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1731
|
+
}
|
|
1732
|
+
var JunitReporter = class {
|
|
1733
|
+
constructor(outputPath) {
|
|
1734
|
+
this.outputPath = outputPath;
|
|
1735
|
+
}
|
|
1736
|
+
output;
|
|
1737
|
+
secretMasker;
|
|
1738
|
+
setSecretMasker(masker) {
|
|
1739
|
+
this.secretMasker = masker;
|
|
1740
|
+
}
|
|
1741
|
+
onRunStart(_collectionName, _testCount) {
|
|
1742
|
+
}
|
|
1743
|
+
onTestStart(_testName) {
|
|
1744
|
+
}
|
|
1745
|
+
onTestComplete(_result) {
|
|
1746
|
+
}
|
|
1747
|
+
onRunComplete(result) {
|
|
1748
|
+
const { summary, results, collectionName, duration } = result;
|
|
1749
|
+
const lines = [];
|
|
1750
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
1751
|
+
lines.push(
|
|
1752
|
+
`<testsuites tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" time="${(duration / 1e3).toFixed(3)}">`
|
|
1753
|
+
);
|
|
1754
|
+
lines.push(
|
|
1755
|
+
` <testsuite name="${escapeXml(collectionName)}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" skipped="${summary.skipped}" time="${(duration / 1e3).toFixed(3)}">`
|
|
1756
|
+
);
|
|
1757
|
+
for (const test of results) {
|
|
1758
|
+
const testTime = (test.duration / 1e3).toFixed(3);
|
|
1759
|
+
lines.push(
|
|
1760
|
+
` <testcase name="${escapeXml(test.testName)}" classname="${escapeXml(collectionName)}" time="${testTime}">`
|
|
1761
|
+
);
|
|
1762
|
+
if (test.status === "failed") {
|
|
1763
|
+
const failedAssertions = test.assertions.filter((a) => !a.passed);
|
|
1764
|
+
const message = failedAssertions.map((a) => a.message).join("; ");
|
|
1765
|
+
lines.push(
|
|
1766
|
+
` <failure message="${escapeXml(this.mask(message))}">${escapeXml(this.mask(message))}</failure>`
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
if (test.status === "error") {
|
|
1770
|
+
const errorMessage = test.error ?? "Unknown error";
|
|
1771
|
+
lines.push(
|
|
1772
|
+
` <error message="${escapeXml(this.mask(errorMessage))}">${escapeXml(this.mask(errorMessage))}</error>`
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
if (test.status === "skipped") {
|
|
1776
|
+
lines.push(" <skipped/>");
|
|
1777
|
+
}
|
|
1778
|
+
lines.push(" </testcase>");
|
|
1779
|
+
}
|
|
1780
|
+
lines.push(" </testsuite>");
|
|
1781
|
+
lines.push("</testsuites>");
|
|
1782
|
+
const xml = lines.join("\n");
|
|
1783
|
+
if (this.outputPath) {
|
|
1784
|
+
this.output = xml;
|
|
1785
|
+
} else {
|
|
1786
|
+
console.log(xml);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
getOutput() {
|
|
1790
|
+
return this.output;
|
|
1791
|
+
}
|
|
1792
|
+
mask(text) {
|
|
1793
|
+
return this.secretMasker ? this.secretMasker.mask(text) : text;
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
// src/testing/reporters/html-reporter.ts
|
|
1798
|
+
import Handlebars from "handlebars";
|
|
1799
|
+
var HTML_TEMPLATE = `<!DOCTYPE html>
|
|
1800
|
+
<html lang="en">
|
|
1801
|
+
<head>
|
|
1802
|
+
<meta charset="UTF-8">
|
|
1803
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1804
|
+
<title>MCPSpec Test Report - {{collectionName}}</title>
|
|
1805
|
+
<style>
|
|
1806
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1807
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 24px; }
|
|
1808
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
1809
|
+
h1 { font-size: 24px; margin-bottom: 16px; }
|
|
1810
|
+
.summary { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
1811
|
+
.card { background: #fff; border-radius: 8px; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
1812
|
+
.card .label { font-size: 12px; color: #888; text-transform: uppercase; }
|
|
1813
|
+
.card .value { font-size: 28px; font-weight: 700; }
|
|
1814
|
+
.passed .value { color: #22c55e; }
|
|
1815
|
+
.failed .value { color: #ef4444; }
|
|
1816
|
+
.errors .value { color: #f97316; }
|
|
1817
|
+
.duration .value { color: #3b82f6; }
|
|
1818
|
+
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
1819
|
+
th { background: #f9fafb; text-align: left; padding: 12px 16px; font-size: 12px; text-transform: uppercase; color: #888; border-bottom: 1px solid #e5e7eb; }
|
|
1820
|
+
td { padding: 12px 16px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
|
1821
|
+
tr:last-child td { border-bottom: none; }
|
|
1822
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
|
1823
|
+
.badge-passed { background: #dcfce7; color: #166534; }
|
|
1824
|
+
.badge-failed { background: #fee2e2; color: #991b1b; }
|
|
1825
|
+
.badge-error { background: #ffedd5; color: #9a3412; }
|
|
1826
|
+
.badge-skipped { background: #fef9c3; color: #854d0e; }
|
|
1827
|
+
.assertions { font-size: 13px; color: #666; margin-top: 4px; }
|
|
1828
|
+
.assertion-fail { color: #ef4444; }
|
|
1829
|
+
.footer { margin-top: 24px; font-size: 12px; color: #aaa; text-align: center; }
|
|
1830
|
+
</style>
|
|
1831
|
+
</head>
|
|
1832
|
+
<body>
|
|
1833
|
+
<div class="container">
|
|
1834
|
+
<h1>MCPSpec: {{collectionName}}</h1>
|
|
1835
|
+
<div class="summary">
|
|
1836
|
+
<div class="card passed"><div class="label">Passed</div><div class="value">{{summary.passed}}</div></div>
|
|
1837
|
+
<div class="card failed"><div class="label">Failed</div><div class="value">{{summary.failed}}</div></div>
|
|
1838
|
+
<div class="card errors"><div class="label">Errors</div><div class="value">{{summary.errors}}</div></div>
|
|
1839
|
+
<div class="card duration"><div class="label">Duration</div><div class="value">{{durationFormatted}}</div></div>
|
|
1840
|
+
</div>
|
|
1841
|
+
<table>
|
|
1842
|
+
<thead><tr><th>Test</th><th>Status</th><th>Duration</th><th>Details</th></tr></thead>
|
|
1843
|
+
<tbody>
|
|
1844
|
+
{{#each results}}
|
|
1845
|
+
<tr>
|
|
1846
|
+
<td>{{this.testName}}</td>
|
|
1847
|
+
<td><span class="badge badge-{{this.status}}">{{this.status}}</span></td>
|
|
1848
|
+
<td>{{this.duration}}ms</td>
|
|
1849
|
+
<td>
|
|
1850
|
+
{{#if this.error}}<div class="assertion-fail">{{this.error}}</div>{{/if}}
|
|
1851
|
+
{{#each this.assertions}}
|
|
1852
|
+
{{#unless this.passed}}<div class="assertion-fail">{{this.message}}</div>{{/unless}}
|
|
1853
|
+
{{/each}}
|
|
1854
|
+
{{#if this.allPassed}}<span style="color:#22c55e">All assertions passed</span>{{/if}}
|
|
1855
|
+
</td>
|
|
1856
|
+
</tr>
|
|
1857
|
+
{{/each}}
|
|
1858
|
+
</tbody>
|
|
1859
|
+
</table>
|
|
1860
|
+
<div class="footer">Generated by MCPSpec at {{timestamp}}</div>
|
|
1861
|
+
</div>
|
|
1862
|
+
</body>
|
|
1863
|
+
</html>`;
|
|
1864
|
+
var HtmlReporter = class {
|
|
1865
|
+
constructor(outputPath) {
|
|
1866
|
+
this.outputPath = outputPath;
|
|
1867
|
+
}
|
|
1868
|
+
output;
|
|
1869
|
+
secretMasker;
|
|
1870
|
+
setSecretMasker(masker) {
|
|
1871
|
+
this.secretMasker = masker;
|
|
1872
|
+
}
|
|
1873
|
+
onRunStart(_collectionName, _testCount) {
|
|
1874
|
+
}
|
|
1875
|
+
onTestStart(_testName) {
|
|
1876
|
+
}
|
|
1877
|
+
onTestComplete(_result) {
|
|
1878
|
+
}
|
|
1879
|
+
onRunComplete(result) {
|
|
1880
|
+
const template = Handlebars.compile(HTML_TEMPLATE);
|
|
1881
|
+
const data = {
|
|
1882
|
+
collectionName: result.collectionName,
|
|
1883
|
+
summary: result.summary,
|
|
1884
|
+
durationFormatted: `${(result.duration / 1e3).toFixed(2)}s`,
|
|
1885
|
+
timestamp: result.completedAt.toISOString(),
|
|
1886
|
+
results: result.results.map((r) => ({
|
|
1887
|
+
testName: r.testName,
|
|
1888
|
+
status: r.status,
|
|
1889
|
+
duration: r.duration,
|
|
1890
|
+
error: r.error ? this.mask(r.error) : void 0,
|
|
1891
|
+
assertions: r.assertions.map((a) => ({
|
|
1892
|
+
passed: a.passed,
|
|
1893
|
+
message: this.mask(a.message)
|
|
1894
|
+
})),
|
|
1895
|
+
allPassed: r.assertions.every((a) => a.passed) && !r.error
|
|
1896
|
+
}))
|
|
1897
|
+
};
|
|
1898
|
+
const html = template(data);
|
|
1899
|
+
if (this.outputPath) {
|
|
1900
|
+
this.output = html;
|
|
1901
|
+
} else {
|
|
1902
|
+
console.log(html);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
getOutput() {
|
|
1906
|
+
return this.output;
|
|
1907
|
+
}
|
|
1908
|
+
mask(text) {
|
|
1909
|
+
return this.secretMasker ? this.secretMasker.mask(text) : text;
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1913
|
+
// src/testing/reporters/tap-reporter.ts
|
|
1914
|
+
var TapReporter = class {
|
|
1915
|
+
testIndex = 0;
|
|
1916
|
+
secretMasker;
|
|
1917
|
+
setSecretMasker(masker) {
|
|
1918
|
+
this.secretMasker = masker;
|
|
1919
|
+
}
|
|
1920
|
+
onRunStart(_collectionName, testCount) {
|
|
1921
|
+
console.log("TAP version 14");
|
|
1922
|
+
console.log(`1..${testCount}`);
|
|
1923
|
+
}
|
|
1924
|
+
onTestStart(_testName) {
|
|
1925
|
+
}
|
|
1926
|
+
onTestComplete(result) {
|
|
1927
|
+
this.testIndex++;
|
|
1928
|
+
const status = result.status === "passed" ? "ok" : "not ok";
|
|
1929
|
+
const directive = result.status === "skipped" ? " # SKIP" : "";
|
|
1930
|
+
console.log(`${status} ${this.testIndex} - ${result.testName}${directive}`);
|
|
1931
|
+
if (result.status === "failed") {
|
|
1932
|
+
console.log(" ---");
|
|
1933
|
+
console.log(" severity: fail");
|
|
1934
|
+
const failedAssertions = result.assertions.filter((a) => !a.passed);
|
|
1935
|
+
if (failedAssertions.length > 0) {
|
|
1936
|
+
console.log(" failures:");
|
|
1937
|
+
for (const a of failedAssertions) {
|
|
1938
|
+
console.log(` - message: "${this.mask(a.message)}"`);
|
|
1939
|
+
if (a.expected !== void 0) console.log(` expected: ${JSON.stringify(a.expected)}`);
|
|
1940
|
+
if (a.actual !== void 0) console.log(` actual: ${JSON.stringify(a.actual)}`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
console.log(` duration_ms: ${result.duration}`);
|
|
1944
|
+
console.log(" ...");
|
|
1945
|
+
}
|
|
1946
|
+
if (result.status === "error") {
|
|
1947
|
+
console.log(" ---");
|
|
1948
|
+
console.log(" severity: error");
|
|
1949
|
+
console.log(` message: "${this.mask(result.error ?? "Unknown error")}"`);
|
|
1950
|
+
console.log(` duration_ms: ${result.duration}`);
|
|
1951
|
+
console.log(" ...");
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
onRunComplete(_result) {
|
|
1955
|
+
}
|
|
1956
|
+
mask(text) {
|
|
1957
|
+
return this.secretMasker ? this.secretMasker.mask(text) : text;
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
|
|
1961
|
+
// src/testing/comparison/baseline-store.ts
|
|
1962
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from "fs";
|
|
1963
|
+
import { join as join2 } from "path";
|
|
1964
|
+
var BaselineStore = class {
|
|
1965
|
+
basePath;
|
|
1966
|
+
constructor(basePath) {
|
|
1967
|
+
this.basePath = basePath ?? join2(getPlatformInfo().dataDir, "baselines");
|
|
1968
|
+
}
|
|
1969
|
+
save(name, result) {
|
|
1970
|
+
this.ensureDir();
|
|
1971
|
+
const filePath = this.getFilePath(name);
|
|
1972
|
+
const serialized = JSON.stringify(
|
|
1973
|
+
{
|
|
1974
|
+
id: result.id,
|
|
1975
|
+
collectionName: result.collectionName,
|
|
1976
|
+
startedAt: result.startedAt.toISOString(),
|
|
1977
|
+
completedAt: result.completedAt.toISOString(),
|
|
1978
|
+
duration: result.duration,
|
|
1979
|
+
results: result.results,
|
|
1980
|
+
summary: result.summary
|
|
1981
|
+
},
|
|
1982
|
+
null,
|
|
1983
|
+
2
|
|
1984
|
+
);
|
|
1985
|
+
writeFileSync(filePath, serialized, "utf-8");
|
|
1986
|
+
return filePath;
|
|
1987
|
+
}
|
|
1988
|
+
load(name) {
|
|
1989
|
+
const filePath = this.getFilePath(name);
|
|
1990
|
+
if (!existsSync(filePath)) {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1994
|
+
const raw = JSON.parse(content);
|
|
1995
|
+
return {
|
|
1996
|
+
...raw,
|
|
1997
|
+
startedAt: new Date(raw.startedAt),
|
|
1998
|
+
completedAt: new Date(raw.completedAt)
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
list() {
|
|
2002
|
+
this.ensureDir();
|
|
2003
|
+
return readdirSync(this.basePath).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""));
|
|
2004
|
+
}
|
|
2005
|
+
getFilePath(name) {
|
|
2006
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2007
|
+
return join2(this.basePath, `${safeName}.json`);
|
|
2008
|
+
}
|
|
2009
|
+
ensureDir() {
|
|
2010
|
+
if (!existsSync(this.basePath)) {
|
|
2011
|
+
mkdirSync(this.basePath, { recursive: true });
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// src/testing/comparison/result-differ.ts
|
|
2017
|
+
var ResultDiffer = class {
|
|
2018
|
+
diff(baseline, current, baselineName = "baseline") {
|
|
2019
|
+
const baselineMap = /* @__PURE__ */ new Map();
|
|
2020
|
+
for (const r of baseline.results) {
|
|
2021
|
+
baselineMap.set(r.testName, r);
|
|
2022
|
+
}
|
|
2023
|
+
const currentMap = /* @__PURE__ */ new Map();
|
|
2024
|
+
for (const r of current.results) {
|
|
2025
|
+
currentMap.set(r.testName, r);
|
|
2026
|
+
}
|
|
2027
|
+
const regressions = [];
|
|
2028
|
+
const fixes = [];
|
|
2029
|
+
const newTests = [];
|
|
2030
|
+
const removedTests = [];
|
|
2031
|
+
const unchanged = [];
|
|
2032
|
+
for (const [name, currentResult] of currentMap) {
|
|
2033
|
+
const baselineResult = baselineMap.get(name);
|
|
2034
|
+
if (!baselineResult) {
|
|
2035
|
+
newTests.push({ testName: name, type: "new", after: currentResult });
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
const wasPassing = baselineResult.status === "passed";
|
|
2039
|
+
const isPassing = currentResult.status === "passed";
|
|
2040
|
+
if (wasPassing && !isPassing) {
|
|
2041
|
+
regressions.push({ testName: name, type: "regression", before: baselineResult, after: currentResult });
|
|
2042
|
+
} else if (!wasPassing && isPassing) {
|
|
2043
|
+
fixes.push({ testName: name, type: "fix", before: baselineResult, after: currentResult });
|
|
2044
|
+
} else {
|
|
2045
|
+
unchanged.push({ testName: name, type: "unchanged", before: baselineResult, after: currentResult });
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
for (const [name, baselineResult] of baselineMap) {
|
|
2049
|
+
if (!currentMap.has(name)) {
|
|
2050
|
+
removedTests.push({ testName: name, type: "removed", before: baselineResult });
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return {
|
|
2054
|
+
baselineName,
|
|
2055
|
+
currentRunId: current.id,
|
|
2056
|
+
regressions,
|
|
2057
|
+
fixes,
|
|
2058
|
+
newTests,
|
|
2059
|
+
removedTests,
|
|
2060
|
+
unchanged,
|
|
2061
|
+
summary: {
|
|
2062
|
+
totalBefore: baseline.results.length,
|
|
2063
|
+
totalAfter: current.results.length,
|
|
2064
|
+
regressions: regressions.length,
|
|
2065
|
+
fixes: fixes.length,
|
|
2066
|
+
newTests: newTests.length,
|
|
2067
|
+
removedTests: removedTests.length
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
// src/security/scan-config.ts
|
|
2074
|
+
var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
|
|
2075
|
+
var PASSIVE_RULES = [
|
|
2076
|
+
"path-traversal",
|
|
2077
|
+
"input-validation",
|
|
2078
|
+
"information-disclosure"
|
|
2079
|
+
];
|
|
2080
|
+
var ACTIVE_RULES = [
|
|
2081
|
+
...PASSIVE_RULES,
|
|
2082
|
+
"resource-exhaustion",
|
|
2083
|
+
"auth-bypass",
|
|
2084
|
+
"injection"
|
|
2085
|
+
];
|
|
2086
|
+
var AGGRESSIVE_RULES = [...ACTIVE_RULES];
|
|
2087
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
2088
|
+
var DEFAULT_MAX_PROBES = 50;
|
|
2089
|
+
var ScanConfig = class {
|
|
2090
|
+
mode;
|
|
2091
|
+
rules;
|
|
2092
|
+
severityThreshold;
|
|
2093
|
+
acknowledgeRisk;
|
|
2094
|
+
timeout;
|
|
2095
|
+
maxProbesPerTool;
|
|
2096
|
+
constructor(config = {}) {
|
|
2097
|
+
this.mode = config.mode ?? "passive";
|
|
2098
|
+
this.severityThreshold = config.severityThreshold ?? "info";
|
|
2099
|
+
this.acknowledgeRisk = config.acknowledgeRisk ?? false;
|
|
2100
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
2101
|
+
this.maxProbesPerTool = config.maxProbesPerTool ?? DEFAULT_MAX_PROBES;
|
|
2102
|
+
const allRulesForMode = this.getRulesForMode(this.mode);
|
|
2103
|
+
if (config.rules && config.rules.length > 0) {
|
|
2104
|
+
this.rules = config.rules.filter((r) => allRulesForMode.includes(r));
|
|
2105
|
+
} else {
|
|
2106
|
+
this.rules = allRulesForMode;
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
requiresConfirmation() {
|
|
2110
|
+
return this.mode !== "passive" && !this.acknowledgeRisk;
|
|
2111
|
+
}
|
|
2112
|
+
meetsThreshold(severity) {
|
|
2113
|
+
const thresholdIdx = SEVERITY_ORDER.indexOf(this.severityThreshold);
|
|
2114
|
+
const severityIdx = SEVERITY_ORDER.indexOf(severity);
|
|
2115
|
+
return severityIdx >= thresholdIdx;
|
|
2116
|
+
}
|
|
2117
|
+
getRulesForMode(mode) {
|
|
2118
|
+
switch (mode) {
|
|
2119
|
+
case "passive":
|
|
2120
|
+
return PASSIVE_RULES;
|
|
2121
|
+
case "active":
|
|
2122
|
+
return ACTIVE_RULES;
|
|
2123
|
+
case "aggressive":
|
|
2124
|
+
return AGGRESSIVE_RULES;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
// src/security/security-scanner.ts
|
|
2130
|
+
import { randomUUID as randomUUID9 } from "crypto";
|
|
2131
|
+
|
|
2132
|
+
// src/security/rules/path-traversal.ts
|
|
2133
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2134
|
+
|
|
2135
|
+
// src/security/payloads/platform-payloads.ts
|
|
2136
|
+
function getPlatformPayloads() {
|
|
2137
|
+
const info = getPlatformInfo();
|
|
2138
|
+
const all = getAllPayloads();
|
|
2139
|
+
return all.filter((p) => p.platforms.includes(info.os) || p.platforms.length === 0);
|
|
2140
|
+
}
|
|
2141
|
+
function getPayloadsForMode(mode) {
|
|
2142
|
+
const modeOrder = ["passive", "active", "aggressive"];
|
|
2143
|
+
const modeIdx = modeOrder.indexOf(mode);
|
|
2144
|
+
return getPlatformPayloads().filter((p) => {
|
|
2145
|
+
const payloadModeIdx = modeOrder.indexOf(p.minMode);
|
|
2146
|
+
return payloadModeIdx <= modeIdx;
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
function getAllPayloads() {
|
|
2150
|
+
return [
|
|
2151
|
+
// Path traversal - active
|
|
2152
|
+
{ category: "path-traversal", label: "unix-parent", value: "../../../etc/passwd", description: "Unix parent directory traversal", minMode: "active", platforms: ["linux", "darwin"] },
|
|
2153
|
+
{ category: "path-traversal", label: "unix-absolute", value: "/etc/passwd", description: "Unix absolute path", minMode: "active", platforms: ["linux", "darwin"] },
|
|
2154
|
+
{ category: "path-traversal", label: "unix-home", value: "~/.ssh/id_rsa", description: "Unix home directory access", minMode: "active", platforms: ["linux", "darwin"] },
|
|
2155
|
+
{ category: "path-traversal", label: "win-parent", value: "..\\..\\..\\windows\\system32\\config\\sam", description: "Windows parent directory traversal", minMode: "active", platforms: ["win32"] },
|
|
2156
|
+
{ category: "path-traversal", label: "win-absolute", value: "C:\\Windows\\System32\\config\\sam", description: "Windows absolute path", minMode: "active", platforms: ["win32"] },
|
|
2157
|
+
{ category: "path-traversal", label: "encoded-traversal", value: "..%2F..%2F..%2Fetc%2Fpasswd", description: "URL-encoded traversal", minMode: "active", platforms: [] },
|
|
2158
|
+
{ category: "path-traversal", label: "null-byte", value: "../../etc/passwd\0.txt", description: "Null byte injection in path", minMode: "active", platforms: [] },
|
|
2159
|
+
// Command injection - active
|
|
2160
|
+
{ category: "command-injection", label: "semicolon", value: "; echo vulnerable", description: "Semicolon command injection", minMode: "active", platforms: ["linux", "darwin"] },
|
|
2161
|
+
{ category: "command-injection", label: "pipe", value: "| echo vulnerable", description: "Pipe command injection", minMode: "active", platforms: [] },
|
|
2162
|
+
{ category: "command-injection", label: "backtick", value: "`echo vulnerable`", description: "Backtick command injection", minMode: "active", platforms: ["linux", "darwin"] },
|
|
2163
|
+
{ category: "command-injection", label: "dollar-paren", value: "$(echo vulnerable)", description: "Dollar-paren command injection", minMode: "active", platforms: ["linux", "darwin"] },
|
|
2164
|
+
{ category: "command-injection", label: "and-chain", value: "&& echo vulnerable", description: "AND chain command injection", minMode: "active", platforms: [] },
|
|
2165
|
+
// SQL injection - active
|
|
2166
|
+
{ category: "sql-injection", label: "single-quote", value: "' OR '1'='1", description: "Classic SQL injection", minMode: "active", platforms: [] },
|
|
2167
|
+
{ category: "sql-injection", label: "union-select", value: "' UNION SELECT * FROM users --", description: "UNION SELECT injection", minMode: "active", platforms: [] },
|
|
2168
|
+
{ category: "sql-injection", label: "drop-table", value: "'; DROP TABLE users; --", description: "DROP TABLE injection", minMode: "active", platforms: [] },
|
|
2169
|
+
{ category: "sql-injection", label: "comment", value: "admin'--", description: "Comment-based SQL injection", minMode: "active", platforms: [] },
|
|
2170
|
+
// Template injection - active
|
|
2171
|
+
{ category: "template-injection", label: "jinja", value: "{{7*7}}", description: "Jinja/Handlebars template injection", minMode: "active", platforms: [] },
|
|
2172
|
+
{ category: "template-injection", label: "erb", value: "<%= 7*7 %>", description: "ERB template injection", minMode: "active", platforms: [] },
|
|
2173
|
+
{ category: "template-injection", label: "expression", value: "${7*7}", description: "Expression language injection", minMode: "active", platforms: [] },
|
|
2174
|
+
// Resource exhaustion - aggressive only
|
|
2175
|
+
{ category: "resource-exhaustion", label: "huge-string", value: "X".repeat(1e4), description: "Very long input string", minMode: "aggressive", platforms: [] },
|
|
2176
|
+
{ category: "resource-exhaustion", label: "deep-nesting", value: JSON.stringify(createDeepObject(20)), description: "Deeply nested object", minMode: "aggressive", platforms: [] },
|
|
2177
|
+
{ category: "resource-exhaustion", label: "many-keys", value: JSON.stringify(createManyKeys(100)), description: "Object with many keys", minMode: "aggressive", platforms: [] }
|
|
2178
|
+
];
|
|
2179
|
+
}
|
|
2180
|
+
function createDeepObject(depth) {
|
|
2181
|
+
let obj = { value: "leaf" };
|
|
2182
|
+
for (let i = 0; i < depth; i++) {
|
|
2183
|
+
obj = { nested: obj };
|
|
2184
|
+
}
|
|
2185
|
+
return obj;
|
|
2186
|
+
}
|
|
2187
|
+
function createManyKeys(count) {
|
|
2188
|
+
const obj = {};
|
|
2189
|
+
for (let i = 0; i < count; i++) {
|
|
2190
|
+
obj[`key_${i}`] = `value_${i}`;
|
|
2191
|
+
}
|
|
2192
|
+
return obj;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// src/security/rules/utils.ts
|
|
2196
|
+
async function callWithTimeout(client, toolName, args, timeout) {
|
|
2197
|
+
try {
|
|
2198
|
+
const result = await Promise.race([
|
|
2199
|
+
client.callTool(toolName, args),
|
|
2200
|
+
new Promise((resolve) => setTimeout(() => resolve(null), timeout))
|
|
2201
|
+
]);
|
|
2202
|
+
return result;
|
|
2203
|
+
} catch {
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// src/security/rules/path-traversal.ts
|
|
2209
|
+
var PATH_PARAM_PATTERNS = /^(path|file|filename|filepath|dir|directory|folder|uri|url|location|src|dest|source|destination|target)$/i;
|
|
2210
|
+
var PathTraversalRule = class {
|
|
2211
|
+
id = "path-traversal";
|
|
2212
|
+
name = "Path Traversal";
|
|
2213
|
+
description = "Tests for directory traversal vulnerabilities in path-based parameters";
|
|
2214
|
+
async scan(client, tools, config) {
|
|
2215
|
+
const findings = [];
|
|
2216
|
+
const payloads = getPayloadsForMode(config.mode).filter((p) => p.category === "path-traversal");
|
|
2217
|
+
for (const tool of tools) {
|
|
2218
|
+
const pathParams = this.findPathParams(tool);
|
|
2219
|
+
if (pathParams.length === 0) continue;
|
|
2220
|
+
for (const param of pathParams) {
|
|
2221
|
+
const passiveResult = await callWithTimeout(
|
|
2222
|
+
client,
|
|
2223
|
+
tool.name,
|
|
2224
|
+
{ [param]: "../test" },
|
|
2225
|
+
config.timeout
|
|
2226
|
+
);
|
|
2227
|
+
if (passiveResult && !passiveResult.isError) {
|
|
2228
|
+
findings.push({
|
|
2229
|
+
id: randomUUID3(),
|
|
2230
|
+
rule: this.id,
|
|
2231
|
+
severity: "medium",
|
|
2232
|
+
title: `Path traversal possible on ${tool.name}.${param}`,
|
|
2233
|
+
description: `The tool "${tool.name}" accepted a relative path "../test" on parameter "${param}" without rejection.`,
|
|
2234
|
+
evidence: JSON.stringify(passiveResult.content).slice(0, 200),
|
|
2235
|
+
remediation: 'Validate and sanitize path inputs. Reject paths containing ".." and resolve to absolute paths within an allowed directory.'
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
for (const payload of payloads) {
|
|
2239
|
+
const result = await callWithTimeout(
|
|
2240
|
+
client,
|
|
2241
|
+
tool.name,
|
|
2242
|
+
{ [param]: payload.value },
|
|
2243
|
+
config.timeout
|
|
2244
|
+
);
|
|
2245
|
+
if (!result) continue;
|
|
2246
|
+
const contentStr = JSON.stringify(result.content);
|
|
2247
|
+
const hasSensitiveContent = /root:|admin:|password|shadow|id_rsa|PRIVATE KEY|sam|SYSTEM/i.test(contentStr);
|
|
2248
|
+
const hasPathDisclosure = /\/etc\/|\/home\/|C:\\Users|C:\\Windows/i.test(contentStr);
|
|
2249
|
+
if (!result.isError && (hasSensitiveContent || hasPathDisclosure)) {
|
|
2250
|
+
findings.push({
|
|
2251
|
+
id: randomUUID3(),
|
|
2252
|
+
rule: this.id,
|
|
2253
|
+
severity: hasSensitiveContent ? "critical" : "high",
|
|
2254
|
+
title: `Path traversal: ${payload.label} on ${tool.name}.${param}`,
|
|
2255
|
+
description: `The tool "${tool.name}" returned sensitive content when given "${payload.label}" payload on parameter "${param}".`,
|
|
2256
|
+
evidence: contentStr.slice(0, 500),
|
|
2257
|
+
remediation: "Restrict file access to a specific directory. Validate paths against an allow-list. Use chroot or similar sandboxing."
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
return findings;
|
|
2264
|
+
}
|
|
2265
|
+
findPathParams(tool) {
|
|
2266
|
+
const schema = tool.inputSchema;
|
|
2267
|
+
if (!schema || typeof schema !== "object") return [];
|
|
2268
|
+
const properties = schema["properties"];
|
|
2269
|
+
if (!properties || typeof properties !== "object") return [];
|
|
2270
|
+
return Object.keys(properties).filter(
|
|
2271
|
+
(key) => PATH_PARAM_PATTERNS.test(key)
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
|
|
2276
|
+
// src/security/rules/input-validation.ts
|
|
2277
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2278
|
+
|
|
2279
|
+
// src/security/payloads/safe-payloads.ts
|
|
2280
|
+
function getSafePayloads() {
|
|
2281
|
+
return [
|
|
2282
|
+
// Empty values
|
|
2283
|
+
{ category: "empty", label: "empty-string", value: "", description: "Empty string input" },
|
|
2284
|
+
{ category: "empty", label: "null-value", value: null, description: "Null value input" },
|
|
2285
|
+
{ category: "empty", label: "undefined-value", value: void 0, description: "Undefined value input" },
|
|
2286
|
+
// Boundary values
|
|
2287
|
+
{ category: "boundary", label: "zero", value: 0, description: "Zero numeric input" },
|
|
2288
|
+
{ category: "boundary", label: "negative", value: -1, description: "Negative numeric input" },
|
|
2289
|
+
{ category: "boundary", label: "max-int", value: Number.MAX_SAFE_INTEGER, description: "Maximum safe integer" },
|
|
2290
|
+
{ category: "boundary", label: "min-int", value: Number.MIN_SAFE_INTEGER, description: "Minimum safe integer" },
|
|
2291
|
+
{ category: "boundary", label: "float", value: 1.5, description: "Float value where int expected" },
|
|
2292
|
+
// Long strings
|
|
2293
|
+
{ category: "long-string", label: "long-256", value: "A".repeat(256), description: "256-char string" },
|
|
2294
|
+
{ category: "long-string", label: "long-1024", value: "B".repeat(1024), description: "1024-char string" },
|
|
2295
|
+
// Special characters
|
|
2296
|
+
{ category: "special-chars", label: "unicode", value: "\0\uFFFF", description: "Unicode control characters" },
|
|
2297
|
+
{ category: "special-chars", label: "newlines", value: "line1\nline2\rline3", description: "Newline characters" },
|
|
2298
|
+
{ category: "special-chars", label: "tabs", value: " ", description: "Tab characters" },
|
|
2299
|
+
{ category: "special-chars", label: "quotes", value: "\"'`", description: "Quote characters" },
|
|
2300
|
+
{ category: "special-chars", label: "backslash", value: "\\\\\\", description: "Backslash characters" },
|
|
2301
|
+
// Type confusion
|
|
2302
|
+
{ category: "type-confusion", label: "string-number", value: "123", description: "Numeric string where number expected" },
|
|
2303
|
+
{ category: "type-confusion", label: "string-boolean", value: "true", description: "Boolean string where boolean expected" },
|
|
2304
|
+
{ category: "type-confusion", label: "array-value", value: [1, 2, 3], description: "Array where scalar expected" },
|
|
2305
|
+
{ category: "type-confusion", label: "object-value", value: { key: "value" }, description: "Object where scalar expected" },
|
|
2306
|
+
{ category: "type-confusion", label: "boolean-value", value: true, description: "Boolean where string expected" }
|
|
2307
|
+
];
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// src/security/rules/input-validation.ts
|
|
2311
|
+
var InputValidationRule = class {
|
|
2312
|
+
id = "input-validation";
|
|
2313
|
+
name = "Input Validation";
|
|
2314
|
+
description = "Tests for missing or inadequate input validation";
|
|
2315
|
+
async scan(client, tools, config) {
|
|
2316
|
+
const findings = [];
|
|
2317
|
+
const payloads = getSafePayloads();
|
|
2318
|
+
for (const tool of tools) {
|
|
2319
|
+
const emptyResult = await callWithTimeout(client, tool.name, {}, config.timeout);
|
|
2320
|
+
if (emptyResult && !emptyResult.isError) {
|
|
2321
|
+
const required = this.getRequiredFields(tool);
|
|
2322
|
+
if (required.length > 0) {
|
|
2323
|
+
findings.push({
|
|
2324
|
+
id: randomUUID4(),
|
|
2325
|
+
rule: this.id,
|
|
2326
|
+
severity: "medium",
|
|
2327
|
+
title: `Missing required field validation on ${tool.name}`,
|
|
2328
|
+
description: `The tool "${tool.name}" accepted a call with no arguments despite having required fields: ${required.join(", ")}.`,
|
|
2329
|
+
evidence: JSON.stringify(emptyResult.content).slice(0, 200),
|
|
2330
|
+
remediation: "Validate that all required parameters are present before processing the request."
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
const properties = this.getProperties(tool);
|
|
2335
|
+
for (const [param, schema] of Object.entries(properties)) {
|
|
2336
|
+
const expectedType = schema["type"];
|
|
2337
|
+
if (!expectedType) continue;
|
|
2338
|
+
const wrongTypePayloads = payloads.filter((p) => p.category === "type-confusion");
|
|
2339
|
+
for (const payload of wrongTypePayloads) {
|
|
2340
|
+
const result = await callWithTimeout(
|
|
2341
|
+
client,
|
|
2342
|
+
tool.name,
|
|
2343
|
+
{ [param]: payload.value },
|
|
2344
|
+
config.timeout
|
|
2345
|
+
);
|
|
2346
|
+
if (result && !result.isError) {
|
|
2347
|
+
const actualType = typeof payload.value;
|
|
2348
|
+
const isArray = Array.isArray(payload.value);
|
|
2349
|
+
const payloadType = isArray ? "array" : actualType;
|
|
2350
|
+
if (payloadType !== expectedType && expectedType !== "any") {
|
|
2351
|
+
findings.push({
|
|
2352
|
+
id: randomUUID4(),
|
|
2353
|
+
rule: this.id,
|
|
2354
|
+
severity: "low",
|
|
2355
|
+
title: `Type confusion accepted on ${tool.name}.${param}`,
|
|
2356
|
+
description: `The tool "${tool.name}" accepted ${payloadType} for parameter "${param}" which expects ${expectedType}.`,
|
|
2357
|
+
evidence: `Input: ${JSON.stringify(payload.value)}, Response: ${JSON.stringify(result.content).slice(0, 200)}`,
|
|
2358
|
+
remediation: "Validate parameter types match the declared schema before processing."
|
|
2359
|
+
});
|
|
2360
|
+
break;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
return findings;
|
|
2367
|
+
}
|
|
2368
|
+
getRequiredFields(tool) {
|
|
2369
|
+
const schema = tool.inputSchema;
|
|
2370
|
+
if (!schema || typeof schema !== "object") return [];
|
|
2371
|
+
const required = schema["required"];
|
|
2372
|
+
if (!Array.isArray(required)) return [];
|
|
2373
|
+
return required;
|
|
2374
|
+
}
|
|
2375
|
+
getProperties(tool) {
|
|
2376
|
+
const schema = tool.inputSchema;
|
|
2377
|
+
if (!schema || typeof schema !== "object") return {};
|
|
2378
|
+
const properties = schema["properties"];
|
|
2379
|
+
if (!properties || typeof properties !== "object") return {};
|
|
2380
|
+
return properties;
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
// src/security/rules/resource-exhaustion.ts
|
|
2385
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2386
|
+
var LARGE_STRING = "X".repeat(1e4);
|
|
2387
|
+
var VERY_LARGE_STRING = "Y".repeat(1e5);
|
|
2388
|
+
var SLOW_THRESHOLD_MS = 5e3;
|
|
2389
|
+
var ResourceExhaustionRule = class {
|
|
2390
|
+
id = "resource-exhaustion";
|
|
2391
|
+
name = "Resource Exhaustion";
|
|
2392
|
+
description = "Tests for resource exhaustion vulnerabilities (DoS potential)";
|
|
2393
|
+
async scan(client, tools, config) {
|
|
2394
|
+
const findings = [];
|
|
2395
|
+
for (const tool of tools) {
|
|
2396
|
+
const params = this.getStringParams(tool);
|
|
2397
|
+
const firstParam = params[0];
|
|
2398
|
+
if (!firstParam) continue;
|
|
2399
|
+
const param = firstParam;
|
|
2400
|
+
const largeStr = config.mode === "aggressive" ? VERY_LARGE_STRING : LARGE_STRING;
|
|
2401
|
+
const start = Date.now();
|
|
2402
|
+
const result = await callWithTimeout(client, tool.name, { [param]: largeStr }, config.timeout);
|
|
2403
|
+
const elapsed = Date.now() - start;
|
|
2404
|
+
if (result === null) {
|
|
2405
|
+
findings.push({
|
|
2406
|
+
id: randomUUID5(),
|
|
2407
|
+
rule: this.id,
|
|
2408
|
+
severity: "high",
|
|
2409
|
+
title: `Timeout with large input on ${tool.name}`,
|
|
2410
|
+
description: `The tool "${tool.name}" timed out when given a ${largeStr.length}-character string for parameter "${param}". This could indicate a resource exhaustion vulnerability.`,
|
|
2411
|
+
remediation: "Implement input size limits. Add timeouts to processing. Validate input length before processing."
|
|
2412
|
+
});
|
|
2413
|
+
} else if (elapsed > SLOW_THRESHOLD_MS) {
|
|
2414
|
+
findings.push({
|
|
2415
|
+
id: randomUUID5(),
|
|
2416
|
+
rule: this.id,
|
|
2417
|
+
severity: "medium",
|
|
2418
|
+
title: `Slow response with large input on ${tool.name}`,
|
|
2419
|
+
description: `The tool "${tool.name}" took ${elapsed}ms to process a ${largeStr.length}-character string for parameter "${param}".`,
|
|
2420
|
+
evidence: `Response time: ${elapsed}ms`,
|
|
2421
|
+
remediation: "Implement input size limits and processing timeouts to prevent slow responses."
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
if (config.mode === "aggressive") {
|
|
2425
|
+
const deepObj = this.createDeepObject(50);
|
|
2426
|
+
const deepStart = Date.now();
|
|
2427
|
+
const deepResult = await callWithTimeout(client, tool.name, { [param]: deepObj }, config.timeout);
|
|
2428
|
+
const deepElapsed = Date.now() - deepStart;
|
|
2429
|
+
if (deepResult === null) {
|
|
2430
|
+
findings.push({
|
|
2431
|
+
id: randomUUID5(),
|
|
2432
|
+
rule: this.id,
|
|
2433
|
+
severity: "high",
|
|
2434
|
+
title: `Timeout with deeply nested input on ${tool.name}`,
|
|
2435
|
+
description: `The tool "${tool.name}" timed out when given a deeply nested object (50 levels) for parameter "${param}".`,
|
|
2436
|
+
remediation: "Implement nesting depth limits on JSON input parsing."
|
|
2437
|
+
});
|
|
2438
|
+
} else if (deepElapsed > SLOW_THRESHOLD_MS) {
|
|
2439
|
+
findings.push({
|
|
2440
|
+
id: randomUUID5(),
|
|
2441
|
+
rule: this.id,
|
|
2442
|
+
severity: "medium",
|
|
2443
|
+
title: `Slow response with nested input on ${tool.name}`,
|
|
2444
|
+
description: `The tool "${tool.name}" took ${deepElapsed}ms to process a deeply nested object for parameter "${param}".`,
|
|
2445
|
+
evidence: `Response time: ${deepElapsed}ms`,
|
|
2446
|
+
remediation: "Implement nesting depth limits on JSON input parsing."
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
return findings;
|
|
2452
|
+
}
|
|
2453
|
+
getStringParams(tool) {
|
|
2454
|
+
const schema = tool.inputSchema;
|
|
2455
|
+
if (!schema || typeof schema !== "object") return [];
|
|
2456
|
+
const properties = schema["properties"];
|
|
2457
|
+
if (!properties || typeof properties !== "object") return [];
|
|
2458
|
+
return Object.entries(properties).filter(([, v]) => v["type"] === "string").map(([k]) => k);
|
|
2459
|
+
}
|
|
2460
|
+
createDeepObject(depth) {
|
|
2461
|
+
let obj = { value: "leaf" };
|
|
2462
|
+
for (let i = 0; i < depth; i++) {
|
|
2463
|
+
obj = { nested: obj };
|
|
2464
|
+
}
|
|
2465
|
+
return obj;
|
|
2466
|
+
}
|
|
2467
|
+
};
|
|
2468
|
+
|
|
2469
|
+
// src/security/rules/auth-bypass.ts
|
|
2470
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
2471
|
+
var ADMIN_PATTERNS = /^(admin|delete|remove|drop|create|update|write|modify|set|config|configure|manage|grant|revoke|reset|destroy|purge|execute|exec|run|deploy|install|uninstall)_?/i;
|
|
2472
|
+
var AuthBypassRule = class {
|
|
2473
|
+
id = "auth-bypass";
|
|
2474
|
+
name = "Auth Bypass";
|
|
2475
|
+
description = "Tests for unrestricted access to administrative or privileged tools";
|
|
2476
|
+
async scan(client, tools, config) {
|
|
2477
|
+
const findings = [];
|
|
2478
|
+
const adminTools = tools.filter((t) => ADMIN_PATTERNS.test(t.name));
|
|
2479
|
+
for (const tool of adminTools) {
|
|
2480
|
+
const result = await callWithTimeout(client, tool.name, {}, config.timeout);
|
|
2481
|
+
if (result && !result.isError) {
|
|
2482
|
+
findings.push({
|
|
2483
|
+
id: randomUUID6(),
|
|
2484
|
+
rule: this.id,
|
|
2485
|
+
severity: "high",
|
|
2486
|
+
title: `Unrestricted access to ${tool.name}`,
|
|
2487
|
+
description: `The administrative tool "${tool.name}" was callable with empty arguments without any authentication or authorization check.`,
|
|
2488
|
+
evidence: JSON.stringify(result.content).slice(0, 200),
|
|
2489
|
+
remediation: "Implement authentication and authorization checks for administrative tools. Require valid credentials or tokens."
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
const required = this.getRequiredFields(tool);
|
|
2493
|
+
if (required.length > 0) {
|
|
2494
|
+
const minimalArgs = {};
|
|
2495
|
+
for (const field of required) {
|
|
2496
|
+
minimalArgs[field] = "test";
|
|
2497
|
+
}
|
|
2498
|
+
const minResult = await callWithTimeout(client, tool.name, minimalArgs, config.timeout);
|
|
2499
|
+
if (minResult && !minResult.isError) {
|
|
2500
|
+
findings.push({
|
|
2501
|
+
id: randomUUID6(),
|
|
2502
|
+
rule: this.id,
|
|
2503
|
+
severity: "high",
|
|
2504
|
+
title: `Admin tool ${tool.name} accessible without auth`,
|
|
2505
|
+
description: `The administrative tool "${tool.name}" accepted minimal arguments without authentication verification.`,
|
|
2506
|
+
evidence: JSON.stringify(minResult.content).slice(0, 200),
|
|
2507
|
+
remediation: "Implement proper authentication and authorization before allowing access to administrative operations."
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
return findings;
|
|
2513
|
+
}
|
|
2514
|
+
getRequiredFields(tool) {
|
|
2515
|
+
const schema = tool.inputSchema;
|
|
2516
|
+
if (!schema || typeof schema !== "object") return [];
|
|
2517
|
+
const required = schema["required"];
|
|
2518
|
+
if (!Array.isArray(required)) return [];
|
|
2519
|
+
return required;
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
|
|
2523
|
+
// src/security/rules/injection.ts
|
|
2524
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
2525
|
+
var SQL_ERROR_PATTERNS = /sql|syntax error|sqlite|mysql|postgresql|ora-\d|unterminated|unexpected token/i;
|
|
2526
|
+
var INJECTION_ECHO_PATTERNS = /vulnerable|<script>|alert\(|onerror=/i;
|
|
2527
|
+
var InjectionRule = class {
|
|
2528
|
+
id = "injection";
|
|
2529
|
+
name = "Injection";
|
|
2530
|
+
description = "Tests for SQL injection, command injection, and template injection vulnerabilities";
|
|
2531
|
+
async scan(client, tools, config) {
|
|
2532
|
+
const findings = [];
|
|
2533
|
+
const categories = ["sql-injection", "command-injection", "template-injection"];
|
|
2534
|
+
const payloads = getPayloadsForMode(config.mode).filter((p) => categories.includes(p.category));
|
|
2535
|
+
for (const tool of tools) {
|
|
2536
|
+
const stringParams = this.getStringParams(tool);
|
|
2537
|
+
if (stringParams.length === 0) continue;
|
|
2538
|
+
for (const param of stringParams) {
|
|
2539
|
+
for (const payload of payloads) {
|
|
2540
|
+
const result = await callWithTimeout(
|
|
2541
|
+
client,
|
|
2542
|
+
tool.name,
|
|
2543
|
+
{ [param]: payload.value },
|
|
2544
|
+
config.timeout
|
|
2545
|
+
);
|
|
2546
|
+
if (!result) continue;
|
|
2547
|
+
const contentStr = JSON.stringify(result.content);
|
|
2548
|
+
if (payload.category === "sql-injection" && SQL_ERROR_PATTERNS.test(contentStr)) {
|
|
2549
|
+
findings.push({
|
|
2550
|
+
id: randomUUID7(),
|
|
2551
|
+
rule: this.id,
|
|
2552
|
+
severity: "critical",
|
|
2553
|
+
title: `SQL injection on ${tool.name}.${param}`,
|
|
2554
|
+
description: `The tool "${tool.name}" returned SQL error messages when given SQL injection payload "${payload.label}" on parameter "${param}".`,
|
|
2555
|
+
evidence: contentStr.slice(0, 500),
|
|
2556
|
+
remediation: "Use parameterized queries or prepared statements. Never concatenate user input into SQL queries."
|
|
2557
|
+
});
|
|
2558
|
+
break;
|
|
2559
|
+
}
|
|
2560
|
+
if (payload.category === "command-injection" && INJECTION_ECHO_PATTERNS.test(contentStr)) {
|
|
2561
|
+
findings.push({
|
|
2562
|
+
id: randomUUID7(),
|
|
2563
|
+
rule: this.id,
|
|
2564
|
+
severity: "critical",
|
|
2565
|
+
title: `Command injection on ${tool.name}.${param}`,
|
|
2566
|
+
description: `The tool "${tool.name}" appears to execute injected commands via parameter "${param}" using payload "${payload.label}".`,
|
|
2567
|
+
evidence: contentStr.slice(0, 500),
|
|
2568
|
+
remediation: "Never pass user input directly to shell commands. Use parameterized APIs or allow-lists for commands."
|
|
2569
|
+
});
|
|
2570
|
+
break;
|
|
2571
|
+
}
|
|
2572
|
+
if (payload.category === "template-injection" && /49/.test(contentStr) && !result.isError) {
|
|
2573
|
+
findings.push({
|
|
2574
|
+
id: randomUUID7(),
|
|
2575
|
+
rule: this.id,
|
|
2576
|
+
severity: "high",
|
|
2577
|
+
title: `Template injection on ${tool.name}.${param}`,
|
|
2578
|
+
description: `The tool "${tool.name}" evaluated a template expression via parameter "${param}" using payload "${payload.label}".`,
|
|
2579
|
+
evidence: contentStr.slice(0, 500),
|
|
2580
|
+
remediation: "Sanitize user input before passing to template engines. Use sandboxed template rendering."
|
|
2581
|
+
});
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return findings;
|
|
2588
|
+
}
|
|
2589
|
+
getStringParams(tool) {
|
|
2590
|
+
const schema = tool.inputSchema;
|
|
2591
|
+
if (!schema || typeof schema !== "object") return [];
|
|
2592
|
+
const properties = schema["properties"];
|
|
2593
|
+
if (!properties || typeof properties !== "object") return [];
|
|
2594
|
+
return Object.entries(properties).filter(([, v]) => v["type"] === "string").map(([k]) => k);
|
|
2595
|
+
}
|
|
2596
|
+
};
|
|
2597
|
+
|
|
2598
|
+
// src/security/rules/information-disclosure.ts
|
|
2599
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
2600
|
+
var STACK_TRACE_PATTERNS = /at\s+\w+\s+\(|Error:\s+|Traceback\s+\(|stack.*trace|\.js:\d+:\d+|\.ts:\d+:\d+|\.py", line \d+/i;
|
|
2601
|
+
var INTERNAL_PATH_PATTERNS = /\/home\/\w+|\/Users\/\w+|C:\\Users\\\w+|\/var\/|\/opt\/|\/srv\//;
|
|
2602
|
+
var CONFIG_PATTERNS = /DATABASE_URL|DB_PASSWORD|API_KEY|SECRET_KEY|PRIVATE_KEY|ACCESS_TOKEN|AWS_SECRET/i;
|
|
2603
|
+
var VERSION_PATTERNS = /node\/\d+\.\d+|express\/\d+|nginx\/\d+|apache\/\d+|python\/\d+/i;
|
|
2604
|
+
var InformationDisclosureRule = class {
|
|
2605
|
+
id = "information-disclosure";
|
|
2606
|
+
name = "Information Disclosure";
|
|
2607
|
+
description = "Tests for unintended information disclosure in error messages and responses";
|
|
2608
|
+
async scan(client, tools, config) {
|
|
2609
|
+
const findings = [];
|
|
2610
|
+
for (const tool of tools) {
|
|
2611
|
+
const errorTriggers = [
|
|
2612
|
+
{},
|
|
2613
|
+
{ nonexistent_param: "test" },
|
|
2614
|
+
{ [this.getFirstParam(tool) ?? "id"]: null },
|
|
2615
|
+
{ [this.getFirstParam(tool) ?? "id"]: "" }
|
|
2616
|
+
];
|
|
2617
|
+
for (const args of errorTriggers) {
|
|
2618
|
+
const result = await callWithTimeout(client, tool.name, args, config.timeout);
|
|
2619
|
+
if (!result) continue;
|
|
2620
|
+
const contentStr = JSON.stringify(result.content);
|
|
2621
|
+
if (STACK_TRACE_PATTERNS.test(contentStr)) {
|
|
2622
|
+
findings.push({
|
|
2623
|
+
id: randomUUID8(),
|
|
2624
|
+
rule: this.id,
|
|
2625
|
+
severity: "medium",
|
|
2626
|
+
title: `Stack trace disclosed by ${tool.name}`,
|
|
2627
|
+
description: `The tool "${tool.name}" exposed a stack trace in its error response, potentially revealing internal implementation details.`,
|
|
2628
|
+
evidence: contentStr.slice(0, 500),
|
|
2629
|
+
remediation: "Return generic error messages to clients. Log detailed errors server-side only."
|
|
2630
|
+
});
|
|
2631
|
+
break;
|
|
2632
|
+
}
|
|
2633
|
+
if (INTERNAL_PATH_PATTERNS.test(contentStr)) {
|
|
2634
|
+
findings.push({
|
|
2635
|
+
id: randomUUID8(),
|
|
2636
|
+
rule: this.id,
|
|
2637
|
+
severity: "low",
|
|
2638
|
+
title: `Internal path disclosed by ${tool.name}`,
|
|
2639
|
+
description: `The tool "${tool.name}" exposed internal file system paths in its response.`,
|
|
2640
|
+
evidence: contentStr.slice(0, 500),
|
|
2641
|
+
remediation: "Sanitize error messages to remove internal file paths before returning to clients."
|
|
2642
|
+
});
|
|
2643
|
+
break;
|
|
2644
|
+
}
|
|
2645
|
+
if (CONFIG_PATTERNS.test(contentStr)) {
|
|
2646
|
+
findings.push({
|
|
2647
|
+
id: randomUUID8(),
|
|
2648
|
+
rule: this.id,
|
|
2649
|
+
severity: "high",
|
|
2650
|
+
title: `Configuration data disclosed by ${tool.name}`,
|
|
2651
|
+
description: `The tool "${tool.name}" exposed configuration values or secrets in its response.`,
|
|
2652
|
+
evidence: contentStr.slice(0, 500),
|
|
2653
|
+
remediation: "Never include configuration values, secrets, or environment variables in error responses."
|
|
2654
|
+
});
|
|
2655
|
+
break;
|
|
2656
|
+
}
|
|
2657
|
+
if (VERSION_PATTERNS.test(contentStr)) {
|
|
2658
|
+
findings.push({
|
|
2659
|
+
id: randomUUID8(),
|
|
2660
|
+
rule: this.id,
|
|
2661
|
+
severity: "info",
|
|
2662
|
+
title: `Version information disclosed by ${tool.name}`,
|
|
2663
|
+
description: `The tool "${tool.name}" exposed server/runtime version information in its response.`,
|
|
2664
|
+
evidence: contentStr.slice(0, 500),
|
|
2665
|
+
remediation: "Remove version headers and version information from error responses."
|
|
2666
|
+
});
|
|
2667
|
+
break;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
return findings;
|
|
2672
|
+
}
|
|
2673
|
+
getFirstParam(tool) {
|
|
2674
|
+
const schema = tool.inputSchema;
|
|
2675
|
+
if (!schema || typeof schema !== "object") return void 0;
|
|
2676
|
+
const properties = schema["properties"];
|
|
2677
|
+
if (!properties || typeof properties !== "object") return void 0;
|
|
2678
|
+
const keys = Object.keys(properties);
|
|
2679
|
+
return keys[0];
|
|
2680
|
+
}
|
|
2681
|
+
};
|
|
2682
|
+
|
|
2683
|
+
// src/security/security-scanner.ts
|
|
2684
|
+
var SEVERITY_ORDER2 = ["info", "low", "medium", "high", "critical"];
|
|
2685
|
+
var SecurityScanner = class {
|
|
2686
|
+
rules = /* @__PURE__ */ new Map();
|
|
2687
|
+
constructor() {
|
|
2688
|
+
this.registerBuiltinRules();
|
|
2689
|
+
}
|
|
2690
|
+
registerRule(rule) {
|
|
2691
|
+
this.rules.set(rule.id, rule);
|
|
2692
|
+
}
|
|
2693
|
+
async scan(client, config, progress) {
|
|
2694
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
2695
|
+
const findings = [];
|
|
2696
|
+
const tools = await client.listTools();
|
|
2697
|
+
for (const ruleId of config.rules) {
|
|
2698
|
+
const rule = this.rules.get(ruleId);
|
|
2699
|
+
if (!rule) continue;
|
|
2700
|
+
progress?.onRuleStart?.(rule.id, rule.name);
|
|
2701
|
+
try {
|
|
2702
|
+
const ruleFindings = await rule.scan(client, tools, config);
|
|
2703
|
+
for (const finding of ruleFindings) {
|
|
2704
|
+
findings.push(finding);
|
|
2705
|
+
progress?.onFinding?.(finding);
|
|
2706
|
+
}
|
|
2707
|
+
progress?.onRuleComplete?.(rule.id, ruleFindings.length);
|
|
2708
|
+
} catch (err) {
|
|
2709
|
+
const errorFinding = {
|
|
2710
|
+
id: randomUUID9(),
|
|
2711
|
+
rule: ruleId,
|
|
2712
|
+
severity: "info",
|
|
2713
|
+
title: `Rule "${ruleId}" failed to complete`,
|
|
2714
|
+
description: `The security rule "${ruleId}" encountered an error during scanning: ${err instanceof Error ? err.message : String(err)}`
|
|
2715
|
+
};
|
|
2716
|
+
findings.push(errorFinding);
|
|
2717
|
+
progress?.onFinding?.(errorFinding);
|
|
2718
|
+
progress?.onRuleComplete?.(ruleId, 0);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
findings.sort((a, b) => {
|
|
2722
|
+
return SEVERITY_ORDER2.indexOf(b.severity) - SEVERITY_ORDER2.indexOf(a.severity);
|
|
2723
|
+
});
|
|
2724
|
+
const completedAt = /* @__PURE__ */ new Date();
|
|
2725
|
+
const serverInfo = client.getServerInfo();
|
|
2726
|
+
return {
|
|
2727
|
+
id: randomUUID9(),
|
|
2728
|
+
serverName: serverInfo?.name ?? "unknown",
|
|
2729
|
+
mode: config.mode,
|
|
2730
|
+
startedAt,
|
|
2731
|
+
completedAt,
|
|
2732
|
+
findings,
|
|
2733
|
+
summary: this.buildSummary(findings)
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
buildSummary(findings) {
|
|
2737
|
+
const bySeverity = {
|
|
2738
|
+
info: 0,
|
|
2739
|
+
low: 0,
|
|
2740
|
+
medium: 0,
|
|
2741
|
+
high: 0,
|
|
2742
|
+
critical: 0
|
|
2743
|
+
};
|
|
2744
|
+
const byRule = {};
|
|
2745
|
+
for (const finding of findings) {
|
|
2746
|
+
bySeverity[finding.severity]++;
|
|
2747
|
+
byRule[finding.rule] = (byRule[finding.rule] ?? 0) + 1;
|
|
2748
|
+
}
|
|
2749
|
+
return {
|
|
2750
|
+
totalFindings: findings.length,
|
|
2751
|
+
bySeverity,
|
|
2752
|
+
byRule
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
registerBuiltinRules() {
|
|
2756
|
+
this.registerRule(new PathTraversalRule());
|
|
2757
|
+
this.registerRule(new InputValidationRule());
|
|
2758
|
+
this.registerRule(new ResourceExhaustionRule());
|
|
2759
|
+
this.registerRule(new AuthBypassRule());
|
|
2760
|
+
this.registerRule(new InjectionRule());
|
|
2761
|
+
this.registerRule(new InformationDisclosureRule());
|
|
2762
|
+
}
|
|
2763
|
+
};
|
|
2764
|
+
|
|
2765
|
+
// src/performance/profiler.ts
|
|
2766
|
+
import { performance as performance2 } from "perf_hooks";
|
|
2767
|
+
function computeStats(sortedDurations) {
|
|
2768
|
+
if (sortedDurations.length === 0) {
|
|
2769
|
+
return { min: 0, max: 0, mean: 0, median: 0, p95: 0, p99: 0, stddev: 0 };
|
|
2770
|
+
}
|
|
2771
|
+
const n = sortedDurations.length;
|
|
2772
|
+
const min = sortedDurations[0];
|
|
2773
|
+
const max = sortedDurations[n - 1];
|
|
2774
|
+
const sum = sortedDurations.reduce((a, b) => a + b, 0);
|
|
2775
|
+
const mean = sum / n;
|
|
2776
|
+
const median = n % 2 === 0 ? (sortedDurations[n / 2 - 1] + sortedDurations[n / 2]) / 2 : sortedDurations[Math.floor(n / 2)];
|
|
2777
|
+
const p95 = sortedDurations[Math.ceil(n * 0.95) - 1];
|
|
2778
|
+
const p99 = sortedDurations[Math.ceil(n * 0.99) - 1];
|
|
2779
|
+
const variance = sortedDurations.reduce((acc, val) => acc + (val - mean) ** 2, 0) / n;
|
|
2780
|
+
const stddev = Math.sqrt(variance);
|
|
2781
|
+
return { min, max, mean, median, p95, p99, stddev };
|
|
2782
|
+
}
|
|
2783
|
+
var Profiler = class {
|
|
2784
|
+
entries = [];
|
|
2785
|
+
async profileCall(client, toolName, args) {
|
|
2786
|
+
const startMs = performance2.now();
|
|
2787
|
+
let success = true;
|
|
2788
|
+
let error;
|
|
2789
|
+
try {
|
|
2790
|
+
const result = await client.callTool(toolName, args);
|
|
2791
|
+
if (result.isError) {
|
|
2792
|
+
success = false;
|
|
2793
|
+
error = JSON.stringify(result.content).slice(0, 200);
|
|
2794
|
+
}
|
|
2795
|
+
} catch (err) {
|
|
2796
|
+
success = false;
|
|
2797
|
+
error = err instanceof Error ? err.message : String(err);
|
|
2798
|
+
}
|
|
2799
|
+
const durationMs = performance2.now() - startMs;
|
|
2800
|
+
const entry = {
|
|
2801
|
+
toolName,
|
|
2802
|
+
startMs,
|
|
2803
|
+
durationMs,
|
|
2804
|
+
success,
|
|
2805
|
+
error
|
|
2806
|
+
};
|
|
2807
|
+
this.entries.push(entry);
|
|
2808
|
+
return entry;
|
|
2809
|
+
}
|
|
2810
|
+
getEntries() {
|
|
2811
|
+
return [...this.entries];
|
|
2812
|
+
}
|
|
2813
|
+
getStats(toolName) {
|
|
2814
|
+
const filtered = toolName ? this.entries.filter((e) => e.toolName === toolName && e.success) : this.entries.filter((e) => e.success);
|
|
2815
|
+
const durations = filtered.map((e) => e.durationMs).sort((a, b) => a - b);
|
|
2816
|
+
return computeStats(durations);
|
|
2817
|
+
}
|
|
2818
|
+
clear() {
|
|
2819
|
+
this.entries = [];
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
|
|
2823
|
+
// src/performance/benchmark-runner.ts
|
|
2824
|
+
import { performance as performance3 } from "perf_hooks";
|
|
2825
|
+
var DEFAULT_CONFIG = {
|
|
2826
|
+
iterations: 100,
|
|
2827
|
+
warmupIterations: 5,
|
|
2828
|
+
concurrency: 1,
|
|
2829
|
+
timeout: 3e4
|
|
2830
|
+
};
|
|
2831
|
+
var BenchmarkRunner = class {
|
|
2832
|
+
async run(client, toolName, args, config = {}, progress) {
|
|
2833
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
2834
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
2835
|
+
if (cfg.warmupIterations > 0) {
|
|
2836
|
+
progress?.onWarmupStart?.(cfg.warmupIterations);
|
|
2837
|
+
for (let i = 0; i < cfg.warmupIterations; i++) {
|
|
2838
|
+
await this.runSingleIteration(client, toolName, args, cfg.timeout);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
const durations = [];
|
|
2842
|
+
let errors = 0;
|
|
2843
|
+
for (let i = 0; i < cfg.iterations; i++) {
|
|
2844
|
+
const result = await this.runSingleIteration(client, toolName, args, cfg.timeout);
|
|
2845
|
+
if (result.success) {
|
|
2846
|
+
durations.push(result.durationMs);
|
|
2847
|
+
} else {
|
|
2848
|
+
errors++;
|
|
2849
|
+
}
|
|
2850
|
+
progress?.onIterationComplete?.(i + 1, cfg.iterations, result.durationMs);
|
|
2851
|
+
}
|
|
2852
|
+
durations.sort((a, b) => a - b);
|
|
2853
|
+
const stats = computeStats(durations);
|
|
2854
|
+
const completedAt = /* @__PURE__ */ new Date();
|
|
2855
|
+
const benchResult = {
|
|
2856
|
+
toolName,
|
|
2857
|
+
iterations: cfg.iterations,
|
|
2858
|
+
stats,
|
|
2859
|
+
errors,
|
|
2860
|
+
startedAt,
|
|
2861
|
+
completedAt
|
|
2862
|
+
};
|
|
2863
|
+
progress?.onComplete?.(benchResult);
|
|
2864
|
+
return benchResult;
|
|
2865
|
+
}
|
|
2866
|
+
async runSingleIteration(client, toolName, args, timeout) {
|
|
2867
|
+
const start = performance3.now();
|
|
2868
|
+
try {
|
|
2869
|
+
const result = await Promise.race([
|
|
2870
|
+
client.callTool(toolName, args),
|
|
2871
|
+
new Promise((resolve) => setTimeout(() => resolve(null), timeout))
|
|
2872
|
+
]);
|
|
2873
|
+
const durationMs = performance3.now() - start;
|
|
2874
|
+
if (result === null) {
|
|
2875
|
+
return { success: false, durationMs };
|
|
2876
|
+
}
|
|
2877
|
+
return { success: !result.isError, durationMs };
|
|
2878
|
+
} catch {
|
|
2879
|
+
const durationMs = performance3.now() - start;
|
|
2880
|
+
return { success: false, durationMs };
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
// src/performance/waterfall-generator.ts
|
|
2886
|
+
var WaterfallGenerator = class {
|
|
2887
|
+
generate(entries) {
|
|
2888
|
+
if (entries.length === 0) return [];
|
|
2889
|
+
const minStart = Math.min(...entries.map((e) => e.startMs));
|
|
2890
|
+
return entries.map((entry) => ({
|
|
2891
|
+
label: `${entry.toolName}${entry.success ? "" : " (ERR)"}`,
|
|
2892
|
+
startMs: entry.startMs - minStart,
|
|
2893
|
+
durationMs: entry.durationMs
|
|
2894
|
+
}));
|
|
2895
|
+
}
|
|
2896
|
+
toAscii(entries, width = 60) {
|
|
2897
|
+
if (entries.length === 0) return "";
|
|
2898
|
+
const maxEnd = Math.max(...entries.map((e) => e.startMs + e.durationMs));
|
|
2899
|
+
if (maxEnd === 0) return "";
|
|
2900
|
+
const maxLabelLen = Math.max(...entries.map((e) => e.label.length));
|
|
2901
|
+
const barWidth = width - maxLabelLen - 12;
|
|
2902
|
+
const lines = [];
|
|
2903
|
+
for (const entry of entries) {
|
|
2904
|
+
const label = entry.label.padEnd(maxLabelLen);
|
|
2905
|
+
const startCol = Math.floor(entry.startMs / maxEnd * barWidth);
|
|
2906
|
+
const endCol = Math.max(startCol + 1, Math.floor((entry.startMs + entry.durationMs) / maxEnd * barWidth));
|
|
2907
|
+
const prefix = " ".repeat(startCol);
|
|
2908
|
+
const bar = "\u2588".repeat(endCol - startCol);
|
|
2909
|
+
const suffix = ` ${entry.durationMs.toFixed(1)}ms`;
|
|
2910
|
+
lines.push(`${label} |${prefix}${bar}${suffix}`);
|
|
2911
|
+
}
|
|
2912
|
+
return lines.join("\n");
|
|
2913
|
+
}
|
|
2914
|
+
};
|
|
2915
|
+
|
|
2916
|
+
// src/documentation/doc-generator.ts
|
|
2917
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
2918
|
+
import { join as join3 } from "path";
|
|
2919
|
+
|
|
2920
|
+
// src/documentation/markdown-generator.ts
|
|
2921
|
+
var MarkdownGenerator = class {
|
|
2922
|
+
generate(data) {
|
|
2923
|
+
const lines = [];
|
|
2924
|
+
const version = data.serverVersion ? ` v${data.serverVersion}` : "";
|
|
2925
|
+
lines.push(`# ${data.serverName}${version}`);
|
|
2926
|
+
lines.push("");
|
|
2927
|
+
if (data.tools.length > 0) {
|
|
2928
|
+
lines.push("## Tools");
|
|
2929
|
+
lines.push("");
|
|
2930
|
+
lines.push("| Name | Description |");
|
|
2931
|
+
lines.push("|------|-------------|");
|
|
2932
|
+
for (const tool of data.tools) {
|
|
2933
|
+
const desc = tool.description ?? "-";
|
|
2934
|
+
lines.push(`| ${tool.name} | ${desc} |`);
|
|
2935
|
+
}
|
|
2936
|
+
lines.push("");
|
|
2937
|
+
for (const tool of data.tools) {
|
|
2938
|
+
lines.push(`### ${tool.name}`);
|
|
2939
|
+
lines.push("");
|
|
2940
|
+
if (tool.description) {
|
|
2941
|
+
lines.push(tool.description);
|
|
2942
|
+
lines.push("");
|
|
2943
|
+
}
|
|
2944
|
+
if (tool.inputSchema) {
|
|
2945
|
+
lines.push("**Input Schema:**");
|
|
2946
|
+
lines.push("");
|
|
2947
|
+
lines.push("```json");
|
|
2948
|
+
lines.push(JSON.stringify(tool.inputSchema, null, 2));
|
|
2949
|
+
lines.push("```");
|
|
2950
|
+
lines.push("");
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
} else {
|
|
2954
|
+
lines.push("## Tools");
|
|
2955
|
+
lines.push("");
|
|
2956
|
+
lines.push("No tools available.");
|
|
2957
|
+
lines.push("");
|
|
2958
|
+
}
|
|
2959
|
+
if (data.resources.length > 0) {
|
|
2960
|
+
lines.push("## Resources");
|
|
2961
|
+
lines.push("");
|
|
2962
|
+
lines.push("| URI | Name | Description | MIME Type |");
|
|
2963
|
+
lines.push("|-----|------|-------------|-----------|");
|
|
2964
|
+
for (const res of data.resources) {
|
|
2965
|
+
const name = res.name ?? "-";
|
|
2966
|
+
const desc = res.description ?? "-";
|
|
2967
|
+
const mime = res.mimeType ?? "-";
|
|
2968
|
+
lines.push(`| ${res.uri} | ${name} | ${desc} | ${mime} |`);
|
|
2969
|
+
}
|
|
2970
|
+
lines.push("");
|
|
2971
|
+
}
|
|
2972
|
+
lines.push("---");
|
|
2973
|
+
lines.push("");
|
|
2974
|
+
lines.push(`*Generated by MCPSpec on ${data.generatedAt.toISOString()}*`);
|
|
2975
|
+
lines.push("");
|
|
2976
|
+
return lines.join("\n");
|
|
2977
|
+
}
|
|
2978
|
+
};
|
|
2979
|
+
|
|
2980
|
+
// src/documentation/html-generator.ts
|
|
2981
|
+
import Handlebars2 from "handlebars";
|
|
2982
|
+
var HTML_TEMPLATE2 = `<!DOCTYPE html>
|
|
2983
|
+
<html lang="en">
|
|
2984
|
+
<head>
|
|
2985
|
+
<meta charset="UTF-8">
|
|
2986
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2987
|
+
<title>{{serverName}} Documentation</title>
|
|
2988
|
+
<style>
|
|
2989
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2990
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #f8f9fa; line-height: 1.6; }
|
|
2991
|
+
.container { max-width: 960px; margin: 0 auto; padding: 2rem; }
|
|
2992
|
+
h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #2D5A27; }
|
|
2993
|
+
h2 { font-size: 1.5rem; margin: 2rem 0 1rem; border-bottom: 2px solid #e0e0e0; padding-bottom: 0.5rem; }
|
|
2994
|
+
h3 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
|
|
2995
|
+
.version { color: #666; font-size: 1rem; font-weight: normal; }
|
|
2996
|
+
.tool-card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
|
2997
|
+
.tool-card h3 { margin-top: 0; }
|
|
2998
|
+
.tool-card p { color: #555; margin-bottom: 0.75rem; }
|
|
2999
|
+
pre { background: #1e1e2e; color: #cdd6f4; padding: 1rem; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; }
|
|
3000
|
+
code { font-family: 'SF Mono', 'Fira Code', monospace; }
|
|
3001
|
+
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
|
3002
|
+
th, td { text-align: left; padding: 0.5rem 0.75rem; border: 1px solid #e0e0e0; }
|
|
3003
|
+
th { background: #f1f3f5; font-weight: 600; }
|
|
3004
|
+
.footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; color: #888; font-size: 0.85rem; }
|
|
3005
|
+
</style>
|
|
3006
|
+
</head>
|
|
3007
|
+
<body>
|
|
3008
|
+
<div class="container">
|
|
3009
|
+
<h1>{{serverName}} {{#if serverVersion}}<span class="version">v{{serverVersion}}</span>{{/if}}</h1>
|
|
3010
|
+
|
|
3011
|
+
<h2>Tools</h2>
|
|
3012
|
+
{{#if tools.length}}
|
|
3013
|
+
{{#each tools}}
|
|
3014
|
+
<div class="tool-card">
|
|
3015
|
+
<h3>{{this.name}}</h3>
|
|
3016
|
+
{{#if this.description}}<p>{{this.description}}</p>{{/if}}
|
|
3017
|
+
{{#if this.inputSchema}}
|
|
3018
|
+
<strong>Input Schema:</strong>
|
|
3019
|
+
<pre><code>{{jsonPretty this.inputSchema}}</code></pre>
|
|
3020
|
+
{{/if}}
|
|
3021
|
+
</div>
|
|
3022
|
+
{{/each}}
|
|
3023
|
+
{{else}}
|
|
3024
|
+
<p>No tools available.</p>
|
|
3025
|
+
{{/if}}
|
|
3026
|
+
|
|
3027
|
+
{{#if resources.length}}
|
|
3028
|
+
<h2>Resources</h2>
|
|
3029
|
+
<table>
|
|
3030
|
+
<thead>
|
|
3031
|
+
<tr><th>URI</th><th>Name</th><th>Description</th><th>MIME Type</th></tr>
|
|
3032
|
+
</thead>
|
|
3033
|
+
<tbody>
|
|
3034
|
+
{{#each resources}}
|
|
3035
|
+
<tr>
|
|
3036
|
+
<td>{{this.uri}}</td>
|
|
3037
|
+
<td>{{or this.name "-"}}</td>
|
|
3038
|
+
<td>{{or this.description "-"}}</td>
|
|
3039
|
+
<td>{{or this.mimeType "-"}}</td>
|
|
3040
|
+
</tr>
|
|
3041
|
+
{{/each}}
|
|
3042
|
+
</tbody>
|
|
3043
|
+
</table>
|
|
3044
|
+
{{/if}}
|
|
3045
|
+
|
|
3046
|
+
<div class="footer">
|
|
3047
|
+
Generated by MCPSpec on {{generatedAt}}
|
|
3048
|
+
</div>
|
|
3049
|
+
</div>
|
|
3050
|
+
</body>
|
|
3051
|
+
</html>`;
|
|
3052
|
+
var HtmlDocGenerator = class {
|
|
3053
|
+
template;
|
|
3054
|
+
constructor() {
|
|
3055
|
+
Handlebars2.registerHelper("jsonPretty", (obj) => {
|
|
3056
|
+
return new Handlebars2.SafeString(JSON.stringify(obj, null, 2));
|
|
3057
|
+
});
|
|
3058
|
+
Handlebars2.registerHelper("or", (a, b) => a || b);
|
|
3059
|
+
this.template = Handlebars2.compile(HTML_TEMPLATE2);
|
|
3060
|
+
}
|
|
3061
|
+
generate(data) {
|
|
3062
|
+
return this.template({
|
|
3063
|
+
...data,
|
|
3064
|
+
generatedAt: data.generatedAt.toISOString()
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
};
|
|
3068
|
+
|
|
3069
|
+
// src/documentation/doc-generator.ts
|
|
3070
|
+
var DocGenerator = class {
|
|
3071
|
+
async introspect(client) {
|
|
3072
|
+
const serverInfo = client.getServerInfo();
|
|
3073
|
+
const tools = await client.listTools();
|
|
3074
|
+
let resources = [];
|
|
3075
|
+
try {
|
|
3076
|
+
resources = await client.listResources();
|
|
3077
|
+
} catch {
|
|
3078
|
+
}
|
|
3079
|
+
return {
|
|
3080
|
+
serverName: serverInfo?.name ?? "Unknown Server",
|
|
3081
|
+
serverVersion: serverInfo?.version,
|
|
3082
|
+
tools,
|
|
3083
|
+
resources,
|
|
3084
|
+
generatedAt: /* @__PURE__ */ new Date()
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
async generate(client, options) {
|
|
3088
|
+
const data = await this.introspect(client);
|
|
3089
|
+
let content;
|
|
3090
|
+
if (options.format === "html") {
|
|
3091
|
+
const generator = new HtmlDocGenerator();
|
|
3092
|
+
content = generator.generate(data);
|
|
3093
|
+
} else {
|
|
3094
|
+
const generator = new MarkdownGenerator();
|
|
3095
|
+
content = generator.generate(data);
|
|
3096
|
+
}
|
|
3097
|
+
if (options.outputDir) {
|
|
3098
|
+
mkdirSync2(options.outputDir, { recursive: true });
|
|
3099
|
+
const filename = options.format === "html" ? "index.html" : "README.md";
|
|
3100
|
+
writeFileSync2(join3(options.outputDir, filename), content, "utf-8");
|
|
3101
|
+
}
|
|
3102
|
+
return content;
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
|
|
3106
|
+
// src/scoring/mcp-score.ts
|
|
3107
|
+
var MCPScoreCalculator = class {
|
|
3108
|
+
async calculate(client, progress) {
|
|
3109
|
+
const tools = await client.listTools();
|
|
3110
|
+
let resources = [];
|
|
3111
|
+
try {
|
|
3112
|
+
resources = await client.listResources();
|
|
3113
|
+
} catch {
|
|
3114
|
+
}
|
|
3115
|
+
progress?.onCategoryStart?.("documentation");
|
|
3116
|
+
const documentation = this.scoreDocumentation(tools, resources);
|
|
3117
|
+
progress?.onCategoryComplete?.("documentation", documentation);
|
|
3118
|
+
progress?.onCategoryStart?.("schemaQuality");
|
|
3119
|
+
const schemaQuality = this.scoreSchemaQuality(tools);
|
|
3120
|
+
progress?.onCategoryComplete?.("schemaQuality", schemaQuality);
|
|
3121
|
+
progress?.onCategoryStart?.("errorHandling");
|
|
3122
|
+
const errorHandling = await this.scoreErrorHandling(client, tools);
|
|
3123
|
+
progress?.onCategoryComplete?.("errorHandling", errorHandling);
|
|
3124
|
+
progress?.onCategoryStart?.("performance");
|
|
3125
|
+
const performance4 = await this.scorePerformance(client, tools);
|
|
3126
|
+
progress?.onCategoryComplete?.("performance", performance4);
|
|
3127
|
+
progress?.onCategoryStart?.("security");
|
|
3128
|
+
const security = await this.scoreSecurity(client);
|
|
3129
|
+
progress?.onCategoryComplete?.("security", security);
|
|
3130
|
+
const overall = Math.round(
|
|
3131
|
+
documentation * 0.25 + schemaQuality * 0.25 + errorHandling * 0.2 + performance4 * 0.15 + security * 0.15
|
|
3132
|
+
);
|
|
3133
|
+
return {
|
|
3134
|
+
overall,
|
|
3135
|
+
categories: {
|
|
3136
|
+
documentation,
|
|
3137
|
+
schemaQuality,
|
|
3138
|
+
errorHandling,
|
|
3139
|
+
performance: performance4,
|
|
3140
|
+
security
|
|
3141
|
+
}
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
scoreDocumentation(tools, resources) {
|
|
3145
|
+
const items = [...tools, ...resources];
|
|
3146
|
+
if (items.length === 0) return 0;
|
|
3147
|
+
const withDescription = items.filter((item) => {
|
|
3148
|
+
const desc = "description" in item ? item.description : void 0;
|
|
3149
|
+
return desc && desc.trim().length > 0;
|
|
3150
|
+
}).length;
|
|
3151
|
+
return Math.round(withDescription / items.length * 100);
|
|
3152
|
+
}
|
|
3153
|
+
scoreSchemaQuality(tools) {
|
|
3154
|
+
if (tools.length === 0) return 0;
|
|
3155
|
+
let totalPoints = 0;
|
|
3156
|
+
for (const tool of tools) {
|
|
3157
|
+
const schema = tool.inputSchema;
|
|
3158
|
+
if (!schema) continue;
|
|
3159
|
+
let toolPoints = 0;
|
|
3160
|
+
if (schema.type) toolPoints += 1 / 3;
|
|
3161
|
+
if (schema.properties && typeof schema.properties === "object") toolPoints += 1 / 3;
|
|
3162
|
+
if (schema.required && Array.isArray(schema.required)) toolPoints += 1 / 3;
|
|
3163
|
+
totalPoints += toolPoints;
|
|
3164
|
+
}
|
|
3165
|
+
return Math.round(totalPoints / tools.length * 100);
|
|
3166
|
+
}
|
|
3167
|
+
async scoreErrorHandling(client, tools) {
|
|
3168
|
+
if (tools.length === 0) return 0;
|
|
3169
|
+
const testTools = tools.slice(0, 5);
|
|
3170
|
+
let totalScore = 0;
|
|
3171
|
+
for (const tool of testTools) {
|
|
3172
|
+
try {
|
|
3173
|
+
const result = await client.callTool(tool.name, {});
|
|
3174
|
+
if (result.isError) {
|
|
3175
|
+
totalScore += 100;
|
|
3176
|
+
} else {
|
|
3177
|
+
totalScore += 50;
|
|
3178
|
+
}
|
|
3179
|
+
} catch {
|
|
3180
|
+
totalScore += 0;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
return Math.round(totalScore / testTools.length);
|
|
3184
|
+
}
|
|
3185
|
+
async scorePerformance(client, tools) {
|
|
3186
|
+
if (tools.length === 0) return 20;
|
|
3187
|
+
const tool = tools[0];
|
|
3188
|
+
const latencies = [];
|
|
3189
|
+
for (let i = 0; i < 5; i++) {
|
|
3190
|
+
const start = performance.now();
|
|
3191
|
+
try {
|
|
3192
|
+
await client.callTool(tool.name, {});
|
|
3193
|
+
} catch {
|
|
3194
|
+
}
|
|
3195
|
+
latencies.push(performance.now() - start);
|
|
3196
|
+
}
|
|
3197
|
+
latencies.sort((a, b) => a - b);
|
|
3198
|
+
const median = latencies[Math.floor(latencies.length / 2)];
|
|
3199
|
+
if (median < 100) return 100;
|
|
3200
|
+
if (median < 500) return 80;
|
|
3201
|
+
if (median < 1e3) return 60;
|
|
3202
|
+
if (median < 5e3) return 40;
|
|
3203
|
+
return 20;
|
|
3204
|
+
}
|
|
3205
|
+
async scoreSecurity(client) {
|
|
3206
|
+
try {
|
|
3207
|
+
const scanner = new SecurityScanner();
|
|
3208
|
+
const config = new ScanConfig({ mode: "passive" });
|
|
3209
|
+
const result = await scanner.scan(client, config);
|
|
3210
|
+
const findingCount = result.summary.totalFindings;
|
|
3211
|
+
if (findingCount === 0) return 100;
|
|
3212
|
+
if (findingCount <= 2) return 70;
|
|
3213
|
+
if (findingCount <= 5) return 40;
|
|
3214
|
+
return 20;
|
|
3215
|
+
} catch {
|
|
3216
|
+
return 50;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
};
|
|
3220
|
+
|
|
3221
|
+
// src/scoring/badge-generator.ts
|
|
3222
|
+
var BadgeGenerator = class {
|
|
3223
|
+
generate(score) {
|
|
3224
|
+
const color = this.getColor(score.overall);
|
|
3225
|
+
const label = "MCP Score";
|
|
3226
|
+
const value = `${score.overall}/100`;
|
|
3227
|
+
const labelWidth = 70;
|
|
3228
|
+
const valueWidth = 50;
|
|
3229
|
+
const totalWidth = labelWidth + valueWidth;
|
|
3230
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
|
|
3231
|
+
<title>${label}: ${value}</title>
|
|
3232
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
3233
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
3234
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
3235
|
+
</linearGradient>
|
|
3236
|
+
<clipPath id="r">
|
|
3237
|
+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
|
|
3238
|
+
</clipPath>
|
|
3239
|
+
<g clip-path="url(#r)">
|
|
3240
|
+
<rect width="${labelWidth}" height="20" fill="#555"/>
|
|
3241
|
+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
|
|
3242
|
+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
3243
|
+
</g>
|
|
3244
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
3245
|
+
<text x="${labelWidth / 2}" y="14">${label}</text>
|
|
3246
|
+
<text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
|
|
3247
|
+
</g>
|
|
3248
|
+
</svg>`;
|
|
3249
|
+
}
|
|
3250
|
+
getColor(score) {
|
|
3251
|
+
if (score >= 80) return "#4c1";
|
|
3252
|
+
if (score >= 60) return "#dfb317";
|
|
3253
|
+
return "#e05d44";
|
|
3254
|
+
}
|
|
3255
|
+
};
|
|
3256
|
+
export {
|
|
3257
|
+
AuthBypassRule,
|
|
3258
|
+
BadgeGenerator,
|
|
3259
|
+
BaselineStore,
|
|
3260
|
+
BenchmarkRunner,
|
|
3261
|
+
ConnectionManager,
|
|
3262
|
+
ConsoleReporter,
|
|
3263
|
+
DocGenerator,
|
|
3264
|
+
ERROR_CODE_MAP,
|
|
3265
|
+
ERROR_TEMPLATES,
|
|
3266
|
+
HtmlDocGenerator,
|
|
3267
|
+
HtmlReporter,
|
|
3268
|
+
InformationDisclosureRule,
|
|
3269
|
+
InjectionRule,
|
|
3270
|
+
InputValidationRule,
|
|
3271
|
+
JsonReporter,
|
|
3272
|
+
JunitReporter,
|
|
3273
|
+
LoggingTransport,
|
|
3274
|
+
MCPClient,
|
|
3275
|
+
MCPScoreCalculator,
|
|
3276
|
+
MCPSpecError,
|
|
3277
|
+
MarkdownGenerator,
|
|
3278
|
+
NotImplementedError,
|
|
3279
|
+
PathTraversalRule,
|
|
3280
|
+
ProcessManagerImpl,
|
|
3281
|
+
ProcessRegistry,
|
|
3282
|
+
Profiler,
|
|
3283
|
+
RateLimiter,
|
|
3284
|
+
ResourceExhaustionRule,
|
|
3285
|
+
ResultDiffer,
|
|
3286
|
+
ScanConfig,
|
|
3287
|
+
SecretMasker,
|
|
3288
|
+
SecurityScanner,
|
|
3289
|
+
TapReporter,
|
|
3290
|
+
TestExecutor,
|
|
3291
|
+
TestRunner,
|
|
3292
|
+
TestScheduler,
|
|
3293
|
+
WaterfallGenerator,
|
|
3294
|
+
YAML_LIMITS,
|
|
3295
|
+
computeStats,
|
|
3296
|
+
formatError,
|
|
3297
|
+
getPayloadsForMode,
|
|
3298
|
+
getPlatformInfo,
|
|
3299
|
+
getPlatformPayloads,
|
|
3300
|
+
getSafePayloads,
|
|
3301
|
+
loadYamlSafely,
|
|
3302
|
+
queryJsonPath,
|
|
3303
|
+
registerCleanupHandlers,
|
|
3304
|
+
resolveVariables
|
|
3305
|
+
};
|