mcpspec 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/index.js +1109 -0
- package/dist/onboarding-5G6D2OYR.js +44 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MCPSpec Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command11 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/test.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { readFileSync, writeFileSync, watch } from "fs";
|
|
9
|
+
import { resolve } from "path";
|
|
10
|
+
import { EXIT_CODES } from "@mcpspec/shared";
|
|
11
|
+
import {
|
|
12
|
+
loadYamlSafely,
|
|
13
|
+
TestRunner,
|
|
14
|
+
ConsoleReporter,
|
|
15
|
+
JsonReporter,
|
|
16
|
+
JunitReporter,
|
|
17
|
+
HtmlReporter,
|
|
18
|
+
TapReporter,
|
|
19
|
+
MCPSpecError,
|
|
20
|
+
formatError
|
|
21
|
+
} from "@mcpspec/core";
|
|
22
|
+
import { collectionSchema } from "@mcpspec/shared";
|
|
23
|
+
function coerceCollection(raw) {
|
|
24
|
+
if (!raw || typeof raw !== "object") {
|
|
25
|
+
throw new MCPSpecError("COLLECTION_PARSE_ERROR", "Collection file is empty or invalid", {});
|
|
26
|
+
}
|
|
27
|
+
const obj = raw;
|
|
28
|
+
const coerced = { ...obj };
|
|
29
|
+
if (Array.isArray(obj["tests"])) {
|
|
30
|
+
coerced["tests"] = obj["tests"].map((test) => {
|
|
31
|
+
const t = { ...test };
|
|
32
|
+
if (t["expectError"] === "true") t["expectError"] = true;
|
|
33
|
+
if (t["expectError"] === "false") t["expectError"] = false;
|
|
34
|
+
if (typeof t["timeout"] === "string") t["timeout"] = parseInt(t["timeout"], 10);
|
|
35
|
+
if (typeof t["retries"] === "string") t["retries"] = parseInt(t["retries"], 10);
|
|
36
|
+
if (Array.isArray(t["assertions"])) {
|
|
37
|
+
t["assertions"] = t["assertions"].map((a) => {
|
|
38
|
+
const assertion = { ...a };
|
|
39
|
+
if (typeof assertion["maxMs"] === "string")
|
|
40
|
+
assertion["maxMs"] = parseInt(assertion["maxMs"], 10);
|
|
41
|
+
if (typeof assertion["value"] === "string" && assertion["type"] === "length")
|
|
42
|
+
assertion["value"] = parseInt(assertion["value"], 10);
|
|
43
|
+
return assertion;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(t["expect"])) {
|
|
47
|
+
t["expect"] = t["expect"].map((e) => {
|
|
48
|
+
return e;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return t;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return coerced;
|
|
55
|
+
}
|
|
56
|
+
var testCommand = new Command("test").description("Run tests from a collection file").argument("[collection]", "Path to collection YAML file", "mcpspec.yaml").option("--env <environment>", "Environment to use").option("--reporter <type>", "Reporter type: console, json, junit, html, tap", "console").option("--output <path>", "Output file path for results").option("--ci", "CI mode (no colors, structured output)", false).option("--tag <tags...>", "Filter tests by tag").option("--parallel <n>", "Number of parallel test executions").option("--baseline <name>", "Compare results against named baseline").option("--watch", "Re-run tests on file changes", false).action(async (collectionPath, options) => {
|
|
57
|
+
if (options.watch) {
|
|
58
|
+
await runWatch(collectionPath, options);
|
|
59
|
+
} else {
|
|
60
|
+
const exitCode = await runOnce(collectionPath, options);
|
|
61
|
+
process.exit(exitCode);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
async function runOnce(collectionPath, options) {
|
|
65
|
+
try {
|
|
66
|
+
const fullPath = resolve(collectionPath);
|
|
67
|
+
let content;
|
|
68
|
+
try {
|
|
69
|
+
content = readFileSync(fullPath, "utf-8");
|
|
70
|
+
} catch {
|
|
71
|
+
const formatted = formatError(
|
|
72
|
+
new MCPSpecError("COLLECTION_PARSE_ERROR", `Cannot read file: ${fullPath}`, {
|
|
73
|
+
filePath: fullPath
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
console.error(`
|
|
77
|
+
${formatted.title}: ${formatted.description}`);
|
|
78
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
79
|
+
return EXIT_CODES.CONFIG_ERROR;
|
|
80
|
+
}
|
|
81
|
+
const raw = loadYamlSafely(content);
|
|
82
|
+
const collection = coerceCollection(raw);
|
|
83
|
+
try {
|
|
84
|
+
collectionSchema.parse(collection);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
+
const formatted = formatError(
|
|
88
|
+
new MCPSpecError("COLLECTION_VALIDATION_ERROR", message, {
|
|
89
|
+
details: message,
|
|
90
|
+
filePath: fullPath
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
console.error(`
|
|
94
|
+
${formatted.title}: ${formatted.description}`);
|
|
95
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
96
|
+
return EXIT_CODES.CONFIG_ERROR;
|
|
97
|
+
}
|
|
98
|
+
const reporter = createReporter(options);
|
|
99
|
+
const runner = new TestRunner();
|
|
100
|
+
const parallelism = options.parallel ? parseInt(options.parallel, 10) : 1;
|
|
101
|
+
const result = await runner.run(collection, {
|
|
102
|
+
environment: options.env,
|
|
103
|
+
reporter,
|
|
104
|
+
parallelism,
|
|
105
|
+
tags: options.tag
|
|
106
|
+
});
|
|
107
|
+
if (options.output) {
|
|
108
|
+
const output = getReporterOutput(reporter, options.reporter);
|
|
109
|
+
if (output) {
|
|
110
|
+
writeFileSync(resolve(options.output), output, "utf-8");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (options.baseline) {
|
|
114
|
+
const { BaselineStore: BaselineStore3, ResultDiffer: ResultDiffer2 } = await import("@mcpspec/core");
|
|
115
|
+
const store = new BaselineStore3();
|
|
116
|
+
const baseline = store.load(options.baseline);
|
|
117
|
+
if (baseline) {
|
|
118
|
+
const differ = new ResultDiffer2();
|
|
119
|
+
const diff = differ.diff(baseline, result, options.baseline);
|
|
120
|
+
console.log(`
|
|
121
|
+
Baseline comparison against "${options.baseline}":`);
|
|
122
|
+
if (diff.summary.regressions > 0) {
|
|
123
|
+
console.log(` Regressions: ${diff.summary.regressions}`);
|
|
124
|
+
for (const r of diff.regressions) {
|
|
125
|
+
console.log(` - ${r.testName}: ${r.before?.status} -> ${r.after?.status}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (diff.summary.fixes > 0) {
|
|
129
|
+
console.log(` Fixes: ${diff.summary.fixes}`);
|
|
130
|
+
}
|
|
131
|
+
if (diff.summary.newTests > 0) {
|
|
132
|
+
console.log(` New tests: ${diff.summary.newTests}`);
|
|
133
|
+
}
|
|
134
|
+
if (diff.summary.removedTests > 0) {
|
|
135
|
+
console.log(` Removed tests: ${diff.summary.removedTests}`);
|
|
136
|
+
}
|
|
137
|
+
if (diff.summary.regressions === 0 && diff.summary.fixes === 0) {
|
|
138
|
+
console.log(" No changes detected.");
|
|
139
|
+
}
|
|
140
|
+
console.log("");
|
|
141
|
+
} else {
|
|
142
|
+
console.log(`
|
|
143
|
+
Baseline "${options.baseline}" not found. Run \`mcpspec baseline save ${options.baseline}\` first.
|
|
144
|
+
`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await runner.cleanup();
|
|
148
|
+
if (result.summary.failed > 0 || result.summary.errors > 0) {
|
|
149
|
+
return EXIT_CODES.TEST_FAILURE;
|
|
150
|
+
}
|
|
151
|
+
return EXIT_CODES.SUCCESS;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const formatted = formatError(err);
|
|
154
|
+
console.error(`
|
|
155
|
+
${formatted.title}: ${formatted.description}`);
|
|
156
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
157
|
+
return formatted.exitCode;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function createReporter(options) {
|
|
161
|
+
switch (options.reporter) {
|
|
162
|
+
case "json":
|
|
163
|
+
return new JsonReporter(options.output);
|
|
164
|
+
case "junit":
|
|
165
|
+
return new JunitReporter(options.output);
|
|
166
|
+
case "html":
|
|
167
|
+
return new HtmlReporter(options.output);
|
|
168
|
+
case "tap":
|
|
169
|
+
return new TapReporter();
|
|
170
|
+
default:
|
|
171
|
+
return new ConsoleReporter({ ci: options.ci });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function getReporterOutput(reporter, type) {
|
|
175
|
+
if (type === "json") return reporter.getOutput();
|
|
176
|
+
if (type === "junit") return reporter.getOutput();
|
|
177
|
+
if (type === "html") return reporter.getOutput();
|
|
178
|
+
return void 0;
|
|
179
|
+
}
|
|
180
|
+
async function runWatch(collectionPath, options) {
|
|
181
|
+
const fullPath = resolve(collectionPath);
|
|
182
|
+
let running = false;
|
|
183
|
+
let debounceTimer = null;
|
|
184
|
+
const run = async () => {
|
|
185
|
+
if (running) return;
|
|
186
|
+
running = true;
|
|
187
|
+
console.log("\n--- Running tests ---\n");
|
|
188
|
+
await runOnce(collectionPath, options);
|
|
189
|
+
running = false;
|
|
190
|
+
};
|
|
191
|
+
await run();
|
|
192
|
+
console.log(`
|
|
193
|
+
Watching ${fullPath} for changes... (Ctrl+C to stop)
|
|
194
|
+
`);
|
|
195
|
+
watch(fullPath, () => {
|
|
196
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
197
|
+
debounceTimer = setTimeout(() => {
|
|
198
|
+
run();
|
|
199
|
+
}, 300);
|
|
200
|
+
});
|
|
201
|
+
await new Promise(() => {
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/commands/inspect.ts
|
|
206
|
+
import { Command as Command2 } from "commander";
|
|
207
|
+
import { createInterface } from "readline";
|
|
208
|
+
import { EXIT_CODES as EXIT_CODES2 } from "@mcpspec/shared";
|
|
209
|
+
import { MCPClient, formatError as formatError2 } from "@mcpspec/core";
|
|
210
|
+
var COLORS = {
|
|
211
|
+
reset: "\x1B[0m",
|
|
212
|
+
green: "\x1B[32m",
|
|
213
|
+
red: "\x1B[31m",
|
|
214
|
+
yellow: "\x1B[33m",
|
|
215
|
+
gray: "\x1B[90m",
|
|
216
|
+
bold: "\x1B[1m",
|
|
217
|
+
cyan: "\x1B[36m"
|
|
218
|
+
};
|
|
219
|
+
var inspectCommand = new Command2("inspect").description("Interactive inspection of an MCP server").argument("<server>", 'Server command (e.g., "npx @modelcontextprotocol/server-filesystem /tmp")').action(async (serverCommand) => {
|
|
220
|
+
let client = null;
|
|
221
|
+
try {
|
|
222
|
+
client = new MCPClient({ serverConfig: serverCommand });
|
|
223
|
+
console.log(`${COLORS.cyan}Connecting to: ${COLORS.reset}${serverCommand}`);
|
|
224
|
+
await client.connect();
|
|
225
|
+
const info = client.getServerInfo();
|
|
226
|
+
if (info) {
|
|
227
|
+
console.log(
|
|
228
|
+
`${COLORS.green}Connected to ${info.name ?? "unknown"} v${info.version ?? "?"}${COLORS.reset}`
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
console.log(`${COLORS.green}Connected${COLORS.reset}`);
|
|
232
|
+
}
|
|
233
|
+
console.log(`
|
|
234
|
+
Type ${COLORS.bold}.help${COLORS.reset} for available commands
|
|
235
|
+
`);
|
|
236
|
+
const rl = createInterface({
|
|
237
|
+
input: process.stdin,
|
|
238
|
+
output: process.stdout,
|
|
239
|
+
prompt: `${COLORS.cyan}mcpspec>${COLORS.reset} `
|
|
240
|
+
});
|
|
241
|
+
rl.prompt();
|
|
242
|
+
rl.on("line", async (line) => {
|
|
243
|
+
const trimmed = line.trim();
|
|
244
|
+
if (!trimmed) {
|
|
245
|
+
rl.prompt();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
if (trimmed === ".exit" || trimmed === ".quit") {
|
|
250
|
+
await client?.disconnect();
|
|
251
|
+
rl.close();
|
|
252
|
+
process.exit(EXIT_CODES2.SUCCESS);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (trimmed === ".help") {
|
|
256
|
+
console.log(`
|
|
257
|
+
${COLORS.bold}Available commands:${COLORS.reset}
|
|
258
|
+
.tools List all available tools
|
|
259
|
+
.resources List all available resources
|
|
260
|
+
.call <tool> <json> Call a tool with JSON arguments
|
|
261
|
+
.schema <tool> Show input schema for a tool
|
|
262
|
+
.info Show server info
|
|
263
|
+
.exit Disconnect and exit
|
|
264
|
+
`);
|
|
265
|
+
rl.prompt();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (trimmed === ".tools") {
|
|
269
|
+
const tools = await client.listTools();
|
|
270
|
+
if (tools.length === 0) {
|
|
271
|
+
console.log(`${COLORS.gray}No tools available${COLORS.reset}`);
|
|
272
|
+
} else {
|
|
273
|
+
console.log(`
|
|
274
|
+
${COLORS.bold}Tools (${tools.length}):${COLORS.reset}`);
|
|
275
|
+
for (const tool of tools) {
|
|
276
|
+
console.log(` ${COLORS.green}${tool.name}${COLORS.reset}`);
|
|
277
|
+
if (tool.description) {
|
|
278
|
+
console.log(` ${COLORS.gray}${tool.description}${COLORS.reset}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
console.log("");
|
|
282
|
+
}
|
|
283
|
+
rl.prompt();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (trimmed === ".resources") {
|
|
287
|
+
const resources = await client.listResources();
|
|
288
|
+
if (resources.length === 0) {
|
|
289
|
+
console.log(`${COLORS.gray}No resources available${COLORS.reset}`);
|
|
290
|
+
} else {
|
|
291
|
+
console.log(`
|
|
292
|
+
${COLORS.bold}Resources (${resources.length}):${COLORS.reset}`);
|
|
293
|
+
for (const resource of resources) {
|
|
294
|
+
console.log(` ${COLORS.green}${resource.uri}${COLORS.reset}`);
|
|
295
|
+
if (resource.name) {
|
|
296
|
+
console.log(` ${COLORS.gray}${resource.name}${COLORS.reset}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
console.log("");
|
|
300
|
+
}
|
|
301
|
+
rl.prompt();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (trimmed === ".info") {
|
|
305
|
+
const serverInfo = client.getServerInfo();
|
|
306
|
+
console.log(JSON.stringify(serverInfo, null, 2));
|
|
307
|
+
rl.prompt();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (trimmed.startsWith(".schema ")) {
|
|
311
|
+
const toolName = trimmed.slice(8).trim();
|
|
312
|
+
const tools = await client.listTools();
|
|
313
|
+
const tool = tools.find((t) => t.name === toolName);
|
|
314
|
+
if (!tool) {
|
|
315
|
+
console.log(`${COLORS.red}Tool "${toolName}" not found${COLORS.reset}`);
|
|
316
|
+
console.log(
|
|
317
|
+
`${COLORS.gray}Available: ${tools.map((t) => t.name).join(", ")}${COLORS.reset}`
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
console.log(JSON.stringify(tool.inputSchema, null, 2));
|
|
321
|
+
}
|
|
322
|
+
rl.prompt();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (trimmed.startsWith(".call ")) {
|
|
326
|
+
const rest = trimmed.slice(6).trim();
|
|
327
|
+
const spaceIdx = rest.indexOf(" ");
|
|
328
|
+
let toolName;
|
|
329
|
+
let args = {};
|
|
330
|
+
if (spaceIdx === -1) {
|
|
331
|
+
toolName = rest;
|
|
332
|
+
} else {
|
|
333
|
+
toolName = rest.slice(0, spaceIdx);
|
|
334
|
+
const jsonStr = rest.slice(spaceIdx + 1).trim();
|
|
335
|
+
try {
|
|
336
|
+
args = JSON.parse(jsonStr);
|
|
337
|
+
} catch {
|
|
338
|
+
console.log(`${COLORS.red}Invalid JSON: ${jsonStr}${COLORS.reset}`);
|
|
339
|
+
rl.prompt();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
console.log(`${COLORS.gray}Calling ${toolName}...${COLORS.reset}`);
|
|
344
|
+
const result = await client.callTool(toolName, args);
|
|
345
|
+
console.log(JSON.stringify(result, null, 2));
|
|
346
|
+
rl.prompt();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
console.log(`${COLORS.yellow}Unknown command. Type .help for available commands.${COLORS.reset}`);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
const formatted = formatError2(err);
|
|
352
|
+
console.log(`${COLORS.red}${formatted.title}: ${formatted.description}${COLORS.reset}`);
|
|
353
|
+
}
|
|
354
|
+
rl.prompt();
|
|
355
|
+
});
|
|
356
|
+
rl.on("close", async () => {
|
|
357
|
+
await client?.disconnect();
|
|
358
|
+
process.exit(EXIT_CODES2.SUCCESS);
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const formatted = formatError2(err);
|
|
362
|
+
console.error(`
|
|
363
|
+
${formatted.title}: ${formatted.description}`);
|
|
364
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
365
|
+
await client?.disconnect();
|
|
366
|
+
process.exit(formatted.exitCode);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// src/commands/init.ts
|
|
371
|
+
import { Command as Command3 } from "commander";
|
|
372
|
+
import { mkdirSync, writeFileSync as writeFileSync2, existsSync } from "fs";
|
|
373
|
+
import { resolve as resolve2, join } from "path";
|
|
374
|
+
import { EXIT_CODES as EXIT_CODES3 } from "@mcpspec/shared";
|
|
375
|
+
var TEMPLATES = {
|
|
376
|
+
minimal: `name: My MCP Tests
|
|
377
|
+
server: npx my-mcp-server
|
|
378
|
+
|
|
379
|
+
tests:
|
|
380
|
+
- name: Basic test
|
|
381
|
+
call: my_tool
|
|
382
|
+
with:
|
|
383
|
+
param: value
|
|
384
|
+
expect:
|
|
385
|
+
- exists: $.content
|
|
386
|
+
`,
|
|
387
|
+
standard: `schemaVersion: "1.0"
|
|
388
|
+
name: My MCP Tests
|
|
389
|
+
description: Test collection for my MCP server
|
|
390
|
+
|
|
391
|
+
server:
|
|
392
|
+
transport: stdio
|
|
393
|
+
command: npx
|
|
394
|
+
args:
|
|
395
|
+
- my-mcp-server
|
|
396
|
+
|
|
397
|
+
tests:
|
|
398
|
+
- name: List tools are available
|
|
399
|
+
call: my_tool
|
|
400
|
+
with:
|
|
401
|
+
param: value
|
|
402
|
+
assertions:
|
|
403
|
+
- type: exists
|
|
404
|
+
path: $.content
|
|
405
|
+
- type: latency
|
|
406
|
+
maxMs: 5000
|
|
407
|
+
|
|
408
|
+
- name: Handle invalid input
|
|
409
|
+
call: my_tool
|
|
410
|
+
with:
|
|
411
|
+
invalid_param: true
|
|
412
|
+
expectError: true
|
|
413
|
+
`,
|
|
414
|
+
full: `schemaVersion: "1.0"
|
|
415
|
+
name: My MCP Tests
|
|
416
|
+
description: Comprehensive test collection
|
|
417
|
+
|
|
418
|
+
server:
|
|
419
|
+
name: my-server
|
|
420
|
+
transport: stdio
|
|
421
|
+
command: npx
|
|
422
|
+
args:
|
|
423
|
+
- my-mcp-server
|
|
424
|
+
env:
|
|
425
|
+
NODE_ENV: test
|
|
426
|
+
|
|
427
|
+
environments:
|
|
428
|
+
dev:
|
|
429
|
+
variables:
|
|
430
|
+
API_URL: http://localhost:3000
|
|
431
|
+
staging:
|
|
432
|
+
variables:
|
|
433
|
+
API_URL: https://staging.example.com
|
|
434
|
+
|
|
435
|
+
defaultEnvironment: dev
|
|
436
|
+
|
|
437
|
+
tests:
|
|
438
|
+
- id: test-basic
|
|
439
|
+
name: Basic tool call
|
|
440
|
+
tags:
|
|
441
|
+
- smoke
|
|
442
|
+
call: my_tool
|
|
443
|
+
with:
|
|
444
|
+
param: value
|
|
445
|
+
assertions:
|
|
446
|
+
- type: schema
|
|
447
|
+
- type: exists
|
|
448
|
+
path: $.content
|
|
449
|
+
- type: latency
|
|
450
|
+
maxMs: 5000
|
|
451
|
+
|
|
452
|
+
- id: test-error
|
|
453
|
+
name: Handle error gracefully
|
|
454
|
+
tags:
|
|
455
|
+
- error-handling
|
|
456
|
+
call: my_tool
|
|
457
|
+
with:
|
|
458
|
+
invalid: true
|
|
459
|
+
expectError: true
|
|
460
|
+
|
|
461
|
+
- id: test-extract
|
|
462
|
+
name: Extract and reuse data
|
|
463
|
+
tags:
|
|
464
|
+
- integration
|
|
465
|
+
call: get_data
|
|
466
|
+
with:
|
|
467
|
+
id: "1"
|
|
468
|
+
assertions:
|
|
469
|
+
- type: exists
|
|
470
|
+
path: $.id
|
|
471
|
+
extract:
|
|
472
|
+
- name: itemId
|
|
473
|
+
path: $.id
|
|
474
|
+
|
|
475
|
+
- id: test-use-extracted
|
|
476
|
+
name: Use extracted variable
|
|
477
|
+
tags:
|
|
478
|
+
- integration
|
|
479
|
+
call: get_detail
|
|
480
|
+
with:
|
|
481
|
+
id: "{{itemId}}"
|
|
482
|
+
assertions:
|
|
483
|
+
- type: exists
|
|
484
|
+
path: $.detail
|
|
485
|
+
`
|
|
486
|
+
};
|
|
487
|
+
function generateFromWizard(result) {
|
|
488
|
+
const serverBlock = result.transport === "stdio" ? `server: ${result.command}` : `server:
|
|
489
|
+
transport: ${result.transport}
|
|
490
|
+
url: ${result.url}`;
|
|
491
|
+
const template = TEMPLATES[result.template];
|
|
492
|
+
const lines = template.split("\n");
|
|
493
|
+
const output = [];
|
|
494
|
+
let skipServer = false;
|
|
495
|
+
for (const line of lines) {
|
|
496
|
+
if (line.startsWith("name:")) {
|
|
497
|
+
output.push(`name: ${result.name}`);
|
|
498
|
+
} else if (line.startsWith("server:") || line.startsWith("server ")) {
|
|
499
|
+
output.push(serverBlock);
|
|
500
|
+
if (line === "server:") {
|
|
501
|
+
skipServer = true;
|
|
502
|
+
}
|
|
503
|
+
} else if (skipServer) {
|
|
504
|
+
if (line.startsWith(" ") && !line.startsWith(" -") && !line.match(/^\w/)) {
|
|
505
|
+
if (line.match(/^[a-zA-Z]/)) {
|
|
506
|
+
skipServer = false;
|
|
507
|
+
output.push(line);
|
|
508
|
+
}
|
|
509
|
+
continue;
|
|
510
|
+
} else {
|
|
511
|
+
skipServer = false;
|
|
512
|
+
output.push(line);
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
output.push(line);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return output.join("\n");
|
|
519
|
+
}
|
|
520
|
+
var initCommand = new Command3("init").description("Initialize a new mcpspec project").argument("[directory]", "Target directory", ".").option("--template <type>", "Template type: minimal, standard, full").action(async (directory, options) => {
|
|
521
|
+
const dir = resolve2(directory);
|
|
522
|
+
try {
|
|
523
|
+
if (!options.template && process.stdin.isTTY) {
|
|
524
|
+
const { runOnboardingWizard } = await import("./onboarding-5G6D2OYR.js");
|
|
525
|
+
const result = await runOnboardingWizard();
|
|
526
|
+
if (!existsSync(dir)) {
|
|
527
|
+
mkdirSync(dir, { recursive: true });
|
|
528
|
+
}
|
|
529
|
+
const collectionPath2 = join(dir, "mcpspec.yaml");
|
|
530
|
+
if (existsSync(collectionPath2)) {
|
|
531
|
+
console.error(`File already exists: ${collectionPath2}`);
|
|
532
|
+
process.exit(EXIT_CODES3.CONFIG_ERROR);
|
|
533
|
+
}
|
|
534
|
+
const content = generateFromWizard(result);
|
|
535
|
+
writeFileSync2(collectionPath2, content, "utf-8");
|
|
536
|
+
console.log(`
|
|
537
|
+
Created ${collectionPath2}`);
|
|
538
|
+
console.log(`
|
|
539
|
+
Edit the file to configure your tests, then run: mcpspec test`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const template = options.template ?? "standard";
|
|
543
|
+
if (!TEMPLATES[template]) {
|
|
544
|
+
console.error(`Unknown template: ${template}. Available: minimal, standard, full`);
|
|
545
|
+
process.exit(EXIT_CODES3.CONFIG_ERROR);
|
|
546
|
+
}
|
|
547
|
+
if (!existsSync(dir)) {
|
|
548
|
+
mkdirSync(dir, { recursive: true });
|
|
549
|
+
}
|
|
550
|
+
const collectionPath = join(dir, "mcpspec.yaml");
|
|
551
|
+
if (existsSync(collectionPath)) {
|
|
552
|
+
console.error(`File already exists: ${collectionPath}`);
|
|
553
|
+
process.exit(EXIT_CODES3.CONFIG_ERROR);
|
|
554
|
+
}
|
|
555
|
+
writeFileSync2(collectionPath, TEMPLATES[template], "utf-8");
|
|
556
|
+
console.log(`Created ${collectionPath}`);
|
|
557
|
+
console.log(`
|
|
558
|
+
Edit the file to configure your MCP server and tests.`);
|
|
559
|
+
console.log(`Then run: mcpspec test`);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
562
|
+
console.error(`Failed to initialize: ${message}`);
|
|
563
|
+
process.exit(EXIT_CODES3.ERROR);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// src/commands/compare.ts
|
|
568
|
+
import { Command as Command4 } from "commander";
|
|
569
|
+
import { EXIT_CODES as EXIT_CODES4 } from "@mcpspec/shared";
|
|
570
|
+
import { BaselineStore, ResultDiffer } from "@mcpspec/core";
|
|
571
|
+
var compareCommand = new Command4("compare").description("Compare test runs against a baseline").argument("[run1]", "First run (baseline name)").argument("[run2]", "Second run (baseline name)").option("--baseline <name>", "Compare latest run against named baseline").action((run1, run2, options) => {
|
|
572
|
+
const store = new BaselineStore();
|
|
573
|
+
const differ = new ResultDiffer();
|
|
574
|
+
if (run1 && run2) {
|
|
575
|
+
const baseline = store.load(run1);
|
|
576
|
+
const current = store.load(run2);
|
|
577
|
+
if (!baseline) {
|
|
578
|
+
console.error(`Baseline "${run1}" not found.`);
|
|
579
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
580
|
+
}
|
|
581
|
+
if (!current) {
|
|
582
|
+
console.error(`Baseline "${run2}" not found.`);
|
|
583
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
584
|
+
}
|
|
585
|
+
const diff = differ.diff(baseline, current, run1);
|
|
586
|
+
printDiff(diff);
|
|
587
|
+
process.exit(diff.summary.regressions > 0 ? EXIT_CODES4.TEST_FAILURE : EXIT_CODES4.SUCCESS);
|
|
588
|
+
}
|
|
589
|
+
if (options.baseline) {
|
|
590
|
+
const baselines = store.list();
|
|
591
|
+
if (baselines.length === 0) {
|
|
592
|
+
console.error("No baselines found. Run `mcpspec baseline save <name>` first.");
|
|
593
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
594
|
+
}
|
|
595
|
+
const baseline = store.load(options.baseline);
|
|
596
|
+
if (!baseline) {
|
|
597
|
+
console.error(`Baseline "${options.baseline}" not found.`);
|
|
598
|
+
console.error(`Available baselines: ${baselines.join(", ")}`);
|
|
599
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
600
|
+
}
|
|
601
|
+
const otherBaselines = baselines.filter((b) => b !== options.baseline);
|
|
602
|
+
if (otherBaselines.length === 0) {
|
|
603
|
+
console.error("Need at least two baselines to compare. Run tests and save another baseline.");
|
|
604
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
605
|
+
}
|
|
606
|
+
const current = store.load(otherBaselines[otherBaselines.length - 1]);
|
|
607
|
+
if (!current) {
|
|
608
|
+
console.error("Could not load comparison baseline.");
|
|
609
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
610
|
+
}
|
|
611
|
+
const diff = differ.diff(baseline, current, options.baseline);
|
|
612
|
+
printDiff(diff);
|
|
613
|
+
process.exit(diff.summary.regressions > 0 ? EXIT_CODES4.TEST_FAILURE : EXIT_CODES4.SUCCESS);
|
|
614
|
+
}
|
|
615
|
+
console.error("Usage: mcpspec compare <run1> <run2> or mcpspec compare --baseline <name>");
|
|
616
|
+
process.exit(EXIT_CODES4.CONFIG_ERROR);
|
|
617
|
+
});
|
|
618
|
+
function printDiff(diff) {
|
|
619
|
+
console.log(`
|
|
620
|
+
Comparison against baseline "${diff.baselineName}":`);
|
|
621
|
+
console.log(` Tests before: ${diff.summary.totalBefore}`);
|
|
622
|
+
console.log(` Tests after: ${diff.summary.totalAfter}`);
|
|
623
|
+
console.log("");
|
|
624
|
+
if (diff.regressions.length > 0) {
|
|
625
|
+
console.log(" Regressions:");
|
|
626
|
+
for (const r of diff.regressions) {
|
|
627
|
+
console.log(` \u2717 ${r.testName}: ${r.before?.status} -> ${r.after?.status}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (diff.fixes.length > 0) {
|
|
631
|
+
console.log(" Fixes:");
|
|
632
|
+
for (const r of diff.fixes) {
|
|
633
|
+
console.log(` \u2713 ${r.testName}: ${r.before?.status} -> ${r.after?.status}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (diff.newTests.length > 0) {
|
|
637
|
+
console.log(" New tests:");
|
|
638
|
+
for (const r of diff.newTests) {
|
|
639
|
+
console.log(` + ${r.testName}: ${r.after?.status}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (diff.removedTests.length > 0) {
|
|
643
|
+
console.log(" Removed tests:");
|
|
644
|
+
for (const r of diff.removedTests) {
|
|
645
|
+
console.log(` - ${r.testName}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (diff.regressions.length === 0 && diff.fixes.length === 0 && diff.newTests.length === 0 && diff.removedTests.length === 0) {
|
|
649
|
+
console.log(" No changes detected.");
|
|
650
|
+
}
|
|
651
|
+
console.log("");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/commands/baseline.ts
|
|
655
|
+
import { Command as Command5 } from "commander";
|
|
656
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
657
|
+
import { resolve as resolve3 } from "path";
|
|
658
|
+
import { EXIT_CODES as EXIT_CODES5 } from "@mcpspec/shared";
|
|
659
|
+
import { BaselineStore as BaselineStore2 } from "@mcpspec/core";
|
|
660
|
+
var baselineCommand = new Command5("baseline").description("Manage test baselines").addCommand(
|
|
661
|
+
new Command5("save").description("Save a test run result as a named baseline").argument("<name>", "Baseline name").argument("[results-file]", "Path to JSON results file").action((name, resultsFile) => {
|
|
662
|
+
const store = new BaselineStore2();
|
|
663
|
+
if (!resultsFile) {
|
|
664
|
+
console.error("Usage: mcpspec baseline save <name> <results-file.json>");
|
|
665
|
+
console.error(" Run tests with --reporter json --output results.json first.");
|
|
666
|
+
process.exit(EXIT_CODES5.CONFIG_ERROR);
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const fullPath = resolve3(resultsFile);
|
|
670
|
+
const content = readFileSync2(fullPath, "utf-8");
|
|
671
|
+
const raw = JSON.parse(content);
|
|
672
|
+
const result = {
|
|
673
|
+
...raw,
|
|
674
|
+
startedAt: new Date(raw.startedAt),
|
|
675
|
+
completedAt: new Date(raw.completedAt)
|
|
676
|
+
};
|
|
677
|
+
const savedPath = store.save(name, result);
|
|
678
|
+
console.log(`Baseline "${name}" saved to ${savedPath}`);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
681
|
+
console.error(`Failed to save baseline: ${message}`);
|
|
682
|
+
process.exit(EXIT_CODES5.ERROR);
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
).addCommand(
|
|
686
|
+
new Command5("list").description("List saved baselines").action(() => {
|
|
687
|
+
const store = new BaselineStore2();
|
|
688
|
+
const baselines = store.list();
|
|
689
|
+
if (baselines.length === 0) {
|
|
690
|
+
console.log("No baselines saved.");
|
|
691
|
+
console.log("Run `mcpspec baseline save <name> <results.json>` to create one.");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
console.log("Saved baselines:");
|
|
695
|
+
for (const name of baselines) {
|
|
696
|
+
console.log(` - ${name}`);
|
|
697
|
+
}
|
|
698
|
+
})
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
// src/commands/ui.ts
|
|
702
|
+
import { Command as Command6 } from "commander";
|
|
703
|
+
var uiCommand = new Command6("ui").description("Launch the MCPSpec web UI").option("-p, --port <port>", "Port to listen on", "6274").option("--host <host>", "Host to bind to", "127.0.0.1").option("--no-open", "Do not auto-open browser").action(async (opts) => {
|
|
704
|
+
const port = parseInt(opts.port, 10);
|
|
705
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
706
|
+
console.error("Invalid port number");
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
const { startServer, UI_DIST_PATH } = await import("@mcpspec/server");
|
|
710
|
+
const uiDistPath = UI_DIST_PATH;
|
|
711
|
+
const server = await startServer({
|
|
712
|
+
port,
|
|
713
|
+
host: opts.host,
|
|
714
|
+
uiDistPath
|
|
715
|
+
});
|
|
716
|
+
const url = `http://${server.host}:${server.port}`;
|
|
717
|
+
if (opts.open) {
|
|
718
|
+
try {
|
|
719
|
+
const { default: open } = await import("open");
|
|
720
|
+
await open(url);
|
|
721
|
+
} catch {
|
|
722
|
+
console.log(`Open ${url} in your browser`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
process.on("SIGINT", () => {
|
|
726
|
+
console.log("\nShutting down...");
|
|
727
|
+
server.close();
|
|
728
|
+
process.exit(0);
|
|
729
|
+
});
|
|
730
|
+
process.on("SIGTERM", () => {
|
|
731
|
+
server.close();
|
|
732
|
+
process.exit(0);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// src/commands/audit.ts
|
|
737
|
+
import { Command as Command7 } from "commander";
|
|
738
|
+
import { EXIT_CODES as EXIT_CODES6 } from "@mcpspec/shared";
|
|
739
|
+
import {
|
|
740
|
+
MCPClient as MCPClient2,
|
|
741
|
+
ScanConfig,
|
|
742
|
+
SecurityScanner,
|
|
743
|
+
formatError as formatError3
|
|
744
|
+
} from "@mcpspec/core";
|
|
745
|
+
var COLORS2 = {
|
|
746
|
+
reset: "\x1B[0m",
|
|
747
|
+
red: "\x1B[31m",
|
|
748
|
+
yellow: "\x1B[33m",
|
|
749
|
+
green: "\x1B[32m",
|
|
750
|
+
cyan: "\x1B[36m",
|
|
751
|
+
gray: "\x1B[90m",
|
|
752
|
+
bold: "\x1B[1m"
|
|
753
|
+
};
|
|
754
|
+
var SEVERITY_COLORS = {
|
|
755
|
+
critical: COLORS2.red + COLORS2.bold,
|
|
756
|
+
high: COLORS2.red,
|
|
757
|
+
medium: COLORS2.yellow,
|
|
758
|
+
low: COLORS2.cyan,
|
|
759
|
+
info: COLORS2.gray
|
|
760
|
+
};
|
|
761
|
+
var auditCommand = new Command7("audit").description("Run security audit on an MCP server").argument("<server>", "Server command or URL").option("--mode <mode>", "Scan mode: passive, active, aggressive", "passive").option("--acknowledge-risk", "Skip confirmation prompt for active/aggressive modes", false).option("--fail-on <severity>", "Fail with exit code 6 if findings at or above severity: info, low, medium, high, critical").option("--rules <rules...>", "Only run specific rules").action(async (serverCommand, options) => {
|
|
762
|
+
let client = null;
|
|
763
|
+
try {
|
|
764
|
+
const mode = options.mode;
|
|
765
|
+
const config = new ScanConfig({
|
|
766
|
+
mode,
|
|
767
|
+
acknowledgeRisk: options.acknowledgeRisk,
|
|
768
|
+
rules: options.rules
|
|
769
|
+
});
|
|
770
|
+
if (config.requiresConfirmation()) {
|
|
771
|
+
console.log(`
|
|
772
|
+
${COLORS2.yellow}${COLORS2.bold} WARNING: Security Scan${COLORS2.reset}`);
|
|
773
|
+
console.log(`${COLORS2.yellow} Mode: ${mode}${COLORS2.reset}`);
|
|
774
|
+
console.log(`${COLORS2.yellow} This sends potentially harmful payloads to the server.${COLORS2.reset}`);
|
|
775
|
+
console.log(`${COLORS2.yellow} NEVER run against production systems!${COLORS2.reset}
|
|
776
|
+
`);
|
|
777
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
778
|
+
const confirmed = await confirm({
|
|
779
|
+
message: "Is this a TEST environment? Continue with scan?",
|
|
780
|
+
default: false
|
|
781
|
+
});
|
|
782
|
+
if (!confirmed) {
|
|
783
|
+
console.log(" Scan cancelled.");
|
|
784
|
+
process.exit(EXIT_CODES6.SUCCESS);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
console.log(`
|
|
788
|
+
${COLORS2.cyan} Connecting to:${COLORS2.reset} ${serverCommand}`);
|
|
789
|
+
client = new MCPClient2({ serverConfig: serverCommand });
|
|
790
|
+
await client.connect();
|
|
791
|
+
const info = client.getServerInfo();
|
|
792
|
+
console.log(`${COLORS2.green} Connected to ${info?.name ?? "unknown"} v${info?.version ?? "?"}${COLORS2.reset}`);
|
|
793
|
+
console.log(`${COLORS2.gray} Scan mode: ${mode} | Rules: ${config.rules.join(", ")}${COLORS2.reset}
|
|
794
|
+
`);
|
|
795
|
+
const scanner = new SecurityScanner();
|
|
796
|
+
const result = await scanner.scan(client, config, {
|
|
797
|
+
onRuleStart: (_ruleId, ruleName) => {
|
|
798
|
+
process.stdout.write(` ${COLORS2.gray}Running ${ruleName}...${COLORS2.reset}`);
|
|
799
|
+
},
|
|
800
|
+
onRuleComplete: (_ruleId, findingCount) => {
|
|
801
|
+
if (findingCount > 0) {
|
|
802
|
+
console.log(` ${COLORS2.yellow}${findingCount} finding(s)${COLORS2.reset}`);
|
|
803
|
+
} else {
|
|
804
|
+
console.log(` ${COLORS2.green}clean${COLORS2.reset}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
console.log(`
|
|
809
|
+
${COLORS2.bold} Security Scan Results${COLORS2.reset}`);
|
|
810
|
+
console.log(` ${"\u2500".repeat(50)}`);
|
|
811
|
+
console.log(` Server: ${result.serverName}`);
|
|
812
|
+
console.log(` Mode: ${result.mode}`);
|
|
813
|
+
console.log(` Findings: ${result.summary.totalFindings}`);
|
|
814
|
+
if (result.summary.totalFindings > 0) {
|
|
815
|
+
console.log(`
|
|
816
|
+
${COLORS2.bold}By Severity:${COLORS2.reset}`);
|
|
817
|
+
for (const sev of ["critical", "high", "medium", "low", "info"]) {
|
|
818
|
+
const count = result.summary.bySeverity[sev];
|
|
819
|
+
if (count > 0) {
|
|
820
|
+
console.log(` ${SEVERITY_COLORS[sev]}${sev.toUpperCase()}${COLORS2.reset}: ${count}`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
console.log(`
|
|
824
|
+
${COLORS2.bold}Findings:${COLORS2.reset}
|
|
825
|
+
`);
|
|
826
|
+
for (const finding of result.findings) {
|
|
827
|
+
const color = SEVERITY_COLORS[finding.severity];
|
|
828
|
+
console.log(` ${color}[${finding.severity.toUpperCase()}]${COLORS2.reset} ${finding.title}`);
|
|
829
|
+
console.log(` ${COLORS2.gray}${finding.description}${COLORS2.reset}`);
|
|
830
|
+
if (finding.evidence) {
|
|
831
|
+
console.log(` ${COLORS2.gray}Evidence: ${finding.evidence.slice(0, 100)}${COLORS2.reset}`);
|
|
832
|
+
}
|
|
833
|
+
if (finding.remediation) {
|
|
834
|
+
console.log(` ${COLORS2.cyan}Fix: ${finding.remediation}${COLORS2.reset}`);
|
|
835
|
+
}
|
|
836
|
+
console.log("");
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
console.log(`
|
|
840
|
+
${COLORS2.green}No security findings detected.${COLORS2.reset}
|
|
841
|
+
`);
|
|
842
|
+
}
|
|
843
|
+
await client.disconnect();
|
|
844
|
+
if (options.failOn) {
|
|
845
|
+
const failOnSeverity = options.failOn;
|
|
846
|
+
const failConfig = new ScanConfig({ severityThreshold: failOnSeverity });
|
|
847
|
+
const matchingFindings = result.findings.filter((f) => failConfig.meetsThreshold(f.severity));
|
|
848
|
+
if (matchingFindings.length > 0) {
|
|
849
|
+
process.exit(EXIT_CODES6.SECURITY_FINDINGS);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
process.exit(EXIT_CODES6.SUCCESS);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
const formatted = formatError3(err);
|
|
855
|
+
console.error(`
|
|
856
|
+
${formatted.title}: ${formatted.description}`);
|
|
857
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
858
|
+
await client?.disconnect();
|
|
859
|
+
process.exit(formatted.exitCode);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// src/commands/bench.ts
|
|
864
|
+
import { Command as Command8 } from "commander";
|
|
865
|
+
import { EXIT_CODES as EXIT_CODES7 } from "@mcpspec/shared";
|
|
866
|
+
import {
|
|
867
|
+
MCPClient as MCPClient3,
|
|
868
|
+
BenchmarkRunner,
|
|
869
|
+
formatError as formatError4
|
|
870
|
+
} from "@mcpspec/core";
|
|
871
|
+
var COLORS3 = {
|
|
872
|
+
reset: "\x1B[0m",
|
|
873
|
+
green: "\x1B[32m",
|
|
874
|
+
cyan: "\x1B[36m",
|
|
875
|
+
gray: "\x1B[90m",
|
|
876
|
+
bold: "\x1B[1m",
|
|
877
|
+
yellow: "\x1B[33m"
|
|
878
|
+
};
|
|
879
|
+
var benchCommand = new Command8("bench").description("Run performance benchmark on an MCP server").argument("<server>", "Server command or URL").option("--iterations <n>", "Number of iterations", "100").option("--tool <name>", "Tool to benchmark (defaults to first available)").option("--args <json>", "JSON arguments for the tool call", "{}").option("--timeout <ms>", "Timeout per call in milliseconds", "30000").option("--warmup <n>", "Number of warmup iterations", "5").action(async (serverCommand, options) => {
|
|
880
|
+
let client = null;
|
|
881
|
+
try {
|
|
882
|
+
console.log(`
|
|
883
|
+
${COLORS3.cyan} Connecting to:${COLORS3.reset} ${serverCommand}`);
|
|
884
|
+
client = new MCPClient3({ serverConfig: serverCommand });
|
|
885
|
+
await client.connect();
|
|
886
|
+
const info = client.getServerInfo();
|
|
887
|
+
console.log(`${COLORS3.green} Connected to ${info?.name ?? "unknown"} v${info?.version ?? "?"}${COLORS3.reset}
|
|
888
|
+
`);
|
|
889
|
+
const tools = await client.listTools();
|
|
890
|
+
if (tools.length === 0) {
|
|
891
|
+
console.error(" No tools available on this server.");
|
|
892
|
+
await client.disconnect();
|
|
893
|
+
process.exit(EXIT_CODES7.ERROR);
|
|
894
|
+
}
|
|
895
|
+
let toolName = options.tool;
|
|
896
|
+
if (!toolName) {
|
|
897
|
+
toolName = tools[0].name;
|
|
898
|
+
console.log(`${COLORS3.gray} No --tool specified, using: ${toolName}${COLORS3.reset}`);
|
|
899
|
+
} else {
|
|
900
|
+
const found = tools.find((t) => t.name === toolName);
|
|
901
|
+
if (!found) {
|
|
902
|
+
console.error(` Tool "${toolName}" not found. Available: ${tools.map((t) => t.name).join(", ")}`);
|
|
903
|
+
await client.disconnect();
|
|
904
|
+
process.exit(EXIT_CODES7.ERROR);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
let args = {};
|
|
908
|
+
if (options.args) {
|
|
909
|
+
try {
|
|
910
|
+
args = JSON.parse(options.args);
|
|
911
|
+
} catch {
|
|
912
|
+
console.error(` Invalid JSON for --args: ${options.args}`);
|
|
913
|
+
await client.disconnect();
|
|
914
|
+
process.exit(EXIT_CODES7.CONFIG_ERROR);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const iterations = parseInt(options.iterations, 10);
|
|
918
|
+
const warmup = parseInt(options.warmup ?? "5", 10);
|
|
919
|
+
const timeout = parseInt(options.timeout ?? "30000", 10);
|
|
920
|
+
console.log(`${COLORS3.bold} Benchmarking: ${toolName}${COLORS3.reset}`);
|
|
921
|
+
console.log(` Iterations: ${iterations} | Warmup: ${warmup} | Timeout: ${timeout}ms
|
|
922
|
+
`);
|
|
923
|
+
const runner = new BenchmarkRunner();
|
|
924
|
+
const result = await runner.run(client, toolName, args, {
|
|
925
|
+
iterations,
|
|
926
|
+
warmupIterations: warmup,
|
|
927
|
+
concurrency: 1,
|
|
928
|
+
timeout
|
|
929
|
+
}, {
|
|
930
|
+
onWarmupStart: (n) => {
|
|
931
|
+
console.log(`${COLORS3.gray} Warming up (${n} iterations)...${COLORS3.reset}`);
|
|
932
|
+
},
|
|
933
|
+
onIterationComplete: (i, total) => {
|
|
934
|
+
if (i % Math.max(1, Math.floor(total / 10)) === 0 || i === total) {
|
|
935
|
+
process.stdout.write(`\r Progress: ${i}/${total}`);
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
onComplete: () => {
|
|
939
|
+
console.log("");
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
console.log(`
|
|
943
|
+
${COLORS3.bold} Benchmark Results${COLORS3.reset}`);
|
|
944
|
+
console.log(` ${"\u2500".repeat(40)}`);
|
|
945
|
+
console.log(` Tool: ${result.toolName}`);
|
|
946
|
+
console.log(` Iterations: ${result.iterations}`);
|
|
947
|
+
console.log(` Errors: ${result.errors}`);
|
|
948
|
+
console.log("");
|
|
949
|
+
console.log(` ${COLORS3.bold}Latency Statistics:${COLORS3.reset}`);
|
|
950
|
+
console.log(` Min: ${result.stats.min.toFixed(2)}ms`);
|
|
951
|
+
console.log(` Max: ${result.stats.max.toFixed(2)}ms`);
|
|
952
|
+
console.log(` Mean: ${result.stats.mean.toFixed(2)}ms`);
|
|
953
|
+
console.log(` Median: ${result.stats.median.toFixed(2)}ms`);
|
|
954
|
+
console.log(` P95: ${result.stats.p95.toFixed(2)}ms`);
|
|
955
|
+
console.log(` P99: ${result.stats.p99.toFixed(2)}ms`);
|
|
956
|
+
console.log(` StdDev: ${result.stats.stddev.toFixed(2)}ms`);
|
|
957
|
+
const durationSec = (result.completedAt.getTime() - result.startedAt.getTime()) / 1e3;
|
|
958
|
+
const rps = result.iterations / durationSec;
|
|
959
|
+
console.log(`
|
|
960
|
+
Throughput: ${COLORS3.green}${rps.toFixed(1)} calls/sec${COLORS3.reset}`);
|
|
961
|
+
console.log("");
|
|
962
|
+
await client.disconnect();
|
|
963
|
+
process.exit(EXIT_CODES7.SUCCESS);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
const formatted = formatError4(err);
|
|
966
|
+
console.error(`
|
|
967
|
+
${formatted.title}: ${formatted.description}`);
|
|
968
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
969
|
+
await client?.disconnect();
|
|
970
|
+
process.exit(formatted.exitCode);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// src/commands/docs.ts
|
|
975
|
+
import { Command as Command9 } from "commander";
|
|
976
|
+
import { EXIT_CODES as EXIT_CODES8 } from "@mcpspec/shared";
|
|
977
|
+
import { MCPClient as MCPClient4, DocGenerator, formatError as formatError5 } from "@mcpspec/core";
|
|
978
|
+
var COLORS4 = {
|
|
979
|
+
reset: "\x1B[0m",
|
|
980
|
+
green: "\x1B[32m",
|
|
981
|
+
cyan: "\x1B[36m",
|
|
982
|
+
bold: "\x1B[1m"
|
|
983
|
+
};
|
|
984
|
+
var docsCommand = new Command9("docs").description("Generate documentation from an MCP server").argument("<server>", "Server command or URL").option("--format <format>", "Output format: markdown, html", "markdown").option("--output <dir>", "Output directory").action(async (serverCommand, options) => {
|
|
985
|
+
let client = null;
|
|
986
|
+
try {
|
|
987
|
+
console.log(`
|
|
988
|
+
${COLORS4.cyan} Connecting to:${COLORS4.reset} ${serverCommand}`);
|
|
989
|
+
client = new MCPClient4({ serverConfig: serverCommand });
|
|
990
|
+
await client.connect();
|
|
991
|
+
const info = client.getServerInfo();
|
|
992
|
+
console.log(`${COLORS4.green} Connected to ${info?.name ?? "unknown"} v${info?.version ?? "?"}${COLORS4.reset}
|
|
993
|
+
`);
|
|
994
|
+
const format = options.format === "html" ? "html" : "markdown";
|
|
995
|
+
const generator = new DocGenerator();
|
|
996
|
+
const content = await generator.generate(client, {
|
|
997
|
+
format,
|
|
998
|
+
outputDir: options.output
|
|
999
|
+
});
|
|
1000
|
+
if (options.output) {
|
|
1001
|
+
const filename = format === "html" ? "index.html" : "README.md";
|
|
1002
|
+
console.log(`${COLORS4.green} Documentation written to ${options.output}/${filename}${COLORS4.reset}
|
|
1003
|
+
`);
|
|
1004
|
+
} else {
|
|
1005
|
+
console.log(content);
|
|
1006
|
+
}
|
|
1007
|
+
await client.disconnect();
|
|
1008
|
+
process.exit(EXIT_CODES8.SUCCESS);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const formatted = formatError5(err);
|
|
1011
|
+
console.error(`
|
|
1012
|
+
${formatted.title}: ${formatted.description}`);
|
|
1013
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
1014
|
+
await client?.disconnect();
|
|
1015
|
+
process.exit(formatted.exitCode);
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// src/commands/score.ts
|
|
1020
|
+
import { Command as Command10 } from "commander";
|
|
1021
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
1022
|
+
import { EXIT_CODES as EXIT_CODES9 } from "@mcpspec/shared";
|
|
1023
|
+
import { MCPClient as MCPClient5, MCPScoreCalculator, BadgeGenerator, formatError as formatError6 } from "@mcpspec/core";
|
|
1024
|
+
var COLORS5 = {
|
|
1025
|
+
reset: "\x1B[0m",
|
|
1026
|
+
green: "\x1B[32m",
|
|
1027
|
+
cyan: "\x1B[36m",
|
|
1028
|
+
yellow: "\x1B[33m",
|
|
1029
|
+
red: "\x1B[31m",
|
|
1030
|
+
bold: "\x1B[1m",
|
|
1031
|
+
gray: "\x1B[90m"
|
|
1032
|
+
};
|
|
1033
|
+
function scoreColor(score) {
|
|
1034
|
+
if (score >= 80) return COLORS5.green;
|
|
1035
|
+
if (score >= 60) return COLORS5.yellow;
|
|
1036
|
+
return COLORS5.red;
|
|
1037
|
+
}
|
|
1038
|
+
var scoreCommand = new Command10("score").description("Calculate MCP Score for a server").argument("<server>", "Server command or URL").option("--badge <path>", "Output badge SVG path").action(async (serverCommand, options) => {
|
|
1039
|
+
let client = null;
|
|
1040
|
+
try {
|
|
1041
|
+
console.log(`
|
|
1042
|
+
${COLORS5.cyan} Connecting to:${COLORS5.reset} ${serverCommand}`);
|
|
1043
|
+
client = new MCPClient5({ serverConfig: serverCommand });
|
|
1044
|
+
await client.connect();
|
|
1045
|
+
const info = client.getServerInfo();
|
|
1046
|
+
console.log(`${COLORS5.green} Connected to ${info?.name ?? "unknown"} v${info?.version ?? "?"}${COLORS5.reset}
|
|
1047
|
+
`);
|
|
1048
|
+
const calculator = new MCPScoreCalculator();
|
|
1049
|
+
const score = await calculator.calculate(client, {
|
|
1050
|
+
onCategoryStart: (category) => {
|
|
1051
|
+
process.stdout.write(`${COLORS5.gray} Evaluating ${category}...${COLORS5.reset}`);
|
|
1052
|
+
},
|
|
1053
|
+
onCategoryComplete: (_category, categoryScore) => {
|
|
1054
|
+
const color = scoreColor(categoryScore);
|
|
1055
|
+
console.log(` ${color}${categoryScore}/100${COLORS5.reset}`);
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
console.log(`
|
|
1059
|
+
${COLORS5.bold} MCP Score${COLORS5.reset}`);
|
|
1060
|
+
console.log(` ${"\u2500".repeat(40)}`);
|
|
1061
|
+
const categories = [
|
|
1062
|
+
{ name: "Documentation", score: score.categories.documentation },
|
|
1063
|
+
{ name: "Schema Quality", score: score.categories.schemaQuality },
|
|
1064
|
+
{ name: "Error Handling", score: score.categories.errorHandling },
|
|
1065
|
+
{ name: "Performance", score: score.categories.performance },
|
|
1066
|
+
{ name: "Security", score: score.categories.security }
|
|
1067
|
+
];
|
|
1068
|
+
for (const cat of categories) {
|
|
1069
|
+
const color = scoreColor(cat.score);
|
|
1070
|
+
const bar = "\u2588".repeat(Math.round(cat.score / 5)) + "\u2591".repeat(20 - Math.round(cat.score / 5));
|
|
1071
|
+
console.log(` ${cat.name.padEnd(16)} ${bar} ${color}${cat.score}/100${COLORS5.reset}`);
|
|
1072
|
+
}
|
|
1073
|
+
const overallColor = scoreColor(score.overall);
|
|
1074
|
+
console.log(`
|
|
1075
|
+
${COLORS5.bold}Overall: ${overallColor}${score.overall}/100${COLORS5.reset}
|
|
1076
|
+
`);
|
|
1077
|
+
if (options.badge) {
|
|
1078
|
+
const badgeGenerator = new BadgeGenerator();
|
|
1079
|
+
const svg = badgeGenerator.generate(score);
|
|
1080
|
+
writeFileSync3(options.badge, svg, "utf-8");
|
|
1081
|
+
console.log(`${COLORS5.green} Badge written to ${options.badge}${COLORS5.reset}
|
|
1082
|
+
`);
|
|
1083
|
+
}
|
|
1084
|
+
await client.disconnect();
|
|
1085
|
+
process.exit(EXIT_CODES9.SUCCESS);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
const formatted = formatError6(err);
|
|
1088
|
+
console.error(`
|
|
1089
|
+
${formatted.title}: ${formatted.description}`);
|
|
1090
|
+
formatted.suggestions.forEach((s) => console.error(` - ${s}`));
|
|
1091
|
+
await client?.disconnect();
|
|
1092
|
+
process.exit(formatted.exitCode);
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// src/index.ts
|
|
1097
|
+
var program = new Command11();
|
|
1098
|
+
program.name("mcpspec").description("The definitive MCP server testing platform").version("1.0.0");
|
|
1099
|
+
program.addCommand(testCommand);
|
|
1100
|
+
program.addCommand(inspectCommand);
|
|
1101
|
+
program.addCommand(initCommand);
|
|
1102
|
+
program.addCommand(compareCommand);
|
|
1103
|
+
program.addCommand(baselineCommand);
|
|
1104
|
+
program.addCommand(uiCommand);
|
|
1105
|
+
program.addCommand(auditCommand);
|
|
1106
|
+
program.addCommand(benchCommand);
|
|
1107
|
+
program.addCommand(docsCommand);
|
|
1108
|
+
program.addCommand(scoreCommand);
|
|
1109
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/wizard/onboarding.ts
|
|
4
|
+
import { input, select } from "@inquirer/prompts";
|
|
5
|
+
async function runOnboardingWizard() {
|
|
6
|
+
console.log("\n Welcome to MCPSpec! Let's set up your test collection.\n");
|
|
7
|
+
const name = await input({
|
|
8
|
+
message: "Collection name:",
|
|
9
|
+
default: "My MCP Tests"
|
|
10
|
+
});
|
|
11
|
+
const transport = await select({
|
|
12
|
+
message: "How does your MCP server communicate?",
|
|
13
|
+
choices: [
|
|
14
|
+
{ name: "stdio (command-line process)", value: "stdio" },
|
|
15
|
+
{ name: "SSE (Server-Sent Events)", value: "sse" },
|
|
16
|
+
{ name: "Streamable HTTP", value: "streamable-http" }
|
|
17
|
+
]
|
|
18
|
+
});
|
|
19
|
+
let command;
|
|
20
|
+
let url;
|
|
21
|
+
if (transport === "stdio") {
|
|
22
|
+
command = await input({
|
|
23
|
+
message: "Server command (e.g., npx @modelcontextprotocol/server-filesystem /tmp):",
|
|
24
|
+
default: "npx my-mcp-server"
|
|
25
|
+
});
|
|
26
|
+
} else {
|
|
27
|
+
url = await input({
|
|
28
|
+
message: "Server URL:",
|
|
29
|
+
default: "http://localhost:3000/mcp"
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const template = await select({
|
|
33
|
+
message: "Template complexity:",
|
|
34
|
+
choices: [
|
|
35
|
+
{ name: "Minimal - simple tests to get started", value: "minimal" },
|
|
36
|
+
{ name: "Standard - tests with assertions (Recommended)", value: "standard" },
|
|
37
|
+
{ name: "Full - environments, tags, extraction", value: "full" }
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
return { name, transport, command, url, template };
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
runOnboardingWizard
|
|
44
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcpspec",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The definitive MCP server testing platform",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"testing",
|
|
9
|
+
"cli"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"mcpspec": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/anthropics/mcpspec.git",
|
|
23
|
+
"directory": "packages/cli"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.0.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@inquirer/prompts": "^7.0.0",
|
|
30
|
+
"commander": "^12.1.0",
|
|
31
|
+
"open": "^10.1.0",
|
|
32
|
+
"@mcpspec/core": "1.0.0",
|
|
33
|
+
"@mcpspec/shared": "1.0.0",
|
|
34
|
+
"@mcpspec/server": "1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"tsup": "^8.0.0",
|
|
38
|
+
"typescript": "^5.4.0",
|
|
39
|
+
"vitest": "^2.1.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"test": "vitest run --passWithNoTests",
|
|
44
|
+
"clean": "rm -rf dist .turbo"
|
|
45
|
+
}
|
|
46
|
+
}
|