@inspectr/mcplab-mcp-server 1.7.1 → 1.8.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
@@ -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'),
@@ -150,8 +166,9 @@ const ServerEntrySchema = z.object({
150
166
  });
151
167
  const ScenarioEntrySchema = z.object({
152
168
  id: z.string(),
169
+ name: z.string().optional(),
153
170
  agent: z.string().optional(),
154
- servers: z.array(z.string()),
171
+ mcp_servers: z.array(z.object({ ref: z.string() })),
155
172
  prompt: z.string(),
156
173
  eval: GenericObjectSchema.optional(),
157
174
  extract: z
@@ -180,14 +197,7 @@ const ResolvedScenarioSchema = z.object({
180
197
  servers: z.array(z.string()),
181
198
  agent: z.string().optional(),
182
199
  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(),
200
+ eval: GenericObjectSchema.optional(),
191
201
  extract: z
192
202
  .array(z
193
203
  .object({
@@ -532,6 +542,7 @@ export function createConfiguredServer() {
532
542
  websiteUrl: 'https://mcplab.inspectr.dev',
533
543
  icons: [{ src: SERVER_ICON_URL, mimeType: 'image/svg+xml' }]
534
544
  });
545
+ registerSkills(server);
535
546
  registerTools(server);
536
547
  registerPrompts(server);
537
548
  return server;
@@ -547,6 +558,79 @@ export function registerTools(server) {
547
558
  annotations: inferToolAnnotations(name, resolvedTitle, config.annotations)
548
559
  }, cb);
549
560
  };
561
+ registerTool('mcplab_build_app_link', {
562
+ description: 'Build a safe deep link to a local MCPLab app view.',
563
+ inputSchema: {
564
+ view: z.enum([
565
+ 'home',
566
+ 'evaluations',
567
+ 'run_evaluation',
568
+ 'results',
569
+ 'result_detail',
570
+ 'compare',
571
+ 'tool_analysis',
572
+ 'tool_analysis_results',
573
+ 'tool_analysis_result',
574
+ 'library_servers',
575
+ 'library_agents',
576
+ 'library_test_cases',
577
+ 'settings'
578
+ ]),
579
+ run_id: z.string().min(1).max(200).optional(),
580
+ tool_analysis_id: z.string().min(1).max(200).optional(),
581
+ config_id: z.string().min(1).max(200).optional(),
582
+ agent: z.string().min(1).max(200).optional(),
583
+ scenario: z.string().min(1).max(200).optional(),
584
+ time_filter: z.enum(['last', 'custom']).optional(),
585
+ time_preset: z.enum(['15min', '30min', '1h', '24h', '7d', '14d', '30d']).optional(),
586
+ time_start: z.string().datetime().optional(),
587
+ time_end: z.string().datetime().optional()
588
+ },
589
+ outputSchema: { url: z.string().url(), view: z.string(), path: z.string() }
590
+ }, async ({ view, run_id, tool_analysis_id, config_id, agent, scenario, time_filter, time_preset, time_start, time_end }) => withToolHandling(() => {
591
+ const paths = {
592
+ home: '/',
593
+ evaluations: '/evaluations',
594
+ run_evaluation: '/run',
595
+ results: '/results',
596
+ result_detail: '/results/detail',
597
+ compare: '/compare',
598
+ tool_analysis: '/tool-analysis',
599
+ tool_analysis_results: '/tool-analysis/results',
600
+ tool_analysis_result: '/tool-analysis/result',
601
+ library_servers: '/servers',
602
+ library_agents: '/agents',
603
+ library_test_cases: '/test-cases',
604
+ settings: '/settings'
605
+ };
606
+ const path = paths[view];
607
+ if (!path)
608
+ throw new Error(`Unsupported app view: ${view}`);
609
+ const url = new URL(path, process.env.MCPLAB_APP_URL ?? 'http://127.0.0.1:8787');
610
+ if (run_id)
611
+ url.searchParams.set('run', run_id);
612
+ if (tool_analysis_id)
613
+ url.searchParams.set('tool_analysis_id', tool_analysis_id);
614
+ if (config_id)
615
+ url.searchParams.set('config', config_id);
616
+ if (agent)
617
+ url.searchParams.set('agent', agent);
618
+ if (scenario)
619
+ url.searchParams.set('scenario', scenario);
620
+ if (time_filter)
621
+ url.searchParams.set('time_filter', time_filter);
622
+ if (time_preset)
623
+ url.searchParams.set('time_preset', time_preset);
624
+ if (time_start)
625
+ url.searchParams.set('time_start', time_start);
626
+ if (time_end)
627
+ url.searchParams.set('time_end', time_end);
628
+ if (view === 'result_detail' && !run_id)
629
+ throw new Error('run_id is required for result_detail');
630
+ if (view === 'tool_analysis_result' && !tool_analysis_id)
631
+ throw new Error('tool_analysis_id is required for tool_analysis_result');
632
+ return ok(`Built app link for ${view}`, { url: url.toString(), view, path });
633
+ }));
550
634
  registerTool('mcplab_write_markdown_report', {
551
635
  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
636
  outputSchema: WriteMarkdownReportOutputSchema,
@@ -688,7 +772,7 @@ export function registerTools(server) {
688
772
  }
689
773
  });
690
774
  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.',
775
+ 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
776
  outputSchema: {
693
777
  reports_dir: z.string(),
694
778
  run_id_filter: z.string().optional(),
@@ -819,7 +903,7 @@ export function registerTools(server) {
819
903
  outputSchema: LibraryEntrySchema,
820
904
  inputSchema: {
821
905
  kind: z
822
- .enum(['all', 'servers', 'agents', 'scenarios'])
906
+ .enum(['all', 'servers', 'agents', 'test_cases', 'scenarios'])
823
907
  .optional()
824
908
  .describe('Which library category to list. Defaults to all.'),
825
909
  includeContent: z
@@ -838,6 +922,7 @@ export function registerTools(server) {
838
922
  bundleRoot: data.bundleRoot,
839
923
  servers: selectedKind === 'servers' ? data.servers : [],
840
924
  agents: selectedKind === 'agents' ? data.agents : [],
925
+ test_cases: selectedKind === 'test_cases' ? data.test_cases : [],
841
926
  scenarios: selectedKind === 'scenarios' ? data.scenarios : []
842
927
  };
843
928
  return ok(`Loaded MCPLab library from ${root}`, structured);
@@ -847,14 +932,16 @@ export function registerTools(server) {
847
932
  description: 'Get a specific reusable server, agent, or scenario definition from a MCPLab library bundle and return both structured data and YAML.',
848
933
  outputSchema: {
849
934
  bundleRoot: z.string(),
850
- kind: z.enum(['servers', 'agents', 'scenarios']),
935
+ kind: z.enum(['servers', 'agents', 'test_cases', 'scenarios']),
851
936
  id: z.string(),
852
937
  file: z.string().optional(),
853
938
  yaml: z.string(),
854
939
  content: GenericObjectSchema
855
940
  },
856
941
  inputSchema: {
857
- kind: z.enum(['servers', 'agents', 'scenarios']).describe('Library category.'),
942
+ kind: z
943
+ .enum(['servers', 'agents', 'test_cases', 'scenarios'])
944
+ .describe('Library category.'),
858
945
  id: z.string().describe('Entry id (for scenarios this is scenario.id, not filename).')
859
946
  }
860
947
  }, async ({ kind, id }) => {
@@ -910,6 +997,183 @@ export function registerTools(server) {
910
997
  });
911
998
  });
912
999
  });
1000
+ registerTool('mcplab_create_test_case', {
1001
+ description: 'Create one reviewed MCPLab Test Case in mcplab/test-cases/. This is a scoped YAML write and refuses duplicate IDs.',
1002
+ outputSchema: { id: z.string(), path: z.string(), test_case: ScenarioEntrySchema },
1003
+ inputSchema: {
1004
+ id: z.string().min(1),
1005
+ name: z.string().optional(),
1006
+ servers: z.array(z.string()).min(1),
1007
+ prompt: z.string().min(1).max(4000),
1008
+ required_tools: z.array(z.string()).optional(),
1009
+ forbidden_tools: z.array(z.string()).optional(),
1010
+ tool_sequence: z
1011
+ .array(z.string())
1012
+ .min(1)
1013
+ .optional()
1014
+ .describe('Required tool calls in order.'),
1015
+ extract_rules: z.array(z.object({ name: z.string(), regex: z.string() })).optional(),
1016
+ response_regex_patterns: z.array(z.string()).optional()
1017
+ }
1018
+ }, async ({ id, name, servers, prompt, required_tools, forbidden_tools, tool_sequence, extract_rules, response_regex_patterns }) => withToolHandling(async () => {
1019
+ const bundleRoot = resolveBundleRoot();
1020
+ const library = readLibrary(bundleRoot, false);
1021
+ const created = createTestCaseFile({
1022
+ librariesDir: bundleRoot,
1023
+ knownServerIds: library.servers.map((item) => item.id),
1024
+ testCase: {
1025
+ id,
1026
+ name,
1027
+ servers,
1028
+ prompt,
1029
+ requiredTools: required_tools,
1030
+ forbiddenTools: forbidden_tools,
1031
+ toolSequence: tool_sequence,
1032
+ extractRules: extract_rules,
1033
+ responseRegexPatterns: response_regex_patterns
1034
+ }
1035
+ });
1036
+ return ok(`Created Test Case '${created.id}'`, {
1037
+ id: created.id,
1038
+ path: created.path,
1039
+ test_case: created.testCase
1040
+ });
1041
+ }));
1042
+ registerTool('mcplab_get_test_case', {
1043
+ description: 'Read one existing MCPLab test case, including its full parsed YAML content.',
1044
+ inputSchema: { file_path: z.string().describe('Path relative to mcplab/test-cases/.') },
1045
+ outputSchema: { path: z.string(), test_case: ScenarioEntrySchema }
1046
+ }, async ({ file_path }) => withToolHandling(() => {
1047
+ const result = readTestCaseFile({ librariesDir: resolveBundleRoot(), filePath: file_path });
1048
+ return ok(`Read Test Case '${result.id}'`, {
1049
+ path: result.path,
1050
+ test_case: result.testCase
1051
+ });
1052
+ }));
1053
+ registerTool('mcplab_update_test_case', {
1054
+ description: 'Replace one existing MCPLab test case in place. Requires confirm=true and never creates a new file.',
1055
+ inputSchema: {
1056
+ file_path: z
1057
+ .string()
1058
+ .describe('Path to an existing test case relative to mcplab/test-cases/.'),
1059
+ confirm: z
1060
+ .literal(true)
1061
+ .describe('Required confirmation that the existing file should be overwritten.'),
1062
+ id: z.string().min(1),
1063
+ name: z.string().optional(),
1064
+ servers: z.array(z.string()).min(1),
1065
+ prompt: z.string().min(1).max(4000),
1066
+ required_tools: z.array(z.string()).optional(),
1067
+ forbidden_tools: z.array(z.string()).optional(),
1068
+ tool_sequence: z.array(z.string()).min(1).optional(),
1069
+ extract_rules: z.array(z.object({ name: z.string(), regex: z.string() })).optional(),
1070
+ response_regex_patterns: z.array(z.string()).optional()
1071
+ },
1072
+ outputSchema: { id: z.string(), path: z.string(), test_case: ScenarioEntrySchema }
1073
+ }, async ({ file_path, confirm: _confirm, id, name, servers, prompt, required_tools, forbidden_tools, tool_sequence, extract_rules, response_regex_patterns }) => withToolHandling(() => {
1074
+ const library = readLibrary(resolveBundleRoot(), false);
1075
+ const updated = updateTestCaseFile({
1076
+ librariesDir: resolveBundleRoot(),
1077
+ knownServerIds: library.servers.map((item) => item.id),
1078
+ filePath: file_path,
1079
+ testCase: {
1080
+ id,
1081
+ name,
1082
+ servers,
1083
+ prompt,
1084
+ requiredTools: required_tools,
1085
+ forbiddenTools: forbidden_tools,
1086
+ toolSequence: tool_sequence,
1087
+ extractRules: extract_rules,
1088
+ responseRegexPatterns: response_regex_patterns
1089
+ }
1090
+ });
1091
+ return ok(`Updated Test Case '${updated.id}'`, {
1092
+ id: updated.id,
1093
+ path: updated.path,
1094
+ test_case: updated.testCase
1095
+ });
1096
+ }));
1097
+ registerTool('mcplab_create_evaluation_config', {
1098
+ description: 'Create one reviewed MCPLab evaluation configuration in mcplab/evals/. This is a scoped YAML write with automatic filename normalization and collision handling.',
1099
+ outputSchema: {
1100
+ file_name: z.string(),
1101
+ path: z.string(),
1102
+ relative_path: z.string(),
1103
+ config: GenericObjectSchema
1104
+ },
1105
+ inputSchema: {
1106
+ file_name: z.string().min(1),
1107
+ config: GenericObjectSchema.describe('Complete MCPLab evaluation configuration.')
1108
+ }
1109
+ }, async ({ file_name, config }) => withToolHandling(async () => {
1110
+ const created = createEvaluationConfigFile({
1111
+ evalsDir: join(resolveBundleRoot(), 'evals'),
1112
+ fileName: file_name,
1113
+ config: config
1114
+ });
1115
+ return ok(`Created evaluation configuration '${created.fileName}'`, {
1116
+ file_name: created.fileName,
1117
+ path: created.path,
1118
+ relative_path: created.relativePath,
1119
+ config: created.config
1120
+ });
1121
+ }));
1122
+ registerTool('mcplab_get_evaluation_config', {
1123
+ description: 'Read one existing MCPLab evaluation config, including its full parsed YAML content.',
1124
+ inputSchema: {
1125
+ config_path: z
1126
+ .string()
1127
+ .describe('Path to an existing eval YAML, relative to the MCPLab evals directory.')
1128
+ },
1129
+ outputSchema: z.object({
1130
+ path: z.string(),
1131
+ relative_path: z.string(),
1132
+ config: GenericObjectSchema
1133
+ })
1134
+ }, async ({ config_path }) => withToolHandling(() => {
1135
+ const result = readEvaluationConfigFile({
1136
+ evalsDir: join(resolveBundleRoot(), 'evals'),
1137
+ filePath: config_path
1138
+ });
1139
+ return ok(`Read evaluation config '${result.relativePath}'`, {
1140
+ path: result.path,
1141
+ relative_path: result.relativePath,
1142
+ config: result.config
1143
+ });
1144
+ }));
1145
+ registerTool('mcplab_update_evaluation_config', {
1146
+ 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.',
1147
+ inputSchema: {
1148
+ config_path: z
1149
+ .string()
1150
+ .describe('Path to an existing eval YAML, relative to the MCPLab evals directory.'),
1151
+ config: GenericObjectSchema.describe('Complete replacement evaluation configuration.'),
1152
+ confirm: z
1153
+ .literal(true)
1154
+ .describe('Required confirmation that the existing file should be overwritten.')
1155
+ },
1156
+ outputSchema: {
1157
+ file_name: z.string(),
1158
+ path: z.string(),
1159
+ relative_path: z.string(),
1160
+ config: GenericObjectSchema,
1161
+ warnings: z.array(z.string())
1162
+ }
1163
+ }, async ({ config_path, config }) => withToolHandling(() => {
1164
+ const updated = updateEvaluationConfigFile({
1165
+ evalsDir: join(resolveBundleRoot(), 'evals'),
1166
+ filePath: config_path,
1167
+ config: config
1168
+ });
1169
+ return ok(`Updated evaluation config '${updated.relativePath}'`, {
1170
+ file_name: updated.fileName,
1171
+ path: updated.path,
1172
+ relative_path: updated.relativePath,
1173
+ config: updated.config,
1174
+ warnings: updated.warnings
1175
+ });
1176
+ }));
913
1177
  registerTool('mcplab_generate_scenario_entry', {
914
1178
  description: 'Generate a MCPLab scenario YAML snippet with prompt, server links, and optional evaluation/extract rules. Optimized for scenario authoring workflows.',
915
1179
  outputSchema: {
@@ -928,7 +1192,7 @@ export function registerTools(server) {
928
1192
  name: z
929
1193
  .string()
930
1194
  .optional()
931
- .describe('Optional human label used only to derive id when id is omitted.'),
1195
+ .describe('Optional human-readable scenario label. Also used to derive id when omitted.'),
932
1196
  agent: z
933
1197
  .string()
934
1198
  .optional()
@@ -936,7 +1200,7 @@ export function registerTools(server) {
936
1200
  servers: z
937
1201
  .array(z.string())
938
1202
  .min(1)
939
- .describe('One or more server ids available to the scenario.'),
1203
+ .describe('One or more server ids; emitted as scenario-owned mcp_servers refs.'),
940
1204
  prompt: z
941
1205
  .string()
942
1206
  .min(1)
@@ -944,10 +1208,11 @@ export function registerTools(server) {
944
1208
  .describe('The task prompt the evaluation agent should execute (1-4000 chars).'),
945
1209
  required_tools: z.array(z.string()).optional().describe('Tools that must be called.'),
946
1210
  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))
1211
+ tool_sequence: z
1212
+ .array(z.string())
1213
+ .min(1)
949
1214
  .optional()
950
- .describe('Allowed tool call sequences (exact order groups).'),
1215
+ .describe('Required tool calls in this exact order.'),
951
1216
  response_regex_patterns: z
952
1217
  .array(z.string())
953
1218
  .optional()
@@ -981,6 +1246,16 @@ export function registerTools(server) {
981
1246
  });
982
1247
  });
983
1248
  });
1249
+ registerTool('mcplab_list_evaluation_configs', {
1250
+ description: 'List MCPLab evaluation configurations with nested-suite filtering and deterministic sorting.',
1251
+ inputSchema: {
1252
+ suite: z.string().optional(),
1253
+ query: z.string().optional(),
1254
+ sort_by: z.enum(['name', 'scenarios', 'agents', 'updated_at']).optional(),
1255
+ sort_direction: z.enum(['asc', 'desc']).optional()
1256
+ },
1257
+ outputSchema: EvaluationConfigListSchema
1258
+ }, async ({ suite, query, sort_by, sort_direction }) => withToolHandling(() => ok('Listed evaluation configurations', listEvaluationConfigs({ suite, query, sortBy: sort_by, sortDirection: sort_direction }))));
984
1259
  registerTool('mcplab_validate_config', {
985
1260
  description: 'Validate and expand a MCPLab config file via mcplab-core loadConfig(), including server/agent/scenario library references.',
986
1261
  outputSchema: {
@@ -1021,7 +1296,7 @@ export function registerTools(server) {
1021
1296
  });
1022
1297
  });
1023
1298
  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.',
1299
+ 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
1300
  outputSchema: {
1026
1301
  run_dir: z.string(),
1027
1302
  total_scenarios: z.number().int().nonnegative(),
@@ -1117,6 +1392,133 @@ export function registerTools(server) {
1117
1392
  });
1118
1393
  });
