@inspectr/mcplab-mcp-server 1.7.2 → 1.8.1

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
@@ -5,10 +5,11 @@ import { basename, dirname, extname, resolve, join, sep, relative } from 'node:p
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
7
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
8
- import { loadConfig, hashConfig, runAll, selectScenarios, loadOrBuildSearchIndex, indexNeedsRefresh, getResultsIndexPaths, resolveRunArtifactPath, searchDocs, getContext, applyRuntimeServerOverrides, readLibraryAgentsAndServers } from '@inspectr/mcplab-core';
8
+ import { loadConfig, hashConfig, runAll, selectScenarios, loadOrBuildSearchIndex, indexNeedsRefresh, getResultsIndexPaths, resolveRunArtifactPath, searchDocs, getContext, applyRuntimeServerOverrides, readLibraryAgentsAndServers, resolveScenarioLibraryDir, createEvaluationConfigFile, updateEvaluationConfigFile, readEvaluationConfigFile, createTestCaseFile, readTestCaseFile, updateTestCaseFile, buildScenarioEntry } 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
+ import { loadSkills, readSkillFile, registerSkills } from './skills.js';
12
13
  import { buildAggregateRunsReport, buildCompareRunsReport } from './mcp-run-calculations.js';
13
14
  const PACKAGE_JSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
14
15
  const SERVER_VERSION = typeof PACKAGE_JSON.version === 'string' && PACKAGE_JSON.version.trim().length > 0
@@ -59,12 +60,7 @@ const AggregateRowSchema = z.object({
59
60
  max: z.string()
60
61
  })
61
62
  .optional(),
62
- total_runs: z.number().int().nonnegative(),
63
- passed_runs: z.number().int().nonnegative(),
64
- failed_runs: z.number().int().nonnegative(),
65
- pass_rate: z.number(),
66
- avg_tool_calls_per_run: z.number(),
67
- avg_tool_latency_ms: z.number().nullable()
63
+ summary: MetricSummarySchema
68
64
  });
