mcp-eval-runner 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.
Files changed (48) hide show
  1. package/.env.example +39 -0
  2. package/CHANGELOG.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +328 -0
  5. package/dist/assertions.d.ts +63 -0
  6. package/dist/assertions.js +187 -0
  7. package/dist/audit-log.d.ts +26 -0
  8. package/dist/audit-log.js +57 -0
  9. package/dist/auth.d.ts +15 -0
  10. package/dist/auth.js +83 -0
  11. package/dist/db.d.ts +40 -0
  12. package/dist/db.js +94 -0
  13. package/dist/deployment-gate.d.ts +27 -0
  14. package/dist/deployment-gate.js +43 -0
  15. package/dist/fixture-library.d.ts +26 -0
  16. package/dist/fixture-library.js +85 -0
  17. package/dist/fixture.d.ts +87 -0
  18. package/dist/fixture.js +170 -0
  19. package/dist/http-server.d.ts +7 -0
  20. package/dist/http-server.js +34 -0
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.js +158 -0
  23. package/dist/llm-judge.d.ts +24 -0
  24. package/dist/llm-judge.js +139 -0
  25. package/dist/rate-limiter.d.ts +13 -0
  26. package/dist/rate-limiter.js +36 -0
  27. package/dist/reporter.d.ts +8 -0
  28. package/dist/reporter.js +163 -0
  29. package/dist/runner.d.ts +57 -0
  30. package/dist/runner.js +339 -0
  31. package/dist/server.d.ts +22 -0
  32. package/dist/server.js +583 -0
  33. package/dist/tools/html_report.d.ts +8 -0
  34. package/dist/tools/html_report.js +188 -0
  35. package/dist/tools/manage.d.ts +11 -0
  36. package/dist/tools/manage.js +41 -0
  37. package/dist/tools/report.d.ts +12 -0
  38. package/dist/tools/report.js +120 -0
  39. package/dist/tools/run.d.ts +20 -0
  40. package/dist/tools/run.js +166 -0
  41. package/dist/tools/scaffold.d.ts +11 -0
  42. package/dist/tools/scaffold.js +90 -0
  43. package/evals/reference/mcp-fetch.yaml +46 -0
  44. package/evals/reference/mcp-filesystem.yaml +63 -0
  45. package/evals/reference/mcp-memory.yaml +70 -0
  46. package/evals/reference/step-piping-example.yaml +25 -0
  47. package/evals/smoke.yaml +12 -0
  48. package/package.json +67 -0