1119
1394
  });
1395
+ registerTool('mcplab_list_runs', {
1396
+ description: 'List MCPLab evaluation runs with optional inclusive ISO time bounds.',
1397
+ inputSchema: {
1398
+ since: z.string().datetime().optional(),
1399
+ until: z.string().datetime().optional(),
1400
+ limit: z.number().int().positive().max(200).optional()
1401
+ },
1402
+ outputSchema: {
1403
+ since: z.string().optional(),
1404
+ until: z.string().optional(),
1405
+ total_matching: z.number(),
1406
+ runs: z.array(z.object({
1407
+ run_id: z.string(),
1408
+ timestamp: z.string().optional(),
1409
+ config_hash: z.string().optional(),
1410
+ summary: z.any().optional()
1411
+ }))
1412
+ }
1413
+ }, async ({ since, until, limit }) => withToolHandling(() => {
1414
+ const sinceMs = since ? new Date(since).getTime() : undefined;
1415
+ const untilMs = until ? new Date(until).getTime() : undefined;
1416
+ if (sinceMs !== undefined && untilMs !== undefined && sinceMs > untilMs)
1417
+ throw new Error('since must be earlier than or equal to until');
1418
+ const matching = listRunsWithFallback(resolveRunsDir(), undefined, true).filter((entry) => {
1419
+ const timestamp = String(entry.metadata && typeof entry.metadata === 'object'
1420
+ ? entry.metadata.timestamp ?? ''
1421
+ : '');
1422
+ const time = new Date(timestamp).getTime();
1423
+ if (sinceMs === undefined && untilMs === undefined)
1424
+ return true;
1425
+ return (!Number.isNaN(time) &&
1426
+ (sinceMs === undefined || time >= sinceMs) &&
1427
+ (untilMs === undefined || time <= untilMs));
1428
+ });
1429
+ const runs = matching.slice(0, limit ?? 50).map((entry) => ({
1430
+ run_id: String(entry.run_id),
1431
+ timestamp: typeof entry.metadata?.timestamp === 'string'
1432
+ ? entry.metadata.timestamp
1433
+ : undefined,
1434
+ config_hash: typeof entry.metadata?.config_hash === 'string'
1435
+ ? entry.metadata.config_hash
1436
+ : undefined,
1437
+ summary: entry.summary
1438
+ }));
1439
+ return ok(`Found ${matching.length} run(s)`, {
1440
+ since,
1441
+ until,
1442
+ total_matching: matching.length,
1443
+ runs
1444
+ });
1445
+ }));
1446
+ registerTool('mcplab_queue_run', {
1447
+ 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.',
1448
+ inputSchema: {
1449
+ config_path: z
1450
+ .string()
1451
+ .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.'),
1452
+ scenario_id: z.string().optional().describe('Optional single scenario id to run.'),
1453
+ scenario_ids: z
1454
+ .array(z.string())
1455
+ .min(1)
1456
+ .optional()
1457
+ .describe('Optional scenario ids to run.'),
1458
+ agents: z.array(z.string()).min(1).optional().describe('Optional agent ids to use.'),
1459
+ runs_per_scenario: z
1460
+ .number()
1461
+ .int()
1462
+ .positive()
1463
+ .optional()
1464
+ .describe('Runs per scenario (default 1).'),
1465
+ run_note: z
1466
+ .string()
1467
+ .max(500)
1468
+ .optional()
1469
+ .describe('Optional note attached to the queued run.'),
1470
+ server_override_all: z
1471
+ .array(z.string())
1472
+ .min(1)
1473
+ .optional()
1474
+ .describe('Replace MCP servers for every selected scenario for this queued run.'),
1475
+ scenario_server_overrides: z
1476
+ .record(z.array(z.string()))
1477
+ .optional()
1478
+ .describe('Map scenario ids to replacement MCP server ids for this queued run.')
1479
+ },
1480
+ outputSchema: z.object({
1481
+ jobId: z.string().describe('APP queue job identifier.'),
1482
+ queued: z.boolean().optional().describe('Whether the APP accepted the job into its queue.'),
1483
+ position: z
1484
+ .number()
1485
+ .int()
1486
+ .nonnegative()
1487
+ .optional()
1488
+ .describe('Current position in the APP queue.'),
1489
+ queue_url: z.string().url().describe('URL of the APP run queue page.')
1490
+ })
1491
+ }, async (input) => withToolHandling(async () => {
1492
+ const appBaseUrl = process.env.MCPLAB_APP_URL ?? 'http://127.0.0.1:8787';
1493
+ const response = await fetch(new URL('/api/runs', appBaseUrl), {
1494
+ method: 'POST',
1495
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1496
+ body: JSON.stringify({
1497
+ configPath: input.config_path,
1498
+ runsPerScenario: input.runs_per_scenario ?? 1,
1499
+ ...(input.scenario_id ? { scenarioId: input.scenario_id } : {}),
1500
+ ...(input.scenario_ids ? { scenarioIds: input.scenario_ids } : {}),
1501
+ ...(input.agents ? { agents: input.agents } : {}),
1502
+ ...(input.run_note ? { runNote: input.run_note } : {}),
1503
+ ...(input.server_override_all ? { serverOverrideAll: input.server_override_all } : {}),
1504
+ ...(input.scenario_server_overrides
1505
+ ? { scenarioServerOverrides: input.scenario_server_overrides }
1506
+ : {})
1507
+ })
1508
+ });
1509
+ const payload = (await response.json().catch(() => ({})));
1510
+ if (!response.ok) {
1511
+ throw new Error(typeof payload.error === 'string'
1512
+ ? payload.error
1513
+ : `APP queue request failed with HTTP ${response.status}`);
1514
+ }
1515
+ if (typeof payload.jobId !== 'string')
1516
+ throw new Error('APP queue response did not include a jobId');
1517
+ return ok(`Queued MCPLab run ${payload.jobId}`, {
1518
+ ...payload,
1519
+ queue_url: new URL('/run', appBaseUrl).toString()
1520
+ });
1521
+ }));
1120
1522
  registerTool('mcplab_aggregate_runs', {
1121
1523
  description: 'Aggregate metrics across historical MCPLab runs with compact summary-first output.',
1122
1524
  outputSchema: z.object({
@@ -1262,7 +1664,7 @@ export function registerTools(server) {
1262
1664
  });
1263
1665
  });
1264
1666
  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).',
