@inspectr/mcplab-mcp-server 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -9,11 +9,346 @@ import { loadConfig, runAll, selectScenarios } from '@inspectr/mcplab-core';
9
9
  import { renderReport } from '@inspectr/mcplab-reporting';
10
10
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
11
  import { z } from 'zod';
12
- const SERVER_VERSION = '0.1.0';
12
+ import { buildAggregateRunsReport, buildCompareRunsReport } from './mcp-run-calculations.js';
13
+ const PACKAGE_JSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
14
+ const SERVER_VERSION = typeof PACKAGE_JSON.version === 'string' && PACKAGE_JSON.version.trim().length > 0
15
+ ? PACKAGE_JSON.version
16
+ : '0.0.0';
17
+ const SERVER_ICON_URL = 'https://mcplab.inspectr.dev/favicon.svg';
13
18
  const DEFAULT_MCP_PATH = '/mcp';
14
19
  const DEFAULT_MCP_PORT = 3011;
15
20
  const DEFAULT_MCP_HOST = '127.0.0.1';
16
21
  const MAX_MARKDOWN_REPORT_READ_BYTES = 2 * 1024 * 1024;
22
+ const GenericObjectSchema = z.object({}).passthrough();
23
+ const ResultsSummarySchema = z.object({
24
+ total_scenarios: z.number().int().nonnegative(),
25
+ total_runs: z.number().int().nonnegative(),
26
+ pass_rate: z.number(),
27
+ avg_tool_calls_per_run: z.number(),
28
+ avg_tool_latency_ms: z.number().nullable()
29
+ });
30
+ const ResultsMetadataSchema = z
31
+ .object({
32
+ run_id: z.string(),
33
+ timestamp: z.string(),
34
+ config_hash: z.string(),
35
+ cli_version: z.string(),
36
+ mcp_server_versions: z.record(z.string())
37
+ })
38
+ .passthrough();
39
+ const MetricSummarySchema = z.object({
40
+ total_runs: z.number().int().nonnegative(),
41
+ passed_runs: z.number().int().nonnegative(),
42
+ failed_runs: z.number().int().nonnegative(),
43
+ pass_rate: z.number(),
44
+ avg_tool_calls_per_run: z.number(),
45
+ avg_tool_latency_ms: z.number().nullable()
46
+ });
47
+ const AggregateRowSchema = z.object({
48
+ key: z.string(),
49
+ run_id: z.string().optional(),
50
+ scenario_id: z.string().optional(),
51
+ agent: z.string().optional(),
52
+ run_count: z.number().int().nonnegative(),
53
+ timestamp_range: z
54
+ .object({
55
+ min: z.string(),
56
+ max: z.string()
57
+ })
58
+ .optional(),
59
+ total_runs: z.number().int().nonnegative(),
60
+ passed_runs: z.number().int().nonnegative(),
61
+ failed_runs: z.number().int().nonnegative(),
62
+ pass_rate: z.number(),
63
+ avg_tool_calls_per_run: z.number(),
64
+ avg_tool_latency_ms: z.number().nullable()
65
+ });
66
+ const CompareRowSchema = z.object({
67
+ key: z.string(),
68
+ scenario_id: z.string(),
69
+ agent: z.string(),
70
+ classification: z.enum(['regressed', 'improved', 'unchanged', 'new', 'missing']),
71
+ left: MetricSummarySchema.nullable(),
72
+ right: MetricSummarySchema.nullable(),
73
+ deltas: z.object({
74
+ pass_rate: z.number().nullable(),
75
+ failed_runs: z.number().nullable(),
76
+ avg_tool_calls_per_run: z.number().nullable(),
77
+ avg_tool_latency_ms: z.number().nullable()
78
+ })
79
+ });
80
+ const RunListEntrySchema = z.object({
81
+ run_id: z.string(),
82
+ path: z.string(),
83
+ summary: ResultsSummarySchema.optional(),
84
+ metadata: ResultsMetadataSchema.optional(),
85
+ summary_error: z.string().optional()
86
+ });
87
+ const LibraryScenarioEntrySchema = z.object({
88
+ file: z.string(),
89
+ id: z.string().optional(),
90
+ content: GenericObjectSchema.optional(),
91
+ yaml: z.string().optional()
92
+ });
93
+ const AgentEntrySchema = z.object({
94
+ provider: z.enum(['openai', 'anthropic', 'azure_openai']),
95
+ model: z.string(),
96
+ temperature: z.number().optional(),
97
+ max_tokens: z.number().int().positive().optional(),
98
+ system: z.string().optional()
99
+ });
100
+ const LibraryEntrySchema = z.object({
101
+ bundleRoot: z.string(),
102
+ servers: z.union([z.array(z.object({ id: z.string() })), z.record(GenericObjectSchema)]),
103
+ agents: z.union([z.array(z.object({ id: z.string() })), z.record(AgentEntrySchema)]),
104
+ scenarios: z.array(LibraryScenarioEntrySchema)
105
+ });
106
+ const ServerAuthSchema = z.union([
107
+ z.object({
108
+ type: z.literal('bearer'),
109
+ token: z.string()
110
+ }),
111
+ z.object({
112
+ type: z.literal('api_key'),
113
+ header_name: z.string().optional(),
114
+ value: z.string()
115
+ }),
116
+ z.object({
117
+ type: z.literal('oauth_client_credentials'),
118
+ token_url: z.string(),
119
+ client_id_env: z.string(),
120
+ client_secret_env: z.string(),
121
+ scope: z.string().optional(),
122
+ audience: z.string().optional()
123
+ })
124
+ ]);
125
+ const ServerEntrySchema = z.object({
126
+ transport: z.literal('http'),
127
+ url: z.string(),
128
+ auth: ServerAuthSchema.optional()
129
+ });
130
+ const ScenarioEntrySchema = z.object({
131
+ id: z.string(),
132
+ agent: z.string().optional(),
133
+ servers: z.array(z.string()),
134
+ prompt: z.string(),
135
+ snapshot_eval_enabled: z.boolean().optional(),
136
+ eval: GenericObjectSchema.optional(),
137
+ extract: z
138
+ .array(z.object({
139
+ name: z.string(),
140
+ from: z.string(),
141
+ regex: z.string()
142
+ }))
143
+ .optional()
144
+ });
145
+ const ConfigSummarySchema = z.object({
146
+ server_count: z.number().int().nonnegative(),
147
+ agent_count: z.number().int().nonnegative(),
148
+ scenario_count: z.number().int().nonnegative(),
149
+ servers: z.array(z.string()),
150
+ agents: z.array(z.string()),
151
+ scenarios: z.array(z.object({
152
+ id: z.string(),
153
+ servers: z.array(z.string()),
154
+ has_eval: z.boolean(),
155
+ extract_count: z.number().int().nonnegative()
156
+ }))
157
+ });
158
+ const ToolAnalysisListItemSchema = z.object({
159
+ report_id: z.string(),
160
+ path: z.string().optional(),
161
+ error: z.string().optional(),
162
+ reportId: z.string().optional(),
163
+ createdAt: z.string().optional(),
164
+ sourceJobId: z.string().optional(),
165
+ serverNames: z.array(z.string()).optional(),
166
+ assistantAgentName: z.string().optional(),
167
+ assistantAgentModel: z.string().optional(),
168
+ modes: GenericObjectSchema.optional(),
169
+ summary: GenericObjectSchema.optional()
170
+ });
171
+ const ToolAnalysisSummarySchema = z.object({
172
+ reportId: z.string().optional(),
173
+ createdAt: z.string().optional(),
174
+ sourceJobId: z.string().optional(),
175
+ serverNames: z.array(z.string()).optional(),
176
+ assistantAgentName: z.string().optional(),
177
+ assistantAgentModel: z.string().optional(),
178
+ modes: GenericObjectSchema.optional(),
179
+ summary: GenericObjectSchema.optional()
180
+ });
181
+ const FlattenedTraceItemSchema = z.union([
182
+ z.object({
183
+ type: z.literal('message'),
184
+ record_index: z.number().int().nonnegative(),
185
+ message_index: z.number().int().nonnegative(),
186
+ scenario_id: z.string(),
187
+ agent: z.string(),
188
+ role: z.enum(['user', 'assistant', 'tool']),
189
+ ts: z.string(),
190
+ usage: z.unknown().optional()
191
+ }),
192
+ z.object({
193
+ type: z.literal('text'),
194
+ record_index: z.number().int().nonnegative(),
195
+ message_index: z.number().int().nonnegative(),
196
+ block_index: z.number().int().nonnegative(),
197
+ scenario_id: z.string(),
198
+ agent: z.string(),
199
+ role: z.enum(['user', 'assistant', 'tool']),
200
+ ts: z.string(),
201
+ text: z.string()
202
+ }),
203
+ z.object({
204
+ type: z.literal('tool_use'),
205
+ record_index: z.number().int().nonnegative(),
206
+ message_index: z.number().int().nonnegative(),
207
+ block_index: z.number().int().nonnegative(),
208
+ scenario_id: z.string(),
209
+ agent: z.string(),
210
+ role: z.enum(['user', 'assistant', 'tool']),
211
+ ts: z.string(),
212
+ id: z.string(),
213
+ name: z.string(),
214
+ server: z.string(),
215
+ input: z.unknown()
216
+ }),
217
+ z.object({
218
+ type: z.literal('tool_result'),
219
+ record_index: z.number().int().nonnegative(),
220
+ message_index: z.number().int().nonnegative(),
221
+ block_index: z.number().int().nonnegative(),
222
+ scenario_id: z.string(),
223
+ agent: z.string(),
224
+ role: z.enum(['user', 'assistant', 'tool']),
225
+ ts: z.string(),
226
+ tool_use_id: z.string(),
227
+ name: z.string(),
228
+ server: z.string(),
229
+ is_error: z.boolean().optional(),
230
+ duration_ms: z.number().optional(),
231
+ content: z.unknown()
232
+ })
233
+ ]);
234
+ const ConversationTimelineItemSchema = z.union([
235
+ z.object({
236
+ index: z.number().int().nonnegative(),
237
+ type: z.enum(['agent_message', 'user_message', 'tool_text']),
238
+ role: z.enum(['user', 'assistant', 'tool']),
239
+ ts: z.string(),
240
+ message_index: z.number().int().nonnegative(),
241
+ block_index: z.number().int().nonnegative(),
242
+ text: z.string()
243
+ }),
244
+ z.object({
245
+ index: z.number().int().nonnegative(),
246
+ type: z.literal('tool_call'),
247
+ role: z.enum(['user', 'assistant', 'tool']),
248
+ ts: z.string(),
249
+ message_index: z.number().int().nonnegative(),
250
+ block_index: z.number().int().nonnegative(),
251
+ id: z.string(),
252
+ server: z.string(),
253
+ tool: z.string(),
254
+ args: z.unknown()
255
+ }),
256
+ z.object({
257
+ index: z.number().int().nonnegative(),
258
+ type: z.literal('tool_result'),
259
+ role: z.enum(['user', 'assistant', 'tool']),
260
+ ts: z.string(),
261
+ message_index: z.number().int().nonnegative(),
262
+ block_index: z.number().int().nonnegative(),
263
+ tool_use_id: z.string(),
264
+ server: z.string(),
265
+ tool: z.string(),
266
+ ok: z.boolean(),
267
+ duration_ms: z.number().optional(),
268
+ content: z.array(z
269
+ .object({
270
+ text: z.string()
271
+ })
272
+ .passthrough())
273
+ })
274
+ ]);
275
+ const WriteMarkdownReportSuccessSchema = z.object({
276
+ ok: z.literal(true),
277
+ path: z.string().describe('Resolved absolute path to the written markdown file.'),
278
+ bytes: z.number().int().nonnegative().describe('UTF-8 byte length written to disk.'),
279
+ chars: z.number().int().nonnegative().describe('Character count written to disk.'),
280
+ overwritten: z.boolean().describe('True when an existing file was replaced.'),
281
+ workspace_root: z.string().describe('Workspace root used for path safety validation.')
282
+ });
283
+ const WriteMarkdownReportErrorSchema = z.object({
284
+ ok: z.literal(false),
285
+ error_code: z.enum([
286
+ 'PATH_ESCAPE',
287
+ 'PERMISSION_DENIED',
288
+ 'FILE_EXISTS',
289
+ 'INVALID_EXTENSION',
290
+ 'PARENT_DIR_MISSING',
291
+ 'IO_ERROR'
292
+ ]),
293
+ error_message: z.string(),
294
+ attempted_path: z.string().optional(),
295
+ violated_constraint: z.string().optional()
296
+ });
297
+ const GenerateServerEntryInputSchema = z
298
+ .object({
299
+ id: z.string(),
300
+ url: z.string(),
301
+ transport: z.enum(['http']).optional(),
302
+ auth_type: z.enum(['none', 'bearer', 'api_key', 'oauth_client_credentials']).optional(),
303
+ bearer_token: z.string().optional(),
304
+ bearer_env: z.string().optional(),
305
+ api_key_header_name: z.string().optional(),
306
+ api_key_value: z.string().optional(),
307
+ oauth_token_url: z.string().optional(),
308
+ oauth_client_id_env: z.string().optional(),
309
+ oauth_client_secret_env: z.string().optional(),
310
+ oauth_scope: z.string().optional(),
311
+ oauth_audience: z.string().optional()
312
+ })
313
+ .superRefine((value, ctx) => {
314
+ const authType = value.auth_type ?? 'none';
315
+ if (authType === 'bearer') {
316
+ if (!value.bearer_token && !value.bearer_env) {
317
+ ctx.addIssue({
318
+ code: z.ZodIssueCode.custom,
319
+ message: 'auth_type=bearer requires at least one of bearer_token or bearer_env.'
320
+ });
321
+ }
322
+ }
323
+ if (authType === 'api_key') {
324
+ if (!value.api_key_value) {
325
+ ctx.addIssue({
326
+ code: z.ZodIssueCode.custom,
327
+ message: 'auth_type=api_key requires api_key_value.'
328
+ });
329
+ }
330
+ }
331
+ if (authType === 'oauth_client_credentials') {
332
+ if (!value.oauth_token_url) {
333
+ ctx.addIssue({
334
+ code: z.ZodIssueCode.custom,
335
+ message: 'auth_type=oauth_client_credentials requires oauth_token_url.'
336
+ });
337
+ }
338
+ if (!value.oauth_client_id_env) {
339
+ ctx.addIssue({
340
+ code: z.ZodIssueCode.custom,
341
+ message: 'auth_type=oauth_client_credentials requires oauth_client_id_env.'
342
+ });
343
+ }
344
+ if (!value.oauth_client_secret_env) {
345
+ ctx.addIssue({
346
+ code: z.ZodIssueCode.custom,
347
+ message: 'auth_type=oauth_client_credentials requires oauth_client_secret_env.'
348
+ });
349
+ }
350
+ }
351
+ });
17
352
  export async function startMcplabMcpServer(options) {
18
353
  const logger = options.logger ?? console;
19
354
  const sessions = new Map();
@@ -75,60 +410,180 @@ export function defaultMcplabMcpServerOptionsFromEnv() {
75
410
  export function createConfiguredServer() {
76
411
  const server = new McpServer({
77
412
  name: 'mcplab-assistant-server',
78
- version: SERVER_VERSION
413
+ version: SERVER_VERSION,
414
+ title: 'MCPLab Assistant Server',
415
+ description: 'MCPLab MCP tools for configs, runs, results, traces, and report workflows.',
416
+ websiteUrl: 'https://mcplab.inspectr.dev',
417
+ icons: [{ src: SERVER_ICON_URL, mimeType: 'image/svg+xml' }]
79
418
  });
80
419
  registerTools(server);
81
420
  registerPrompts(server);
82
421
  return server;
83
422
  }
84
423
  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.',
424
+ const registerTool = (name, config, cb) => {
425
+ const resolvedTitle = resolveToolTitle(name, config.title, config.annotations?.title);
426
+ const outputSchema = config.outputSchema ?? z.object({}).passthrough().describe('Structured tool response.');
427
+ server.registerTool(name, {
428
+ ...config,
429
+ title: resolvedTitle,
430
+ outputSchema,
431
+ annotations: inferToolAnnotations(name, resolvedTitle, config.annotations)
432
+ }, cb);
433
+ };
434
+ registerTool('mcplab_write_markdown_report', {
435
+ description: 'Write a Markdown (.md or .markdown) file to a path within the current workspace. Returns structured output with ok:true and the resolved path on success. On failure, returns ok:false with an error_code from: PATH_ESCAPE (path traversal attempt), FILE_EXISTS (file already exists and overwrite is false), PERMISSION_DENIED, PARENT_DIR_MISSING (create_dirs is false and parent does not exist), INVALID_EXTENSION (not .md or .markdown), IO_ERROR.',
436
+ outputSchema: z.union([WriteMarkdownReportSuccessSchema, WriteMarkdownReportErrorSchema]),
87
437
  inputSchema: {
88
438
  output_path: z
89
439
  .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.'),
440
+ .describe("Target .md/.markdown path. Use a relative path (e.g. mcplab/reports/my-report.md) — relative paths are always safe and resolve against the server's working directory (process.cwd()). Absolute paths are accepted only if they stay inside that directory; any path that escapes it is rejected with error_code PATH_ESCAPE."),
441
+ markdown: z
442
+ .string()
443
+ .min(1, 'Markdown content must not be empty.')
444
+ .max(10485760, 'Markdown must not exceed 10 MiB')
445
+ .describe('Markdown content to write. Maximum 10 MiB.'),
92
446
  overwrite: z
93
447
  .boolean()
94
- .optional()
448
+ .default(false)
95
449
  .describe('Overwrite existing file if true. Defaults to false.'),
96
450
  create_dirs: z
97
451
  .boolean()
98
- .optional()
452
+ .default(true)
99
453
  .describe('Create missing parent directories if true. Defaults to true.')
100
454
  }
101
455
  }, async ({ output_path, markdown, overwrite, create_dirs }) => {
102
- return withToolHandling(async () => {
456
+ try {
103
457
  const targetPath = resolvePathInsideWorkspace(output_path);
104
458
  const extension = extname(targetPath).toLowerCase();
105
459
  if (extension !== '.md' && extension !== '.markdown') {
106
- throw new Error('output_path must end with .md or .markdown');
460
+ const structured = {
461
+ ok: false,
462
+ error_code: 'INVALID_EXTENSION',
463
+ error_message: 'output_path must end with .md or .markdown',
464
+ attempted_path: output_path,
465
+ violated_constraint: 'MARKDOWN_EXTENSION_REQUIRED'
466
+ };
467
+ return {
468
+ isError: true,
469
+ content: [{ type: 'text', text: `Error: ${structured.error_message}` }],
470
+ structuredContent: structured
471
+ };
107
472
  }
108
473
  const parentDir = dirname(targetPath);
109
- if (Boolean(create_dirs ?? true)) {
110
- mkdirSync(parentDir, { recursive: true });
474
+ if (create_dirs) {
475
+ try {
476
+ mkdirSync(parentDir, { recursive: true });
477
+ }
478
+ catch (error) {
479
+ const code = typeof error === 'object' && error !== null && 'code' in error
480
+ ? String(error.code ?? '')
481
+ : '';
482
+ const structured = {
483
+ ok: false,
484
+ error_code: (code === 'EACCES' || code === 'EPERM'
485
+ ? 'PERMISSION_DENIED'
486
+ : 'IO_ERROR'),
487
+ error_message: error instanceof Error ? error.message : String(error),
488
+ attempted_path: targetPath
489
+ };
490
+ return {
491
+ isError: true,
492
+ content: [{ type: 'text', text: `Error: ${structured.error_message}` }],
493
+ structuredContent: structured
494
+ };
495
+ }
111
496
  }
112
497
  else if (!existsSync(parentDir)) {
113
- throw new Error(`Parent directory does not exist: ${parentDir}`);
498
+ const structured = {
499
+ ok: false,
500
+ error_code: 'PARENT_DIR_MISSING',
501
+ error_message: `Parent directory does not exist: ${parentDir}`,
502
+ attempted_path: targetPath
503
+ };
504
+ return {
505
+ isError: true,
506
+ content: [{ type: 'text', text: `Error: ${structured.error_message}` }],
507
+ structuredContent: structured
508
+ };
114
509
  }
115
510
  const fileExists = existsSync(targetPath);
116
- if (fileExists && !Boolean(overwrite)) {
117
- throw new Error(`File already exists: ${targetPath} (set overwrite=true to replace it)`);
511
+ if (fileExists && !overwrite) {
512
+ const structured = {
513
+ ok: false,
514
+ error_code: 'FILE_EXISTS',
515
+ error_message: `File already exists: ${targetPath} (set overwrite=true to replace it)`,
516
+ attempted_path: targetPath
517
+ };
518
+ return {
519
+ isError: true,
520
+ content: [{ type: 'text', text: `Error: ${structured.error_message}` }],
521
+ structuredContent: structured
522
+ };
118
523
  }
119
524
  const normalized = markdown.endsWith('\n') ? markdown : `${markdown}\n`;
120
- writeFileSync(targetPath, normalized, 'utf8');
525
+ try {
526
+ writeFileSync(targetPath, normalized, 'utf8');
527
+ }
528
+ catch (error) {
529
+ const code = typeof error === 'object' && error !== null && 'code' in error
530
+ ? String(error.code ?? '')
531
+ : '';
532
+ const structured = {
533
+ ok: false,
534
+ error_code: (code === 'EACCES' || code === 'EPERM'
535
+ ? 'PERMISSION_DENIED'
536
+ : 'IO_ERROR'),
537
+ error_message: error instanceof Error ? error.message : String(error),
538
+ attempted_path: targetPath
539
+ };
540
+ return {
541
+ isError: true,
542
+ content: [{ type: 'text', text: `Error: ${structured.error_message}` }],
543
+ structuredContent: structured
544
+ };
545
+ }
121
546
  return ok(`Wrote Markdown report to ${targetPath}`, {
547
+ ok: true,
122
548
  path: targetPath,
123
549
  bytes: Buffer.byteLength(normalized, 'utf8'),
124
550
  chars: normalized.length,
125
551
  overwritten: fileExists,
126
552
  workspace_root: process.cwd()
127
553
  });
128
- });
554
+ }
555
+ catch (error) {
556
+ const message = error instanceof Error ? error.message : String(error);
557
+ const isPathEscape = message.toLowerCase().includes('escapes workspace root');
558
+ const structured = {
559
+ ok: false,
560
+ error_code: (isPathEscape ? 'PATH_ESCAPE' : 'IO_ERROR'),
561
+ error_message: message,
562
+ attempted_path: output_path,
563
+ violated_constraint: isPathEscape ? 'WORKSPACE_CONTAINMENT' : undefined
564
+ };
565
+ return {
566
+ isError: true,
567
+ content: [{ type: 'text', text: `Error: ${structured.error_message}` }],
568
+ structuredContent: removeUndefined(structured)
569
+ };
570
+ }
129
571
  });
130
- server.registerTool('mcplab_list_markdown_reports', {
572
+ registerTool('mcplab_search_markdown_reports', {
131
573
  description: 'List saved markdown reports under mcplab/reports. Supports filtering by run id substring to find reports linked to a result.',
574
+ outputSchema: {
575
+ reports_dir: z.string(),
576
+ run_id_filter: z.string().optional(),
577
+ query: z.string().optional(),
578
+ total_matching: z.number().int().nonnegative(),
579
+ items: z.array(z.object({
580
+ path: z.string(),
581
+ relativePath: z.string(),
582
+ name: z.string(),
583
+ sizeBytes: z.number().int().nonnegative(),
584
+ mtime: z.string()
585
+ }))
586
+ },
132
587
  inputSchema: {
133
588
  reports_dir: z
134
589
  .string()
@@ -138,6 +593,10 @@ export function registerTools(server) {
138
593
  .string()
139
594
  .optional()
140
595
  .describe('Optional run id substring filter (matches path/name).'),
596
+ query: z
597
+ .string()
598
+ .optional()
599
+ .describe('Optional case-insensitive search query across report path/name fields.'),
141
600
  limit: z
142
601
  .number()
143
602
  .int()
@@ -146,25 +605,47 @@ export function registerTools(server) {
146
605
  .optional()
147
606
  .describe('Max reports to return (default 20).')
148
607
  }
149
- }, async ({ reports_dir, run_id, limit }) => {
608
+ }, async ({ reports_dir, run_id, query, limit }) => {
150
609
  return withToolHandling(async () => {
151
610
  const root = resolveMarkdownReportsDir(reports_dir);
152
611
  const all = listMarkdownReportsFromDisk(root);
153
612
  const runFilter = String(run_id ?? '').trim();
154
- const filtered = runFilter
155
- ? all.filter((item) => item.relativePath.includes(runFilter) || item.name.includes(runFilter))
156
- : all;
613
+ const searchQuery = String(query ?? '')
614
+ .trim()
615
+ .toLowerCase();
616
+ const filtered = all.filter((item) => {
617
+ if (runFilter &&
618
+ !item.relativePath.includes(runFilter) &&
619
+ !item.name.includes(runFilter)) {
620
+ return false;
621
+ }
622
+ if (!searchQuery)
623
+ return true;
624
+ const hay = `${item.path}\n${item.relativePath}\n${item.name}`.toLowerCase();
625
+ return hay.includes(searchQuery);
626
+ });
157
627
  const capped = filtered.slice(0, limit ?? 20);
158
628
  return ok(`Found ${capped.length}/${filtered.length} markdown report(s) in ${root}`, {
159
629
  reports_dir: root,
160
630
  run_id_filter: runFilter || undefined,
631
+ query: searchQuery || undefined,
161
632
  total_matching: filtered.length,
162
633
  items: capped
163
634
  });
164
635
  });
165
636
  });
166
- server.registerTool('mcplab_read_markdown_report', {
637
+ registerTool('mcplab_read_markdown_report', {
167
638
  description: 'Read a saved markdown report by relative path (under mcplab/reports by default) or by workspace-relative path, with optional truncation.',
639
+ outputSchema: {
640
+ reports_dir: z.string(),
641
+ path: z.string(),
642
+ relativePath: z.string(),
643
+ name: z.string(),
644
+ sizeBytes: z.number().int().nonnegative(),
645
+ mtime: z.string(),
646
+ truncated: z.boolean(),
647
+ content: z.string()
648
+ },
168
649
  inputSchema: {
169
650
  path: z
170
651
  .string()
@@ -207,8 +688,9 @@ export function registerTools(server) {
207
688
  });
208
689
  });
209
690
  });
210
- server.registerTool('mcplab_list_library', {
691
+ registerTool('mcplab_list_library', {
211
692
  description: 'List reusable MCPLab library entries (servers, agents, scenarios) from a bundle root such as mcplab/ or examples/libraries/.',
693
+ outputSchema: LibraryEntrySchema,
212
694
  inputSchema: {
213
695
  bundleRoot: z
214
696
  .string()
@@ -237,8 +719,16 @@ export function registerTools(server) {
237
719
  return ok(`Loaded MCPLab library from ${root}`, structured);
238
720
  });
239
721
  });
240
- server.registerTool('mcplab_get_library_item', {
722
+ registerTool('mcplab_get_library_item', {
241
723
  description: 'Get a specific reusable server, agent, or scenario definition from a MCPLab library bundle and return both structured data and YAML.',
724
+ outputSchema: {
725
+ bundleRoot: z.string(),
726
+ kind: z.enum(['servers', 'agents', 'scenarios']),
727
+ id: z.string(),
728
+ file: z.string().optional(),
729
+ yaml: z.string(),
730
+ content: GenericObjectSchema
731
+ },
242
732
  inputSchema: {
243
733
  bundleRoot: z.string().optional().describe('Optional library bundle root path.'),
244
734
  kind: z.enum(['servers', 'agents', 'scenarios']).describe('Library category.'),
@@ -251,8 +741,13 @@ export function registerTools(server) {
251
741
  return ok(`Loaded ${kind.slice(0, -1)} '${id}' from ${root}`, item);
252
742
  });
253
743
  });
254
- server.registerTool('mcplab_generate_server_entry', {
744
+ registerTool('mcplab_generate_server_entry', {
255
745
  description: 'Generate a MCPLab servers.yaml entry (or inline config block) for an MCP server connection.',
746
+ outputSchema: {
747
+ id: z.string(),
748
+ entry: ServerEntrySchema,
749
+ yaml: z.string()
750
+ },
256
751
  inputSchema: {
257
752
  id: z.string().describe('Server id key (kebab-case recommended).'),
258
753
  url: z.string().describe('MCP server URL (Streamable HTTP endpoint).'),
@@ -288,16 +783,22 @@ export function registerTools(server) {
288
783
  }
289
784
  }, async (input) => {
290
785
  return withToolHandling(async () => {
291
- const entry = buildServerEntry(input);
292
- return ok(`Generated server entry '${input.id}'`, {
293
- id: input.id,
786
+ const parsed = GenerateServerEntryInputSchema.parse(input);
787
+ const entry = buildServerEntry(parsed);
788
+ return ok(`Generated server entry '${parsed.id}'`, {
789
+ id: parsed.id,
294
790
  entry,
295
- yaml: stringifyYaml({ [input.id]: entry }).trimEnd()
791
+ yaml: stringifyYaml({ [parsed.id]: entry }).trimEnd()
296
792
  });
297
793
  });
298
794
  });
299
- server.registerTool('mcplab_generate_agent_entry', {
795
+ registerTool('mcplab_generate_agent_entry', {
300
796
  description: 'Generate a MCPLab agents.yaml entry (provider/model/system settings) for evaluation runs.',
797
+ outputSchema: {
798
+ id: z.string(),
799
+ entry: AgentEntrySchema,
800
+ yaml: z.string()
801
+ },
301
802
  inputSchema: {
302
803
  id: z.string().describe('Agent id key (kebab-case recommended).'),
303
804
  provider: z
@@ -318,8 +819,16 @@ export function registerTools(server) {
318
819
  });
319
820
  });
320
821
  });
321
- server.registerTool('mcplab_generate_scenario_entry', {
822
+ registerTool('mcplab_generate_scenario_entry', {
322
823
  description: 'Generate a MCPLab scenario YAML snippet with prompt, server links, and optional evaluation/extract rules. Optimized for scenario authoring workflows.',
824
+ outputSchema: {
825
+ scenario: ScenarioEntrySchema,
826
+ yaml: z.string(),
827
+ yaml_library_file: z.string(),
828
+ yaml_inline_list_item: z.string(),
829
+ format: z.enum(['library-scenario-file', 'inline-scenarios-list-item']),
830
+ warnings: z.array(z.string())
831
+ },
323
832
  inputSchema: {
324
833
  id: z
325
834
  .string()
@@ -337,7 +846,11 @@ export function registerTools(server) {
337
846
  .array(z.string())
338
847
  .min(1)
339
848
  .describe('One or more server ids available to the scenario.'),
340
- prompt: z.string().describe('The task prompt the evaluation agent should execute.'),
849
+ prompt: z
850
+ .string()
851
+ .min(1)
852
+ .max(4000)
853
+ .describe('The task prompt the evaluation agent should execute (1-4000 chars).'),
341
854
  snapshot_eval_enabled: z
342
855
  .boolean()
343
856
  .optional()
@@ -381,8 +894,20 @@ export function registerTools(server) {
381
894
  });
382
895
  });
383
896
  });
384
- server.registerTool('mcplab_validate_config', {
897
+ registerTool('mcplab_validate_config', {
385
898
  description: 'Validate and expand a MCPLab config file via mcplab-core loadConfig(), including server/agent/scenario library references.',
899
+ outputSchema: {
900
+ configPath: z.string(),
901
+ bundleRoot: z.string(),
902
+ hash: z.string(),
903
+ summary: ConfigSummarySchema,
904
+ resolved_config: z.object({
905
+ servers: z.record(GenericObjectSchema),
906
+ agents: z.record(AgentEntrySchema),
907
+ scenarios: z.array(GenericObjectSchema),
908
+ run_defaults: GenericObjectSchema.optional()
909
+ })
910
+ },
386
911
  inputSchema: {
387
912
  config_path: z.string().describe('Path to MCPLab eval YAML config.'),
388
913
  bundle_root: z
@@ -412,8 +937,26 @@ export function registerTools(server) {
412
937
  });
413
938
  });
414
939
  });
415
- server.registerTool('mcplab_run_eval', {
940
+ registerTool('mcplab_run_eval', {
416
941
  description: 'Run a MCPLab evaluation using mcplab-core runAll() from a config file and return the run directory plus summary metrics.',
942
+ outputSchema: {
943
+ run_dir: z.string(),
944
+ total_scenarios: z.number().int().nonnegative(),
945
+ total_runs: z.number().int().nonnegative(),
946
+ passed_runs: z.number().int().nonnegative(),
947
+ failed_runs: z.number().int().nonnegative(),
948
+ skipped_runs: z.number().int().nonnegative(),
949
+ duration_ms: z.number().nonnegative(),
950
+ summary: ResultsSummarySchema,
951
+ metadata: ResultsMetadataSchema,
952
+ scenarios: z.array(z.object({
953
+ scenario_id: z.string(),
954
+ agent: z.string(),
955
+ pass_rate: z.number(),
956
+ tool_usage_frequency: z.record(z.number())
957
+ })),
958
+ report_html_preview: z.string()
959
+ },
417
960
  inputSchema: {
418
961
  config_path: z.string().describe('Path to MCPLab eval YAML config.'),
419
962
  bundle_root: z
@@ -447,8 +990,28 @@ export function registerTools(server) {
447
990
  runsDir: runs_dir ?? 'mcplab/results/evaluation-runs'
448
991
  });
449
992
  const reportHtml = renderReport(results);
993
+ const allRuns = results.scenarios.flatMap((scenario) => scenario.runs);
994
+ const passedRuns = allRuns.filter((run) => run.pass === true).length;
995
+ const failedRuns = allRuns.filter((run) => run.pass === false).length;
996
+ const skippedRuns = Math.max(0, allRuns.length - passedRuns - failedRuns);
997
+ const durationMs = allRuns.reduce((sum, run) => {
998
+ const directRaw = run.duration_ms;
999
+ const direct = typeof directRaw === 'number' ? directRaw : null;
1000
+ if (direct !== null)
1001
+ return sum + Math.max(0, direct);
1002
+ const fromToolDurations = Array.isArray(run.tool_durations_ms)
1003
+ ? run.tool_durations_ms.reduce((runSum, value) => runSum + (typeof value === 'number' && Number.isFinite(value) ? value : 0), 0)
1004
+ : 0;
1005
+ return sum + Math.max(0, fromToolDurations);
1006
+ }, 0);
450
1007
  return ok(`MCPLab run completed: ${runDir}`, {
451
- runDir,
1008
+ run_dir: runDir,
1009
+ total_scenarios: results.summary.total_scenarios,
1010
+ total_runs: results.summary.total_runs,
1011
+ passed_runs: passedRuns,
1012
+ failed_runs: failedRuns,
1013
+ skipped_runs: skippedRuns,
1014
+ duration_ms: durationMs,
452
1015
  summary: results.summary,
453
1016
  metadata: results.metadata,
454
1017
  scenarios: results.scenarios.map((scenario) => ({
@@ -461,68 +1024,266 @@ export function registerTools(server) {
461
1024
  });
462
1025
  });
463
1026
  });
464
- server.registerTool('mcplab_list_runs', {
465
- description: 'List MCPLab run artifact directories and optionally summarize each run from results.json when present.',
1027
+ registerTool('mcplab_search_runs', {
1028
+ description: 'Search MCPLab run artifact directories with results.json summary metrics. Use the optional query parameter to filter by run_id, path, or summary fields (case-insensitive substring). Set include_summary=false to skip reading results.json for faster listing.',
1029
+ outputSchema: {
1030
+ runsDir: z.string(),
1031
+ query: z.string().optional(),
1032
+ total_matching: z.number().int().nonnegative(),
1033
+ runs: z.array(RunListEntrySchema)
1034
+ },
466
1035
  inputSchema: {
467
1036
  runs_dir: z
468
1037
  .string()
469
1038
  .optional()
470
1039
  .describe('Runs directory (default mcplab/results/evaluation-runs).'),
1040
+ query: z
1041
+ .string()
1042
+ .optional()
1043
+ .describe('Optional case-insensitive search query across run id/path and summary fields.'),
471
1044
  limit: z
472
1045
  .number()
473
1046
  .int()
474
1047
  .positive()
475
1048
  .max(100)
476
- .optional()
477
- .describe('Max runs to return (default 10).'),
1049
+ .default(10)
1050
+ .describe('Max runs to return. Defaults to 10.'),
478
1051
  include_summary: z
479
1052
  .boolean()
480
- .optional()
481
- .describe('Read results.json summary for each run when available.')
1053
+ .default(true)
1054
+ .describe('Read results.json summary for each run when available. Defaults to true.')
482
1055
  }
483
- }, async ({ runs_dir, limit, include_summary }) => {
1056
+ }, async ({ runs_dir, query, limit, include_summary }) => {
484
1057
  return withToolHandling(async () => {
485
1058
  const base = resolveRunsDir(runs_dir);
486
- const entries = listRunsWithFallback(base, limit ?? 10, Boolean(include_summary));
487
- return ok(`Found ${entries.length} run(s) in ${base}`, {
1059
+ const entries = listRunsWithFallback(base, undefined, include_summary);
1060
+ const searchQuery = String(query ?? '')
1061
+ .trim()
1062
+ .toLowerCase();
1063
+ const filtered = searchQuery
1064
+ ? entries.filter((entry) => searchableText(entry).includes(searchQuery))
1065
+ : entries;
1066
+ const capped = filtered.slice(0, limit);
1067
+ return ok(`Found ${filtered.length} run(s) in ${base}`, {
488
1068
  runsDir: base,
489
- runs: entries
1069
+ query: searchQuery || undefined,
1070
+ total_matching: filtered.length,
1071
+ runs: capped
1072
+ });
1073
+ });
1074
+ });
1075
+ registerTool('mcplab_aggregate_runs', {
1076
+ description: 'Aggregate metrics across historical MCPLab runs with compact summary-first output.',
1077
+ outputSchema: z.object({
1078
+ runs: z.array(z.object({
1079
+ run_id: z.string(),
1080
+ timestamp: z.string(),
1081
+ config_hash: z.string()
1082
+ })),
1083
+ group_by: z.enum(['run', 'scenario', 'agent']),
1084
+ filters: z
1085
+ .object({
1086
+ scenario_ids: z.array(z.string()).optional(),
1087
+ agents: z.array(z.string()).optional()
1088
+ })
1089
+ .optional(),
1090
+ summary: MetricSummarySchema.extend({
1091
+ selected_run_count: z.number().int().nonnegative()
1092
+ }),
1093
+ top_worst: z.array(AggregateRowSchema),
1094
+ top_best: z.array(AggregateRowSchema),
1095
+ details: z.array(AggregateRowSchema).optional()
1096
+ }),
1097
+ inputSchema: {
1098
+ runs_dir: z
1099
+ .string()
1100
+ .optional()
1101
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
1102
+ run_ids: z
1103
+ .array(z.string())
1104
+ .optional()
1105
+ .describe("Explicit run ids. If present, takes precedence over latest_n. Supports 'LATEST'."),
1106
+ latest_n: z
1107
+ .number()
1108
+ .int()
1109
+ .positive()
1110
+ .max(200)
1111
+ .optional()
1112
+ .describe('Number of latest runs to aggregate when run_ids are not provided (default 20).'),
1113
+ scenario_ids: z.array(z.string()).optional().describe('Optional scenario id filter.'),
1114
+ agents: z.array(z.string()).optional().describe('Optional agent filter.'),
1115
+ group_by: z
1116
+ .enum(['run', 'scenario', 'agent'])
1117
+ .optional()
1118
+ .describe('Grouping for row-level ranking (default run).'),
1119
+ top_n: z
1120
+ .number()
1121
+ .int()
1122
+ .positive()
1123
+ .max(50)
1124
+ .optional()
1125
+ .describe('Max rows for worst/best ranking output (default 10).'),
1126
+ include_details: z
1127
+ .boolean()
1128
+ .optional()
1129
+ .describe('Include full grouped rows. Defaults to false (summary-first).')
1130
+ }
1131
+ }, async ({ runs_dir, run_ids, latest_n, scenario_ids, agents, group_by, top_n, include_details }) => {
1132
+ return withToolHandling(async () => {
1133
+ const loaded = loadRunsForAnalysis({
1134
+ runsDirInput: runs_dir,
1135
+ runIds: run_ids,
1136
+ latestN: latest_n ?? 20
1137
+ });
1138
+ const report = buildAggregateRunsReport({
1139
+ runs: loaded,
1140
+ scenarioIds: scenario_ids,
1141
+ agents,
1142
+ groupBy: group_by ?? 'run',
1143
+ topN: top_n ?? 10,
1144
+ includeDetails: include_details ?? false
1145
+ });
1146
+ return ok(`Aggregated ${loaded.length} run(s)`, report);
1147
+ });
1148
+ });
1149
+ registerTool('mcplab_compare_runs', {
1150
+ description: 'Compare two MCPLab runs and surface compact deltas with regressions/improvements first.',
1151
+ outputSchema: z.object({
1152
+ left_run: z.object({
1153
+ run_id: z.string(),
1154
+ timestamp: z.string(),
1155
+ config_hash: z.string()
1156
+ }),
1157
+ right_run: z.object({
1158
+ run_id: z.string(),
1159
+ timestamp: z.string(),
1160
+ config_hash: z.string()
1161
+ }),
1162
+ filters: z
1163
+ .object({
1164
+ scenario_ids: z.array(z.string()).optional(),
1165
+ agents: z.array(z.string()).optional()
1166
+ })
1167
+ .optional(),
1168
+ summary: z.object({
1169
+ left: MetricSummarySchema,
1170
+ right: MetricSummarySchema,
1171
+ deltas: z.object({
1172
+ pass_rate: z.number(),
1173
+ failed_runs: z.number(),
1174
+ avg_tool_calls_per_run: z.number(),
1175
+ avg_tool_latency_ms: z.number().nullable()
1176
+ }),
1177
+ classification_counts: z.object({
1178
+ regressed: z.number().int().nonnegative(),
1179
+ improved: z.number().int().nonnegative(),
1180
+ unchanged: z.number().int().nonnegative(),
1181
+ new: z.number().int().nonnegative(),
1182
+ missing: z.number().int().nonnegative()
1183
+ })
1184
+ }),
1185
+ regressions: z.array(CompareRowSchema),
1186
+ improvements: z.array(CompareRowSchema),
1187
+ new_items: z.array(CompareRowSchema),
1188
+ missing_items: z.array(CompareRowSchema),
1189
+ details: z.array(CompareRowSchema).optional()
1190
+ }),
1191
+ inputSchema: {
1192
+ runs_dir: z
1193
+ .string()
1194
+ .optional()
1195
+ .describe('Runs directory (default mcplab/results/evaluation-runs).'),
1196
+ left_run_id: z.string().describe("Left run id or 'LATEST'."),
1197
+ right_run_id: z.string().describe("Right run id or 'LATEST'."),
1198
+ scenario_ids: z.array(z.string()).optional().describe('Optional scenario id filter.'),
1199
+ agents: z.array(z.string()).optional().describe('Optional agent filter.'),
1200
+ top_n: z
1201
+ .number()
1202
+ .int()
1203
+ .positive()
1204
+ .max(100)
1205
+ .optional()
1206
+ .describe('Max rows for regressions/improvements (default 20).'),
1207
+ include_details: z
1208
+ .boolean()
1209
+ .optional()
1210
+ .describe('Include full classification rows. Defaults to false (summary-first).')
1211
+ }
1212
+ }, async ({ runs_dir, left_run_id, right_run_id, scenario_ids, agents, top_n, include_details }) => {
1213
+ return withToolHandling(async () => {
1214
+ const base = resolveRunsDir(runs_dir);
1215
+ const left = loadSingleRunForAnalysis(base, left_run_id);
1216
+ const right = loadSingleRunForAnalysis(base, right_run_id);
1217
+ const report = buildCompareRunsReport({
1218
+ left,
1219
+ right,
1220
+ scenarioIds: scenario_ids,
1221
+ agents,
1222
+ topN: top_n ?? 20,
1223
+ includeDetails: include_details ?? false
490
1224
  });
1225
+ return ok(`Compared run ${left.run_id} against ${right.run_id}`, report);
491
1226
  });
492
1227
  });
493
- server.registerTool('mcplab_list_tool_analysis_results', {
494
- description: 'List saved MCP tool analysis reports persisted by the MCPLab app (default: mcplab/results/tool-analysis).',
1228
+ registerTool('mcplab_search_tool_analysis_results', {
1229
+ description: 'Search saved MCP tool analysis reports from mcplab/results/tool-analysis. Use the optional query parameter to filter by report_id, server name, agent name, or summary metadata (case-insensitive substring).',
1230
+ outputSchema: {
1231
+ tool_analysis_results_dir: z.string(),
1232
+ total: z.number().int().nonnegative(),
1233
+ items: z.array(ToolAnalysisListItemSchema)
1234
+ },
495
1235
  inputSchema: {
496
1236
  tool_analysis_results_dir: z
497
1237
  .string()
498
1238
  .optional()
499
1239
  .describe('Directory containing saved tool analysis report folders.'),
1240
+ query: z
1241
+ .string()
1242
+ .optional()
1243
+ .describe('Optional case-insensitive search query across report id/path/summary metadata.'),
500
1244
  limit: z
501
1245
  .number()
502
1246
  .int()
503
1247
  .positive()
504
1248
  .max(100)
505
- .optional()
506
- .describe('Max reports to return (default 20).')
1249
+ .default(20)
1250
+ .describe('Max reports to return. Defaults to 20.')
507
1251
  }
508
- }, async ({ tool_analysis_results_dir, limit }) => {
1252
+ }, async ({ tool_analysis_results_dir, query, limit }) => {
509
1253
  return withToolHandling(async () => {
510
1254
  const baseDir = resolveToolAnalysisResultsDir(tool_analysis_results_dir);
511
- const reports = listToolAnalysisReportsFromDiskWithFallback(baseDir, limit ?? 20);
512
- return ok(`Found ${reports.length} tool analysis report(s) in ${baseDir}`, {
1255
+ const reports = listToolAnalysisReportsFromDiskWithFallback(baseDir, undefined);
1256
+ const searchQuery = String(query ?? '')
1257
+ .trim()
1258
+ .toLowerCase();
1259
+ const filtered = searchQuery
1260
+ ? reports.filter((report) => searchableText(report).includes(searchQuery))
1261
+ : reports;
1262
+ const capped = filtered.slice(0, limit);
1263
+ return ok(`Found ${filtered.length} tool analysis report(s) in ${baseDir}`, {
513
1264
  tool_analysis_results_dir: baseDir,
514
- items: reports
1265
+ query: searchQuery || undefined,
1266
+ total: filtered.length,
1267
+ items: capped
515
1268
  });
516
1269
  });
517
1270
  });
518
- server.registerTool('mcplab_read_tool_analysis_result', {
1271
+ registerTool('mcplab_read_tool_analysis_result', {
519
1272
  description: 'Read a saved MCP tool analysis report record (report.json) by report id and return parsed metadata plus optional raw JSON preview.',
1273
+ outputSchema: z.object({
1274
+ path: z.string(),
1275
+ report_id: z.string(),
1276
+ truncated: z.boolean(),
1277
+ raw_json_preview: z.string(),
1278
+ summary: ToolAnalysisSummarySchema,
1279
+ record: GenericObjectSchema.optional()
1280
+ }),
520
1281
  inputSchema: {
521
1282
  report_id: z.string().describe("Report id directory name (or 'LATEST')."),
522
1283
  tool_analysis_results_dir: z
523
1284
  .string()
524
1285
  .optional()
525
- .describe('Directory containing saved tool analysis reports.'),
1286
+ .describe('Directory containing saved tool analysis reports (default mcplab/results/tool-analysis).'),
526
1287
  max_chars: z
527
1288
  .number()
528
1289
  .int()
@@ -562,32 +1323,83 @@ export function registerTools(server) {
562
1323
  return ok(`Read tool analysis report ${resolvedReportId}`, structured);
563
1324
  });
564
1325
  });
565
- server.registerTool('mcplab_delete_tool_analysis_result', {
1326
+ registerTool('mcplab_delete_tool_analysis_result', {
566
1327
  description: 'Delete a saved MCP tool analysis report directory by report id (from mcplab/results/tool-analysis by default).',
1328
+ outputSchema: {
1329
+ status: z.enum(['deleted', 'not_found', 'dry_run']),
1330
+ report_id: z.string(),
1331
+ path: z.string(),
1332
+ tool_analysis_results_dir: z.string(),
1333
+ existed: z.boolean(),
1334
+ deleted: z.boolean(),
1335
+ would_delete: z.boolean()
1336
+ },
567
1337
  inputSchema: {
568
1338
  report_id: z.string().describe('Report id directory name to delete.'),
569
1339
  tool_analysis_results_dir: z
570
1340
  .string()
571
1341
  .optional()
572
- .describe('Directory containing saved tool analysis reports.')
1342
+ .describe('Directory containing saved tool analysis reports (default mcplab/results/tool-analysis).'),
1343
+ dry_run: z
1344
+ .boolean()
1345
+ .optional()
1346
+ .describe('If true, return what would be deleted without deleting anything.'),
1347
+ confirm: z
1348
+ .boolean()
1349
+ .optional()
1350
+ .describe('Must be true to execute deletion when dry_run is false.')
573
1351
  }
574
- }, async ({ report_id, tool_analysis_results_dir }) => {
1352
+ }, async ({ report_id, tool_analysis_results_dir, dry_run, confirm }) => {
575
1353
  return withToolHandling(async () => {
576
1354
  const baseDir = resolveToolAnalysisResultsDir(tool_analysis_results_dir);
577
1355
  const dirPath = toolAnalysisReportDirPathWithFallback(baseDir, report_id.trim());
578
- if (!existsSync(dirPath)) {
579
- throw new Error(`Tool analysis report not found: ${dirPath}`);
1356
+ const existed = existsSync(dirPath);
1357
+ const isDryRun = Boolean(dry_run);
1358
+ if (isDryRun) {
1359
+ return ok(`Dry run for delete tool analysis report ${report_id}`, {
1360
+ status: 'dry_run',
1361
+ report_id: report_id.trim(),
1362
+ path: dirPath,
1363
+ tool_analysis_results_dir: baseDir,
1364
+ existed,
1365
+ deleted: false,
1366
+ would_delete: existed
1367
+ });
1368
+ }
1369
+ if (confirm !== true) {
1370
+ throw new Error('confirm=true is required to delete a tool analysis report');
1371
+ }
1372
+ if (!existed) {
1373
+ return ok(`Tool analysis report not found: ${report_id}`, {
1374
+ status: 'not_found',
1375
+ report_id: report_id.trim(),
1376
+ path: dirPath,
1377
+ tool_analysis_results_dir: baseDir,
1378
+ existed: false,
1379
+ deleted: false,
1380
+ would_delete: false
1381
+ });
580
1382
  }
581
1383
  rmSync(dirPath, { recursive: true, force: false });
582
1384
  return ok(`Deleted tool analysis report ${report_id}`, {
1385
+ status: 'deleted',
583
1386
  report_id: report_id.trim(),
584
1387
  path: dirPath,
585
- tool_analysis_results_dir: baseDir
1388
+ tool_analysis_results_dir: baseDir,
1389
+ existed: true,
1390
+ deleted: true,
1391
+ would_delete: true
586
1392
  });
587
1393
  });
588
1394
  });
589
- server.registerTool('mcplab_trace_list_events', {
1395
+ registerTool('mcplab_trace_list_events', {
590
1396
  description: 'List structured trace timeline items for a MCPLab run (flattened from scenario_run trace records) with optional type/scenario/agent filtering.',
1397
+ outputSchema: {
1398
+ run_id: z.string(),
1399
+ legacy_trace_detected: z.boolean().optional(),
1400
+ total_matching: z.number().int().nonnegative(),
1401
+ items: z.array(FlattenedTraceItemSchema)
1402
+ },
591
1403
  inputSchema: {
592
1404
  runs_dir: z
593
1405
  .string()
@@ -637,8 +1449,20 @@ export function registerTools(server) {
637
1449
  });
638
1450
  });
639
1451
  });
640
- server.registerTool('mcplab_trace_get_final_answers', {
1452
+ registerTool('mcplab_trace_get_final_answers', {
641
1453
  description: 'Extract final assistant answers from a run trace (scenario_run documents) for easy agent output comparison.',
1454
+ outputSchema: {
1455
+ run_id: z.string(),
1456
+ legacy_trace_detected: z.boolean().optional(),
1457
+ items: z.array(z.object({
1458
+ index: z.number().int().nonnegative(),
1459
+ scenario_id: z.string(),
1460
+ agent: z.string(),
1461
+ ts: z.string().optional(),
1462
+ truncated: z.boolean(),
1463
+ text: z.string()
1464
+ }))
1465
+ },
642
1466
  inputSchema: {
643
1467
  runs_dir: z
644
1468
  .string()
@@ -684,8 +1508,15 @@ export function registerTools(server) {
684
1508
  });
685
1509
  });
686
1510
  });
687
- server.registerTool('mcplab_trace_get_conversation', {
1511
+ registerTool('mcplab_trace_get_conversation', {
688
1512
  description: 'Return a structured conversation timeline (messages + tool blocks) for a specific scenario+agent in a scenario_run trace.',
1513
+ outputSchema: {
1514
+ run_id: z.string(),
1515
+ scenario_id: z.string(),
1516
+ agent: z.string(),
1517
+ legacy_trace_detected: z.boolean().optional(),
1518
+ timeline: z.array(ConversationTimelineItemSchema)
1519
+ },
689
1520
  inputSchema: {
690
1521
  runs_dir: z
691
1522
  .string()
@@ -726,8 +1557,14 @@ export function registerTools(server) {
726
1557
  });
727
1558
  });
728
1559
  });
729
- server.registerTool('mcplab_trace_search', {
1560
+ registerTool('mcplab_trace_search', {
730
1561
  description: 'Search scenario_run trace content for a text query and return matching message/block items.',
1562
+ outputSchema: {
1563
+ run_id: z.string(),
1564
+ query: z.string(),
1565
+ legacy_trace_detected: z.boolean().optional(),
1566
+ matches: z.array(FlattenedTraceItemSchema)
1567
+ },
731
1568
  inputSchema: {
732
1569
  runs_dir: z
733
1570
  .string()
@@ -776,8 +1613,24 @@ export function registerTools(server) {
776
1613
  });
777
1614
  });
778
1615
  });
779
- server.registerTool('mcplab_trace_stats', {
1616
+ registerTool('mcplab_trace_stats', {
780
1617
  description: 'Compute trace statistics for a run (message/block counts, tool usage, durations, and final-answer counts).',
1618
+ outputSchema: {
1619
+ run_id: z.string(),
1620
+ legacy_trace_detected: z.boolean().optional(),
1621
+ total_scenario_records: z.number().int().nonnegative(),
1622
+ message_role_counts: z.record(z.number()),
1623
+ block_type_counts: z.record(z.number()),
1624
+ scenario_agent_pairs: z.number().int().nonnegative(),
1625
+ tool_call_count: z.number().int().nonnegative(),
1626
+ tool_result_count: z.number().int().nonnegative(),
1627
+ final_answer_count: z.number().int().nonnegative(),
1628
+ avg_tool_result_duration_ms: z.number().nullable(),
1629
+ tool_usage: z.array(z.object({
1630
+ tool: z.string(),
1631
+ count: z.number().int().nonnegative()
1632
+ }))
1633
+ },
781
1634
  inputSchema: {
782
1635
  runs_dir: z
783
1636
  .string()
@@ -832,8 +1685,31 @@ export function registerTools(server) {
832
1685
  });
833
1686
  });
834
1687
  });
835
- server.registerTool('mcplab_read_run_artifact', {
1688
+ registerTool('mcplab_read_run_artifact', {
836
1689
  description: 'Read MCPLab run artifacts such as results.json, summary.md, trace.jsonl, resolved-config.yaml, or report.html.',
1690
+ outputSchema: {
1691
+ path: z.string(),
1692
+ run_id: z.string(),
1693
+ artifact: z.enum([
1694
+ 'results.json',
1695
+ 'summary.md',
1696
+ 'trace.jsonl',
1697
+ 'resolved-config.yaml',
1698
+ 'report.html'
1699
+ ]),
1700
+ line_range: z.string().optional(),
1701
+ truncated: z.boolean(),
1702
+ content: z.string(),
1703
+ summary: ResultsSummarySchema.optional(),
1704
+ metadata: ResultsMetadataSchema.optional(),
1705
+ scenarios: z
1706
+ .array(z.object({
1707
+ scenario_id: z.string(),
1708
+ agent: z.string(),
1709
+ pass_rate: z.number()
1710
+ }))
1711
+ .optional()
1712
+ },
837
1713
  inputSchema: {
838
1714
  runs_dir: z
839
1715
  .string()
@@ -917,8 +1793,32 @@ export function registerTools(server) {
917
1793
  return ok(`Read ${artifact} from run ${resolvedRunId}`, structured);
918
1794
  });
919
1795
  });
920
- server.registerTool('mcplab_grep_run_artifact', {
1796
+ registerTool('mcplab_grep_run_artifact', {
921
1797
  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.',
1798
+ outputSchema: {
1799
+ run_id: z.string(),
1800
+ artifact: z.enum([
1801
+ 'results.json',
1802
+ 'summary.md',
1803
+ 'trace.jsonl',
1804
+ 'resolved-config.yaml',
1805
+ 'report.html'
1806
+ ]),
1807
+ query: z.string(),
1808
+ total_lines: z.number().int().nonnegative(),
1809
+ match_count: z.number().int().nonnegative(),
1810
+ truncated_at_limit: z.boolean(),
1811
+ matches: z.array(z.object({
1812
+ match_line: z.number().int().positive(),
1813
+ context_start_line: z.number().int().positive(),
1814
+ context_end_line: z.number().int().positive(),
1815
+ lines: z.array(z.object({
1816
+ line: z.number().int().positive(),
1817
+ text: z.string(),
1818
+ is_match: z.boolean()
1819
+ }))
1820
+ }))
1821
+ },
922
1822
  inputSchema: {
923
1823
  runs_dir: z
924
1824
  .string()
@@ -1068,6 +1968,75 @@ export function registerPrompts(server) {
1068
1968
  };
1069
1969
  });
1070
1970
  }
1971
+ const DESTRUCTIVE_TOOLS = new Set([
1972
+ 'mcplab_delete_tool_analysis_result',
1973
+ 'mcplab_write_markdown_report',
1974
+ 'mcplab_run_eval'
1975
+ ]);
1976
+ const MUTATING_TOOLS = new Set(['mcplab_write_markdown_report', 'mcplab_run_eval']);
1977
+ const OPEN_WORLD_TOOLS = new Set(['mcplab_run_eval']);
1978
+ const PREFERRED_TOOL_TITLES = {
1979
+ mcplab_write_markdown_report: 'Write Markdown Report to Disk',
1980
+ mcplab_search_markdown_reports: 'Search Markdown Reports',
1981
+ mcplab_list_library: 'Search Library Entries',
1982
+ mcplab_generate_agent_entry: 'Generate MCPLab agents.yaml Entry',
1983
+ mcplab_search_runs: 'Search Evaluation Runs',
1984
+ mcplab_search_tool_analysis_results: 'Search Tool Analysis Results',
1985
+ mcplab_trace_search: 'Search Trace Events',
1986
+ mcplab_grep_run_artifact: 'Search Run Artifact Text'
1987
+ };
1988
+ function normalizeOptionalNonEmpty(value) {
1989
+ if (typeof value !== 'string')
1990
+ return undefined;
1991
+ const trimmed = value.trim();
1992
+ return trimmed.length > 0 ? trimmed : undefined;
1993
+ }
1994
+ function resolveToolTitle(toolName, explicitTitle, annotationTitle) {
1995
+ return (normalizeOptionalNonEmpty(explicitTitle) ??
1996
+ normalizeOptionalNonEmpty(annotationTitle) ??
1997
+ PREFERRED_TOOL_TITLES[toolName] ??
1998
+ humanizeToolName(toolName));
1999
+ }
2000
+ function inferToolAnnotations(toolName, resolvedTitle, override) {
2001
+ const readOnly = !MUTATING_TOOLS.has(toolName) && !DESTRUCTIVE_TOOLS.has(toolName);
2002
+ const openWorld = OPEN_WORLD_TOOLS.has(toolName);
2003
+ const title = normalizeOptionalNonEmpty(override?.title) ?? resolvedTitle;
2004
+ const baseHints = readOnly
2005
+ ? {
2006
+ title,
2007
+ readOnlyHint: true,
2008
+ idempotentHint: true,
2009
+ openWorldHint: openWorld
2010
+ }
2011
+ : DESTRUCTIVE_TOOLS.has(toolName)
2012
+ ? {
2013
+ title,
2014
+ readOnlyHint: false,
2015
+ destructiveHint: true,
2016
+ idempotentHint: false,
2017
+ openWorldHint: openWorld
2018
+ }
2019
+ : {
2020
+ title,
2021
+ readOnlyHint: false,
2022
+ destructiveHint: false,
2023
+ idempotentHint: false,
2024
+ openWorldHint: openWorld
2025
+ };
2026
+ return {
2027
+ ...baseHints,
2028
+ ...override,
2029
+ title
2030
+ };
2031
+ }
2032
+ function humanizeToolName(toolName) {
2033
+ const base = toolName.startsWith('mcplab_') ? toolName.slice('mcplab_'.length) : toolName;
2034
+ return base
2035
+ .split('_')
2036
+ .filter(Boolean)
2037
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
2038
+ .join(' ');
2039
+ }
1071
2040
  function resolveBundleRoot(bundleRoot) {
1072
2041
  if (bundleRoot?.trim())
1073
2042
  return resolve(bundleRoot);
@@ -1282,6 +2251,74 @@ function summarizeConfig(config) {
1282
2251
  }))
1283
2252
  };
1284
2253
  }
2254
+ function normalizeOptionalFilterSet(values) {
2255
+ if (!values || values.length === 0)
2256
+ return null;
2257
+ const normalized = values.map((value) => value.trim()).filter(Boolean);
2258
+ return normalized.length > 0 ? new Set(normalized) : null;
2259
+ }
2260
+ function filterScenarios(scenarios, scenarioFilter, agentFilter) {
2261
+ if (!scenarioFilter && !agentFilter)
2262
+ return scenarios;
2263
+ return scenarios.filter((scenario) => {
2264
+ const scenarioMatch = !scenarioFilter || scenarioFilter.has(scenario.scenario_id);
2265
+ const agentMatch = !agentFilter || agentFilter.has(scenario.agent);
2266
+ return scenarioMatch && agentMatch;
2267
+ });
2268
+ }
2269
+ function loadRunsForAnalysis(params) {
2270
+ const base = resolveRunsDir(params.runsDirInput);
2271
+ const ids = selectRunIdsForAnalysis(base, params.runIds, params.latestN);
2272
+ return ids.map((id) => loadSingleRunForAnalysis(base, id));
2273
+ }
2274
+ function loadSingleRunForAnalysis(primaryRunsDir, runIdInput) {
2275
+ const resolvedRunId = resolveRunIdToken(primaryRunsDir, runIdInput);
2276
+ const readBase = resolveExistingRunReadDir(primaryRunsDir, resolvedRunId);
2277
+ const runPath = join(readBase, resolvedRunId);
2278
+ const resultsPath = join(runPath, 'results.json');
2279
+ if (!existsSync(resultsPath)) {
2280
+ throw new Error(`results.json not found for run '${resolvedRunId}' at ${resultsPath}`);
2281
+ }
2282
+ const parsed = JSON.parse(readFileSync(resultsPath, 'utf8'));
2283
+ return {
2284
+ run_id: resolvedRunId,
2285
+ path: runPath,
2286
+ results: parsed
2287
+ };
2288
+ }
2289
+ function selectRunIdsForAnalysis(primaryRunsDir, runIds, latestN) {
2290
+ if (runIds && runIds.length > 0) {
2291
+ const out = [];
2292
+ const seen = new Set();
2293
+ for (const token of runIds) {
2294
+ const resolved = resolveRunIdToken(primaryRunsDir, token);
2295
+ if (!seen.has(resolved)) {
2296
+ out.push(resolved);
2297
+ seen.add(resolved);
2298
+ }
2299
+ }
2300
+ return out;
2301
+ }
2302
+ const discovered = listRunsWithFallback(primaryRunsDir, latestN, false)
2303
+ .map((entry) => String(entry.run_id ?? '').trim())
2304
+ .filter(Boolean);
2305
+ if (discovered.length === 0) {
2306
+ throw new Error(`No runs found in ${primaryRunsDir}`);
2307
+ }
2308
+ return discovered;
2309
+ }
2310
+ function resolveRunIdToken(primaryRunsDir, runIdInput) {
2311
+ const token = String(runIdInput ?? '').trim();
2312
+ if (!token)
2313
+ throw new Error('run id is required');
2314
+ if (token !== 'LATEST')
2315
+ return token;
2316
+ const latest = latestRunId(primaryRunsDir);
2317
+ if (!latest) {
2318
+ throw new Error(`No runs found in ${primaryRunsDir}`);
2319
+ }
2320
+ return latest;
2321
+ }
1285
2322
  function listRuns(runsDir, limit, includeSummary) {
1286
2323
  if (!existsSync(runsDir))
1287
2324
  return [];
@@ -1296,9 +2333,9 @@ function listRuns(runsDir, limit, includeSummary) {
1296
2333
  }
1297
2334
  })
1298
2335
  .sort()
1299
- .reverse()
1300
- .slice(0, limit);
1301
- return dirNames.map((runId) => {
2336
+ .reverse();
2337
+ const cappedDirNames = typeof limit === 'number' ? dirNames.slice(0, limit) : dirNames;
2338
+ return cappedDirNames.map((runId) => {
1302
2339
  const out = {
1303
2340
  run_id: runId,
1304
2341
  path: join(runsDir, runId)
@@ -1349,7 +2386,7 @@ function listRunsWithFallback(primaryRunsDir, limit, includeSummary) {
1349
2386
  }
1350
2387
  return Array.from(merged.values())
1351
2388
  .sort((a, b) => String(b.run_id ?? '').localeCompare(String(a.run_id ?? '')))
1352
- .slice(0, limit);
2389
+ .slice(0, typeof limit === 'number' ? limit : Number.MAX_SAFE_INTEGER);
1353
2390
  }
1354
2391
  function resolveExistingRunReadDir(primaryRunsDir, runId) {
1355
2392
  if (!runId)
@@ -1546,10 +2583,10 @@ function listToolAnalysisReportsFromDisk(baseDir, limit) {
1546
2583
  }
1547
2584
  })
1548
2585
  .sort()
1549
- .reverse()
1550
- .slice(0, limit);
2586
+ .reverse();
2587
+ const cappedIds = typeof limit === 'number' ? ids.slice(0, limit) : ids;
1551
2588
  const out = [];
1552
- for (const reportId of ids) {
2589
+ for (const reportId of cappedIds) {
1553
2590
  try {
1554
2591
  const filePath = toolAnalysisReportFilePath(baseDir, reportId);
1555
2592
  if (!existsSync(filePath))
@@ -1582,7 +2619,7 @@ function listToolAnalysisReportsFromDiskWithFallback(baseDir, limit) {
1582
2619
  }
1583
2620
  return Array.from(merged.values())
1584
2621
  .sort((a, b) => String(b.report_id ?? '').localeCompare(String(a.report_id ?? '')))
1585
- .slice(0, limit);
2622
+ .slice(0, typeof limit === 'number' ? limit : Number.MAX_SAFE_INTEGER);
1586
2623
  }
1587
2624
  function toolAnalysisReportDirPathWithFallback(baseDir, reportId) {
1588
2625
  for (const dir of toolAnalysisReadDirs(baseDir)) {
@@ -1836,6 +2873,35 @@ async function withToolHandling(fn) {
1836
2873
  function removeUndefined(value) {
1837
2874
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
1838
2875
  }
2876
+ function searchableText(value) {
2877
+ const tokens = [];
2878
+ const visit = (node) => {
2879
+ if (node === null || node === undefined)
2880
+ return;
2881
+ if (typeof node === 'string') {
2882
+ const trimmed = node.trim();
2883
+ if (trimmed)
2884
+ tokens.push(trimmed.toLowerCase());
2885
+ return;
2886
+ }
2887
+ if (typeof node === 'number' || typeof node === 'boolean') {
2888
+ tokens.push(String(node).toLowerCase());
2889
+ return;
2890
+ }
2891
+ if (Array.isArray(node)) {
2892
+ for (const item of node)
2893
+ visit(item);
2894
+ return;
2895
+ }
2896
+ if (typeof node === 'object') {
2897
+ for (const entryValue of Object.values(node)) {
2898
+ visit(entryValue);
2899
+ }
2900
+ }
2901
+ };
2902
+ visit(value);
2903
+ return tokens.join(' ');
2904
+ }
1839
2905
  function normalizeStringArray(values) {
1840
2906
  if (!values)
1841
2907
  return undefined;