@inspectr/mcplab-mcp-server 0.1.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.
@@ -0,0 +1,1948 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ import { basename, dirname, extname, resolve, join, sep, relative } from 'node:path';
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
8
+ import { loadConfig, runAll, selectScenarios } from '@inspectr/mcplab-core';
9
+ import { renderReport } from '@inspectr/mcplab-reporting';
10
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
+ import { z } from 'zod';
12
+ const SERVER_VERSION = '0.1.0';
13
+ const DEFAULT_MCP_PATH = '/mcp';
14
+ const DEFAULT_MCP_PORT = 3011;
15
+ const DEFAULT_MCP_HOST = '127.0.0.1';
16
+ const MAX_MARKDOWN_REPORT_READ_BYTES = 2 * 1024 * 1024;
17
+ export async function startMcplabMcpServer(options) {
18
+ const logger = options.logger ?? console;
19
+ const sessions = new Map();
20
+ const httpServer = createServer(async (req, res) => {
21
+ try {
22
+ await handleHttpRequest(req, res, sessions, options.path);
23
+ }
24
+ catch (error) {
25
+ logger.error('[mcplab-mcp-server] request error:', error);
26
+ if (!res.headersSent) {
27
+ sendJson(res, 500, {
28
+ jsonrpc: '2.0',
29
+ error: { code: -32603, message: 'Internal server error' },
30
+ id: null
31
+ });
32
+ }
33
+ else {
34
+ res.end();
35
+ }
36
+ }
37
+ });
38
+ await new Promise((resolveListen, rejectListen) => {
39
+ httpServer.once('error', rejectListen);
40
+ httpServer.listen(options.port, options.host, () => {
41
+ httpServer.off('error', rejectListen);
42
+ logger.error(`[mcplab-mcp-server] Streamable HTTP listening on http://${options.host}:${options.port}${options.path}`);
43
+ resolveListen();
44
+ });
45
+ });
46
+ const close = async () => {
47
+ for (const [sessionId, runtime] of sessions) {
48
+ try {
49
+ await runtime.transport.close();
50
+ await runtime.server.close();
51
+ }
52
+ catch (error) {
53
+ logger.error(`[mcplab-mcp-server] failed to close session ${sessionId}:`, error);
54
+ }
55
+ }
56
+ sessions.clear();
57
+ await new Promise((resolveClose) => {
58
+ httpServer.close(() => resolveClose());
59
+ });
60
+ };
61
+ return {
62
+ host: options.host,
63
+ port: options.port,
64
+ path: options.path,
65
+ close
66
+ };
67
+ }
68
+ export function defaultMcplabMcpServerOptionsFromEnv() {
69
+ return {
70
+ host: process.env.MCP_HOST || DEFAULT_MCP_HOST,
71
+ port: Number.parseInt(process.env.MCP_PORT ?? String(DEFAULT_MCP_PORT), 10),
72
+ path: process.env.MCP_PATH || DEFAULT_MCP_PATH
73
+ };
74
+ }
75
+ export function createConfiguredServer() {
76
+ const server = new McpServer({
77
+ name: 'mcplab-assistant-server',
78
+ version: SERVER_VERSION
79
+ });
80
+ registerTools(server);
81
+ registerPrompts(server);
82
+ return server;
83
+ }
84
+ export function registerTools(server) {
85
+ server.registerTool('mcplab_write_markdown_report', {
86
+ description: 'Write a Markdown report file to disk (for example under mcplab/reports/) and return the resolved path. Paths must stay inside the current workspace.',
87
+ inputSchema: {
88
+ output_path: z
89
+ .string()
90
+ .describe('Target .md/.markdown path, relative to the current workspace or absolute within it.'),
91
+ markdown: z.string().describe('Markdown content to write.'),
92
+ overwrite: z
93
+ .boolean()
94
+ .optional()
95
+ .describe('Overwrite existing file if true. Defaults to false.'),
96
+ create_dirs: z
97
+ .boolean()
98
+ .optional()
99
+ .describe('Create missing parent directories if true. Defaults to true.')
100
+ }
101
+ }, async ({ output_path, markdown, overwrite, create_dirs }) => {
102
+ return withToolHandling(async () => {
103
+ const targetPath = resolvePathInsideWorkspace(output_path);
104
+ const extension = extname(targetPath).toLowerCase();
105
+ if (extension !== '.md' && extension !== '.markdown') {
106
+ throw new Error('output_path must end with .md or .markdown');
107
+ }
108
+ const parentDir = dirname(targetPath);
109
+ if (Boolean(create_dirs ?? true)) {
110
+ mkdirSync(parentDir, { recursive: true });
111
+ }
112
+ else if (!existsSync(parentDir)) {
113
+ throw new Error(`Parent directory does not exist: ${parentDir}`);
114
+ }
115
+ const fileExists = existsSync(targetPath);
116
+ if (fileExists && !Boolean(overwrite)) {
117
+ throw new Error(`File already exists: ${targetPath} (set overwrite=true to replace it)`);
118
+ }
119
+ const normalized = markdown.endsWith('\n') ? markdown : `${markdown}\n`;
120
+ writeFileSync(targetPath, normalized, 'utf8');
121
+ return ok(`Wrote Markdown report to ${targetPath}`, {
122
+ path: targetPath,
123
+ bytes: Buffer.byteLength(normalized, 'utf8'),
124
+ chars: normalized.length,
125
+ overwritten: fileExists,
126
+ workspace_root: process.cwd()
127
+ });
128
+ });
129
+ });
130
+ server.registerTool('mcplab_list_markdown_reports', {
131
+ description: 'List saved markdown reports under mcplab/reports. Supports filtering by run id substring to find reports linked to a result.',
132
+ inputSchema: {
133
+ reports_dir: z
134
+ .string()
135
+ .optional()
136
+ .describe('Markdown reports root (default mcplab/reports).'),
137
+ run_id: z
138
+ .string()
139
+ .optional()
140
+ .describe('Optional run id substring filter (matches path/name).'),
141
+ limit: z
142
+ .number()
143
+ .int()
144
+ .positive()
145
+ .max(200)
146
+ .optional()
147
+ .describe('Max reports to return (default 20).')
148
+ }
149
+ }, async ({ reports_dir, run_id, limit }) => {
150
+ return withToolHandling(async () => {
151
+ const root = resolveMarkdownReportsDir(reports_dir);
152
+ const all = listMarkdownReportsFromDisk(root);
153
+ const runFilter = String(run_id ?? '').trim();
154
+ const filtered = runFilter
155
+ ? all.filter((item) => item.relativePath.includes(runFilter) || item.name.includes(runFilter))
156
+ : all;
157
+ const capped = filtered.slice(0, limit ?? 20);
158
+ return ok(`Found ${capped.length}/${filtered.length} markdown report(s) in ${root}`, {
159
+ reports_dir: root,
160
+ run_id_filter: runFilter || undefined,
161
+ total_matching: filtered.length,
162
+ items: capped
163
+ });
164
+ });
165
+ });
166
+ server.registerTool('mcplab_read_markdown_report', {
167
+ description: 'Read a saved markdown report by relative path (under mcplab/reports by default) or by workspace-relative path, with optional truncation.',
168
+ inputSchema: {
169
+ path: z
170
+ .string()
171
+ .describe('Report path (relative to reports root or workspace-relative, e.g. mcplab/reports/... ).'),
172
+ reports_dir: z
173
+ .string()
174
+ .optional()
175
+ .describe('Markdown reports root (default mcplab/reports).'),
176
+ max_chars: z
177
+ .number()
178
+ .int()
179
+ .positive()
180
+ .optional()
181
+ .describe('Optional truncation for markdown content preview (default 20000).')
182
+ }
183
+ }, async ({ path, reports_dir, max_chars }) => {
184
+ return withToolHandling(async () => {
185
+ const root = resolveMarkdownReportsDir(reports_dir);
186
+ const targetPath = resolveMarkdownReportPath(root, path);
187
+ if (!isMarkdownReportExt(targetPath)) {
188
+ throw new Error('path must point to a .md or .markdown file');
189
+ }
190
+ const st = statSync(targetPath);
191
+ if (!st.isFile())
192
+ throw new Error(`Report not found: ${targetPath}`);
193
+ if (st.size > MAX_MARKDOWN_REPORT_READ_BYTES) {
194
+ throw new Error(`Report exceeds ${MAX_MARKDOWN_REPORT_READ_BYTES} bytes`);
195
+ }
196
+ const raw = readFileSync(targetPath, 'utf8');
197
+ const preview = truncate(raw, max_chars ?? 20_000);
198
+ return ok(`Read markdown report ${relative(process.cwd(), targetPath).split(sep).join('/')}`, {
199
+ reports_dir: root,
200
+ path: relative(process.cwd(), targetPath).split(sep).join('/'),
201
+ relativePath: relative(root, targetPath).split(sep).join('/'),
202
+ name: basename(targetPath),
203
+ sizeBytes: st.size,
204
+ mtime: st.mtime.toISOString(),
205
+ truncated: preview.length < raw.length,
206
+ content: preview
207
+ });
208
+ });
209
+ });
210
+ server.registerTool('mcplab_list_library', {
211
+ description: 'List reusable MCPLab library entries (servers, agents, scenarios) from a bundle root such as mcplab/ or examples/libraries/.',
212
+ inputSchema: {
213
+ bundleRoot: z
214
+ .string()
215
+ .optional()
216
+ .describe('Optional library bundle root. Defaults to mcplab/ or examples/libraries/ if present.'),
217
+ kind: z
218
+ .enum(['all', 'servers', 'agents', 'scenarios'])
219
+ .optional()
220
+ .describe('Which library category to list. Defaults to all.'),
221
+ includeContent: z
222
+ .boolean()
223
+ .optional()
224
+ .describe('Include parsed YAML content for each item (larger output).')
225
+ }
226
+ }, async ({ bundleRoot, kind, includeContent }) => {
227
+ return withToolHandling(async () => {
228
+ const root = resolveBundleRoot(bundleRoot);
229
+ const data = readLibrary(root, Boolean(includeContent));
230
+ const selectedKind = kind ?? 'all';
231
+ const structured = selectedKind === 'all'
232
+ ? data
233
+ : {
234
+ bundleRoot: data.bundleRoot,
235
+ [selectedKind]: data[selectedKind]
236
+ };
237
+ return ok(`Loaded MCPLab library from ${root}`, structured);
238
+ });
239
+ });
240
+ server.registerTool('mcplab_get_library_item', {
241
+ description: 'Get a specific reusable server, agent, or scenario definition from a MCPLab library bundle and return both structured data and YAML.',
242
+ inputSchema: {
243
+ bundleRoot: z.string().optional().describe('Optional library bundle root path.'),
244
+ kind: z.enum(['servers', 'agents', 'scenarios']).describe('Library category.'),
245
+ id: z.string().describe('Entry id (for scenarios this is scenario.id, not filename).')
246
+ }
247
+ }, async ({ bundleRoot, kind, id }) => {
248
+ return withToolHandling(async () => {
249
+ const root = resolveBundleRoot(bundleRoot);
250
+ const item = getLibraryItem(root, kind, id);
251
+ return ok(`Loaded ${kind.slice(0, -1)} '${id}' from ${root}`, item);
252
+ });
253
+ });
254
+ server.registerTool('mcplab_generate_server_entry', {
255
+ description: 'Generate a MCPLab servers.yaml entry (or inline config block) for an MCP server connection.',
256
+ inputSchema: {
257
+ id: z.string().describe('Server id key (kebab-case recommended).'),
258
+ url: z.string().describe('MCP server URL (Streamable HTTP endpoint).'),
259
+ transport: z.enum(['http']).optional().describe('MCPLab transport type (currently http).'),
260
+ auth_type: z
261
+ .enum(['none', 'bearer', 'oauth_client_credentials'])
262
+ .optional()
263
+ .describe('Authentication mode.'),
264
+ bearer_env: z
265
+ .string()
266
+ .optional()
267
+ .describe('Env var for bearer token when auth_type=bearer.'),
268
+ oauth_token_url: z
269
+ .string()
270
+ .optional()
271
+ .describe('OAuth token URL when auth_type=oauth_client_credentials.'),
272
+ oauth_client_id_env: z.string().optional().describe('OAuth client id env var.'),
273
+ oauth_client_secret_env: z.string().optional().describe('OAuth client secret env var.'),
274
+ oauth_scope: z.string().optional().describe('Optional OAuth scope.'),
275
+ oauth_audience: z.string().optional().describe('Optional OAuth audience.')
276
+ }
277
+ }, async (input) => {
278
+ return withToolHandling(async () => {
279
+ const entry = buildServerEntry(input);
280
+ return ok(`Generated server entry '${input.id}'`, {
281
+ id: input.id,
282
+ entry,
283
+ yaml: stringifyYaml({ [input.id]: entry }).trimEnd()
284
+ });
285
+ });
286
+ });
287
+ server.registerTool('mcplab_generate_agent_entry', {
288
+ description: 'Generate a MCPLab agents.yaml entry (provider/model/system settings) for evaluation runs.',
289
+ inputSchema: {
290
+ id: z.string().describe('Agent id key (kebab-case recommended).'),
291
+ provider: z
292
+ .enum(['openai', 'anthropic', 'azure_openai'])
293
+ .describe('LLM provider supported by MCPLab.'),
294
+ model: z.string().describe('Model id or deployment name (for Azure OpenAI).'),
295
+ temperature: z.number().optional().describe('Sampling temperature.'),
296
+ max_tokens: z.number().int().positive().optional().describe('Maximum output tokens.'),
297
+ system: z.string().optional().describe('Optional system prompt.')
298
+ }
299
+ }, async ({ id, ...agent }) => {
300
+ return withToolHandling(async () => {
301
+ const entry = removeUndefined(agent);
302
+ return ok(`Generated agent entry '${id}'`, {
303
+ id,
304
+ entry,
305
+ yaml: stringifyYaml({ [id]: entry }).trimEnd()
306
+ });
307
+ });
308
+ });
309
+ server.registerTool('mcplab_generate_scenario_entry', {
310
+ description: 'Generate a MCPLab scenario YAML snippet with prompt, server links, and optional evaluation/extract rules. Optimized for scenario authoring workflows.',
311
+ inputSchema: {
312
+ id: z
313
+ .string()
314
+ .optional()
315
+ .describe('Scenario id (kebab-case). Auto-derived from name if omitted.'),
316
+ name: z
317
+ .string()
318
+ .optional()
319
+ .describe('Optional human label used only to derive id when id is omitted.'),
320
+ agent: z
321
+ .string()
322
+ .optional()
323
+ .describe('Optional pinned agent id. Omit to use mcplab run --agents selection.'),
324
+ servers: z
325
+ .array(z.string())
326
+ .min(1)
327
+ .describe('One or more server ids available to the scenario.'),
328
+ prompt: z.string().describe('The task prompt the evaluation agent should execute.'),
329
+ snapshot_eval_enabled: z
330
+ .boolean()
331
+ .optional()
332
+ .describe('Per-scenario baseline drift evaluation toggle.'),
333
+ required_tools: z.array(z.string()).optional().describe('Tools that must be called.'),
334
+ forbidden_tools: z.array(z.string()).optional().describe('Tools that must not be called.'),
335
+ allowed_tool_sequences: z
336
+ .array(z.array(z.string()).min(1))
337
+ .optional()
338
+ .describe('Allowed tool call sequences (exact order groups).'),
339
+ response_regex_patterns: z
340
+ .array(z.string())
341
+ .optional()
342
+ .describe('Regex patterns that must match the final response text.'),
343
+ extract_rules: z
344
+ .array(z.object({
345
+ name: z.string().describe('Extracted field name.'),
346
+ regex: z.string().describe('Regex applied to final_text.')
347
+ }))
348
+ .optional()
349
+ .describe('Value extraction rules from final_text.'),
350
+ as_library_file: z
351
+ .boolean()
352
+ .optional()
353
+ .describe('True returns standalone scenario YAML file content; false returns list item snippet.')
354
+ }
355
+ }, async (input) => {
356
+ return withToolHandling(async () => {
357
+ const scenario = buildScenario(input);
358
+ const asLibraryFile = Boolean(input.as_library_file);
359
+ const yamlLibraryFile = stringifyYaml(scenario).trimEnd();
360
+ const yamlInlineListItem = indentBlock(stringifyYaml([scenario]).trimEnd(), 2);
361
+ const warnings = validateScenarioHeuristics(scenario);
362
+ return ok(`Generated scenario '${scenario.id}'`, {
363
+ scenario,
364
+ yaml: asLibraryFile ? yamlLibraryFile : yamlInlineListItem,
365
+ yaml_library_file: yamlLibraryFile,
366
+ yaml_inline_list_item: yamlInlineListItem,
367
+ format: asLibraryFile ? 'library-scenario-file' : 'inline-scenarios-list-item',
368
+ warnings
369
+ });
370
+ });
371
+ });
372
+ server.registerTool('mcplab_validate_config', {
373
+ description: 'Validate and expand a MCPLab config file via mcplab-core loadConfig(), including server/agent/scenario library references.',
374
+ inputSchema: {
375
+ config_path: z.string().describe('Path to MCPLab eval YAML config.'),
376
+ bundle_root: z
377
+ .string()
378
+ .optional()
379
+ .describe('Optional bundle root override for refs resolution.'),
380
+ scenario_id: z
381
+ .string()
382
+ .optional()
383
+ .describe('Optional single scenario id to validate selection.')
384
+ }
385
+ }, async ({ config_path, bundle_root, scenario_id }) => {
386
+ return withToolHandling(async () => {
387
+ const loaded = loadConfig(resolve(config_path), {
388
+ bundleRoot: bundle_root ? resolve(bundle_root) : undefined
389
+ });
390
+ const selected = selectScenarios(loaded.config, scenario_id);
391
+ const summary = summarizeConfig(selected);
392
+ return ok(`Validated config ${config_path}`, {
393
+ configPath: resolve(config_path),
394
+ bundleRoot: bundle_root
395
+ ? resolve(bundle_root)
396
+ : detectLikelyBundleRoot(resolve(config_path)),
397
+ hash: loaded.hash,
398
+ summary,
399
+ resolved_config: selected
400
+ });
401
+ });
402
+ });
403
+ server.registerTool('mcplab_run_eval', {
404
+ description: 'Run a MCPLab evaluation using mcplab-core runAll() from a config file and return the run directory plus summary metrics.',
405
+ inputSchema: {
406
+ config_path: z.string().describe('Path to MCPLab eval YAML config.'),
407
+ bundle_root: z
408
+ .string()
409
+ .optional()
410
+ .describe('Optional bundle root override for library refs.'),
411
+ scenario_id: z.string().optional().describe('Optional scenario id to run.'),
412
+ runs_per_scenario: z
413
+ .number()
414
+ .int()
415
+ .positive()
416
+ .optional()
417
+ .describe('Runs per scenario (default 1).'),
418
+ runs_dir: z
419
+ .string()
420
+ .optional()
421
+ .describe('Output directory for run artifacts (default mcplab/results/evaluation-runs).')
422
+ }
423
+ }, async ({ config_path, bundle_root, scenario_id, runs_per_scenario, runs_dir }) => {
424
+ return withToolHandling(async () => {
425
+ const loaded = loadConfig(resolve(config_path), {
426
+ bundleRoot: bundle_root ? resolve(bundle_root) : undefined
427
+ });
428
+ const selected = selectScenarios(loaded.config, scenario_id);
429
+ const executable = expandConfigForAgents(selected, selected.run_defaults?.selected_agents);
430
+ const { runDir, results } = await runAll(executable, {
431
+ runsPerScenario: runs_per_scenario ?? 1,
432
+ scenarioId: scenario_id,
433
+ configHash: loaded.hash,
434
+ cliVersion: `mcplab-mcp-server/${SERVER_VERSION}`,
435
+ runsDir: runs_dir ?? 'mcplab/results/evaluation-runs'
436
+ });
437
+ const reportHtml = renderReport(results);
438
+ return ok(`MCPLab run completed: ${runDir}`, {
439
+ runDir,
440
+ summary: results.summary,
441
+ metadata: results.metadata,
442
+ scenarios: results.scenarios.map((scenario) => ({
443
+ scenario_id: scenario.scenario_id,
444
+ agent: scenario.agent,
445
+ pass_rate: scenario.pass_rate,
446
+ tool_usage_frequency: scenario.tool_usage_frequency
447
+ })),
448
+ report_html_preview: truncate(reportHtml, 4000)
449
+ });
450
+ });
451
+ });
452
+ server.registerTool('mcplab_list_runs', {
453
+ description: 'List MCPLab run artifact directories and optionally summarize each run from results.json when present.',
454
+ inputSchema: {
455
+ runs_dir: z
456
+ .string()
457
+ .optional()
458
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
459
+ limit: z
460
+ .number()
461
+ .int()
462
+ .positive()
463
+ .max(100)
464
+ .optional()
465
+ .describe('Max runs to return (default 10).'),
466
+ include_summary: z
467
+ .boolean()
468
+ .optional()
469
+ .describe('Read results.json summary for each run when available.')
470
+ }
471
+ }, async ({ runs_dir, limit, include_summary }) => {
472
+ return withToolHandling(async () => {
473
+ const base = resolveRunsDir(runs_dir);
474
+ const entries = listRunsWithFallback(base, limit ?? 10, Boolean(include_summary));
475
+ return ok(`Found ${entries.length} run(s) in ${base}`, {
476
+ runsDir: base,
477
+ runs: entries
478
+ });
479
+ });
480
+ });
481
+ server.registerTool('mcplab_list_tool_analysis_results', {
482
+ description: 'List saved MCP tool analysis reports persisted by the MCPLab app (default: mcplab/results/tool-analysis).',
483
+ inputSchema: {
484
+ tool_analysis_results_dir: z
485
+ .string()
486
+ .optional()
487
+ .describe('Directory containing saved tool analysis report folders.'),
488
+ limit: z
489
+ .number()
490
+ .int()
491
+ .positive()
492
+ .max(100)
493
+ .optional()
494
+ .describe('Max reports to return (default 20).')
495
+ }
496
+ }, async ({ tool_analysis_results_dir, limit }) => {
497
+ return withToolHandling(async () => {
498
+ const baseDir = resolveToolAnalysisResultsDir(tool_analysis_results_dir);
499
+ const reports = listToolAnalysisReportsFromDiskWithFallback(baseDir, limit ?? 20);
500
+ return ok(`Found ${reports.length} tool analysis report(s) in ${baseDir}`, {
501
+ tool_analysis_results_dir: baseDir,
502
+ items: reports
503
+ });
504
+ });
505
+ });
506
+ server.registerTool('mcplab_read_tool_analysis_result', {
507
+ description: 'Read a saved MCP tool analysis report record (report.json) by report id and return parsed metadata plus optional raw JSON preview.',
508
+ inputSchema: {
509
+ report_id: z.string().describe("Report id directory name (or 'LATEST')."),
510
+ tool_analysis_results_dir: z
511
+ .string()
512
+ .optional()
513
+ .describe('Directory containing saved tool analysis reports.'),
514
+ max_chars: z
515
+ .number()
516
+ .int()
517
+ .positive()
518
+ .optional()
519
+ .describe('Optional truncation for raw JSON preview (default 20000).'),
520
+ include_record: z
521
+ .boolean()
522
+ .optional()
523
+ .describe('Include the full parsed record in structured content. Defaults to true.')
524
+ }
525
+ }, async ({ report_id, tool_analysis_results_dir, max_chars, include_record }) => {
526
+ return withToolHandling(async () => {
527
+ const baseDir = resolveToolAnalysisResultsDir(tool_analysis_results_dir);
528
+ const resolvedReportId = report_id === 'LATEST'
529
+ ? latestToolAnalysisReportIdWithFallback(baseDir)
530
+ : report_id.trim();
531
+ if (!resolvedReportId) {
532
+ throw new Error(`No tool analysis reports found in ${baseDir}`);
533
+ }
534
+ const filePath = toolAnalysisReportFilePathWithFallback(baseDir, resolvedReportId);
535
+ if (!existsSync(filePath)) {
536
+ throw new Error(`Tool analysis report not found: ${filePath}`);
537
+ }
538
+ const raw = readFileSync(filePath, 'utf8');
539
+ const parsed = parseToolAnalysisRecord(raw);
540
+ const content = truncate(raw, max_chars ?? 20_000);
541
+ const summary = summarizeToolAnalysisRecord(parsed);
542
+ const structured = removeUndefined({
543
+ path: filePath,
544
+ report_id: resolvedReportId,
545
+ truncated: content.length < raw.length,
546
+ raw_json_preview: content,
547
+ summary,
548
+ record: include_record === false ? undefined : parsed
549
+ });
550
+ return ok(`Read tool analysis report ${resolvedReportId}`, structured);
551
+ });
552
+ });
553
+ server.registerTool('mcplab_delete_tool_analysis_result', {
554
+ description: 'Delete a saved MCP tool analysis report directory by report id (from mcplab/results/tool-analysis by default).',
555
+ inputSchema: {
556
+ report_id: z.string().describe('Report id directory name to delete.'),
557
+ tool_analysis_results_dir: z
558
+ .string()
559
+ .optional()
560
+ .describe('Directory containing saved tool analysis reports.')
561
+ }
562
+ }, async ({ report_id, tool_analysis_results_dir }) => {
563
+ return withToolHandling(async () => {
564
+ const baseDir = resolveToolAnalysisResultsDir(tool_analysis_results_dir);
565
+ const dirPath = toolAnalysisReportDirPathWithFallback(baseDir, report_id.trim());
566
+ if (!existsSync(dirPath)) {
567
+ throw new Error(`Tool analysis report not found: ${dirPath}`);
568
+ }
569
+ rmSync(dirPath, { recursive: true, force: false });
570
+ return ok(`Deleted tool analysis report ${report_id}`, {
571
+ report_id: report_id.trim(),
572
+ path: dirPath,
573
+ tool_analysis_results_dir: baseDir
574
+ });
575
+ });
576
+ });
577
+ server.registerTool('mcplab_trace_list_events', {
578
+ description: 'List structured trace timeline items for a MCPLab run (flattened from scenario_run trace records) with optional type/scenario/agent filtering.',
579
+ inputSchema: {
580
+ runs_dir: z
581
+ .string()
582
+ .optional()
583
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
584
+ run_id: z.string().describe("Run id directory name or 'LATEST'."),
585
+ event_types: z
586
+ .array(z.string())
587
+ .optional()
588
+ .describe('Optional timeline item type filters (e.g. text, tool_use, tool_result).'),
589
+ scenario_id: z.string().optional().describe('Optional scenario id filter.'),
590
+ agent: z.string().optional().describe('Optional agent filter.'),
591
+ limit: z
592
+ .number()
593
+ .int()
594
+ .positive()
595
+ .max(1000)
596
+ .optional()
597
+ .describe('Max items to return (default 200).')
598
+ }
599
+ }, async ({ runs_dir, run_id, event_types, scenario_id, agent, limit }) => {
600
+ return withToolHandling(async () => {
601
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(runs_dir, run_id);
602
+ const typeSet = event_types?.length
603
+ ? new Set(event_types)
604
+ : null;
605
+ const flattened = flattenScenarioRunTraceRecords(records);
606
+ const filtered = flattened.filter((item) => {
607
+ const itemType = typeof item.type === 'string' ? item.type : '';
608
+ const itemScenario = typeof item.scenario_id === 'string' ? item.scenario_id : undefined;
609
+ const itemAgent = typeof item.agent === 'string' ? item.agent : undefined;
610
+ if (typeSet && !typeSet.has(itemType))
611
+ return false;
612
+ if (scenario_id && itemScenario !== scenario_id)
613
+ return false;
614
+ if (agent && itemAgent !== agent)
615
+ return false;
616
+ return true;
617
+ });
618
+ const max = limit ?? 200;
619
+ const items = filtered.slice(0, max);
620
+ return ok(`Listed ${items.length}/${filtered.length} trace item(s) for run ${runId}`, {
621
+ run_id: runId,
622
+ legacy_trace_detected: legacyDetected || undefined,
623
+ total_matching: filtered.length,
624
+ items
625
+ });
626
+ });
627
+ });
628
+ server.registerTool('mcplab_trace_get_final_answers', {
629
+ description: 'Extract final assistant answers from a run trace (scenario_run documents) for easy agent output comparison.',
630
+ inputSchema: {
631
+ runs_dir: z
632
+ .string()
633
+ .optional()
634
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
635
+ run_id: z.string().describe("Run id directory name or 'LATEST'."),
636
+ scenario_id: z.string().optional().describe('Optional scenario id filter.'),
637
+ agent: z.string().optional().describe('Optional agent filter.'),
638
+ max_chars_per_answer: z
639
+ .number()
640
+ .int()
641
+ .positive()
642
+ .max(20000)
643
+ .optional()
644
+ .describe('Optional truncation per final answer text (default 8000).')
645
+ }
646
+ }, async ({ runs_dir, run_id, scenario_id, agent, max_chars_per_answer }) => {
647
+ return withToolHandling(async () => {
648
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(runs_dir, run_id);
649
+ const maxChars = max_chars_per_answer ?? 8000;
650
+ const items = records
651
+ .filter((record) => (!scenario_id || record.scenario_id === scenario_id) &&
652
+ (!agent || record.agent === agent))
653
+ .map((record, index) => {
654
+ const full = extractFinalAssistantText(record);
655
+ if (!full)
656
+ return null;
657
+ const text = truncate(full, maxChars);
658
+ return removeUndefined({
659
+ index,
660
+ scenario_id: record.scenario_id,
661
+ agent: record.agent,
662
+ ts: record.ts_end,
663
+ truncated: text.length < full.length,
664
+ text
665
+ });
666
+ })
667
+ .filter(Boolean);
668
+ return ok(`Extracted ${items.length} final answer(s) from run ${runId}`, {
669
+ run_id: runId,
670
+ legacy_trace_detected: legacyDetected || undefined,
671
+ items
672
+ });
673
+ });
674
+ });
675
+ server.registerTool('mcplab_trace_get_conversation', {
676
+ description: 'Return a structured conversation timeline (messages + tool blocks) for a specific scenario+agent in a scenario_run trace.',
677
+ inputSchema: {
678
+ runs_dir: z
679
+ .string()
680
+ .optional()
681
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
682
+ run_id: z.string().describe("Run id directory name or 'LATEST'."),
683
+ scenario_id: z.string().describe('Scenario id to filter.'),
684
+ agent: z.string().describe('Agent name to filter.'),
685
+ max_items: z
686
+ .number()
687
+ .int()
688
+ .positive()
689
+ .max(1000)
690
+ .optional()
691
+ .describe('Max timeline items (default 300).'),
692
+ max_text_chars: z
693
+ .number()
694
+ .int()
695
+ .positive()
696
+ .max(20000)
697
+ .optional()
698
+ .describe('Max chars for text fields (default 4000).')
699
+ }
700
+ }, async ({ runs_dir, run_id, scenario_id, agent, max_items, max_text_chars }) => {
701
+ return withToolHandling(async () => {
702
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(runs_dir, run_id);
703
+ const textMax = max_text_chars ?? 4000;
704
+ const record = records.find((r) => r.scenario_id === scenario_id && r.agent === agent);
705
+ const timeline = record
706
+ ? buildConversationTimeline(record, textMax).slice(0, max_items ?? 300)
707
+ : [];
708
+ return ok(`Built conversation timeline (${timeline.length} items) for ${scenario_id} / ${agent}`, {
709
+ run_id: runId,
710
+ scenario_id,
711
+ agent,
712
+ legacy_trace_detected: legacyDetected || undefined,
713
+ timeline
714
+ });
715
+ });
716
+ });
717
+ server.registerTool('mcplab_trace_search', {
718
+ description: 'Search scenario_run trace content for a text query and return matching message/block items.',
719
+ inputSchema: {
720
+ runs_dir: z
721
+ .string()
722
+ .optional()
723
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
724
+ run_id: z.string().describe("Run id directory name or 'LATEST'."),
725
+ query: z.string().describe('Case-insensitive text query.'),
726
+ event_types: z
727
+ .array(z.enum(['message', 'text', 'tool_use', 'tool_result']))
728
+ .optional()
729
+ .describe('Optional item type filters.'),
730
+ limit: z
731
+ .number()
732
+ .int()
733
+ .positive()
734
+ .max(200)
735
+ .optional()
736
+ .describe('Max matches to return (default 50).')
737
+ }
738
+ }, async ({ runs_dir, run_id, query, event_types, limit }) => {
739
+ return withToolHandling(async () => {
740
+ const q = query.trim().toLowerCase();
741
+ if (!q)
742
+ throw new Error('query is required');
743
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(runs_dir, run_id);
744
+ const typeSet = event_types?.length
745
+ ? new Set(event_types)
746
+ : null;
747
+ const matches = [];
748
+ for (const item of flattenScenarioRunTraceRecords(records)) {
749
+ const itemType = typeof item.type === 'string' ? item.type : '';
750
+ if (typeSet && !typeSet.has(itemType))
751
+ continue;
752
+ const hay = JSON.stringify(item).toLowerCase();
753
+ if (!hay.includes(q))
754
+ continue;
755
+ matches.push(item);
756
+ if (matches.length >= (limit ?? 50))
757
+ break;
758
+ }
759
+ return ok(`Found ${matches.length} trace match(es) for "${query}" in run ${runId}`, {
760
+ run_id: runId,
761
+ query,
762
+ legacy_trace_detected: legacyDetected || undefined,
763
+ matches
764
+ });
765
+ });
766
+ });
767
+ server.registerTool('mcplab_trace_stats', {
768
+ description: 'Compute trace statistics for a run (message/block counts, tool usage, durations, and final-answer counts).',
769
+ inputSchema: {
770
+ runs_dir: z
771
+ .string()
772
+ .optional()
773
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
774
+ run_id: z.string().describe("Run id directory name or 'LATEST'.")
775
+ }
776
+ }, async ({ runs_dir, run_id }) => {
777
+ return withToolHandling(async () => {
778
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(runs_dir, run_id);
779
+ const messageRoleCounts = {};
780
+ const blockTypeCounts = {};
781
+ const toolUsage = {};
782
+ const scenarioAgentKeys = new Set();
783
+ let toolCallCount = 0;
784
+ let toolResultCount = 0;
785
+ let finalAnswerCount = 0;
786
+ let totalToolDurationMs = 0;
787
+ for (const record of records) {
788
+ scenarioAgentKeys.add(`${record.scenario_id}::${record.agent}`);
789
+ finalAnswerCount += extractFinalAssistantText(record) ? 1 : 0;
790
+ for (const message of record.messages) {
791
+ messageRoleCounts[message.role] = (messageRoleCounts[message.role] ?? 0) + 1;
792
+ for (const block of message.content) {
793
+ blockTypeCounts[block.type] = (blockTypeCounts[block.type] ?? 0) + 1;
794
+ if (block.type === 'tool_use') {
795
+ toolCallCount += 1;
796
+ const key = `${block.server}::${block.name}`;
797
+ toolUsage[key] = (toolUsage[key] ?? 0) + 1;
798
+ }
799
+ else if (block.type === 'tool_result') {
800
+ toolResultCount += 1;
801
+ totalToolDurationMs += block.duration_ms ?? 0;
802
+ }
803
+ }
804
+ }
805
+ }
806
+ return ok(`Computed trace stats for run ${runId}`, {
807
+ run_id: runId,
808
+ legacy_trace_detected: legacyDetected || undefined,
809
+ total_scenario_records: records.length,
810
+ message_role_counts: messageRoleCounts,
811
+ block_type_counts: blockTypeCounts,
812
+ scenario_agent_pairs: scenarioAgentKeys.size,
813
+ tool_call_count: toolCallCount,
814
+ tool_result_count: toolResultCount,
815
+ final_answer_count: finalAnswerCount,
816
+ avg_tool_result_duration_ms: toolResultCount > 0 ? Number((totalToolDurationMs / toolResultCount).toFixed(2)) : null,
817
+ tool_usage: Object.entries(toolUsage)
818
+ .sort((a, b) => b[1] - a[1])
819
+ .map(([tool, count]) => ({ tool, count }))
820
+ });
821
+ });
822
+ });
823
+ server.registerTool('mcplab_read_run_artifact', {
824
+ description: 'Read MCPLab run artifacts such as results.json, summary.md, trace.jsonl, resolved-config.yaml, or report.html.',
825
+ inputSchema: {
826
+ runs_dir: z
827
+ .string()
828
+ .optional()
829
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
830
+ run_id: z.string().describe('Run id directory name or LATEST.'),
831
+ artifact: z
832
+ .enum([
833
+ 'results.json',
834
+ 'summary.md',
835
+ 'trace.jsonl',
836
+ 'resolved-config.yaml',
837
+ 'report.html'
838
+ ])
839
+ .describe('Artifact filename to read.'),
840
+ max_chars: z
841
+ .number()
842
+ .int()
843
+ .positive()
844
+ .optional()
845
+ .describe('Optional content truncation limit.'),
846
+ line_start: z
847
+ .number()
848
+ .int()
849
+ .positive()
850
+ .optional()
851
+ .describe('1-indexed line to start reading from (inclusive). Use with line_end to read a specific range.'),
852
+ line_end: z
853
+ .number()
854
+ .int()
855
+ .positive()
856
+ .optional()
857
+ .describe('1-indexed line to stop reading at (inclusive).')
858
+ }
859
+ }, async ({ runs_dir, run_id, artifact, max_chars, line_start, line_end }) => {
860
+ return withToolHandling(async () => {
861
+ const base = resolveRunsDir(runs_dir);
862
+ const readBase = resolveExistingRunReadDir(base, run_id === 'LATEST' ? undefined : run_id);
863
+ const resolvedRunId = run_id === 'LATEST' ? latestRunId(readBase) : run_id;
864
+ if (!resolvedRunId) {
865
+ throw new Error(`No runs found in ${base}`);
866
+ }
867
+ const fullPath = join(readBase, resolvedRunId, artifact);
868
+ if (!existsSync(fullPath)) {
869
+ throw new Error(`Artifact not found: ${fullPath}`);
870
+ }
871
+ const raw = readFileSync(fullPath, 'utf8');
872
+ let sliced = raw;
873
+ let lineRangeNote;
874
+ if (line_start !== undefined || line_end !== undefined) {
875
+ const allLines = raw.split('\n');
876
+ const from = Math.max(0, (line_start ?? 1) - 1);
877
+ const to = line_end !== undefined ? Math.min(allLines.length, line_end) : allLines.length;
878
+ sliced = allLines.slice(from, to).join('\n');
879
+ lineRangeNote = `lines ${from + 1}–${to} of ${allLines.length}`;
880
+ }
881
+ const content = truncate(sliced, max_chars ?? 20_000);
882
+ const structured = {
883
+ path: fullPath,
884
+ run_id: resolvedRunId,
885
+ artifact,
886
+ ...(lineRangeNote ? { line_range: lineRangeNote } : {}),
887
+ truncated: content.length < sliced.length,
888
+ content
889
+ };
890
+ if (artifact === 'results.json') {
891
+ try {
892
+ const parsed = JSON.parse(raw);
893
+ structured.summary = parsed.summary;
894
+ structured.metadata = parsed.metadata;
895
+ structured.scenarios = parsed.scenarios.map((scenario) => ({
896
+ scenario_id: scenario.scenario_id,
897
+ agent: scenario.agent,
898
+ pass_rate: scenario.pass_rate
899
+ }));
900
+ }
901
+ catch {
902
+ // Keep raw text if JSON parsing fails.
903
+ }
904
+ }
905
+ return ok(`Read ${artifact} from run ${resolvedRunId}`, structured);
906
+ });
907
+ });
908
+ server.registerTool('mcplab_grep_run_artifact', {
909
+ description: 'Search for text within a MCPLab run artifact and return matching lines with surrounding context. Use this to find specific sections in large files (e.g. a tool name in report.html) without reading the full file. Returns line numbers so you can follow up with mcplab_read_run_artifact line_start/line_end to read the full section.',
910
+ inputSchema: {
911
+ runs_dir: z
912
+ .string()
913
+ .optional()
914
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
915
+ run_id: z.string().describe("Run id directory name or 'LATEST'."),
916
+ artifact: z
917
+ .enum([
918
+ 'results.json',
919
+ 'summary.md',
920
+ 'trace.jsonl',
921
+ 'resolved-config.yaml',
922
+ 'report.html'
923
+ ])
924
+ .describe('Artifact filename to search.'),
925
+ query: z.string().describe('Text to search for (case-insensitive by default).'),
926
+ context_lines: z
927
+ .number()
928
+ .int()
929
+ .min(0)
930
+ .max(50)
931
+ .optional()
932
+ .describe('Lines of context before and after each match (default 5).'),
933
+ max_matches: z
934
+ .number()
935
+ .int()
936
+ .positive()
937
+ .max(50)
938
+ .optional()
939
+ .describe('Maximum number of matches to return (default 10).'),
940
+ case_sensitive: z.boolean().optional().describe('Case-sensitive search (default false).')
941
+ }
942
+ }, async ({ runs_dir, run_id, artifact, query, context_lines, max_matches, case_sensitive }) => {
943
+ return withToolHandling(async () => {
944
+ const base = resolveRunsDir(runs_dir);
945
+ const readBase = resolveExistingRunReadDir(base, run_id === 'LATEST' ? undefined : run_id);
946
+ const resolvedRunId = run_id === 'LATEST' ? latestRunId(readBase) : run_id;
947
+ if (!resolvedRunId)
948
+ throw new Error(`No runs found in ${base}`);
949
+ const fullPath = join(readBase, resolvedRunId, artifact);
950
+ if (!existsSync(fullPath))
951
+ throw new Error(`Artifact not found: ${fullPath}`);
952
+ const lines = readFileSync(fullPath, 'utf8').split('\n');
953
+ const q = case_sensitive ? query.trim() : query.trim().toLowerCase();
954
+ if (!q)
955
+ throw new Error('query must not be empty');
956
+ const ctx = context_lines ?? 5;
957
+ const limit = max_matches ?? 10;
958
+ const matchIndices = [];
959
+ for (let i = 0; i < lines.length; i++) {
960
+ const hay = case_sensitive ? lines[i] : lines[i].toLowerCase();
961
+ if (hay.includes(q)) {
962
+ matchIndices.push(i);
963
+ if (matchIndices.length >= limit)
964
+ break;
965
+ }
966
+ }
967
+ const matches = matchIndices.map((matchIdx) => {
968
+ const start = Math.max(0, matchIdx - ctx);
969
+ const end = Math.min(lines.length - 1, matchIdx + ctx);
970
+ return {
971
+ match_line: matchIdx + 1,
972
+ context_start_line: start + 1,
973
+ context_end_line: end + 1,
974
+ lines: lines.slice(start, end + 1).map((text, offset) => ({
975
+ line: start + offset + 1,
976
+ text,
977
+ is_match: start + offset === matchIdx
978
+ }))
979
+ };
980
+ });
981
+ return ok(`Found ${matchIndices.length} match(es) for "${query}" in ${artifact} (run ${resolvedRunId})`, {
982
+ run_id: resolvedRunId,
983
+ artifact,
984
+ query,
985
+ total_lines: lines.length,
986
+ match_count: matches.length,
987
+ truncated_at_limit: matchIndices.length >= limit,
988
+ matches
989
+ });
990
+ });
991
+ });
992
+ }
993
+ export function registerPrompts(server) {
994
+ server.registerPrompt('mcplab-scenario-author', {
995
+ description: 'Guide an LLM to author or refine MCPLab scenarios, prioritizing reusable scenario library files and deterministic eval rules.',
996
+ argsSchema: {
997
+ task: z.string().describe('What the scenario should test.'),
998
+ bundle_root: z
999
+ .string()
1000
+ .optional()
1001
+ .describe('Optional MCPLab library bundle root to inspect.'),
1002
+ server_ids: z
1003
+ .string()
1004
+ .optional()
1005
+ .describe('Comma-separated server ids to target if already known.'),
1006
+ agent_id: z.string().optional().describe('Optional pinned agent id.')
1007
+ }
1008
+ }, async ({ task, bundle_root, server_ids, agent_id }) => {
1009
+ const maybeServers = server_ids
1010
+ ? `Target servers (if valid): ${server_ids}\n`
1011
+ : 'First inspect available servers with mcplab_list_library.\n';
1012
+ const maybeAgent = agent_id ? `Pinned agent (optional): ${agent_id}\n` : '';
1013
+ const maybeBundle = bundle_root ? `Bundle root hint: ${bundle_root}\n` : '';
1014
+ return {
1015
+ messages: [
1016
+ {
1017
+ role: 'user',
1018
+ content: {
1019
+ type: 'text',
1020
+ text: `Help me author a MCPLab scenario for this testing task:\n\n${task}\n\n` +
1021
+ `${maybeBundle}${maybeServers}${maybeAgent}` +
1022
+ `Workflow:\n` +
1023
+ `1. Inspect library entries (servers/agents/scenarios) if needed.\n` +
1024
+ `2. Draft a scenario with mcplab_generate_scenario_entry.\n` +
1025
+ `3. Suggest exact eval rules (required tools / regex assertions / extract rules).\n` +
1026
+ `4. Validate the final config with mcplab_validate_config when a config path is available.\n` +
1027
+ `Prefer reusable scenario files when possible.`
1028
+ }
1029
+ }
1030
+ ]
1031
+ };
1032
+ });
1033
+ server.registerPrompt('mcplab-config-author', {
1034
+ description: 'Guide an LLM to build MCPLab config blocks (servers, agents, scenarios) and validate them incrementally.',
1035
+ argsSchema: {
1036
+ goal: z.string().describe('What should be evaluated and against which MCP server(s).'),
1037
+ config_path: z.string().optional().describe('Existing config path to update and validate.')
1038
+ }
1039
+ }, async ({ goal, config_path }) => {
1040
+ const validationStep = config_path
1041
+ ? `Validate updates with mcplab_validate_config using config_path=${config_path}.`
1042
+ : `Ask for or choose a config path, then validate with mcplab_validate_config.`;
1043
+ return {
1044
+ messages: [
1045
+ {
1046
+ role: 'user',
1047
+ content: {
1048
+ type: 'text',
1049
+ text: `Help me build/update a MCPLab evaluation config.\n\nGoal:\n${goal}\n\n` +
1050
+ `Use mcplab_generate_server_entry, mcplab_generate_agent_entry, and mcplab_generate_scenario_entry as needed.\n` +
1051
+ `Prioritize small deterministic changes and explicit YAML snippets.\n` +
1052
+ `${validationStep}`
1053
+ }
1054
+ }
1055
+ ]
1056
+ };
1057
+ });
1058
+ }
1059
+ function resolveBundleRoot(bundleRoot) {
1060
+ if (bundleRoot?.trim())
1061
+ return resolve(bundleRoot);
1062
+ const cwd = process.cwd();
1063
+ const candidates = ['mcplab', 'examples/libraries'];
1064
+ for (const candidate of candidates) {
1065
+ const abs = resolve(cwd, candidate);
1066
+ if (existsSync(abs))
1067
+ return abs;
1068
+ }
1069
+ return resolve(cwd, 'mcplab');
1070
+ }
1071
+ function readLibrary(bundleRoot, includeContent) {
1072
+ const serversPath = join(bundleRoot, 'servers.yaml');
1073
+ const agentsPath = join(bundleRoot, 'agents.yaml');
1074
+ const scenariosDir = join(bundleRoot, 'scenarios');
1075
+ const servers = existsSync(serversPath)
1076
+ ? parseYaml(readFileSync(serversPath, 'utf8')) ?? {}
1077
+ : {};
1078
+ const agents = existsSync(agentsPath)
1079
+ ? parseYaml(readFileSync(agentsPath, 'utf8')) ?? {}
1080
+ : {};
1081
+ const scenarioEntries = [];
1082
+ if (existsSync(scenariosDir)) {
1083
+ const files = readdirSync(scenariosDir)
1084
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
1085
+ .sort();
1086
+ for (const file of files) {
1087
+ const fullPath = join(scenariosDir, file);
1088
+ const raw = readFileSync(fullPath, 'utf8');
1089
+ const parsed = parseYaml(raw) ?? {};
1090
+ scenarioEntries.push(removeUndefined({
1091
+ file,
1092
+ id: typeof parsed.id === 'string' ? parsed.id : undefined,
1093
+ ...(includeContent ? { content: parsed, yaml: raw } : {})
1094
+ }));
1095
+ }
1096
+ }
1097
+ const out = {
1098
+ bundleRoot,
1099
+ servers: includeContent
1100
+ ? servers
1101
+ : Object.keys(servers)
1102
+ .sort()
1103
+ .map((id) => ({ id })),
1104
+ agents: includeContent
1105
+ ? agents
1106
+ : Object.keys(agents)
1107
+ .sort()
1108
+ .map((id) => ({ id })),
1109
+ scenarios: scenarioEntries
1110
+ };
1111
+ return out;
1112
+ }
1113
+ function getLibraryItem(bundleRoot, kind, id) {
1114
+ if (kind === 'servers' || kind === 'agents') {
1115
+ const file = join(bundleRoot, `${kind}.yaml`);
1116
+ if (!existsSync(file)) {
1117
+ throw new Error(`Library file not found: ${file}`);
1118
+ }
1119
+ const raw = readFileSync(file, 'utf8');
1120
+ const parsed = parseYaml(raw) ?? {};
1121
+ if (!(id in parsed)) {
1122
+ throw new Error(`'${id}' not found in ${file}`);
1123
+ }
1124
+ const entry = parsed[id];
1125
+ return {
1126
+ bundleRoot,
1127
+ kind,
1128
+ id,
1129
+ yaml: stringifyYaml({ [id]: entry }).trimEnd(),
1130
+ content: entry
1131
+ };
1132
+ }
1133
+ const dir = join(bundleRoot, 'scenarios');
1134
+ if (!existsSync(dir)) {
1135
+ throw new Error(`Scenario library directory not found: ${dir}`);
1136
+ }
1137
+ const files = readdirSync(dir).filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
1138
+ for (const file of files) {
1139
+ const fullPath = join(dir, file);
1140
+ const raw = readFileSync(fullPath, 'utf8');
1141
+ const parsed = parseYaml(raw) ?? {};
1142
+ if (parsed.id === id) {
1143
+ return {
1144
+ bundleRoot,
1145
+ kind,
1146
+ id,
1147
+ file,
1148
+ yaml: raw.trimEnd(),
1149
+ content: parsed
1150
+ };
1151
+ }
1152
+ }
1153
+ throw new Error(`Scenario '${id}' not found in ${dir}`);
1154
+ }
1155
+ function buildServerEntry(input) {
1156
+ const transport = input.transport ?? 'http';
1157
+ const authType = input.auth_type ?? 'none';
1158
+ if (authType === 'none') {
1159
+ return { transport, url: input.url };
1160
+ }
1161
+ if (authType === 'bearer') {
1162
+ if (!input.bearer_env) {
1163
+ throw new Error('bearer_env is required when auth_type=bearer');
1164
+ }
1165
+ return {
1166
+ transport,
1167
+ url: input.url,
1168
+ auth: {
1169
+ type: 'bearer',
1170
+ env: input.bearer_env
1171
+ }
1172
+ };
1173
+ }
1174
+ if (!input.oauth_token_url || !input.oauth_client_id_env || !input.oauth_client_secret_env) {
1175
+ throw new Error('oauth_token_url, oauth_client_id_env, and oauth_client_secret_env are required for oauth_client_credentials');
1176
+ }
1177
+ return {
1178
+ transport,
1179
+ url: input.url,
1180
+ auth: removeUndefined({
1181
+ type: 'oauth_client_credentials',
1182
+ token_url: input.oauth_token_url,
1183
+ client_id_env: input.oauth_client_id_env,
1184
+ client_secret_env: input.oauth_client_secret_env,
1185
+ scope: input.oauth_scope,
1186
+ audience: input.oauth_audience
1187
+ })
1188
+ };
1189
+ }
1190
+ function buildScenario(input) {
1191
+ const id = input.id?.trim() || slugify(input.name?.trim() || input.prompt.slice(0, 40));
1192
+ if (!id) {
1193
+ throw new Error('Unable to derive scenario id. Provide id or name.');
1194
+ }
1195
+ const scenario = removeUndefined({
1196
+ id,
1197
+ agent: input.agent?.trim() || undefined,
1198
+ servers: input.servers,
1199
+ prompt: input.prompt,
1200
+ snapshot_eval_enabled: input.snapshot_eval_enabled,
1201
+ eval: buildEvalRules(input),
1202
+ extract: input.extract_rules?.map((rule) => ({
1203
+ name: rule.name,
1204
+ from: 'final_text',
1205
+ regex: rule.regex
1206
+ }))
1207
+ });
1208
+ return scenario;
1209
+ }
1210
+ function buildEvalRules(input) {
1211
+ const toolConstraints = input.required_tools?.length || input.forbidden_tools?.length
1212
+ ? removeUndefined({
1213
+ required_tools: normalizeStringArray(input.required_tools),
1214
+ forbidden_tools: normalizeStringArray(input.forbidden_tools)
1215
+ })
1216
+ : undefined;
1217
+ const toolSequence = input.allowed_tool_sequences && input.allowed_tool_sequences.length > 0
1218
+ ? { allow: input.allowed_tool_sequences }
1219
+ : undefined;
1220
+ const responseAssertions = input.response_regex_patterns && input.response_regex_patterns.length > 0
1221
+ ? input.response_regex_patterns.map((pattern) => ({ type: 'regex', pattern }))
1222
+ : undefined;
1223
+ const evalRules = removeUndefined({
1224
+ tool_constraints: toolConstraints,
1225
+ tool_sequence: toolSequence,
1226
+ response_assertions: responseAssertions
1227
+ });
1228
+ if (Object.keys(evalRules ?? {}).length === 0) {
1229
+ return undefined;
1230
+ }
1231
+ return evalRules;
1232
+ }
1233
+ function validateScenarioHeuristics(scenario) {
1234
+ const warnings = [];
1235
+ if (!scenario.eval) {
1236
+ warnings.push('No eval rules defined yet. Add required_tools and/or response assertions for deterministic checks.');
1237
+ }
1238
+ if (!scenario.extract || scenario.extract.length === 0) {
1239
+ warnings.push('No extract rules defined. Consider adding domain metrics for trend tracking.');
1240
+ }
1241
+ if (scenario.prompt.trim().length < 40) {
1242
+ warnings.push('Prompt is very short; scenario quality usually improves with explicit success criteria and output format instructions.');
1243
+ }
1244
+ return warnings;
1245
+ }
1246
+ function summarizeConfig(config) {
1247
+ return {
1248
+ server_count: Object.keys(config.servers).length,
1249
+ agent_count: Object.keys(config.agents).length,
1250
+ scenario_count: config.scenarios.length,
1251
+ servers: Object.keys(config.servers).sort(),
1252
+ agents: Object.keys(config.agents).sort(),
1253
+ scenarios: config.scenarios.map((scenario) => ({
1254
+ id: scenario.id,
1255
+ servers: scenario.servers,
1256
+ has_eval: Boolean(scenario.eval),
1257
+ extract_count: scenario.extract?.length ?? 0
1258
+ }))
1259
+ };
1260
+ }
1261
+ function listRuns(runsDir, limit, includeSummary) {
1262
+ if (!existsSync(runsDir))
1263
+ return [];
1264
+ const dirNames = readdirSync(runsDir)
1265
+ .filter((name) => {
1266
+ const full = join(runsDir, name);
1267
+ try {
1268
+ return statSync(full).isDirectory();
1269
+ }
1270
+ catch {
1271
+ return false;
1272
+ }
1273
+ })
1274
+ .sort()
1275
+ .reverse()
1276
+ .slice(0, limit);
1277
+ return dirNames.map((runId) => {
1278
+ const out = {
1279
+ run_id: runId,
1280
+ path: join(runsDir, runId)
1281
+ };
1282
+ if (includeSummary) {
1283
+ const resultsPath = join(runsDir, runId, 'results.json');
1284
+ if (existsSync(resultsPath)) {
1285
+ try {
1286
+ const parsed = JSON.parse(readFileSync(resultsPath, 'utf8'));
1287
+ out.summary = parsed.summary;
1288
+ out.metadata = parsed.metadata;
1289
+ }
1290
+ catch (error) {
1291
+ out.summary_error = error instanceof Error ? error.message : String(error);
1292
+ }
1293
+ }
1294
+ }
1295
+ return out;
1296
+ });
1297
+ }
1298
+ function defaultRunsDirPath() {
1299
+ return resolvePathInsideWorkspace('mcplab/results/evaluation-runs');
1300
+ }
1301
+ function legacyRunsDirPath() {
1302
+ return resolvePathInsideWorkspace('mcplab/runs');
1303
+ }
1304
+ function resolveRunsDir(input) {
1305
+ return resolve(input?.trim() ? input : defaultRunsDirPath());
1306
+ }
1307
+ function runReadDirs(primaryRunsDir) {
1308
+ const dirs = [primaryRunsDir];
1309
+ const defaultNew = defaultRunsDirPath();
1310
+ const legacy = legacyRunsDirPath();
1311
+ if (primaryRunsDir === defaultNew && legacy !== defaultNew) {
1312
+ dirs.push(legacy);
1313
+ }
1314
+ return Array.from(new Set(dirs));
1315
+ }
1316
+ function listRunsWithFallback(primaryRunsDir, limit, includeSummary) {
1317
+ const merged = new Map();
1318
+ for (const dir of runReadDirs(primaryRunsDir)) {
1319
+ for (const entry of listRuns(dir, limit, includeSummary)) {
1320
+ const runId = String(entry.run_id ?? '');
1321
+ if (!runId || merged.has(runId))
1322
+ continue;
1323
+ merged.set(runId, entry);
1324
+ }
1325
+ }
1326
+ return Array.from(merged.values())
1327
+ .sort((a, b) => String(b.run_id ?? '').localeCompare(String(a.run_id ?? '')))
1328
+ .slice(0, limit);
1329
+ }
1330
+ function resolveExistingRunReadDir(primaryRunsDir, runId) {
1331
+ if (!runId)
1332
+ return primaryRunsDir;
1333
+ for (const dir of runReadDirs(primaryRunsDir)) {
1334
+ if (existsSync(join(dir, runId)))
1335
+ return dir;
1336
+ }
1337
+ return primaryRunsDir;
1338
+ }
1339
+ function expandConfigForAgents(config, requestedAgents) {
1340
+ const selectedAgents = requestedAgents && requestedAgents.length > 0 ? requestedAgents : Object.keys(config.agents);
1341
+ const missing = selectedAgents.filter((agent) => !config.agents[agent]);
1342
+ if (missing.length > 0) {
1343
+ throw new Error(`Unknown agents: ${missing.join(', ')}. Available: ${Object.keys(config.agents).join(', ')}`);
1344
+ }
1345
+ const scenarios = config.scenarios.flatMap((scenario) => selectedAgents.map((agent) => ({
1346
+ ...scenario,
1347
+ agent,
1348
+ scenario_exec_id: `${scenario.id}-${agent}`
1349
+ })));
1350
+ return { ...config, scenarios };
1351
+ }
1352
+ function latestRunId(runsDir) {
1353
+ return listRunsWithFallback(runsDir, 1, false)[0]?.run_id;
1354
+ }
1355
+ function detectLikelyBundleRoot(configPath) {
1356
+ const configDir = dirname(configPath);
1357
+ const candidateFromConfigs = dirname(configDir);
1358
+ if (existsSync(join(candidateFromConfigs, 'servers.yaml')) ||
1359
+ existsSync(join(candidateFromConfigs, 'scenarios'))) {
1360
+ return candidateFromConfigs;
1361
+ }
1362
+ const fallback = resolveBundleRoot();
1363
+ return existsSync(fallback) ? fallback : null;
1364
+ }
1365
+ function resolveToolAnalysisResultsDir(input) {
1366
+ return resolvePathInsideWorkspace(input?.trim() ? input : 'mcplab/results/tool-analysis');
1367
+ }
1368
+ function resolveMarkdownReportsDir(input) {
1369
+ return resolvePathInsideWorkspace(input?.trim() ? input : 'mcplab/reports');
1370
+ }
1371
+ function isMarkdownReportExt(path) {
1372
+ const ext = extname(path).toLowerCase();
1373
+ return ext === '.md' || ext === '.markdown';
1374
+ }
1375
+ function listMarkdownReportsFromDisk(root) {
1376
+ if (!existsSync(root))
1377
+ return [];
1378
+ const items = [];
1379
+ const walk = (dir) => {
1380
+ let entries;
1381
+ try {
1382
+ entries = readdirSync(dir, { withFileTypes: true });
1383
+ }
1384
+ catch {
1385
+ return;
1386
+ }
1387
+ for (const entry of entries) {
1388
+ const fullPath = join(dir, entry.name);
1389
+ if (entry.isDirectory()) {
1390
+ walk(fullPath);
1391
+ continue;
1392
+ }
1393
+ if (!entry.isFile() || !isMarkdownReportExt(fullPath))
1394
+ continue;
1395
+ try {
1396
+ const st = statSync(fullPath);
1397
+ if (!st.isFile())
1398
+ continue;
1399
+ items.push({
1400
+ path: relative(process.cwd(), fullPath).split(sep).join('/'),
1401
+ relativePath: relative(root, fullPath).split(sep).join('/'),
1402
+ name: basename(fullPath),
1403
+ sizeBytes: st.size,
1404
+ mtime: st.mtime.toISOString()
1405
+ });
1406
+ }
1407
+ catch {
1408
+ // Skip unreadable entries.
1409
+ }
1410
+ }
1411
+ };
1412
+ walk(root);
1413
+ items.sort((a, b) => {
1414
+ const aMtime = String(a.mtime ?? '');
1415
+ const bMtime = String(b.mtime ?? '');
1416
+ if (aMtime === bMtime)
1417
+ return String(a.path ?? '').localeCompare(String(b.path ?? ''));
1418
+ return bMtime.localeCompare(aMtime);
1419
+ });
1420
+ return items;
1421
+ }
1422
+ function resolveMarkdownReportPath(root, pathInput) {
1423
+ const trimmed = pathInput.trim();
1424
+ if (!trimmed)
1425
+ throw new Error('path is required');
1426
+ const workspaceRelativePrefix = `mcplab${sep}reports${sep}`;
1427
+ const normalized = trimmed.replaceAll('/', sep);
1428
+ const candidate = normalized === `mcplab${sep}reports` || normalized.startsWith(workspaceRelativePrefix)
1429
+ ? resolvePathInsideWorkspace(normalized)
1430
+ : resolve(root, normalized);
1431
+ const withinRoot = candidate === root || candidate.startsWith(`${root}${sep}`);
1432
+ if (!withinRoot)
1433
+ throw new Error('path escapes markdown reports root');
1434
+ return candidate;
1435
+ }
1436
+ function legacyToolAnalysisResultsDir() {
1437
+ return resolvePathInsideWorkspace('mcplab/tool-analysis-results');
1438
+ }
1439
+ function toolAnalysisReadDirs(baseDir) {
1440
+ const dirs = [baseDir];
1441
+ const defaultNew = resolvePathInsideWorkspace('mcplab/results/tool-analysis');
1442
+ const legacy = legacyToolAnalysisResultsDir();
1443
+ if (baseDir === defaultNew && legacy !== defaultNew) {
1444
+ dirs.push(legacy);
1445
+ }
1446
+ return Array.from(new Set(dirs));
1447
+ }
1448
+ function toolAnalysisReportDirPath(baseDir, reportId) {
1449
+ const trimmed = reportId.trim();
1450
+ if (!trimmed)
1451
+ throw new Error('report_id is required');
1452
+ return resolvePathInsideWorkspace(join(baseDir, trimmed));
1453
+ }
1454
+ function toolAnalysisReportFilePath(baseDir, reportId) {
1455
+ return resolvePathInsideWorkspace(join(toolAnalysisReportDirPath(baseDir, reportId), 'report.json'));
1456
+ }
1457
+ function latestToolAnalysisReportId(baseDir) {
1458
+ if (!existsSync(baseDir))
1459
+ return undefined;
1460
+ return readdirSync(baseDir)
1461
+ .filter((name) => {
1462
+ try {
1463
+ return statSync(join(baseDir, name)).isDirectory();
1464
+ }
1465
+ catch {
1466
+ return false;
1467
+ }
1468
+ })
1469
+ .sort()
1470
+ .reverse()[0];
1471
+ }
1472
+ function latestToolAnalysisReportIdWithFallback(baseDir) {
1473
+ const ids = new Set();
1474
+ for (const dir of toolAnalysisReadDirs(baseDir)) {
1475
+ const id = latestToolAnalysisReportId(dir);
1476
+ if (id)
1477
+ ids.add(id);
1478
+ }
1479
+ return Array.from(ids).sort().reverse()[0];
1480
+ }
1481
+ function parseToolAnalysisRecord(raw) {
1482
+ const parsed = JSON.parse(raw);
1483
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1484
+ throw new Error('Invalid tool analysis report record');
1485
+ }
1486
+ return parsed;
1487
+ }
1488
+ function summarizeToolAnalysisRecord(record) {
1489
+ const report = record.report;
1490
+ const reportObj = report && typeof report === 'object' && !Array.isArray(report)
1491
+ ? report
1492
+ : undefined;
1493
+ return removeUndefined({
1494
+ reportId: typeof record.reportId === 'string' ? record.reportId : undefined,
1495
+ createdAt: typeof record.createdAt === 'string' ? record.createdAt : undefined,
1496
+ sourceJobId: typeof record.sourceJobId === 'string' ? record.sourceJobId : undefined,
1497
+ serverNames: Array.isArray(record.serverNames) ? record.serverNames : undefined,
1498
+ assistantAgentName: reportObj && typeof reportObj.assistantAgentName === 'string'
1499
+ ? reportObj.assistantAgentName
1500
+ : undefined,
1501
+ assistantAgentModel: reportObj && typeof reportObj.assistantAgentModel === 'string'
1502
+ ? reportObj.assistantAgentModel
1503
+ : undefined,
1504
+ modes: reportObj && typeof reportObj.modes === 'object' && !Array.isArray(reportObj.modes)
1505
+ ? reportObj.modes
1506
+ : undefined,
1507
+ summary: reportObj && typeof reportObj.summary === 'object' && !Array.isArray(reportObj.summary)
1508
+ ? reportObj.summary
1509
+ : undefined
1510
+ });
1511
+ }
1512
+ function listToolAnalysisReportsFromDisk(baseDir, limit) {
1513
+ if (!existsSync(baseDir))
1514
+ return [];
1515
+ const ids = readdirSync(baseDir)
1516
+ .filter((name) => {
1517
+ try {
1518
+ return statSync(join(baseDir, name)).isDirectory();
1519
+ }
1520
+ catch {
1521
+ return false;
1522
+ }
1523
+ })
1524
+ .sort()
1525
+ .reverse()
1526
+ .slice(0, limit);
1527
+ const out = [];
1528
+ for (const reportId of ids) {
1529
+ try {
1530
+ const filePath = toolAnalysisReportFilePath(baseDir, reportId);
1531
+ if (!existsSync(filePath))
1532
+ continue;
1533
+ const parsed = parseToolAnalysisRecord(readFileSync(filePath, 'utf8'));
1534
+ out.push(removeUndefined({
1535
+ report_id: reportId,
1536
+ path: toolAnalysisReportDirPath(baseDir, reportId),
1537
+ ...summarizeToolAnalysisRecord(parsed)
1538
+ }));
1539
+ }
1540
+ catch (error) {
1541
+ out.push({
1542
+ report_id: reportId,
1543
+ error: error instanceof Error ? error.message : String(error)
1544
+ });
1545
+ }
1546
+ }
1547
+ return out;
1548
+ }
1549
+ function listToolAnalysisReportsFromDiskWithFallback(baseDir, limit) {
1550
+ const merged = new Map();
1551
+ for (const dir of toolAnalysisReadDirs(baseDir)) {
1552
+ for (const item of listToolAnalysisReportsFromDisk(dir, limit)) {
1553
+ const reportId = typeof item.report_id === 'string' ? item.report_id : '';
1554
+ if (!reportId || merged.has(reportId))
1555
+ continue;
1556
+ merged.set(reportId, item);
1557
+ }
1558
+ }
1559
+ return Array.from(merged.values())
1560
+ .sort((a, b) => String(b.report_id ?? '').localeCompare(String(a.report_id ?? '')))
1561
+ .slice(0, limit);
1562
+ }
1563
+ function toolAnalysisReportDirPathWithFallback(baseDir, reportId) {
1564
+ for (const dir of toolAnalysisReadDirs(baseDir)) {
1565
+ const candidate = toolAnalysisReportDirPath(dir, reportId);
1566
+ if (existsSync(candidate))
1567
+ return candidate;
1568
+ }
1569
+ return toolAnalysisReportDirPath(baseDir, reportId);
1570
+ }
1571
+ function toolAnalysisReportFilePathWithFallback(baseDir, reportId) {
1572
+ for (const dir of toolAnalysisReadDirs(baseDir)) {
1573
+ const candidate = toolAnalysisReportFilePath(dir, reportId);
1574
+ if (existsSync(candidate))
1575
+ return candidate;
1576
+ }
1577
+ return toolAnalysisReportFilePath(baseDir, reportId);
1578
+ }
1579
+ function isTraceMessage(value) {
1580
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1581
+ return false;
1582
+ const v = value;
1583
+ if (v.role !== 'user' && v.role !== 'assistant' && v.role !== 'tool')
1584
+ return false;
1585
+ if (!Array.isArray(v.content))
1586
+ return false;
1587
+ return true;
1588
+ }
1589
+ function isScenarioRunTraceRecord(value) {
1590
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1591
+ return false;
1592
+ const v = value;
1593
+ return (v.type === 'scenario_run' &&
1594
+ v.trace_version === 3 &&
1595
+ typeof v.scenario_id === 'string' &&
1596
+ typeof v.agent === 'string' &&
1597
+ typeof v.provider === 'string' &&
1598
+ typeof v.model === 'string' &&
1599
+ typeof v.ts_start === 'string' &&
1600
+ typeof v.ts_end === 'string' &&
1601
+ typeof v.pass === 'boolean' &&
1602
+ Array.isArray(v.messages) &&
1603
+ v.messages.every(isTraceMessage));
1604
+ }
1605
+ function readScenarioRunTraceRecordsForRun(runsDirInput, runIdInput) {
1606
+ const base = resolveRunsDir(runsDirInput);
1607
+ const readBase = resolveExistingRunReadDir(base, runIdInput === 'LATEST' ? undefined : runIdInput);
1608
+ const runId = runIdInput === 'LATEST' ? latestRunId(readBase) : runIdInput;
1609
+ if (!runId)
1610
+ throw new Error(`No runs found in ${base}`);
1611
+ const tracePath = join(readBase, runId, 'trace.jsonl');
1612
+ if (!existsSync(tracePath))
1613
+ throw new Error(`Artifact not found: ${tracePath}`);
1614
+ const raw = readFileSync(tracePath, 'utf8');
1615
+ const lines = raw.split(/\r?\n/).filter(Boolean);
1616
+ const records = [];
1617
+ let legacyDetected = false;
1618
+ for (const line of lines) {
1619
+ let parsed;
1620
+ try {
1621
+ parsed = JSON.parse(line);
1622
+ }
1623
+ catch {
1624
+ continue;
1625
+ }
1626
+ if (isScenarioRunTraceRecord(parsed)) {
1627
+ records.push(parsed);
1628
+ continue;
1629
+ }
1630
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1631
+ const p = parsed;
1632
+ if (typeof p.type === 'string' && p.type !== 'trace_meta') {
1633
+ legacyDetected = true;
1634
+ }
1635
+ }
1636
+ }
1637
+ return { runId, tracePath, records, legacyDetected };
1638
+ }
1639
+ function flattenScenarioRunTraceRecords(records) {
1640
+ const out = [];
1641
+ for (const [recordIndex, record] of records.entries()) {
1642
+ for (const [messageIndex, message] of record.messages.entries()) {
1643
+ out.push(removeUndefined({
1644
+ type: 'message',
1645
+ record_index: recordIndex,
1646
+ message_index: messageIndex,
1647
+ scenario_id: record.scenario_id,
1648
+ agent: record.agent,
1649
+ role: message.role,
1650
+ ts: message.ts,
1651
+ usage: message.usage
1652
+ }));
1653
+ for (const [blockIndex, block] of message.content.entries()) {
1654
+ if (block.type === 'text') {
1655
+ out.push({
1656
+ type: 'text',
1657
+ record_index: recordIndex,
1658
+ message_index: messageIndex,
1659
+ block_index: blockIndex,
1660
+ scenario_id: record.scenario_id,
1661
+ agent: record.agent,
1662
+ role: message.role,
1663
+ ts: message.ts,
1664
+ text: block.text
1665
+ });
1666
+ continue;
1667
+ }
1668
+ if (block.type === 'tool_use') {
1669
+ out.push({
1670
+ type: 'tool_use',
1671
+ record_index: recordIndex,
1672
+ message_index: messageIndex,
1673
+ block_index: blockIndex,
1674
+ scenario_id: record.scenario_id,
1675
+ agent: record.agent,
1676
+ role: message.role,
1677
+ ts: message.ts,
1678
+ id: block.id,
1679
+ name: block.name,
1680
+ server: block.server,
1681
+ input: block.input
1682
+ });
1683
+ continue;
1684
+ }
1685
+ out.push(removeUndefined({
1686
+ type: 'tool_result',
1687
+ record_index: recordIndex,
1688
+ message_index: messageIndex,
1689
+ block_index: blockIndex,
1690
+ scenario_id: record.scenario_id,
1691
+ agent: record.agent,
1692
+ role: message.role,
1693
+ ts: block.ts_end ?? block.ts_start ?? message.ts,
1694
+ tool_use_id: block.tool_use_id,
1695
+ name: block.name,
1696
+ server: block.server,
1697
+ is_error: block.is_error,
1698
+ duration_ms: block.duration_ms,
1699
+ content: block.content
1700
+ }));
1701
+ }
1702
+ }
1703
+ }
1704
+ return out;
1705
+ }
1706
+ function extractTextBlocks(blocks) {
1707
+ return blocks
1708
+ .filter((b) => b.type === 'text')
1709
+ .map((b) => b.text);
1710
+ }
1711
+ function extractFinalAssistantText(record) {
1712
+ for (let i = record.messages.length - 1; i >= 0; i -= 1) {
1713
+ const message = record.messages[i];
1714
+ if (message.role !== 'assistant')
1715
+ continue;
1716
+ const text = extractTextBlocks(message.content).join('\n\n').trim();
1717
+ if (text)
1718
+ return text;
1719
+ }
1720
+ return '';
1721
+ }
1722
+ function buildConversationTimeline(record, textMax) {
1723
+ const timeline = [];
1724
+ for (const [messageIndex, message] of record.messages.entries()) {
1725
+ for (const [blockIndex, block] of message.content.entries()) {
1726
+ if (block.type === 'text') {
1727
+ timeline.push({
1728
+ index: timeline.length,
1729
+ type: message.role === 'assistant'
1730
+ ? 'agent_message'
1731
+ : message.role === 'user'
1732
+ ? 'user_message'
1733
+ : 'tool_text',
1734
+ role: message.role,
1735
+ ts: message.ts,
1736
+ message_index: messageIndex,
1737
+ block_index: blockIndex,
1738
+ text: truncate(block.text, textMax)
1739
+ });
1740
+ continue;
1741
+ }
1742
+ if (block.type === 'tool_use') {
1743
+ timeline.push({
1744
+ index: timeline.length,
1745
+ type: 'tool_call',
1746
+ role: message.role,
1747
+ ts: message.ts,
1748
+ message_index: messageIndex,
1749
+ block_index: blockIndex,
1750
+ id: block.id,
1751
+ server: block.server,
1752
+ tool: block.name,
1753
+ args: block.input
1754
+ });
1755
+ continue;
1756
+ }
1757
+ timeline.push({
1758
+ index: timeline.length,
1759
+ type: 'tool_result',
1760
+ role: message.role,
1761
+ ts: block.ts_end ?? block.ts_start ?? message.ts,
1762
+ message_index: messageIndex,
1763
+ block_index: blockIndex,
1764
+ tool_use_id: block.tool_use_id,
1765
+ server: block.server,
1766
+ tool: block.name,
1767
+ ok: !block.is_error,
1768
+ duration_ms: block.duration_ms,
1769
+ content: block.content.map((c) => ({ ...c, text: truncate(c.text, textMax) }))
1770
+ });
1771
+ }
1772
+ }
1773
+ return timeline;
1774
+ }
1775
+ function resolvePathInsideWorkspace(pathInput) {
1776
+ const workspaceRoot = resolve(process.cwd());
1777
+ const target = resolve(workspaceRoot, pathInput);
1778
+ const withinWorkspace = target === workspaceRoot || target.startsWith(`${workspaceRoot}${sep}`);
1779
+ if (!withinWorkspace) {
1780
+ throw new Error(`Path escapes workspace root: ${pathInput}`);
1781
+ }
1782
+ return target;
1783
+ }
1784
+ function ok(summary, structuredContent) {
1785
+ const payload = structuredContent ?? {};
1786
+ return {
1787
+ content: [
1788
+ {
1789
+ type: 'text',
1790
+ text: `${summary}\n\n${JSON.stringify(payload, null, 2)}`
1791
+ }
1792
+ ],
1793
+ structuredContent: payload
1794
+ };
1795
+ }
1796
+ function err(error) {
1797
+ const message = error instanceof Error ? error.message : String(error);
1798
+ return {
1799
+ isError: true,
1800
+ content: [{ type: 'text', text: `Error: ${message}` }],
1801
+ structuredContent: { error: message }
1802
+ };
1803
+ }
1804
+ async function withToolHandling(fn) {
1805
+ try {
1806
+ return await fn();
1807
+ }
1808
+ catch (error) {
1809
+ return err(error);
1810
+ }
1811
+ }
1812
+ function removeUndefined(value) {
1813
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
1814
+ }
1815
+ function normalizeStringArray(values) {
1816
+ if (!values)
1817
+ return undefined;
1818
+ const out = values.map((value) => value.trim()).filter(Boolean);
1819
+ return out.length > 0 ? out : undefined;
1820
+ }
1821
+ function slugify(input) {
1822
+ return input
1823
+ .toLowerCase()
1824
+ .replace(/[^a-z0-9]+/g, '-')
1825
+ .replace(/^-+|-+$/g, '')
1826
+ .slice(0, 80);
1827
+ }
1828
+ function indentBlock(text, spaces) {
1829
+ const prefix = ' '.repeat(spaces);
1830
+ return text
1831
+ .split('\n')
1832
+ .map((line) => `${prefix}${line}`)
1833
+ .join('\n');
1834
+ }
1835
+ function truncate(text, maxChars) {
1836
+ if (text.length <= maxChars)
1837
+ return text;
1838
+ return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]`;
1839
+ }
1840
+ export async function handleMcplabMcpHttpRequest(req, res, sessions, options) {
1841
+ await handleHttpRequest(req, res, sessions, options?.path ?? DEFAULT_MCP_PATH);
1842
+ }
1843
+ async function handleHttpRequest(req, res, sessions, mcpPath) {
1844
+ const method = req.method ?? 'GET';
1845
+ const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname;
1846
+ if (pathname === '/' && method === 'GET') {
1847
+ sendJson(res, 200, {
1848
+ name: 'mcplab-assistant-server',
1849
+ version: SERVER_VERSION,
1850
+ transport: 'streamable-http',
1851
+ mcp_endpoint: mcpPath
1852
+ });
1853
+ return;
1854
+ }
1855
+ if (pathname !== mcpPath) {
1856
+ sendPlain(res, 404, 'Not Found');
1857
+ return;
1858
+ }
1859
+ if (method === 'POST') {
1860
+ const body = await readJsonBody(req);
1861
+ const sessionId = getSessionId(req);
1862
+ if (sessionId && sessions.has(sessionId)) {
1863
+ const runtime = sessions.get(sessionId);
1864
+ await runtime.transport.handleRequest(req, res, body);
1865
+ return;
1866
+ }
1867
+ if (!sessionId && isInitializeRequest(body)) {
1868
+ let runtime;
1869
+ const transport = new StreamableHTTPServerTransport({
1870
+ sessionIdGenerator: () => randomUUID(),
1871
+ onsessioninitialized: (sid) => {
1872
+ sessions.set(sid, runtime);
1873
+ },
1874
+ onsessionclosed: (sid) => {
1875
+ sessions.delete(sid);
1876
+ }
1877
+ });
1878
+ const mcpServer = createConfiguredServer();
1879
+ runtime = { transport, server: mcpServer };
1880
+ transport.onclose = () => {
1881
+ const sid = transport.sessionId;
1882
+ if (sid)
1883
+ sessions.delete(sid);
1884
+ };
1885
+ await mcpServer.connect(transport);
1886
+ await transport.handleRequest(req, res, body);
1887
+ return;
1888
+ }
1889
+ sendJson(res, 400, {
1890
+ jsonrpc: '2.0',
1891
+ error: {
1892
+ code: -32000,
1893
+ message: 'Bad Request: missing/invalid MCP session or initialize request'
1894
+ },
1895
+ id: null
1896
+ });
1897
+ return;
1898
+ }
1899
+ if (method === 'GET' || method === 'DELETE') {
1900
+ const sessionId = getSessionId(req);
1901
+ if (!sessionId || !sessions.has(sessionId)) {
1902
+ sendPlain(res, 400, 'Invalid or missing session ID');
1903
+ return;
1904
+ }
1905
+ const runtime = sessions.get(sessionId);
1906
+ await runtime.transport.handleRequest(req, res);
1907
+ if (method === 'DELETE') {
1908
+ sessions.delete(sessionId);
1909
+ try {
1910
+ await runtime.server.close();
1911
+ }
1912
+ catch {
1913
+ // Transport already handled protocol delete; ignore close errors.
1914
+ }
1915
+ }
1916
+ return;
1917
+ }
1918
+ sendPlain(res, 405, 'Method Not Allowed');
1919
+ }
1920
+ function getSessionId(req) {
1921
+ const header = req.headers['mcp-session-id'];
1922
+ if (Array.isArray(header))
1923
+ return header[0];
1924
+ return header;
1925
+ }
1926
+ async function readJsonBody(req) {
1927
+ const chunks = [];
1928
+ for await (const chunk of req) {
1929
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1930
+ }
1931
+ if (chunks.length === 0)
1932
+ return undefined;
1933
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
1934
+ if (!raw)
1935
+ return undefined;
1936
+ return JSON.parse(raw);
1937
+ }
1938
+ function sendJson(res, statusCode, payload) {
1939
+ res.statusCode = statusCode;
1940
+ res.setHeader('content-type', 'application/json');
1941
+ res.end(`${JSON.stringify(payload)}\n`);
1942
+ }
1943
+ function sendPlain(res, statusCode, body) {
1944
+ res.statusCode = statusCode;
1945
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
1946
+ res.end(body);
1947
+ }
1948
+ //# sourceMappingURL=runtime.js.map