1667
+ 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
1668
  outputSchema: {
1267
1669
  tool_analysis_results_dir: z.string(),
1268
1670
  total: z.number().int().nonnegative(),
@@ -1525,7 +1927,7 @@ export function registerTools(server) {
1525
1927
  });
1526
1928
  });
1527
1929
  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.',
1930
+ 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
1931
  outputSchema: {
1530
1932
  run_id: z.string(),
1531
1933
  scenario_id: z.string(),
@@ -1557,6 +1959,10 @@ export function registerTools(server) {
1557
1959
  const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(run_id);
1558
1960
  const textMax = max_text_chars ?? 4000;
1559
1961
  const record = records.find((r) => r.scenario_id === scenario_id && r.agent === agent);
1962
+ if (!record) {
1963
+ const available = Array.from(new Set(records.map((r) => `${r.scenario_id} / ${r.agent}`)));
1964
+ throw new Error(`No conversation found for ${scenario_id} / ${agent}. Available conversations: ${available.join(', ') || 'none'}`);
1965
+ }
1560
1966
  const timeline = record
1561
1967
  ? buildConversationTimeline(record, textMax).slice(0, max_items ?? 300)
1562
1968
  : [];
@@ -1569,8 +1975,35 @@ export function registerTools(server) {
1569
1975
  });
1570
1976
  });