package/dist/server.js ADDED
@@ -0,0 +1,583 @@
1
+ /**
2
+ * MCP Server for mcp-eval-runner.
3
+ * Exposes tools: run_suite, run_case, list_cases, create_test_case,
4
+ * regression_report, compare_results, generate_html_report, scaffold_fixture.
5
+ * Exposes resources: eval://{fixture_name}
6
+ * Exposes prompts: write-test-case
7
+ */
8
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { CancelledNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { z } from "zod";
12
+ import { EvalDb } from "./db.js";
13
+ import { runSuiteTool, runCaseTool } from "./tools/run.js";
14
+ import { listCasesTool, createTestCaseTool } from "./tools/manage.js";
15
+ import { regressionReportTool, compareResultsTool } from "./tools/report.js";
16
+ import { generateHtmlReportTool } from "./tools/html_report.js";
17
+ import { scaffoldFixtureTool } from "./tools/scaffold.js";
18
+ import { loadFixturesFromDir } from "./fixture.js";
19
+ import { evaluateGate } from "./deployment-gate.js";
20
+ import { discoverFixtures } from "./fixture-library.js";
21
+ import { AuditLog } from "./audit-log.js";
22
+ import fs from "fs";
23
+ import path from "path";
24
+ import os from "os";
25
+ import crypto from "crypto";
26
+ // ── Cancellation registry ─────────────────────────────────────────────────────
27
+ const cancellationRegistry = new Map();
28
+ /**
29
+ * Check whether the given requestId has been cancelled by the client.
30
+ */
31
+ export function isCancelled(requestId) {
32
+ return cancellationRegistry.get(requestId) === true;
33
+ }
34
+ export async function createServer(opts) {
35
+ const db = new EvalDb(opts.dbPath);
36
+ const auditLog = new AuditLog();
37
+ const runnerOptions = {
38
+ fixturesDir: opts.fixturesDir,
39
+ dbPath: opts.dbPath,
40
+ timeoutMs: opts.timeoutMs,
41
+ format: opts.format,
42
+ concurrency: opts.concurrency,
43
+ };
44
+ const server = new McpServer({
45
+ name: "mcp-eval-runner",
46
+ version: "0.2.0",
47
+ });
48
+ const toolOpts = {
49
+ fixturesDir: opts.fixturesDir,
50
+ db,
51
+ runnerOptions,
52
+ server,
53
+ };
54
+ // ── Cancellation handler ───────────────────────────────────────────────────
55
+ // Intercept notifications/cancelled to track cancelled request IDs.
56
+ server.server.setNotificationHandler(CancelledNotificationSchema, async (notification) => {
57
+ const requestId = notification.params?.requestId;
58
+ if (requestId !== undefined) {
59
+ cancellationRegistry.set(String(requestId), true);
60
+ }
61
+ });
62
+ // ── run_suite ─────────────────────────────────────────────────────────────
63
+ server.tool("run_suite", [
64
+ "Execute all fixtures in the fixtures directory. Returns a pass/fail summary.",
65
+ "",
66
+ "Execution modes:",
67
+ " Live mode: fixture has a 'server' block with command/args — spawns the server",
68
+ " and calls tools via real MCP stdio transport.",
69
+ " Simulation mode: no server block — assertions run against expected_output fields.",
70
+ "",
71
+ "Assertion types supported in fixture expect blocks:",
72
+ ' output_contains: "substring"',
73
+ ' output_not_contains: "substring"',
74
+ ' output_equals: "exact text"',
75
+ ' output_matches: "regex"',
76
+ ' tool_called: "tool_name"',
77
+ " latency_under: 500",
78
+ ' schema_match: { type: "object", required: ["result"], properties: { result: { type: "string" } } }',
79
+ "",
80
+ "Step output piping: use {{steps.<step_id>.output}} in input values to reference",
81
+ "the output of a previous step.",
82
+ ].join("\n"), {}, { readOnlyHint: false }, async () => {
83
+ const runStart = Date.now();
84
+ const runId = crypto.randomUUID();
85
+ try {
86
+ const text = await runSuiteTool(toolOpts);
87
+ auditLog.record({
88
+ timestamp: new Date().toISOString(),
89
+ run_id: runId,
90
+ fixture_name: "suite:all",
91
+ passed: !text.toLowerCase().includes("failed"),
92
+ duration_ms: Date.now() - runStart,
93
+ });
94
+ return { content: [{ type: "text", text }] };
95
+ }
96
+ catch (err) {
97
+ const message = err instanceof Error ? err.message : String(err);
98
+ auditLog.record({
99
+ timestamp: new Date().toISOString(),
100
+ run_id: runId,
101
+ fixture_name: "suite:all",
102
+ passed: false,
103
+ duration_ms: Date.now() - runStart,
104
+ });
105
+ return {
106
+ content: [{ type: "text", text: `Error running suite: ${message}` }],
107
+ isError: true,
108
+ };
109
+ }
110
+ });
111
+ // ── run_case ──────────────────────────────────────────────────────────────
112
+ server.tool("run_case", [
113
+ "Run a single named test case from the fixtures directory.",
114
+ "",
115
+ "Assertion types supported in fixture expect blocks:",
116
+ ' output_contains: "substring"',
117
+ ' output_equals: "exact text"',
118
+ ' tool_called: "tool_name"',
119
+ " latency_under: 500",
120
+ ' schema_match: { type: "object", required: ["id"], properties: { id: { type: "number" } } }',
121
+ ].join("\n"), {
122
+ name: z.string().describe("The name of the fixture to run"),
123
+ }, { readOnlyHint: false }, async ({ name }) => {
124
+ if (!name || name.trim() === "") {
125
+ return {
126
+ content: [
127
+ {
128
+ type: "text",
129
+ text: 'Error: "name" parameter is required and must be a non-empty string.',
130
+ },
131
+ ],
132
+ isError: true,
133
+ };
134
+ }
135
+ const caseStart = Date.now();
136
+ const caseRunId = crypto.randomUUID();
137
+ try {
138
+ const text = await runCaseTool(name, toolOpts);
139
+ auditLog.record({
140
+ timestamp: new Date().toISOString(),
141
+ run_id: caseRunId,
142
+ fixture_name: name,
143
+ passed: !text.toLowerCase().includes("failed"),
144
+ duration_ms: Date.now() - caseStart,
145
+ });
146
+ return { content: [{ type: "text", text }] };
147
+ }
148
+ catch (err) {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ auditLog.record({
151
+ timestamp: new Date().toISOString(),
152
+ run_id: caseRunId,
153
+ fixture_name: name,
154
+ passed: false,
155
+ duration_ms: Date.now() - caseStart,
156
+ });
157
+ return {
158
+ content: [{ type: "text", text: `Error running case "${name}": ${message}` }],
159
+ isError: true,
160
+ };
161
+ }
162
+ });
163
+ // ── list_cases ────────────────────────────────────────────────────────────
164
+ server.tool("list_cases", "Enumerate all available fixtures with their step counts and assertion types.", {}, { readOnlyHint: true }, async () => {
165
+ try {
166
+ const text = listCasesTool(opts.fixturesDir);
167
+ return { content: [{ type: "text", text }] };
168
+ }
169
+ catch (err) {
170
+ const message = err instanceof Error ? err.message : String(err);
171
+ return {
172
+ content: [{ type: "text", text: `Error listing cases: ${message}` }],
173
+ isError: true,
174
+ };
175
+ }
176
+ });
177
+ // ── create_test_case ──────────────────────────────────────────────────────
178
+ server.tool("create_test_case", [
179
+ "Create a new YAML fixture file in the fixtures directory.",
180
+ "",
181
+ "Supported assertion types in each step's expect block:",
182
+ ' output_contains: "substring" — checks output includes this text',
183
+ ' output_equals: "exact" — checks exact output match',
184
+ ' tool_called: "tool_name" — verifies which tool was called',
185
+ " latency_under: 500 — asserts response time under N ms",
186
+ " schema_match: { type: 'object', ... } — validates output JSON against schema",
187
+ ].join("\n"), {
188
+ name: z.string().describe("The name of the new test case"),
189
+ description: z.string().optional().describe("Optional description for the test case"),
190
+ steps: z
191
+ .array(z.object({
192
+ id: z.string().describe("Unique step identifier"),
193
+ description: z.string().optional(),
194
+ tool: z.string().describe("Tool name to call"),
195
+ input: z.record(z.string(), z.any()).optional().default({}),
196
+ expected_output: z.string().optional(),
197
+ expect: z
198
+ .object({
199
+ output_contains: z.string().optional(),
200
+ output_equals: z.string().optional(),
201
+ tool_called: z.string().optional(),
202
+ latency_under: z.number().optional(),
203
+ schema_match: z.record(z.string(), z.any()).optional(),
204
+ })
205
+ .optional(),
206
+ }))
207
+ .describe("Array of steps for the test case"),
208
+ }, { readOnlyHint: false }, async ({ name, steps }) => {
209
+ if (!name || name.trim() === "") {
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: 'Error: "name" parameter is required and must be a non-empty string.',
215
+ },
216
+ ],
217
+ isError: true,
218
+ };
219
+ }
220
+ if (!steps || steps.length === 0) {
221
+ return {
222
+ content: [
223
+ {
224
+ type: "text",
225
+ text: 'Error: "steps" parameter is required and must be a non-empty array.',
226
+ },
227
+ ],
228
+ isError: true,
229
+ };
230
+ }
231
+ try {
232
+ const text = createTestCaseTool(name, steps, opts.fixturesDir);
233
+ return { content: [{ type: "text", text }] };
234
+ }
235
+ catch (err) {
236
+ const message = err instanceof Error ? err.message : String(err);
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text",
241
+ text: `Error creating test case "${name}": ${message}`,
242
+ },
243
+ ],
244
+ isError: true,
245
+ };
246
+ }
247
+ });
248
+ // ── regression_report ─────────────────────────────────────────────────────
249
+ server.tool("regression_report", "Compare current state to the last run and return what changed (regressions, fixes, new cases).", {}, { readOnlyHint: true }, async () => {
250
+ try {
251
+ const text = await regressionReportTool(opts.fixturesDir, db);
252
+ return { content: [{ type: "text", text }] };
253
+ }
254
+ catch (err) {
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ return {
257
+ content: [
258
+ {
259
+ type: "text",
260
+ text: `Error generating regression report: ${message}`,
261
+ },
262
+ ],
263
+ isError: true,
264
+ };
265
+ }
266
+ });
267
+ // ── compare_results ───────────────────────────────────────────────────────
268
+ server.tool("compare_results", "Diff two named run results by run ID. Shows regressions, fixes, new and removed cases.", {
269
+ run_id_a: z.string().describe("First run ID to compare"),
270
+ run_id_b: z.string().describe("Second run ID to compare"),
271
+ }, { readOnlyHint: true }, async ({ run_id_a, run_id_b }) => {
272
+ try {
273
+ const text = await compareResultsTool(run_id_a, run_id_b, db);
274
+ return { content: [{ type: "text", text }] };
275
+ }
276
+ catch (err) {
277
+ const message = err instanceof Error ? err.message : String(err);
278
+ return {
279
+ content: [
280
+ {
281
+ type: "text",
282
+ text: `Error comparing results: ${message}`,
283
+ },
284
+ ],
285
+ isError: true,
286
+ };
287
+ }
288
+ });
289
+ // ── generate_html_report ──────────────────────────────────────────────────
290
+ server.tool("generate_html_report", [
291
+ "Generate a full single-file HTML report for a completed run.",
292
+ "The report includes: suite summary (pass/fail counts, duration),",
293
+ "per-case drill-down with per-assertion pass/fail details, and",
294
+ "color-coded status (green=pass, red=fail, yellow=error).",
295
+ "",
296
+ "Use run_suite or run_case first, then pass the returned run_id here.",
297
+ ].join("\n"), {
298
+ run_id: z.string().describe("The run ID returned by run_suite or run_case"),
299
+ }, { readOnlyHint: true }, async ({ run_id }) => {
300
+ try {
301
+ const html = generateHtmlReportTool(run_id, db);
302
+ return { content: [{ type: "text", text: html }] };
303
+ }
304
+ catch (err) {
305
+ const message = err instanceof Error ? err.message : String(err);
306
+ return {
307
+ content: [
308
+ {
309
+ type: "text",
310
+ text: `Error generating HTML report: ${message}`,
311
+ },
312
+ ],
313
+ isError: true,
314
+ };
315
+ }
316
+ });
317
+ // ── scaffold_fixture ──────────────────────────────────────────────────────
318
+ server.tool("scaffold_fixture", [
319
+ "Generate a boilerplate YAML fixture file in the fixtures directory.",
320
+ "Each tool name provided becomes a documented step with placeholder",
321
+ "input parameters and all supported assertion types pre-filled as comments.",
322
+ "",
323
+ "Example: scaffold_fixture({ name: 'search_test', tool_names: ['search', 'summarize'] })",
324
+ "",
325
+ "Supported assertion types (pre-filled as TODOs in the generated file):",
326
+ " output_contains, output_equals, tool_called, latency_under, schema_match",
327
+ ].join("\n"), {
328
+ name: z.string().describe("Name for the new fixture (becomes filename)"),
329
+ tool_names: z.array(z.string()).describe("List of tool names — each becomes a fixture step"),
330
+ }, { readOnlyHint: false }, async ({ name, tool_names }) => {
331
+ try {
332
+ const filePath = scaffoldFixtureTool(name, tool_names, opts.fixturesDir);
333
+ return {
334
+ content: [
335
+ {
336
+ type: "text",
337
+ text: [
338
+ `Scaffold created: ${filePath}`,
339
+ ` Name: ${name}`,
340
+ ` Steps: ${tool_names.length} (${tool_names.join(", ")})`,
341
+ "",
342
+ "Edit the TODO fields, then run run_case or run_suite.",
343
+ ].join("\n"),
344
+ },
345
+ ],
346
+ };
347
+ }
348
+ catch (err) {
349
+ const message = err instanceof Error ? err.message : String(err);
350
+ return {
351
+ content: [
352
+ {
353
+ type: "text",
354
+ text: `Error scaffolding fixture: ${message}`,
355
+ },
356
+ ],
357
+ isError: true,
358
+ };
359
+ }
360
+ });
361
+ // ── evaluate_deployment_gate ──────────────────────────────────────────────
362
+ server.tool("evaluate_deployment_gate", [
363
+ "CI gate — evaluate whether recent eval runs meet a minimum pass rate threshold.",
364
+ "Fails (passed: false) if the pass rate of recent runs drops below min_pass_rate.",
365
+ "Use this in CI pipelines to block deployments on regressions.",
366
+ ].join("\n"), {
367
+ workflow_name: z
368
+ .string()
369
+ .optional()
370
+ .describe("Optional suite/workflow name to filter runs by"),
371
+ min_pass_rate: z.number().min(0).max(1).describe("Minimum acceptable pass rate (0.0 – 1.0)"),
372
+ lookback_runs: z
373
+ .number()
374
+ .int()
375
+ .positive()
376
+ .optional()
377
+ .describe("Number of most-recent runs to consider (default: 10)"),
378
+ }, { readOnlyHint: true }, async ({ workflow_name, min_pass_rate, lookback_runs }) => {
379
+ try {
380
+ const result = evaluateGate(db, { workflow_name, min_pass_rate, lookback_runs });
381
+ const lines = [
382
+ `Gate: ${result.passed ? "PASSED" : "FAILED"}`,
383
+ `Current pass rate: ${(result.current_rate * 100).toFixed(1)}%`,
384
+ `Threshold: ${(result.threshold * 100).toFixed(1)}%`,
385
+ `Runs evaluated: ${result.run_count}`,
386
+ ];
387
+ return {
388
+ content: [{ type: "text", text: lines.join("\n") }],
389
+ isError: !result.passed,
390
+ };
391
+ }
392
+ catch (err) {
393
+ const message = err instanceof Error ? err.message : String(err);
394
+ return {
395
+ content: [{ type: "text", text: `Error evaluating deployment gate: ${message}` }],
396
+ isError: true,
397
+ };
398
+ }
399
+ });
400
+ // ── discover_fixtures ─────────────────────────────────────────────────────
401
+ server.tool("discover_fixtures", [
402
+ "Discover fixture files across one or more directories.",
403
+ "Returns a list of fixtures with their names, paths, and step counts.",
404
+ "If no dirs are provided, uses the configured fixtures directory and",
405
+ "FIXTURE_LIBRARY_DIRS env var (colon-separated list of paths).",
406
+ ].join("\n"), {
407
+ dirs: z.array(z.string()).optional().describe("List of directories to scan for fixtures"),
408
+ }, { readOnlyHint: true }, async ({ dirs }) => {
409
+ try {
410
+ // Build directory list: explicit dirs, env var dirs, and default dir
411
+ const envDirs = (process.env.FIXTURE_LIBRARY_DIRS ?? "")
412
+ .split(":")
413
+ .map((d) => d.trim())
414
+ .filter(Boolean)
415
+ .map((d) => (d.startsWith("~") ? d.replace("~", os.homedir()) : d));
416
+ const searchDirs = dirs && dirs.length > 0 ? dirs : [opts.fixturesDir, ...envDirs];
417
+ const entries = discoverFixtures(searchDirs);
418
+ if (entries.length === 0) {
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `No fixtures found in: ${searchDirs.join(", ")}`,
424
+ },
425
+ ],
426
+ };
427
+ }
428
+ const lines = [
429
+ `Found ${entries.length} fixture(s):`,
430
+ "",
431
+ ...entries.map((e) => ` ${e.name}\n path: ${e.path}\n suites: ${e.suite_count}, steps: ${e.case_count}`),
432
+ ];
433
+ return { content: [{ type: "text", text: lines.join("\n") }] };
434
+ }
435
+ catch (err) {
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ return {
438
+ content: [{ type: "text", text: `Error discovering fixtures: ${message}` }],
439
+ isError: true,
440
+ };
441
+ }
442
+ });
443
+ // ── MCP Resources: eval://{fixture_name} ─────────────────────────────────
444
+ // Expose each fixture file as a resource accessible at eval://{fixture_name}
445
+ const fixtureTemplate = new ResourceTemplate("eval://{fixture_name}", {
446
+ list: async () => {
447
+ const fixtures = loadFixturesFromDir(opts.fixturesDir);
448
+ return {
449
+ resources: fixtures.map((f) => ({
450
+ uri: `eval://${encodeURIComponent(f.name)}`,
451
+ name: f.name,
452
+ description: f.description ?? `Fixture "${f.name}" with ${f.steps.length} step(s)`,
453
+ mimeType: "application/yaml",
454
+ })),
455
+ };
456
+ },
457
+ });
458
+ server.resource("fixture", fixtureTemplate, async (uri, { fixture_name }) => {
459
+ const decodedName = decodeURIComponent(String(fixture_name));
460
+ // Attempt to find by fixture name first
461
+ const fixtures = loadFixturesFromDir(opts.fixturesDir);
462
+ const found = fixtures.find((f) => f.name === decodedName);
463
+ if (found) {
464
+ // Try to read the raw file content
465
+ const safeName = decodedName.replace(/[^a-z0-9_-]/gi, "_");
466
+ const candidates = [
467
+ path.join(opts.fixturesDir, `${safeName}.yaml`),
468
+ path.join(opts.fixturesDir, `${safeName}.yml`),
469
+ path.join(opts.fixturesDir, `${safeName}.json`),
470
+ ];
471
+ for (const candidate of candidates) {
472
+ if (fs.existsSync(candidate)) {
473
+ const rawContent = fs.readFileSync(candidate, "utf-8");
474
+ return {
475
+ contents: [
476
+ {
477
+ uri: uri.href,
478
+ mimeType: candidate.endsWith(".json") ? "application/json" : "application/yaml",
479
+ text: rawContent,
480
+ },
481
+ ],
482
+ };
483
+ }
484
+ }
485
+ }
486
+ // Try to load directly by filename variant
487
+ const candidates = [
488
+ path.join(opts.fixturesDir, `${decodedName}.yaml`),
489
+ path.join(opts.fixturesDir, `${decodedName}.yml`),
490
+ path.join(opts.fixturesDir, `${decodedName}.json`),
491
+ ];
492
+ for (const candidate of candidates) {
493
+ if (fs.existsSync(candidate)) {
494
+ const rawContent = fs.readFileSync(candidate, "utf-8");
495
+ return {
496
+ contents: [
497
+ {
498
+ uri: uri.href,
499
+ mimeType: candidate.endsWith(".json") ? "application/json" : "application/yaml",
500
+ text: rawContent,
501
+ },
502
+ ],
503
+ };
504
+ }
505
+ }
506
+ throw new Error(`Fixture not found: ${decodedName}`);
507
+ });
508
+ // ── MCP Prompt: write-test-case ───────────────────────────────────────────
509
+ server.prompt("write-test-case", {
510
+ fixture_description: z
511
+ .string()
512
+ .describe("Describe what the fixture should test, e.g. 'search for a term and verify results contain it'"),
513
+ tool_names: z
514
+ .string()
515
+ .optional()
516
+ .describe("Comma-separated list of tool names used in this fixture"),
517
+ }, async ({ fixture_description, tool_names }) => {
518
+ const toolList = tool_names
519
+ ? tool_names
520
+ .split(",")
521
+ .map((t) => t.trim())
522
+ .filter(Boolean)
523
+ : [];
524
+ const toolSection = toolList.length > 0 ? `\nThe fixture should test these tools: ${toolList.join(", ")}.` : "";
525
+ const promptText = [
526
+ "You are helping write a YAML fixture file for mcp-eval-runner, a testing harness for MCP tool calls.",
527
+ "",
528
+ `Goal: ${fixture_description}${toolSection}`,
529
+ "",
530
+ "A valid fixture has this structure:",
531
+ "```yaml",
532
+ "name: my_test_case",
533
+ "description: What this test does",
534
+ "steps:",
535
+ " - id: step_1",
536
+ " description: Call the tool",
537
+ " tool: tool_name",
538
+ " input:",
539
+ " param: value",
540
+ " expected_output: The expected output text",
541
+ " expect:",
542
+ ' output_contains: "expected substring"',
543
+ ' output_equals: "exact expected text" # optional',
544
+ " tool_called: tool_name # optional",
545
+ " latency_under: 5000 # optional, ms",
546
+ " schema_match: # optional",
547
+ " type: object",
548
+ " required: [result]",
549
+ " properties:",
550
+ " result:",
551
+ " type: string",
552
+ "```",
553
+ "",
554
+ "Rules:",
555
+ "- Each step must have a unique `id` and a `tool` name.",
556
+ "- `input` is a key-value map of parameters passed to the tool.",
557
+ "- `expected_output` is the literal string output to simulate in Phase 1.",
558
+ "- `expect` contains assertions evaluated against the simulated output.",
559
+ "- `schema_match` validates that the output (parsed as JSON) matches a JSON Schema.",
560
+ " Supported keywords: type, properties, required, additionalProperties, items.",
561
+ "- You may include multiple assertions in one step's `expect` block.",
562
+ "",
563
+ "Please write the complete YAML fixture file now.",
564
+ ].join("\n");
565
+ return {
566
+ messages: [
567
+ {
568
+ role: "user",
569
+ content: {
570
+ type: "text",
571
+ text: promptText,
572
+ },
573
+ },
574
+ ],
575
+ };
576
+ });
577
+ return server;
578
+ }
579
+ export async function startServer(opts) {
580
+ const server = await createServer(opts);
581
+ const transport = new StdioServerTransport();
582
+ await server.connect(transport);
583
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * generate_html_report tool implementation.
3
+ *
4
+ * Generates a full single-file HTML report for a given run_id.
5
+ * All styles are inlined — no external CDN required.
6
+ */
7
+ import type { EvalDb } from "../db.js";
8
+ export declare function generateHtmlReportTool(runId: string, db: EvalDb): string;