69
65
  const CompareRowSchema = z.object({
70
66
  key: z.string(),
@@ -122,8 +118,28 @@ const LibraryEntrySchema = z.object({
122
118
  bundleRoot: z.string(),
123
119
  servers: z.array(LibraryServerEntrySchema),
124
120
  agents: z.array(LibraryAgentEntrySchema),
121
+ test_cases: z.array(LibraryScenarioEntrySchema),
125
122
  scenarios: z.array(LibraryScenarioEntrySchema)
126
123
  });
124
+ const EvaluationConfigListItemSchema = z.object({
125
+ id: z.string(),
126
+ name: z.string(),
127
+ relative_path: z.string(),
128
+ config_path: z.string(),
129
+ suite_path: z.string(),
130
+ updated_at: z.string(),
131
+ scenario_count: z.number().int().nonnegative(),
132
+ agent_count: z.number().int().nonnegative(),
133
+ error: z.string().optional()
134
+ });
135
+ const EvaluationConfigListSchema = z.object({
136
+ evals_dir: z.string(),
137
+ total: z.number().int().nonnegative(),
138
+ matching: z.number().int().nonnegative(),
139
+ filters: GenericObjectSchema,
140
+ sort: z.object({ by: z.string(), direction: z.enum(['asc', 'desc']) }),
141
+ configs: z.array(EvaluationConfigListItemSchema)
142
+ });
127
143
  const ServerAuthSchema = z.union([
128
144
  z.object({
129
145
  type: z.literal('bearer'),
@@ -148,12 +164,148 @@ const ServerEntrySchema = z.object({
148
164
  url: z.string(),
149
165
  auth: ServerAuthSchema.optional()
150
166
  });
167
+ const ResponseAssertionSchema = z
168
+ .discriminatedUnion('type', [
169
+ z.object({
170
+ type: z.literal('regex').describe('Match the final response against a regular expression.'),
171
+ pattern: z.string().describe('JavaScript regex pattern; matching is case-insensitive.')
172
+ }),
173
+ z.object({
174
+ type: z.literal('contains').describe('Require literal text in the final response.'),
175
+ value: z.string().describe('Case-insensitive text that must appear.')
176
+ }),
177
+ z.object({
178
+ type: z
179
+ .literal('not_contains')
180
+ .describe('Require literal text to be absent from the final response.'),
181
+ value: z.string().describe('Case-insensitive text that must not appear.')
182
+ }),
183
+ z.object({
184
+ type: z
185
+ .literal('starts_with')
186
+ .describe('Require the final response to start with literal text.'),
187
+ value: z.string().describe('Case-insensitive prefix.')
188
+ }),
189
+ z.object({
190
+ type: z.literal('ends_with').describe('Require the final response to end with literal text.'),
191
+ value: z.string().describe('Case-insensitive suffix.')
192
+ }),
193
+ z.object({
194
+ type: z
195
+ .literal('equals')
196
+ .describe('Require the entire final response to equal literal text.'),
197
+ value: z.string().describe('Case-insensitive expected response.')
198
+ }),
199
+ z.object({
200
+ type: z.literal('jsonpath').describe('Inspect a value in a JSON final response.'),
201
+ path: z.string().describe('JSONPath expression.'),
202
+ equals: z
203
+ .union([z.string(), z.number(), z.boolean()])
204
+ .optional()
205
+ .describe('Optional primitive value that must match.')
206
+ }),
207
+ z.object({
208
+ type: z
209
+ .literal('jsonpath_exists')
210
+ .describe('Require a JSONPath to exist in a JSON final response.'),
211
+ path: z.string().describe('JSONPath expression.')
212
+ }),
213
+ z.object({
214
+ type: z
215
+ .literal('jsonpath_not_exists')
216
+ .describe('Require a JSONPath to be absent from a JSON final response.'),
217
+ path: z.string().describe('JSONPath expression.')
218
+ })
219
+ ])
220
+ .describe('Assertions evaluated against the final response text or JSON.');
221
+ const ToolInputAssertionSchema = z
222
+ .discriminatedUnion('type', [
223
+ z.object({
224
+ type: z.literal('contains').describe('Require text in serialized tool arguments.'),
225
+ tool: z.string().describe('Raw MCP tool name.'),
226
+ value: z.string().describe('Case-insensitive text that must appear in arguments.')
227
+ }),
228
+ z.object({
229
+ type: z.literal('regex').describe('Match serialized tool arguments with a regex.'),
230
+ tool: z.string().describe('Raw MCP tool name.'),
231
+ pattern: z.string().describe('JavaScript regex pattern.')
232
+ }),
233
+ z.object({
234
+ type: z.literal('jsonpath').describe('Inspect a value in tool arguments.'),
235
+ tool: z.string().describe('Raw MCP tool name.'),
236
+ path: z.string().describe('JSONPath expression over the tool arguments.'),
237
+ equals: z
238
+ .union([z.string(), z.number(), z.boolean()])
239
+ .optional()
240
+ .describe('Optional primitive value that must match.')
241
+ })
242
+ ])
243
+ .describe('Assertions evaluated against calls to a named MCP tool.');
244
+ const AgentAssertionSchema = z
245
+ .object({
246
+ label: z.string().describe('Stable name for the semantic check.'),
247
+ prompt: z.string().describe('Question the judge should answer about the scenario result.')
248
+ })
249
+ .describe('Semantic checks for requirements that are not reliably deterministic.');
250
+ const AgentContextSchema = z
251
+ .object({
252
+ include_prompt: z
253
+ .boolean()
254
+ .optional()
255
+ .describe('Include the scenario prompt in judge context.'),
256
+ include_tool_sequence: z
257
+ .boolean()
258
+ .optional()
259
+ .describe('Include called raw MCP tool names in judge context.'),
260
+ include_tool_inputs: z
261
+ .boolean()
262
+ .optional()
263
+ .describe('Include raw MCP tool arguments in judge context.')
264
+ })
265
+ .describe('Shared context sent to every agent assertion.');
266
+ const EvalRulesSchema = z
267
+ .object({
268
+ tool_constraints: z
269
+ .object({
270
+ required_tools: z
271
+ .array(z.string())
272
+ .optional()
273
+ .describe('Raw MCP tools that must be called.'),
274
+ forbidden_tools: z
275
+ .array(z.string())
276
+ .optional()
277
+ .describe('Raw MCP tools that must not be called.')
278
+ })
279
+ .strict()
280
+ .describe('Deterministic MCP tool-usage constraints.')
281
+ .optional(),
282
+ tool_sequence: z
283
+ .array(z.string())
284
+ .optional()
285
+ .describe('Flat ordered list of raw MCP tool names that must be called.'),
286
+ tool_input_assertions: z
287
+ .array(ToolInputAssertionSchema)
288
+ .optional()
289
+ .describe('Checks over MCP tool arguments.'),
290
+ response_assertions: z
291
+ .array(ResponseAssertionSchema)
292
+ .optional()
293
+ .describe('Checks over the final response.'),
294
+ agent_assertions: z
295
+ .array(AgentAssertionSchema)
296
+ .optional()
297
+ .describe('Semantic checks judged by an evaluation agent.'),
298
+ agent_context: AgentContextSchema.optional()
299
+ })
300
+ .strict()
301
+ .describe('Full MCPLab evaluation rules. Use this object for all supported assertion types.');
151
302
  const ScenarioEntrySchema = z.object({
152
303
  id: z.string(),
304
+ name: z.string().optional(),
153
305
  agent: z.string().optional(),
154
- servers: z.array(z.string()),
306
+ mcp_servers: z.array(z.object({ ref: z.string() })),
155
307
  prompt: z.string(),
156
- eval: GenericObjectSchema.optional(),
308
+ eval: EvalRulesSchema.optional(),
157
309
  extract: z
158
310
  .array(z.object({
159
311
  name: z.string(),
@@ -180,14 +332,7 @@ const ResolvedScenarioSchema = z.object({
180
332
  servers: z.array(z.string()),
181
333
  agent: z.string().optional(),
182
334
  prompt: z.string().optional(),
183
- eval: z
184
- .object({
185
- type: z.string(),
186
- assertions: z.array(z.string()).optional(),
187
- rubric: GenericObjectSchema.optional()
188
- })
189
- .passthrough()
190
- .optional(),
335
+ eval: GenericObjectSchema.optional(),
191
336
  extract: z
192
337
  .array(z
193
338
  .object({
@@ -532,6 +677,7 @@ export function createConfiguredServer() {
532
677
  websiteUrl: 'https://mcplab.inspectr.dev',
533
678
  icons: [{ src: SERVER_ICON_URL, mimeType: 'image/svg+xml' }]
534
679
  });
680
+ registerSkills(server);
535
681
  registerTools(server);
536
682
  registerPrompts(server);
537
683
  return server;
@@ -547,6 +693,79 @@ export function registerTools(server) {
547
693
  annotations: inferToolAnnotations(name, resolvedTitle, config.annotations)
548
694
  }, cb);
549
695
  };
696
+ registerTool('mcplab_build_app_link', {
697
+ description: 'Build a safe deep link to a local MCPLab app view.',
698
+ inputSchema: {
699
+ view: z.enum([
700
+ 'home',
701
+ 'evaluations',
702
+ 'run_evaluation',
703
+ 'results',
704
+ 'result_detail',
705
+ 'compare',
706
+ 'tool_analysis',
707
+ 'tool_analysis_results',
708
+ 'tool_analysis_result',
709
+ 'library_servers',
710
+ 'library_agents',
711
+ 'library_test_cases',
712
+ 'settings'
713
+ ]),
714
+ run_id: z.string().min(1).max(200).optional(),
715
+ tool_analysis_id: z.string().min(1).max(200).optional(),
716
+ config_id: z.string().min(1).max(200).optional(),
717
+ agent: z.string().min(1).max(200).optional(),
718
+ scenario: z.string().min(1).max(200).optional(),
719
+ time_filter: z.enum(['last', 'custom']).optional(),
720
+ time_preset: z.enum(['15min', '30min', '1h', '24h', '7d', '14d', '30d']).optional(),
721
+ time_start: z.string().datetime().optional(),
722
+ time_end: z.string().datetime().optional()
723
+ },
724
+ outputSchema: { url: z.string().url(), view: z.string(), path: z.string() }
725
+ }, async ({ view, run_id, tool_analysis_id, config_id, agent, scenario, time_filter, time_preset, time_start, time_end }) => withToolHandling(() => {
726
+ const paths = {
727
+ home: '/',
728
+ evaluations: '/evaluations',
729
+ run_evaluation: '/run',
730
+ results: '/results',
731
+ result_detail: '/results/detail',
732
+ compare: '/compare',
733
+ tool_analysis: '/tool-analysis',
734
+ tool_analysis_results: '/tool-analysis/results',
735
+ tool_analysis_result: '/tool-analysis/result',
736
+ library_servers: '/servers',
737
+ library_agents: '/agents',
738
+ library_test_cases: '/test-cases',
739
+ settings: '/settings'
740
+ };
741
+ const path = paths[view];
742
+ if (!path)
743
+ throw new Error(`Unsupported app view: ${view}`);
744
+ const url = new URL(path, process.env.MCPLAB_APP_URL ?? 'http://127.0.0.1:8787');
745
+ if (run_id)
746
+ url.searchParams.set('run', run_id);
747
+ if (tool_analysis_id)
748
+ url.searchParams.set('tool_analysis_id', tool_analysis_id);
749
+ if (config_id)
750
+ url.searchParams.set('config', config_id);
751
+ if (agent)
752
+ url.searchParams.set('agent', agent);
753
+ if (scenario)
754
+ url.searchParams.set('scenario', scenario);
755
+ if (time_filter)
756
+ url.searchParams.set('time_filter', time_filter);
757
+ if (time_preset)
758
+ url.searchParams.set('time_preset', time_preset);
759
+ if (time_start)
760
+ url.searchParams.set('time_start', time_start);
761
+ if (time_end)
762
+ url.searchParams.set('time_end', time_end);
763
+ if (view === 'result_detail' && !run_id)
764
+ throw new Error('run_id is required for result_detail');
765
+ if (view === 'tool_analysis_result' && !tool_analysis_id)
766
+ throw new Error('tool_analysis_id is required for tool_analysis_result');
767
+ return ok(`Built app link for ${view}`, { url: url.toString(), view, path });
768
+ }));
550
769
  registerTool('mcplab_write_markdown_report', {
551
770
  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.',
552
771
  outputSchema: WriteMarkdownReportOutputSchema,
@@ -688,7 +907,7 @@ export function registerTools(server) {
688
907
  }
689
908
  });
690
909
  registerTool('mcplab_search_markdown_reports', {
691
- description: 'List saved markdown reports under mcplab/reports. Supports filtering by run id substring to find reports linked to a result.',
910
+ description: 'Search saved Markdown report FILES under mcplab/reports. Use this to locate report documents by filename, path, run-id substring, or report text metadata; it does not search evaluation answers, traces, or tool-analysis records.',
692
911
  outputSchema: {
693
912
  reports_dir: z.string(),
694
913
  run_id_filter: z.string().optional(),
@@ -819,7 +1038,7 @@ export function registerTools(server) {
819
1038
  outputSchema: LibraryEntrySchema,
820
1039
  inputSchema: {
821
1040
  kind: z
822
- .enum(['all', 'servers', 'agents', 'scenarios'])
1041
+ .enum(['all', 'servers', 'agents', 'test_cases', 'scenarios'])
823
1042
  .optional()
824
1043
  .describe('Which library category to list. Defaults to all.'),
825
1044
  includeContent: z
@@ -838,6 +1057,7 @@ export function registerTools(server) {
838
1057
  bundleRoot: data.bundleRoot,
839
1058
  servers: selectedKind === 'servers' ? data.servers : [],
840
1059
  agents: selectedKind === 'agents' ? data.agents : [],
1060
+ test_cases: selectedKind === 'test_cases' ? data.test_cases : [],
841
1061
  scenarios: selectedKind === 'scenarios' ? data.scenarios : []
842
1062
  };
843
1063
  return ok(`Loaded MCPLab library from ${root}`, structured);
@@ -847,14 +1067,16 @@ export function registerTools(server) {
847
1067
  description: 'Get a specific reusable server, agent, or scenario definition from a MCPLab library bundle and return both structured data and YAML.',
848
1068
  outputSchema: {
849
1069
  bundleRoot: z.string(),
850
- kind: z.enum(['servers', 'agents', 'scenarios']),
1070
+ kind: z.enum(['servers', 'agents', 'test_cases', 'scenarios']),
851
1071
  id: z.string(),
852
1072
  file: z.string().optional(),
853
1073
  yaml: z.string(),
854
1074
  content: GenericObjectSchema
855
1075
  },
856
1076
  inputSchema: {
857
- kind: z.enum(['servers', 'agents', 'scenarios']).describe('Library category.'),
1077
+ kind: z
1078
+ .enum(['servers', 'agents', 'test_cases', 'scenarios'])
1079
+ .describe('Library category.'),
858
1080
  id: z.string().describe('Entry id (for scenarios this is scenario.id, not filename).')
859
1081
  }
860
1082
  }, async ({ kind, id }) => {
@@ -910,8 +1132,246 @@ export function registerTools(server) {
910
1132
  });
911
1133
  });
912
1134
  });
1135
+ registerTool('mcplab_create_test_case', {
1136
+ description: 'Create one reviewed MCPLab Test Case in mcplab/test-cases/. Put evaluation checks in the eval object for full support of response, tool-input, and agent assertions. Simple shorthand fields are supported for basic cases; do not provide a shorthand field together with its equivalent eval field. This is a scoped YAML write and refuses duplicate IDs.',
1137
+ outputSchema: { id: z.string(), path: z.string(), test_case: ScenarioEntrySchema },
1138
+ inputSchema: {
1139
+ id: z
1140
+ .string()
1141
+ .min(1)
1142
+ .describe('Stable Test Case id; letters, numbers, hyphens, and underscores.'),
1143
+ name: z
1144
+ .string()
1145
+ .optional()
1146
+ .describe('Human-readable Test Case label; defaults to id when blank.'),
1147
+ servers: z.array(z.string()).min(1).describe('MCP server ids available to this Test Case.'),
1148
+ prompt: z
1149
+ .string()
1150
+ .min(1)
1151
+ .max(4000)
1152
+ .describe('Task prompt executed by the evaluation agent.'),
1153
+ required_tools: z
1154
+ .array(z.string())
1155
+ .optional()
1156
+ .describe('Simple shorthand for eval.tool_constraints.required_tools. Use for basic checks only; put new or advanced rules under eval. Do not provide both.'),
1157
+ forbidden_tools: z
1158
+ .array(z.string())
1159
+ .optional()
1160
+ .describe('Simple shorthand for eval.tool_constraints.forbidden_tools. Use for basic checks only; put new or advanced rules under eval. Do not provide both.'),
1161
+ tool_sequence: z
1162
+ .array(z.string())
1163
+ .min(1)
1164
+ .optional()
1165
+ .describe('Simple shorthand for eval.tool_sequence: a flat ordered list of raw MCP tool names. Use eval for new or advanced rules. Do not provide both.'),
1166
+ extract_rules: z
1167
+ .array(z.object({
1168
+ name: z.string().describe('Extracted field name.'),
1169
+ regex: z.string().describe('Regex applied to final_text.')
1170
+ }))
1171
+ .optional()
1172
+ .describe('Extract values from the final response for trend analysis.'),
1173
+ response_regex_patterns: z
1174
+ .array(z.string())
1175
+ .optional()
1176
+ .describe('Simple shorthand for response regex checks. Use eval.response_assertions for new or advanced checks; choose type=contains for literal text. Do not provide both.'),
1177
+ eval: EvalRulesSchema.optional().describe('Full evaluation-rule object. Use this for new authoring or whenever you need more than one rule type; do not combine it with equivalent shorthand fields.')
1178
+ }
1179
+ }, async ({ id, name, servers, prompt, required_tools, forbidden_tools, tool_sequence, extract_rules, response_regex_patterns, eval: evalRules }) => withToolHandling(async () => {
1180
+ const bundleRoot = resolveBundleRoot();
1181
+ const library = readLibrary(bundleRoot, false);
1182
+ const created = createTestCaseFile({
1183
+ librariesDir: bundleRoot,
1184
+ knownServerIds: library.servers.map((item) => item.id),
1185
+ testCase: {
1186
+ id,
1187
+ name,
1188
+ servers,
1189
+ prompt,
1190
+ requiredTools: required_tools,
1191
+ forbiddenTools: forbidden_tools,
1192
+ toolSequence: tool_sequence,
1193
+ extractRules: extract_rules,
1194
+ responseRegexPatterns: response_regex_patterns,
1195
+ eval: evalRules
1196
+ }
1197
+ });
1198
+ return ok(`Created Test Case '${created.id}'`, {
1199
+ id: created.id,
1200
+ path: created.path,
1201
+ test_case: created.testCase
1202
+ });
1203
+ }));
1204
+ registerTool('mcplab_get_test_case', {
1205
+ description: 'Read one existing MCPLab test case, including its full parsed YAML content.',
1206
+ inputSchema: { file_path: z.string().describe('Path relative to mcplab/test-cases/.') },
1207
+ outputSchema: { path: z.string(), test_case: ScenarioEntrySchema }
1208
+ }, async ({ file_path }) => withToolHandling(() => {
1209
+ const result = readTestCaseFile({ librariesDir: resolveBundleRoot(), filePath: file_path });
1210
+ return ok(`Read Test Case '${result.id}'`, {
1211
+ path: result.path,
1212
+ test_case: result.testCase
1213
+ });
1214
+ }));
1215
+ registerTool('mcplab_update_test_case', {
1216
+ description: 'Replace one existing MCPLab test case in place. Put evaluation checks in the eval object for full support of response, tool-input, and agent assertions. Simple shorthand fields are supported for basic cases; do not provide a shorthand field together with its equivalent eval field. Requires confirm=true and never creates a new file.',
1217
+ inputSchema: {
1218
+ file_path: z
1219
+ .string()
1220
+ .describe('Path to an existing test case relative to mcplab/test-cases/.'),
1221
+ confirm: z
1222
+ .literal(true)
1223
+ .describe('Required confirmation that the existing file should be overwritten.'),
1224
+ id: z
1225
+ .string()
1226
+ .min(1)
1227
+ .describe('Stable Test Case id; letters, numbers, hyphens, and underscores.'),
1228
+ name: z
1229
+ .string()
1230
+ .optional()
1231
+ .describe('Human-readable Test Case label; defaults to id when blank.'),
1232
+ servers: z.array(z.string()).min(1).describe('MCP server ids available to this Test Case.'),
1233
+ prompt: z
1234
+ .string()
1235
+ .min(1)
1236
+ .max(4000)
1237
+ .describe('Task prompt executed by the evaluation agent.'),
1238
+ required_tools: z
1239
+ .array(z.string())
1240
+ .optional()
1241
+ .describe('Simple shorthand for eval.tool_constraints.required_tools. Use for basic checks only; put new or advanced rules under eval. Do not provide both.'),
1242
+ forbidden_tools: z
1243
+ .array(z.string())
1244
+ .optional()
1245
+ .describe('Simple shorthand for eval.tool_constraints.forbidden_tools. Use for basic checks only; put new or advanced rules under eval. Do not provide both.'),
1246
+ tool_sequence: z
1247
+ .array(z.string())
1248
+ .min(1)
1249
+ .optional()
1250
+ .describe('Simple shorthand for eval.tool_sequence: a flat ordered list of raw MCP tool names. Use eval for new or advanced rules. Do not provide both.'),
1251
+ extract_rules: z
1252
+ .array(z.object({
1253
+ name: z.string().describe('Extracted field name.'),
1254
+ regex: z.string().describe('Regex applied to final_text.')
1255
+ }))
1256
+ .optional()
1257
+ .describe('Extract values from the final response for trend analysis.'),
1258
+ response_regex_patterns: z
1259
+ .array(z.string())
1260
+ .optional()
1261
+ .describe('Simple shorthand for response regex checks. Use eval.response_assertions for new or advanced checks; choose type=contains for literal text. Do not provide both.'),
1262
+ eval: EvalRulesSchema.optional().describe('Full evaluation-rule object. Use this for new authoring or whenever you need more than one rule type; do not combine it with equivalent shorthand fields.')
1263
+ },
1264
+ outputSchema: { id: z.string(), path: z.string(), test_case: ScenarioEntrySchema }
1265
+ }, async ({ file_path, confirm: _confirm, id, name, servers, prompt, required_tools, forbidden_tools, tool_sequence, extract_rules, response_regex_patterns, eval: evalRules }) => withToolHandling(() => {
1266
+ const library = readLibrary(resolveBundleRoot(), false);
1267
+ const updated = updateTestCaseFile({
1268
+ librariesDir: resolveBundleRoot(),
1269
+ knownServerIds: library.servers.map((item) => item.id),
1270
+ filePath: file_path,
1271
+ testCase: {
1272
+ id,
1273
+ name,
1274
+ servers,
1275
+ prompt,
1276
+ requiredTools: required_tools,
1277
+ forbiddenTools: forbidden_tools,
1278
+ toolSequence: tool_sequence,
1279
+ extractRules: extract_rules,
1280
+ responseRegexPatterns: response_regex_patterns,
1281
+ eval: evalRules
1282
+ }
1283
+ });
1284
+ return ok(`Updated Test Case '${updated.id}'`, {
1285
+ id: updated.id,
1286
+ path: updated.path,
1287
+ test_case: updated.testCase
1288
+ });
1289
+ }));
1290
+ registerTool('mcplab_create_evaluation_config', {
1291
+ description: 'Create one reviewed MCPLab evaluation configuration in mcplab/evals/. This is a scoped YAML write with automatic filename normalization and collision handling. In scenario eval blocks, use the full evaluation rules for response, tool-input, and agent checks: tool_constraints, flat tool_sequence, response_assertions (contains, equals, regex, and JSONPath variants), tool_input_assertions, agent_assertions, and agent_context. Use contains for literal text; use regex only for patterns. Do not combine eval rules with equivalent shorthand fields.',
1292
+ outputSchema: {
1293
+ file_name: z.string(),
1294
+ path: z.string(),
1295
+ relative_path: z.string(),
1296
+ config: GenericObjectSchema
1297
+ },
1298
+ inputSchema: {
1299
+ file_name: z
1300
+ .string()
1301
+ .min(1)
1302
+ .describe('Destination YAML filename under mcplab/evals/. Extension is added if omitted.'),
1303
+ config: GenericObjectSchema.describe('Complete MCPLab evaluation configuration. Scenario eval blocks support tool_constraints, a flat tool_sequence, response_assertions (contains, not_contains, starts_with, ends_with, equals, regex, and JSONPath variants), tool_input_assertions, agent_assertions, and agent_context. Use contains for literal response text; use regex only when pattern matching is required.')
1304
+ }
1305
+ }, async ({ file_name, config }) => withToolHandling(async () => {
1306
+ const created = createEvaluationConfigFile({
1307
+ evalsDir: join(resolveBundleRoot(), 'evals'),
1308
+ fileName: file_name,
1309
+ config: config
1310
+ });
1311
+ return ok(`Created evaluation configuration '${created.fileName}'`, {
1312
+ file_name: created.fileName,
1313
+ path: created.path,
1314
+ relative_path: created.relativePath,
1315
+ config: created.config
1316
+ });
1317
+ }));
1318
+ registerTool('mcplab_get_evaluation_config', {
1319
+ description: 'Read one existing MCPLab evaluation config, including its full parsed YAML content. Returned scenario eval blocks may use full tool constraints, flat tool sequences, response/tool-input assertions, agent assertions, and agent context.',
1320
+ inputSchema: {
1321
+ config_path: z
1322
+ .string()
1323
+ .describe('Path to an existing eval YAML, relative to the MCPLab evals directory. Invalid, missing, or out-of-root paths return an error; the tool never silently coerces them.')
1324
+ },
1325
+ outputSchema: z.object({
1326
+ path: z.string(),
1327
+ relative_path: z.string(),
1328
+ config: GenericObjectSchema
1329
+ })
1330
+ }, async ({ config_path }) => withToolHandling(() => {
1331
+ const result = readEvaluationConfigFile({
1332
+ evalsDir: join(resolveBundleRoot(), 'evals'),
1333
+ filePath: config_path
1334
+ });
1335
+ return ok(`Read evaluation config '${result.relativePath}'`, {
1336
+ path: result.path,
1337
+ relative_path: result.relativePath,
1338
+ config: result.config
1339
+ });
1340
+ }));
1341
+ registerTool('mcplab_update_evaluation_config', {
1342
+ description: 'Replace one existing MCPLab evaluation config in place after normalization. Requires confirm=true; refuses missing or out-of-root paths and never creates a new file. In scenario eval blocks, use the full evaluation rules for response, tool-input, and agent checks: tool_constraints, flat tool_sequence, response_assertions (contains, equals, regex, and JSONPath variants), tool_input_assertions, agent_assertions, and agent_context. Use contains for literal text; use regex only for patterns. Do not combine eval rules with equivalent shorthand fields.',
1343
+ inputSchema: {
1344
+ config_path: z
1345
+ .string()
1346
+ .describe('Path to an existing eval YAML, relative to the MCPLab evals directory. Invalid, missing, or out-of-root paths return an error; the tool never silently coerces them.'),
1347
+ config: GenericObjectSchema.describe('Complete replacement evaluation configuration. Scenario eval blocks support tool_constraints, a flat tool_sequence, response_assertions (contains, not_contains, starts_with, ends_with, equals, regex, and JSONPath variants), tool_input_assertions, agent_assertions, and agent_context. Use contains for literal response text; use regex only when pattern matching is required.'),
1348
+ confirm: z
1349
+ .literal(true)
1350
+ .describe('Required confirmation that the existing file should be overwritten.')
1351
+ },
1352
+ outputSchema: {
1353
+ file_name: z.string(),
1354
+ path: z.string(),
1355
+ relative_path: z.string(),
1356
+ config: GenericObjectSchema,
1357
+ warnings: z.array(z.string())
1358
+ }
1359
+ }, async ({ config_path, config }) => withToolHandling(() => {
1360
+ const updated = updateEvaluationConfigFile({
1361
+ evalsDir: join(resolveBundleRoot(), 'evals'),
1362
+ filePath: config_path,
1363
+ config: config
1364
+ });
1365
+ return ok(`Updated evaluation config '${updated.relativePath}'`, {
1366
+ file_name: updated.fileName,
1367
+ path: updated.path,
1368
+ relative_path: updated.relativePath,
1369
+ config: updated.config,
1370
+ warnings: updated.warnings
1371
+ });
1372
+ }));
913
1373
  registerTool('mcplab_generate_scenario_entry', {
914
- description: 'Generate a MCPLab scenario YAML snippet with prompt, server links, and optional evaluation/extract rules. Optimized for scenario authoring workflows.',
1374
+ description: 'Generate a MCPLab scenario YAML snippet with prompt, server links, and optional evaluation/extract rules. Put checks in the eval object for full assertion support; simple shorthand fields are supported for basic cases and must not be combined with equivalent eval fields.',
915
1375
  outputSchema: {
916
1376
  scenario: ScenarioEntrySchema,
917
1377
  yaml: z.string(),
@@ -928,7 +1388,7 @@ export function registerTools(server) {
928
1388
  name: z
929
1389
  .string()
930
1390
  .optional()
931
- .describe('Optional human label used only to derive id when id is omitted.'),
1391
+ .describe('Optional human-readable scenario label. Also used to derive id when omitted.'),
932
1392
  agent: z
933
1393
  .string()
934
1394
  .optional()
@@ -936,22 +1396,30 @@ export function registerTools(server) {
936
1396
  servers: z
937
1397
  .array(z.string())
938
1398
  .min(1)
939
- .describe('One or more server ids available to the scenario.'),
1399
+ .describe('One or more server ids; emitted as scenario-owned mcp_servers refs.'),
940
1400
  prompt: z
941
1401
  .string()
942
1402
  .min(1)
943
1403
  .max(4000)
944
1404
  .describe('The task prompt the evaluation agent should execute (1-4000 chars).'),
945
- required_tools: z.array(z.string()).optional().describe('Tools that must be called.'),
946
- forbidden_tools: z.array(z.string()).optional().describe('Tools that must not be called.'),
947
- allowed_tool_sequences: z
948
- .array(z.array(z.string()).min(1))
1405
+ required_tools: z
1406
+ .array(z.string())
1407
+ .optional()
1408
+ .describe('Simple shorthand for eval.tool_constraints.required_tools. Use for basic checks only; put new or advanced rules under eval. Do not provide both.'),
1409
+ forbidden_tools: z
1410
+ .array(z.string())
949
1411
  .optional()
950
- .describe('Allowed tool call sequences (exact order groups).'),
1412
+ .describe('Simple shorthand for eval.tool_constraints.forbidden_tools. Use for basic checks only; put new or advanced rules under eval. Do not provide both.'),
1413
+ tool_sequence: z
1414
+ .array(z.string())
1415
+ .min(1)
1416
+ .optional()
1417
+ .describe('Simple shorthand for eval.tool_sequence: a flat ordered list of raw MCP tool names. Use eval for new or advanced rules. Do not provide both.'),
951
1418
  response_regex_patterns: z
952
1419
  .array(z.string())
953
1420
  .optional()
954
- .describe('Regex patterns that must match the final response text.'),
1421
+ .describe('Simple shorthand for response regex checks. Use eval.response_assertions for new or advanced checks; choose type=contains for literal text. Do not provide both.'),
1422
+ eval: EvalRulesSchema.optional().describe('Full evaluation-rule object. Use this for new authoring or whenever you need more than one rule type; do not combine it with equivalent shorthand fields.'),
955
1423
  extract_rules: z
956
1424
  .array(z.object({
957
1425
  name: z.string().describe('Extracted field name.'),
@@ -981,6 +1449,16 @@ export function registerTools(server) {
981
1449
  });
982
1450
  });
983
1451
  });
1452
+ registerTool('mcplab_list_evaluation_configs', {
1453
+ description: 'List MCPLab evaluation configurations with nested-suite filtering and deterministic sorting.',
1454
+ inputSchema: {
1455
+ suite: z.string().optional(),
1456
+ query: z.string().optional(),
1457
+ sort_by: z.enum(['name', 'scenarios', 'agents', 'updated_at']).optional(),
1458
+ sort_direction: z.enum(['asc', 'desc']).optional()
1459
+ },
1460
+ outputSchema: EvaluationConfigListSchema
1461
+ }, async ({ suite, query, sort_by, sort_direction }) => withToolHandling(() => ok('Listed evaluation configurations', listEvaluationConfigs({ suite, query, sortBy: sort_by, sortDirection: sort_direction }))));
984
1462
  registerTool('mcplab_validate_config', {
985
1463
  description: 'Validate and expand a MCPLab config file via mcplab-core loadConfig(), including server/agent/scenario library references.',
986
1464
  outputSchema: {
@@ -1021,7 +1499,7 @@ export function registerTools(server) {
1021
1499
  });
1022
1500
  });
1023
1501
  registerTool('mcplab_run_eval', {
1024
- description: 'Run a MCPLab evaluation using mcplab-core runAll() from a config file and return the run directory plus summary metrics.',
1502
+ description: 'Execute a MCPLab evaluation immediately in this MCP server process using mcplab-core runAll(). Use this when the caller needs results now; use mcplab_queue_run to hand the run to the APP queue instead.',
1025
1503
  outputSchema: {
1026
1504
  run_dir: z.string(),
1027
1505
  total_scenarios: z.number().int().nonnegative(),
@@ -1117,6 +1595,133 @@ export function registerTools(server) {
1117
1595
  });
1118
1596
  });
1119
1597
  });
1598
+ registerTool('mcplab_list_runs', {
1599
+ description: 'List MCPLab evaluation runs with optional inclusive ISO time bounds.',
1600
+ inputSchema: {
1601
+ since: z.string().datetime().optional(),
1602
+ until: z.string().datetime().optional(),
1603
+ limit: z.number().int().positive().max(200).optional()
1604
+ },
1605
+ outputSchema: {
1606
+ since: z.string().optional(),
1607
+ until: z.string().optional(),
1608
+ total_matching: z.number(),
1609
+ runs: z.array(z.object({
1610
+ run_id: z.string(),
1611
+ timestamp: z.string().optional(),
1612
+ config_hash: z.string().optional(),
1613
+ summary: z.any().optional()
1614
+ }))
1615
+ }
1616
+ }, async ({ since, until, limit }) => withToolHandling(() => {
1617
+ const sinceMs = since ? new Date(since).getTime() : undefined;
1618
+ const untilMs = until ? new Date(until).getTime() : undefined;
1619
+ if (sinceMs !== undefined && untilMs !== undefined && sinceMs > untilMs)
1620
+ throw new Error('since must be earlier than or equal to until');
1621
+ const matching = listRunsWithFallback(resolveRunsDir(), undefined, true).filter((entry) => {
1622
+ const timestamp = String(entry.metadata && typeof entry.metadata === 'object'
1623
+ ? entry.metadata.timestamp ?? ''
1624
+ : '');
1625
+ const time = new Date(timestamp).getTime();
1626
+ if (sinceMs === undefined && untilMs === undefined)
1627
+ return true;
1628
+ return (!Number.isNaN(time) &&
1629
+ (sinceMs === undefined || time >= sinceMs) &&
1630
+ (untilMs === undefined || time <= untilMs));
1631
+ });
1632
+ const runs = matching.slice(0, limit ?? 50).map((entry) => ({
1633
+ run_id: String(entry.run_id),
1634
+ timestamp: typeof entry.metadata?.timestamp === 'string'
1635
+ ? entry.metadata.timestamp
1636
+ : undefined,
1637
+ config_hash: typeof entry.metadata?.config_hash === 'string'
1638
+ ? entry.metadata.config_hash
1639
+ : undefined,
1640
+ summary: entry.summary
1641
+ }));
1642
+ return ok(`Found ${matching.length} run(s)`, {
1643
+ since,
1644
+ until,
1645
+ total_matching: matching.length,
1646
+ runs
1647
+ });
1648
+ }));
1649
+ registerTool('mcplab_queue_run', {
1650
+ description: 'Add a MCPLab evaluation to the local APP queue for the APP worker to execute later; this tool does not execute the evaluation or return results. Use mcplab_run_eval when results are needed immediately. Returns the queued job id, position, and APP /run URL.',
1651
+ inputSchema: {
1652
+ config_path: z
1653
+ .string()
1654
+ .describe('Path to the eval YAML. Relative paths resolve under the APP evals directory; absolute paths must remain inside that directory. Invalid, missing, or disallowed paths return an error—the tool never silently coerces them.'),
1655
+ scenario_id: z.string().optional().describe('Optional single scenario id to run.'),
1656
+ scenario_ids: z
1657
+ .array(z.string())
1658
+ .min(1)
1659
+ .optional()
1660
+ .describe('Optional scenario ids to run.'),
1661
+ agents: z.array(z.string()).min(1).optional().describe('Optional agent ids to use.'),
1662
+ runs_per_scenario: z
1663
+ .number()
1664
+ .int()
1665
+ .positive()
1666
+ .optional()
1667
+ .describe('Runs per scenario (default 1).'),
1668
+ run_note: z
1669
+ .string()
1670
+ .max(500)
1671
+ .optional()
1672
+ .describe('Optional note attached to the queued run.'),
1673
+ server_override_all: z
1674
+ .array(z.string())
1675
+ .min(1)
1676
+ .optional()
1677
+ .describe('Replace MCP servers for every selected scenario for this queued run.'),
1678
+ scenario_server_overrides: z
1679
+ .record(z.array(z.string()))
1680
+ .optional()
1681
+ .describe('Map scenario ids to replacement MCP server ids for this queued run.')
1682
+ },
1683
+ outputSchema: z.object({
1684
+ jobId: z.string().describe('APP queue job identifier.'),
1685
+ queued: z.boolean().optional().describe('Whether the APP accepted the job into its queue.'),
1686
+ position: z
1687
+ .number()
1688
+ .int()
1689
+ .nonnegative()
1690
+ .optional()
1691
+ .describe('Current position in the APP queue.'),
1692
+ queue_url: z.string().url().describe('URL of the APP run queue page.')
1693
+ })
1694
+ }, async (input) => withToolHandling(async () => {
1695
+ const appBaseUrl = process.env.MCPLAB_APP_URL ?? 'http://127.0.0.1:8787';
1696
+ const response = await fetch(new URL('/api/runs', appBaseUrl), {
1697
+ method: 'POST',
1698
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1699
+ body: JSON.stringify({
1700
+ configPath: input.config_path,
1701
+ runsPerScenario: input.runs_per_scenario ?? 1,
1702
+ ...(input.scenario_id ? { scenarioId: input.scenario_id } : {}),
1703
+ ...(input.scenario_ids ? { scenarioIds: input.scenario_ids } : {}),
1704
+ ...(input.agents ? { agents: input.agents } : {}),
1705
+ ...(input.run_note ? { runNote: input.run_note } : {}),
1706
+ ...(input.server_override_all ? { serverOverrideAll: input.server_override_all } : {}),
1707
+ ...(input.scenario_server_overrides
1708
+ ? { scenarioServerOverrides: input.scenario_server_overrides }
1709
+ : {})
1710
+ })
1711
+ });
1712
+ const payload = (await response.json().catch(() => ({})));
1713
+ if (!response.ok) {
1714
+ throw new Error(typeof payload.error === 'string'
1715
+ ? payload.error
1716
+ : `APP queue request failed with HTTP ${response.status}`);
1717
+ }
1718
+ if (typeof payload.jobId !== 'string')
1719
+ throw new Error('APP queue response did not include a jobId');
1720
+ return ok(`Queued MCPLab run ${payload.jobId}`, {
1721
+ ...payload,
1722
+ queue_url: new URL('/run', appBaseUrl).toString()
1723
+ });
1724
+ }));
1120
1725
  registerTool('mcplab_aggregate_runs', {
1121
1726
  description: 'Aggregate metrics across historical MCPLab runs with compact summary-first output.',
1122
1727
  outputSchema: z.object({
@@ -1262,7 +1867,7 @@ export function registerTools(server) {
1262
1867
  });
1263
1868
  });
1264
1869
  registerTool('mcplab_search_tool_analysis_results', {
1265
- 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).',
1870
+ description: 'Search saved MCP TOOL-ANALYSIS records under mcplab/results/tool-analysis. Use this for aggregate tool usage, latency, and tool-analysis summaries; do not use it for final answers, raw execution traces, or Markdown report files.',
1266
1871
  outputSchema: {
1267
1872
  tool_analysis_results_dir: z.string(),
1268
1873
  total: z.number().int().nonnegative(),
@@ -1525,7 +2130,7 @@ export function registerTools(server) {
1525
2130
  });
1526
2131
  });
1527
2132
  registerTool('mcplab_trace_get_conversation', {
1528
- description: 'Return a structured conversation timeline (messages + tool blocks) for a specific scenario+agent in a scenario_run trace.',
2133
+ description: 'Return one structured conversation timeline (messages and tool blocks) for a specific scenario and agent in a run. If you do not know the scenario_id and agent, call mcplab_trace_list_conversations first.',
1529
2134
  outputSchema: {
1530
2135
  run_id: z.string(),
1531
2136
  scenario_id: z.string(),
@@ -1557,6 +2162,10 @@ export function registerTools(server) {
1557
2162
  const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(run_id);
1558
2163
  const textMax = max_text_chars ?? 4000;
1559
2164
  const record = records.find((r) => r.scenario_id === scenario_id && r.agent === agent);
2165
+ if (!record) {
2166
+ const available = Array.from(new Set(records.map((r) => `${r.scenario_id} / ${r.agent}`)));
2167
+ throw new Error(`No conversation found for ${scenario_id} / ${agent}. Available conversations: ${available.join(', ') || 'none'}`);
2168
+ }
1560
2169
  const timeline = record
1561
2170
  ? buildConversationTimeline(record, textMax).slice(0, max_items ?? 300)
1562
2171
  : [];
@@ -1569,8 +2178,35 @@ export function registerTools(server) {
1569
2178
  });
1570
2179
  });
1571
2180
  });
2181
+ registerTool('mcplab_trace_list_conversations', {
2182
+ description: 'List the scenario and agent conversation targets available in one run. Use this before mcplab_trace_get_conversation when you need to discover the required scenario_id and agent values.',
2183
+ outputSchema: {
2184
+ run_id: z.string(),
2185
+ legacy_trace_detected: z.boolean().optional(),
2186
+ conversations: z.array(z.object({
2187
+ scenario_id: z.string(),
2188
+ agent: z.string(),
2189
+ timeline_item_count: z.number().int().nonnegative()
2190
+ }))
2191
+ },
2192
+ inputSchema: {
2193
+ run_id: z.string().describe("Run id directory name or 'LATEST'.")
2194
+ }
2195
+ }, async ({ run_id }) => withToolHandling(() => {
2196
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(run_id);
2197
+ const conversations = records.map((record) => ({
2198
+ scenario_id: record.scenario_id,
2199
+ agent: record.agent,
2200
+ timeline_item_count: buildConversationTimeline(record, 4000).length
2201
+ }));
2202
+ return ok(`Found ${conversations.length} conversation(s) in run ${runId}`, {
2203
+ run_id: runId,
2204
+ legacy_trace_detected: legacyDetected || undefined,
2205
+ conversations
2206
+ });
2207
+ }));
1572
2208
  registerTool('mcplab_trace_search', {
1573
- description: 'Search scenario_run trace content for a text query and return matching message/block items.',
2209
+ description: 'Search RAW SCENARIO-RUN TRACE events and message/block content for a text query. Use this for execution-level evidence such as tool calls, arguments, ordering, or intermediate messages; use mcplab_results_search for indexed result/answer content instead.',
1574
2210
  outputSchema: {
1575
2211
  run_id: z.string(),
1576
2212
  query: z.string().trim().min(1),
@@ -1821,7 +2457,7 @@ export function registerTools(server) {
1821
2457
  });
1822
2458
  });
1823
2459
  registerTool('mcplab_results_search', {
1824
- description: 'Search MCPLab run results in compact LLM-first format. Auto-refreshes index when artifacts changed.',
2460
+ description: 'Search INDEXED MCPLab evaluation RESULTS and final-answer content in compact LLM-first format. Use this for finding passed/failed scenarios, answer text, and result metadata; use mcplab_trace_search for raw execution events, mcplab_search_tool_analysis_results for aggregate tool-analysis records, and mcplab_search_markdown_reports for report files.',
1825
2461
  outputSchema: {
1826
2462
  query: z.string(),
1827
2463
  runs_dir: z.string(),
@@ -1872,7 +2508,7 @@ export function registerTools(server) {
1872
2508
  });
1873
2509
  });
1874
2510
  registerTool('mcplab_results_context', {
1875
- description: 'Fetch focused context for a scenario/run from results, trace, or summary. Returns bounded excerpts only. around is trace-line context and is only valid with source=trace (or when source is omitted).',
2511
+ description: 'Fetch bounded evidence for one run/scenario after a search hit. Use this to inspect a focused result, summary, or trace excerpt; it is not a broad search tool. For broad final-answer/result searches use mcplab_results_search, and for raw trace discovery use mcplab_trace_search.',
1876
2512
  outputSchema: {
1877
2513
  run_id: z.string(),
1878
2514
  scenario_id: z.string(),
@@ -1922,6 +2558,30 @@ export function registerTools(server) {
1922
2558
  });
1923
2559
  });
1924
2560
  });
2561
+ registerTool('mcplab_list_skills', {
2562
+ description: 'List MCPLab skills bundled with this MCP server.',
2563
+ inputSchema: {},
2564
+ outputSchema: {
2565
+ skills: z.array(z.object({ name: z.string(), description: z.string(), files: z.array(z.string()) }))
2566
+ }
2567
+ }, async () => withToolHandling(() => {
2568
+ const skills = loadSkills();
2569
+ return ok(`Found ${skills.length} MCPLab skill(s)`, {
2570
+ skills: skills.map(({ name, description, files }) => ({ name, description, files }))
2571
+ });
2572
+ }));
2573
+ registerTool('mcplab_get_skill', {
2574
+ description: 'Read one file from a bundled MCPLab skill. Defaults to SKILL.md.',
2575
+ inputSchema: { name: z.string(), file: z.string().optional() },
2576
+ outputSchema: { name: z.string(), file: z.string(), content: z.string() }
2577
+ }, async ({ name, file }) => withToolHandling(() => {
2578
+ const targetFile = file ?? 'SKILL.md';
2579
+ return ok(`Read ${name}/${targetFile}`, {
2580
+ name,
2581
+ file: targetFile,
2582
+ content: readSkillFile(name, targetFile)
2583
+ });
2584
+ }));
1925
2585
  }
1926
2586
  export function registerPrompts(server) {
1927
2587
  server.registerPrompt('mcplab-scenario-author', {
@@ -1988,17 +2648,33 @@ export function registerPrompts(server) {
1988
2648
  const DESTRUCTIVE_TOOLS = new Set([
1989
2649
  'mcplab_delete_tool_analysis_result',
1990
2650
  'mcplab_write_markdown_report',
1991
- 'mcplab_run_eval'
2651
+ 'mcplab_run_eval',
2652
+ 'mcplab_queue_run',
2653
+ 'mcplab_create_test_case',
2654
+ 'mcplab_update_test_case',
2655
+ 'mcplab_create_evaluation_config',
2656
+ 'mcplab_update_evaluation_config'
2657
+ ]);
2658
+ const MUTATING_TOOLS = new Set([
2659
+ 'mcplab_write_markdown_report',
2660
+ 'mcplab_run_eval',
2661
+ 'mcplab_queue_run',
2662
+ 'mcplab_create_test_case',
2663
+ 'mcplab_update_test_case',
2664
+ 'mcplab_create_evaluation_config',
2665
+ 'mcplab_update_evaluation_config'
1992
2666
  ]);
1993
- const MUTATING_TOOLS = new Set(['mcplab_write_markdown_report', 'mcplab_run_eval']);
1994
- const OPEN_WORLD_TOOLS = new Set(['mcplab_run_eval']);
2667
+ const OPEN_WORLD_TOOLS = new Set(['mcplab_run_eval', 'mcplab_queue_run']);
1995
2668
  const PREFERRED_TOOL_TITLES = {
1996
2669
  mcplab_write_markdown_report: 'Write Markdown Report to Disk',
1997
2670
  mcplab_search_markdown_reports: 'Search Markdown Reports',
1998
2671
  mcplab_list_library: 'Search Library Entries',
1999
2672
  mcplab_generate_agent_entry: 'Generate MCPLab agents.yaml Entry',
2000
2673
  mcplab_search_tool_analysis_results: 'Search Tool Analysis Results',
2001
- mcplab_trace_search: 'Search Trace Events'
2674
+ mcplab_trace_search: 'Search Raw Trace Events',
2675
+ mcplab_results_search: 'Search Indexed Evaluation Results',
2676
+ mcplab_results_context: 'Fetch Focused Result or Trace Context',
2677
+ mcplab_queue_run: 'Queue MCPLab Evaluation in APP'
2002
2678
  };
2003
2679
  function normalizeOptionalNonEmpty(value) {
2004
2680
  if (typeof value !== 'string')
@@ -2089,7 +2765,7 @@ function mergeLibraryServersAndAgents(config, bundleRoot) {
2089
2765
  function readLibrary(bundleRoot, includeContent) {
2090
2766
  const serversPath = join(bundleRoot, 'servers.yaml');
2091
2767
  const agentsPath = join(bundleRoot, 'agents.yaml');
2092
- const scenariosDir = join(bundleRoot, 'scenarios');
2768
+ const scenariosDir = resolveScenarioLibraryDir(bundleRoot).path;
2093
2769
  const servers = existsSync(serversPath)
2094
2770
  ? parseYaml(readFileSync(serversPath, 'utf8')) ?? {}
2095
2771
  : {};
@@ -2126,6 +2802,7 @@ function readLibrary(bundleRoot, includeContent) {
2126
2802
  id,
2127
2803
  ...(includeContent ? { entry: agents[id] } : {})
2128
2804
  })),
2805
+ test_cases: scenarioEntries,
2129
2806
  scenarios: scenarioEntries
2130
2807
  };
2131
2808
  return out;
@@ -2150,7 +2827,7 @@ function getLibraryItem(bundleRoot, kind, id) {
2150
2827
  content: entry
2151
2828
  };
2152
2829
  }
2153
- const dir = join(bundleRoot, 'scenarios');
2830
+ const dir = resolveScenarioLibraryDir(bundleRoot).path;
2154
2831
  if (!existsSync(dir)) {
2155
2832
  throw new Error(`Scenario library directory not found: ${dir}`);
2156
2833
  }
@@ -2172,6 +2849,93 @@ function getLibraryItem(bundleRoot, kind, id) {
2172
2849
  }
2173
2850
  throw new Error(`Scenario '${id}' not found in ${dir}`);
2174
2851
  }
2852
+ function listEvaluationConfigs(params) {
2853
+ const evalsDir = join(resolveBundleRoot(), 'evals');
2854
+ const files = [];
2855
+ const walk = (dir) => {
2856
+ if (!existsSync(dir))
2857
+ return;
2858
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2859
+ const path = join(dir, entry.name);
2860
+ if (entry.isDirectory())
2861
+ walk(path);
2862
+ else if (entry.isFile() && /\.ya?ml$/i.test(entry.name))
2863
+ files.push(path);
2864
+ }
2865
+ };
2866
+ walk(evalsDir);
2867
+ const all = files.map((path) => {
2868
+ const relativePath = relative(evalsDir, path).split(sep).join('/');
2869
+ const suitePath = dirname(relativePath) === '.' ? '' : dirname(relativePath).split(sep).join('/');
2870
+ const fallbackName = basename(path, extname(path));
2871
+ const stat = statSync(path);
2872
+ try {
2873
+ const parsed = parseYaml(readFileSync(path, 'utf8'));
2874
+ return {
2875
+ id: Buffer.from(relativePath, 'utf8').toString('base64url'),
2876
+ name: typeof parsed?.name === 'string' && parsed.name.trim()
2877
+ ? parsed.name.trim()
2878
+ : fallbackName,
2879
+ relative_path: relativePath,
2880
+ config_path: path,
2881
+ suite_path: suitePath,
2882
+ updated_at: stat.mtime.toISOString(),
2883
+ scenario_count: Array.isArray(parsed?.scenarios) ? parsed.scenarios.length : 0,
2884
+ agent_count: Array.isArray(parsed?.agents) ? parsed.agents.length : 0
2885
+ };
2886
+ }
2887
+ catch (error) {
2888
+ return {
2889
+ id: Buffer.from(relativePath, 'utf8').toString('base64url'),
2890
+ name: fallbackName,
2891
+ relative_path: relativePath,
2892
+ config_path: path,
2893
+ suite_path: suitePath,
2894
+ updated_at: stat.mtime.toISOString(),
2895
+ scenario_count: 0,
2896
+ agent_count: 0,
2897
+ error: error instanceof Error ? error.message : String(error)
2898
+ };
2899
+ }
2900
+ });
2901
+ const suite = params.suite?.trim().replace(/^suite:/, '');
2902
+ const query = params.query?.trim().toLowerCase();
2903
+ const matching = all.filter((config) => (!suite || config.suite_path === suite || config.suite_path.startsWith(`${suite}/`)) &&
2904
+ (!query ||
2905
+ `${config.name} ${config.relative_path} ${config.error ?? ''}`
2906
+ .toLowerCase()
2907
+ .includes(query)));
2908
+ const sortBy = params.sortBy ?? 'name';
2909
+ const direction = params.sortDirection ?? 'asc';
2910
+ matching.sort((a, b) => {
2911
+ const left = sortBy === 'scenarios'
2912
+ ? a.scenario_count
2913
+ : sortBy === 'agents'
2914
+ ? a.agent_count
2915
+ : sortBy === 'updated_at'
2916
+ ? Date.parse(a.updated_at)
2917
+ : a.name;
2918
+ const right = sortBy === 'scenarios'
2919
+ ? b.scenario_count
2920
+ : sortBy === 'agents'
2921
+ ? b.agent_count
2922
+ : sortBy === 'updated_at'
2923
+ ? Date.parse(b.updated_at)
2924
+ : b.name;
2925
+ const comparison = typeof left === 'number' && typeof right === 'number'
2926
+ ? left - right
2927
+ : String(left).localeCompare(String(right));
2928
+ return direction === 'asc' ? comparison : -comparison;
2929
+ });
2930
+ return {
2931
+ evals_dir: evalsDir,
2932
+ total: all.length,
2933
+ matching: matching.length,
2934
+ filters: removeUndefined({ suite, query }),
2935
+ sort: { by: sortBy, direction },
2936
+ configs: matching
2937
+ };
2938
+ }
2175
2939
  function buildServerEntry(input) {
2176
2940
  const transport = input.transport ?? 'http';
2177
2941
  const authType = input.auth_type ?? 'none';
@@ -2220,46 +2984,7 @@ function buildServerEntry(input) {
2220
2984
  };
2221
2985
  }
2222
2986
  function buildScenario(input) {
2223
- const id = input.id?.trim() || slugify(input.name?.trim() || input.prompt.slice(0, 40));
2224
- if (!id) {
2225
- throw new Error('Unable to derive scenario id. Provide id or name.');
2226
- }
2227
- const scenario = removeUndefined({
2228
- id,
2229
- agent: input.agent?.trim() || undefined,
2230
- servers: input.servers,
2231
- prompt: input.prompt,
2232
- eval: buildEvalRules(input),
2233
- extract: input.extract_rules?.map((rule) => ({
2234
- name: rule.name,
2235
- from: 'final_text',
2236
- regex: rule.regex
2237
- }))
2238
- });
2239
- return scenario;
2240
- }
2241
- function buildEvalRules(input) {
2242
- const toolConstraints = input.required_tools?.length || input.forbidden_tools?.length
2243
- ? removeUndefined({
2244
- required_tools: normalizeStringArray(input.required_tools),
2245
- forbidden_tools: normalizeStringArray(input.forbidden_tools)
2246
- })
2247
- : undefined;
2248
- const toolSequence = input.allowed_tool_sequences && input.allowed_tool_sequences.length > 0
2249
- ? { allow: input.allowed_tool_sequences }
2250
- : undefined;
2251
- const responseAssertions = input.response_regex_patterns && input.response_regex_patterns.length > 0
2252
- ? input.response_regex_patterns.map((pattern) => ({ type: 'regex', pattern }))
2253
- : undefined;
2254
- const evalRules = removeUndefined({
2255
- tool_constraints: toolConstraints,
2256
- tool_sequence: toolSequence,
2257
- response_assertions: responseAssertions
2258
- });
2259
- if (Object.keys(evalRules ?? {}).length === 0) {
2260
- return undefined;
2261
- }
2262
- return evalRules;
2987
+ return buildScenarioEntry(input);
2263
2988
  }
2264
2989
  function validateScenarioHeuristics(scenario) {
2265
2990
  const warnings = [];
@@ -2981,19 +3706,6 @@ function searchableText(value) {
2981
3706
  visit(value);
2982
3707
  return tokens.join(' ');
2983
3708
  }
2984
- function normalizeStringArray(values) {
2985
- if (!values)
2986
- return undefined;
2987
- const out = values.map((value) => value.trim()).filter(Boolean);
2988
- return out.length > 0 ? out : undefined;
2989
- }
2990
- function slugify(input) {
2991
- return input
2992
- .toLowerCase()
2993
- .replace(/[^a-z0-9]+/g, '-')
2994
- .replace(/^-+|-+$/g, '')
2995
- .slice(0, 80);
2996
- }
2997
3709
  function indentBlock(text, spaces) {
2998
3710
  const prefix = ' '.repeat(spaces);
2999
3711
  return text
@@ -3033,7 +3745,14 @@ async function handleHttpRequest(req, res, sessions, mcpPath) {
3033
3745
  await runtime.transport.handleRequest(req, res, body);
3034
3746
  return;
3035
3747
  }
3036
- if (!sessionId && isInitializeRequest(body)) {
3748
+ // Per the MCP Streamable HTTP transport, an unknown session indicates that
3749
+ // the server was restarted or the session expired. The client must receive
3750
+ // 404 so it can discard the stale session and initialize a new connection.
3751
+ if (sessionId) {
3752
+ sendPlain(res, 404, 'Not Found');
3753
+ return;
3754
+ }
3755
+ if (isInitializeRequest(body)) {
3037
3756
  let runtime;
3038
3757
  const transport = new StreamableHTTPServerTransport({
3039
3758
  sessionIdGenerator: () => randomUUID(),
@@ -3067,7 +3786,11 @@ async function handleHttpRequest(req, res, sessions, mcpPath) {
3067
3786
  }
3068
3787
  if (method === 'GET' || method === 'DELETE') {
3069
3788
  const sessionId = getSessionId(req);
3070
- if (!sessionId || !sessions.has(sessionId)) {
3789
+ if (sessionId && !sessions.has(sessionId)) {
3790
+ sendPlain(res, 404, 'Not Found');
3791
+ return;
3792
+ }
3793
+ if (!sessionId) {
3071
3794
  sendPlain(res, 400, 'Invalid or missing session ID');
3072
3795
  return;
3073
3796
  }