1571
1977
  });
1978
+ registerTool('mcplab_trace_list_conversations', {
1979
+ 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.',
1980
+ outputSchema: {
1981
+ run_id: z.string(),
1982
+ legacy_trace_detected: z.boolean().optional(),
1983
+ conversations: z.array(z.object({
1984
+ scenario_id: z.string(),
1985
+ agent: z.string(),
1986
+ timeline_item_count: z.number().int().nonnegative()
1987
+ }))
1988
+ },
1989
+ inputSchema: {
1990
+ run_id: z.string().describe("Run id directory name or 'LATEST'.")
1991
+ }
1992
+ }, async ({ run_id }) => withToolHandling(() => {
1993
+ const { runId, records, legacyDetected } = readScenarioRunTraceRecordsForRun(run_id);
1994
+ const conversations = records.map((record) => ({
1995
+ scenario_id: record.scenario_id,
1996
+ agent: record.agent,
1997
+ timeline_item_count: buildConversationTimeline(record, 4000).length
1998
+ }));
1999
+ return ok(`Found ${conversations.length} conversation(s) in run ${runId}`, {
2000
+ run_id: runId,
2001
+ legacy_trace_detected: legacyDetected || undefined,
2002
+ conversations
2003
+ });
2004
+ }));
1572
2005
  registerTool('mcplab_trace_search', {
1573
- description: 'Search scenario_run trace content for a text query and return matching message/block items.',
2006
+ 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
2007
  outputSchema: {
1575
2008
  run_id: z.string(),
1576
2009
  query: z.string().trim().min(1),
@@ -1821,7 +2254,7 @@ export function registerTools(server) {
1821
2254
  });
1822
2255
  });
1823
2256
  registerTool('mcplab_results_search', {
1824
- description: 'Search MCPLab run results in compact LLM-first format. Auto-refreshes index when artifacts changed.',
2257
+ 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
2258
  outputSchema: {
1826
2259
  query: z.string(),
1827
2260
  runs_dir: z.string(),
@@ -1872,7 +2305,7 @@ export function registerTools(server) {
1872
2305
  });
1873
2306
  });
1874
2307
  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).',
2308
+ 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
2309
  outputSchema: {
1877
2310
  run_id: z.string(),
1878
2311
  scenario_id: z.string(),
@@ -1922,6 +2355,30 @@ export function registerTools(server) {
1922
2355
  });
1923
2356
  });
1924
2357
  });
2358
+ registerTool('mcplab_list_skills', {
2359
+ description: 'List MCPLab skills bundled with this MCP server.',
2360
+ inputSchema: {},
2361
+ outputSchema: {
2362
+ skills: z.array(z.object({ name: z.string(), description: z.string(), files: z.array(z.string()) }))
2363
+ }
2364
+ }, async () => withToolHandling(() => {
2365
+ const skills = loadSkills();
2366
+ return ok(`Found ${skills.length} MCPLab skill(s)`, {
2367
+ skills: skills.map(({ name, description, files }) => ({ name, description, files }))
2368
+ });
2369
+ }));
2370
+ registerTool('mcplab_get_skill', {
2371
+ description: 'Read one file from a bundled MCPLab skill. Defaults to SKILL.md.',
2372
+ inputSchema: { name: z.string(), file: z.string().optional() },
2373
+ outputSchema: { name: z.string(), file: z.string(), content: z.string() }
2374
+ }, async ({ name, file }) => withToolHandling(() => {
2375
+ const targetFile = file ?? 'SKILL.md';
2376
+ return ok(`Read ${name}/${targetFile}`, {
2377
+ name,
2378
+ file: targetFile,
2379
+ content: readSkillFile(name, targetFile)
2380
+ });
2381
+ }));
1925
2382
  }
1926
2383
  export function registerPrompts(server) {
1927
2384
  server.registerPrompt('mcplab-scenario-author', {
@@ -1988,17 +2445,33 @@ export function registerPrompts(server) {
1988
2445
  const DESTRUCTIVE_TOOLS = new Set([
1989
2446
  'mcplab_delete_tool_analysis_result',
1990
2447
  'mcplab_write_markdown_report',
1991
- 'mcplab_run_eval'
2448
+ 'mcplab_run_eval',
2449
+ 'mcplab_queue_run',
2450
+ 'mcplab_create_test_case',
2451
+ 'mcplab_update_test_case',
2452
+ 'mcplab_create_evaluation_config',
2453
+ 'mcplab_update_evaluation_config'
1992
2454
  ]);
1993
- const MUTATING_TOOLS = new Set(['mcplab_write_markdown_report', 'mcplab_run_eval']);
1994
- const OPEN_WORLD_TOOLS = new Set(['mcplab_run_eval']);
2455
+ const MUTATING_TOOLS = new Set([
2456
+ 'mcplab_write_markdown_report',
2457
+ 'mcplab_run_eval',
2458
+ 'mcplab_queue_run',
2459
+ 'mcplab_create_test_case',
2460
+ 'mcplab_update_test_case',
2461
+ 'mcplab_create_evaluation_config',
2462
+ 'mcplab_update_evaluation_config'
2463
+ ]);
2464
+ const OPEN_WORLD_TOOLS = new Set(['mcplab_run_eval', 'mcplab_queue_run']);
1995
2465
  const PREFERRED_TOOL_TITLES = {
1996
2466
  mcplab_write_markdown_report: 'Write Markdown Report to Disk',
1997
2467
  mcplab_search_markdown_reports: 'Search Markdown Reports',
1998
2468
  mcplab_list_library: 'Search Library Entries',
1999
2469
  mcplab_generate_agent_entry: 'Generate MCPLab agents.yaml Entry',
2000
2470
  mcplab_search_tool_analysis_results: 'Search Tool Analysis Results',
2001
- mcplab_trace_search: 'Search Trace Events'
2471
+ mcplab_trace_search: 'Search Raw Trace Events',
2472
+ mcplab_results_search: 'Search Indexed Evaluation Results',
2473
+ mcplab_results_context: 'Fetch Focused Result or Trace Context',
2474
+ mcplab_queue_run: 'Queue MCPLab Evaluation in APP'
2002
2475
  };
2003
2476
  function normalizeOptionalNonEmpty(value) {
2004
2477
  if (typeof value !== 'string')
@@ -2089,7 +2562,7 @@ function mergeLibraryServersAndAgents(config, bundleRoot) {
2089
2562
  function readLibrary(bundleRoot, includeContent) {
2090
2563
  const serversPath = join(bundleRoot, 'servers.yaml');
2091
2564
  const agentsPath = join(bundleRoot, 'agents.yaml');
2092
- const scenariosDir = join(bundleRoot, 'scenarios');
2565
+ const scenariosDir = resolveScenarioLibraryDir(bundleRoot).path;
2093
2566
  const servers = existsSync(serversPath)
2094
2567
  ? parseYaml(readFileSync(serversPath, 'utf8')) ?? {}
2095
2568
  : {};
@@ -2126,6 +2599,7 @@ function readLibrary(bundleRoot, includeContent) {
2126
2599
  id,
2127
2600
  ...(includeContent ? { entry: agents[id] } : {})
2128
2601
  })),
2602
+ test_cases: scenarioEntries,
2129
2603
  scenarios: scenarioEntries
2130
2604
  };
2131
2605
  return out;
@@ -2150,7 +2624,7 @@ function getLibraryItem(bundleRoot, kind, id) {
2150
2624
  content: entry
2151
2625
  };
2152
2626
  }
2153
- const dir = join(bundleRoot, 'scenarios');
2627
+ const dir = resolveScenarioLibraryDir(bundleRoot).path;
2154
2628
  if (!existsSync(dir)) {
2155
2629
  throw new Error(`Scenario library directory not found: ${dir}`);
2156
2630
  }
@@ -2172,6 +2646,93 @@ function getLibraryItem(bundleRoot, kind, id) {
2172
2646
  }
2173
2647
  throw new Error(`Scenario '${id}' not found in ${dir}`);
2174
2648
  }
2649
+ function listEvaluationConfigs(params) {
2650
+ const evalsDir = join(resolveBundleRoot(), 'evals');
2651
+ const files = [];
2652
+ const walk = (dir) => {
2653
+ if (!existsSync(dir))
2654
+ return;
2655
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2656
+ const path = join(dir, entry.name);
2657
+ if (entry.isDirectory())
2658
+ walk(path);
2659
+ else if (entry.isFile() && /\.ya?ml$/i.test(entry.name))
2660
+ files.push(path);
2661
+ }
2662
+ };
2663
+ walk(evalsDir);
2664
+ const all = files.map((path) => {
2665
+ const relativePath = relative(evalsDir, path).split(sep).join('/');
2666
+ const suitePath = dirname(relativePath) === '.' ? '' : dirname(relativePath).split(sep).join('/');
2667
+ const fallbackName = basename(path, extname(path));
2668
+ const stat = statSync(path);
2669
+ try {
2670
+ const parsed = parseYaml(readFileSync(path, 'utf8'));
2671
+ return {
2672
+ id: Buffer.from(relativePath, 'utf8').toString('base64url'),
2673
+ name: typeof parsed?.name === 'string' && parsed.name.trim()
2674
+ ? parsed.name.trim()
2675
+ : fallbackName,
2676
+ relative_path: relativePath,
2677
+ config_path: path,
2678
+ suite_path: suitePath,
2679
+ updated_at: stat.mtime.toISOString(),
2680
+ scenario_count: Array.isArray(parsed?.scenarios) ? parsed.scenarios.length : 0,
2681
+ agent_count: Array.isArray(parsed?.agents) ? parsed.agents.length : 0
2682
+ };
2683
+ }
2684
+ catch (error) {
2685
+ return {
2686
+ id: Buffer.from(relativePath, 'utf8').toString('base64url'),
2687
+ name: fallbackName,
2688
+ relative_path: relativePath,
2689
+ config_path: path,
2690
+ suite_path: suitePath,
2691
+ updated_at: stat.mtime.toISOString(),
2692
+ scenario_count: 0,
2693
+ agent_count: 0,
2694
+ error: error instanceof Error ? error.message : String(error)
2695
+ };
2696
+ }
2697
+ });
2698
+ const suite = params.suite?.trim().replace(/^suite:/, '');
2699
+ const query = params.query?.trim().toLowerCase();
2700
+ const matching = all.filter((config) => (!suite || config.suite_path === suite || config.suite_path.startsWith(`${suite}/`)) &&
2701
+ (!query ||
2702
+ `${config.name} ${config.relative_path} ${config.error ?? ''}`
2703
+ .toLowerCase()
2704
+ .includes(query)));
2705
+ const sortBy = params.sortBy ?? 'name';
2706
+ const direction = params.sortDirection ?? 'asc';
2707
+ matching.sort((a, b) => {
2708
+ const left = sortBy === 'scenarios'
2709
+ ? a.scenario_count
2710
+ : sortBy === 'agents'
2711
+ ? a.agent_count
2712
+ : sortBy === 'updated_at'
2713
+ ? Date.parse(a.updated_at)
2714
+ : a.name;
2715
+ const right = sortBy === 'scenarios'
2716
+ ? b.scenario_count
2717
+ : sortBy === 'agents'
2718
+ ? b.agent_count
2719
+ : sortBy === 'updated_at'
2720
+ ? Date.parse(b.updated_at)
2721
+ : b.name;
2722
+ const comparison = typeof left === 'number' && typeof right === 'number'
2723
+ ? left - right
2724
+ : String(left).localeCompare(String(right));
2725
+ return direction === 'asc' ? comparison : -comparison;
2726
+ });
2727
+ return {
2728
+ evals_dir: evalsDir,
2729
+ total: all.length,
2730
+ matching: matching.length,
2731
+ filters: removeUndefined({ suite, query }),
2732
+ sort: { by: sortBy, direction },
2733
+ configs: matching
2734
+ };
2735
+ }
2175
2736
  function buildServerEntry(input) {
2176
2737
  const transport = input.transport ?? 'http';
2177
2738
  const authType = input.auth_type ?? 'none';
@@ -2220,46 +2781,7 @@ function buildServerEntry(input) {
2220
2781
  };
2221
2782
  }
2222
2783
  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;
2784
+ return buildScenarioEntry(input);
2263
2785
  }
2264
2786
  function validateScenarioHeuristics(scenario) {
2265
2787
  const warnings = [];
@@ -2981,19 +3503,6 @@ function searchableText(value) {
2981
3503
  visit(value);
2982
3504
  return tokens.join(' ');
2983
3505
  }
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
3506
  function indentBlock(text, spaces) {
2998
3507
  const prefix = ' '.repeat(spaces);
2999
3508
  return text
@@ -3033,7 +3542,14 @@ async function handleHttpRequest(req, res, sessions, mcpPath) {
3033
3542
  await runtime.transport.handleRequest(req, res, body);
3034
3543
  return;
3035
3544
  }
3036
- if (!sessionId && isInitializeRequest(body)) {
3545
+ // Per the MCP Streamable HTTP transport, an unknown session indicates that
3546
+ // the server was restarted or the session expired. The client must receive
3547
+ // 404 so it can discard the stale session and initialize a new connection.
3548
+ if (sessionId) {
3549
+ sendPlain(res, 404, 'Not Found');
3550
+ return;
3551
+ }
3552
+ if (isInitializeRequest(body)) {
3037
3553
  let runtime;
3038
3554
  const transport = new StreamableHTTPServerTransport({
3039
3555
  sessionIdGenerator: () => randomUUID(),
@@ -3067,7 +3583,11 @@ async function handleHttpRequest(req, res, sessions, mcpPath) {
3067
3583
  }
3068
3584
  if (method === 'GET' || method === 'DELETE') {
3069
3585
  const sessionId = getSessionId(req);
3070
- if (!sessionId || !sessions.has(sessionId)) {
3586
+ if (sessionId && !sessions.has(sessionId)) {
3587
+ sendPlain(res, 404, 'Not Found');
3588
+ return;
3589
+ }
3590
+ if (!sessionId) {
3071
3591
  sendPlain(res, 400, 'Invalid or missing session ID');
3072
3592
  return;
3073
3593
  }