unreal-engine-mcp-server 0.5.3 → 0.5.4

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.
@@ -6,7 +6,9 @@ import {
6
6
  DEFAULT_AUTOMATION_PORT,
7
7
  DEFAULT_NEGOTIATED_PROTOCOLS,
8
8
  DEFAULT_HEARTBEAT_INTERVAL_MS,
9
- DEFAULT_MAX_PENDING_REQUESTS
9
+ DEFAULT_MAX_PENDING_REQUESTS,
10
+ DEFAULT_MAX_QUEUED_REQUESTS,
11
+ MAX_WS_MESSAGE_SIZE_BYTES
10
12
  } from '../constants.js';
11
13
  import { createRequire } from 'node:module';
12
14
  import {
@@ -45,6 +47,7 @@ export class AutomationBridge extends EventEmitter {
45
47
  private readonly clientPort: number;
46
48
  private readonly serverLegacyEnabled: boolean;
47
49
  private readonly maxConcurrentConnections: number;
50
+ private readonly maxQueuedRequests: number;
48
51
 
49
52
  private connectionManager: ConnectionManager;
50
53
  private requestTracker: RequestTracker;
@@ -131,6 +134,7 @@ export class AutomationBridge extends EventEmitter {
131
134
 
132
135
  const maxPendingRequests = Math.max(1, options.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS);
133
136
  const maxConcurrentConnections = Math.max(1, options.maxConcurrentConnections ?? 10);
137
+ this.maxQueuedRequests = Math.max(0, options.maxQueuedRequests ?? DEFAULT_MAX_QUEUED_REQUESTS);
134
138
 
135
139
  this.clientHost = options.clientHost ?? process.env.MCP_AUTOMATION_CLIENT_HOST ?? DEFAULT_AUTOMATION_HOST;
136
140
  this.clientPort = options.clientPort ?? sanitizePort(process.env.MCP_AUTOMATION_CLIENT_PORT) ?? DEFAULT_AUTOMATION_PORT;
@@ -185,13 +189,21 @@ export class AutomationBridge extends EventEmitter {
185
189
 
186
190
  this.log.debug(`Negotiated protocols: ${JSON.stringify(this.negotiatedProtocols)}`);
187
191
 
188
- // Compatibility fix: If only one protocol, pass as string to ensure ws/plugin compatibility
189
- const protocols = 'mcp-automation';
192
+ const protocols = this.negotiatedProtocols.length === 1
193
+ ? this.negotiatedProtocols[0]
194
+ : this.negotiatedProtocols;
190
195
 
191
196
  this.log.debug(`Using WebSocket protocols arg: ${JSON.stringify(protocols)}`);
192
197
 
198
+ const headers: Record<string, string> | undefined = this.capabilityToken
199
+ ? {
200
+ 'X-MCP-Capability': this.capabilityToken,
201
+ 'X-MCP-Capability-Token': this.capabilityToken
202
+ }
203
+ : undefined;
204
+
193
205
  const socket = new WebSocket(url, protocols, {
194
- headers: this.capabilityToken ? { 'X-MCP-Capability': this.capabilityToken } : undefined,
206
+ headers,
195
207
  perMessageDeflate: false
196
208
  });
197
209
 
@@ -233,10 +245,69 @@ export class AutomationBridge extends EventEmitter {
233
245
  protocol: socket.protocol || null
234
246
  });
235
247
 
236
- // Set up message handling for the authenticated socket
248
+ const getRawDataByteLength = (data: unknown): number => {
249
+ if (typeof data === 'string') {
250
+ return Buffer.byteLength(data, 'utf8');
251
+ }
252
+
253
+ if (Buffer.isBuffer(data)) {
254
+ return data.length;
255
+ }
256
+
257
+ if (Array.isArray(data)) {
258
+ return data.reduce((total, item) => total + (Buffer.isBuffer(item) ? item.length : 0), 0);
259
+ }
260
+
261
+ if (data instanceof ArrayBuffer) {
262
+ return data.byteLength;
263
+ }
264
+
265
+ if (ArrayBuffer.isView(data)) {
266
+ return data.byteLength;
267
+ }
268
+
269
+ return 0;
270
+ };
271
+
272
+ const rawDataToUtf8String = (data: unknown, byteLengthHint?: number): string => {
273
+ if (typeof data === 'string') {
274
+ return data;
275
+ }
276
+
277
+ if (Buffer.isBuffer(data)) {
278
+ return data.toString('utf8');
279
+ }
280
+
281
+ if (Array.isArray(data)) {
282
+ const buffers = data.filter((item): item is Buffer => Buffer.isBuffer(item));
283
+ const totalLength = typeof byteLengthHint === 'number'
284
+ ? byteLengthHint
285
+ : buffers.reduce((total, item) => total + item.length, 0);
286
+ return Buffer.concat(buffers, totalLength).toString('utf8');
287
+ }
288
+
289
+ if (data instanceof ArrayBuffer) {
290
+ return Buffer.from(data).toString('utf8');
291
+ }
292
+
293
+ if (ArrayBuffer.isView(data)) {
294
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
295
+ }
296
+
297
+ return '';
298
+ };
299
+
237
300
  socket.on('message', (data) => {
238
301
  try {
239
- const text = typeof data === 'string' ? data : data.toString('utf8');
302
+ const byteLength = getRawDataByteLength(data);
303
+ if (byteLength > MAX_WS_MESSAGE_SIZE_BYTES) {
304
+ this.log.error(
305
+ `Received oversized message (${byteLength} bytes, max: ${MAX_WS_MESSAGE_SIZE_BYTES}). Dropping.`
306
+ );
307
+ return;
308
+ }
309
+
310
+ const text = rawDataToUtf8String(data, byteLength);
240
311
  this.log.debug(`[AutomationBridge Client] Received message: ${text.substring(0, 1000)}`);
241
312
  const parsed = JSON.parse(text) as AutomationBridgeMessage;
242
313
  this.connectionManager.updateLastMessageTime();
@@ -431,11 +502,10 @@ export class AutomationBridge extends EventEmitter {
431
502
  throw new Error('Automation bridge not connected');
432
503
  }
433
504
 
434
- // Check if we need to queue (unless it's a priority request which standard ones are not)
435
- // We use requestTracker directly to check limit as it's the source of truth
436
- // Note: requestTracker exposes maxPendingRequests via constructor but generic check logic isn't public
437
- // We assumed getPendingCount() is available
438
505
  if (this.requestTracker.getPendingCount() >= this.requestTracker.getMaxPendingRequests()) {
506
+ if (this.queuedRequestItems.length >= this.maxQueuedRequests) {
507
+ throw new Error(`Automation bridge request queue is full (max: ${this.maxQueuedRequests}). Please retry later.`);
508
+ }
439
509
  return new Promise<T>((resolve, reject) => {
440
510
  this.queuedRequestItems.push({
441
511
  resolve,
@@ -12,6 +12,7 @@ export interface AutomationBridgeOptions {
12
12
  heartbeatIntervalMs?: number;
13
13
  maxPendingRequests?: number;
14
14
  maxConcurrentConnections?: number;
15
+ maxQueuedRequests?: number;
15
16
  clientMode?: boolean;
16
17
  clientHost?: string;
17
18
  clientPort?: number;
package/src/constants.ts CHANGED
@@ -6,6 +6,7 @@ export const DEFAULT_NEGOTIATED_PROTOCOLS = ['mcp-automation'];
6
6
  export const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
7
7
  export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
8
8
  export const DEFAULT_MAX_PENDING_REQUESTS = 25;
9
+ export const DEFAULT_MAX_QUEUED_REQUESTS = 100;
9
10
  export const DEFAULT_TIME_OF_DAY = 9;
10
11
  export const DEFAULT_SUN_INTENSITY = 10000;
11
12
  export const DEFAULT_SKYLIGHT_INTENSITY = 1;
@@ -22,3 +23,7 @@ export const LONG_RUNNING_OP_TIMEOUT_MS = 300000;
22
23
  export const CONSOLE_COMMAND_TIMEOUT_MS = 30000;
23
24
  export const ENGINE_QUERY_TIMEOUT_MS = 15000;
24
25
  export const CONNECTION_TIMEOUT_MS = 15000;
26
+
27
+ // Message size limits
28
+ export const MAX_WS_MESSAGE_SIZE_BYTES = 5 * 1024 * 1024;
29
+
@@ -50,11 +50,32 @@ export class GraphQLServer {
50
50
  return;
51
51
  }
52
52
 
53
+ const isLoopback = this.config.host === '127.0.0.1' ||
54
+ this.config.host === '::1' ||
55
+ this.config.host.toLowerCase() === 'localhost';
56
+
57
+ const allowRemote = process.env.GRAPHQL_ALLOW_REMOTE === 'true';
58
+
59
+ if (!isLoopback && !allowRemote) {
60
+ this.log.warn(
61
+ `GraphQL server is configured to bind to non-loopback host '${this.config.host}'. GraphQL is for local debugging only. ` +
62
+ 'To allow remote binding, set GRAPHQL_ALLOW_REMOTE=true. Aborting start.'
63
+ );
64
+ return;
65
+ }
66
+
67
+ if (!isLoopback && allowRemote) {
68
+ if (this.config.cors.origin === '*') {
69
+ this.log.warn(
70
+ "GraphQL server is binding to a remote host with permissive CORS origin '*'. " +
71
+ 'Set GRAPHQL_CORS_ORIGIN to specific origins for production. Using permissive CORS for now.'
72
+ );
73
+ }
74
+ }
75
+
53
76
  try {
54
- // Create GraphQL schema
55
77
  const schema = createGraphQLSchema(this.bridge, this.automationBridge);
56
78
 
57
- // Create Yoga server
58
79
  const yoga = createYoga({
59
80
  schema,
60
81
  graphqlEndpoint: this.config.path,
@@ -77,12 +98,10 @@ export class GraphQLServer {
77
98
  }
78
99
  });
79
100
 
80
- // Create HTTP server with Yoga's request handler
81
101
  this.server = createServer(
82
102
  yoga as any
83
103
  );
84
104
 
85
- // Start server
86
105
  await new Promise<void>((resolve, reject) => {
87
106
  if (!this.server) {
88
107
  reject(new Error('Server not initialized'));
@@ -102,9 +121,6 @@ export class GraphQLServer {
102
121
  resolve();
103
122
  });
104
123
  });
105
-
106
- // Setup graceful shutdown
107
- this.setupShutdown();
108
124
  } catch (error) {
109
125
  this.log.error('Failed to start GraphQL server:', error);
110
126
  throw error;
@@ -130,22 +146,6 @@ export class GraphQLServer {
130
146
  });
131
147
  }
132
148
 
133
- private setupShutdown(): void {
134
- const gracefulShutdown = async (signal: string) => {
135
- this.log.info(`Received ${signal}, shutting down GraphQL server...`);
136
- try {
137
- await this.stop();
138
- process.exit(0);
139
- } catch (error) {
140
- this.log.error('Error during GraphQL server shutdown:', error);
141
- process.exit(1);
142
- }
143
- };
144
-
145
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
146
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
147
- }
148
-
149
149
  getConfig() {
150
150
  return this.config;
151
151
  }
package/src/index.ts CHANGED
@@ -30,7 +30,7 @@ const DEFAULT_SERVER_NAME = typeof packageInfo.name === 'string' && packageInfo.
30
30
  : 'unreal-engine-mcp';
31
31
  const DEFAULT_SERVER_VERSION = typeof packageInfo.version === 'string' && packageInfo.version.trim().length > 0
32
32
  ? packageInfo.version
33
- : '0.5.3';
33
+ : '0.5.4';
34
34
 
35
35
  function routeStdoutLogsToStderr(): void {
36
36
  if (!config.MCP_ROUTE_STDOUT_LOGS) {
@@ -3,7 +3,6 @@ import { HealthMonitor } from './health-monitor.js';
3
3
  import { AutomationBridge } from '../automation/index.js';
4
4
  import { Logger } from '../utils/logger.js';
5
5
  import { wasmIntegration } from '../wasm/index.js';
6
- import { DEFAULT_AUTOMATION_HOST } from '../constants.js';
7
6
 
8
7
  interface MetricsServerOptions {
9
8
  healthMonitor: HealthMonitor;
@@ -96,6 +95,8 @@ export function startMetricsServer(options: MetricsServerOptions): http.Server |
96
95
  return null;
97
96
  }
98
97
 
98
+ const host = process.env.MCP_METRICS_HOST || '127.0.0.1';
99
+
99
100
  // Simple rate limiting: max 60 requests per minute per IP
100
101
  const RATE_LIMIT_WINDOW_MS = 60000;
101
102
  const RATE_LIMIT_MAX_REQUESTS = 60;
@@ -139,7 +140,6 @@ export function startMetricsServer(options: MetricsServerOptions): http.Server |
139
140
  return;
140
141
  }
141
142
 
142
- // Apply rate limiting
143
143
  const clientIp = req.socket.remoteAddress || 'unknown';
144
144
  if (!checkRateLimit(clientIp)) {
145
145
  res.statusCode = 429;
@@ -160,8 +160,8 @@ export function startMetricsServer(options: MetricsServerOptions): http.Server |
160
160
  }
161
161
  });
162
162
 
163
- server.listen(port, () => {
164
- logger.info(`Prometheus metrics server listening on http://${DEFAULT_AUTOMATION_HOST}:${port}/metrics`);
163
+ server.listen(port, host, () => {
164
+ logger.info(`Prometheus metrics server listening on http://${host}:${port}/metrics`);
165
165
  });
166
166
 
167
167
  server.on('error', (err) => {
@@ -6,6 +6,67 @@ import { spawn } from 'child_process';
6
6
  import path from 'path';
7
7
  import fs from 'fs';
8
8
 
9
+ function validateUbtArgumentsString(extraArgs: string): void {
10
+ if (!extraArgs || typeof extraArgs !== 'string') {
11
+ return;
12
+ }
13
+
14
+ const forbiddenChars = ['\n', '\r', ';', '|', '`', '&&', '||', '>', '<'];
15
+ for (const char of forbiddenChars) {
16
+ if (extraArgs.includes(char)) {
17
+ throw new Error(
18
+ `UBT arguments contain forbidden character(s) and are blocked for safety. Blocked: ${JSON.stringify(char)}.`
19
+ );
20
+ }
21
+ }
22
+ }
23
+
24
+ function tokenizeArgs(extraArgs: string): string[] {
25
+ if (!extraArgs) {
26
+ return [];
27
+ }
28
+
29
+ const args: string[] = [];
30
+ let current = '';
31
+ let inQuotes = false;
32
+ let escapeNext = false;
33
+
34
+ for (let i = 0; i < extraArgs.length; i++) {
35
+ const ch = extraArgs[i];
36
+
37
+ if (escapeNext) {
38
+ current += ch;
39
+ escapeNext = false;
40
+ continue;
41
+ }
42
+
43
+ if (ch === '\\') {
44
+ escapeNext = true;
45
+ continue;
46
+ }
47
+
48
+ if (ch === '"') {
49
+ inQuotes = !inQuotes;
50
+ continue;
51
+ }
52
+
53
+ if (!inQuotes && /\s/.test(ch)) {
54
+ if (current.length > 0) {
55
+ args.push(current);
56
+ current = '';
57
+ }
58
+ } else {
59
+ current += ch;
60
+ }
61
+ }
62
+
63
+ if (current.length > 0) {
64
+ args.push(current);
65
+ }
66
+
67
+ return args;
68
+ }
69
+
9
70
  export async function handlePipelineTools(action: string, args: PipelineArgs, tools: ITools) {
10
71
  switch (action) {
11
72
  case 'run_ubt': {
@@ -18,8 +79,9 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
18
79
  throw new Error('Target is required for run_ubt');
19
80
  }
20
81
 
21
- // Try to find UnrealBuildTool
22
- let ubtPath = 'UnrealBuildTool'; // Assume in PATH by default
82
+ validateUbtArgumentsString(extraArgs);
83
+
84
+ let ubtPath = 'UnrealBuildTool';
23
85
  const enginePath = process.env.UE_ENGINE_PATH || process.env.UNREAL_ENGINE_PATH;
24
86
 
25
87
  if (enginePath) {
@@ -38,10 +100,8 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
38
100
  throw new Error('UE_PROJECT_PATH environment variable is not set and no projectPath argument was provided.');
39
101
  }
40
102
 
41
- // If projectPath points to a .uproject file, use it. If it's a directory, look for a .uproject file.
42
103
  let uprojectFile = projectPath;
43
104
  if (!uprojectFile.endsWith('.uproject')) {
44
- // Find first .uproject in the directory
45
105
  try {
46
106
  const files = fs.readdirSync(projectPath);
47
107
  const found = files.find(f => f.endsWith('.uproject'));
@@ -53,16 +113,19 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
53
113
  }
54
114
  }
55
115
 
116
+ const projectArg = `-Project="${uprojectFile}"`;
117
+ const extraTokens = tokenizeArgs(extraArgs);
118
+
56
119
  const cmdArgs = [
57
120
  target,
58
121
  platform,
59
122
  configuration,
60
- `-Project="${uprojectFile}"`,
61
- extraArgs
62
- ].filter(Boolean);
123
+ projectArg,
124
+ ...extraTokens
125
+ ];
63
126
 
64
127
  return new Promise((resolve) => {
65
- const child = spawn(ubtPath, cmdArgs, { shell: true });
128
+ const child = spawn(ubtPath, cmdArgs, { shell: false });
66
129
 
67
130
  const MAX_OUTPUT_SIZE = 20 * 1024; // 20KB cap
68
131
  let stdout = '';
@@ -91,12 +154,14 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
91
154
  ? '\n[Output truncated for response payload]'
92
155
  : '';
93
156
 
157
+ const quotedArgs = cmdArgs.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
158
+
94
159
  if (code === 0) {
95
160
  resolve({
96
161
  success: true,
97
162
  message: 'UnrealBuildTool finished successfully',
98
163
  output: stdout + truncatedNote,
99
- command: `${ubtPath} ${cmdArgs.join(' ')}`
164
+ command: `${ubtPath} ${quotedArgs.join(' ')}`
100
165
  });
101
166
  } else {
102
167
  resolve({
@@ -105,23 +170,24 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
105
170
  message: `UnrealBuildTool failed with code ${code}`,
106
171
  output: stdout + truncatedNote,
107
172
  errorOutput: stderr + truncatedNote,
108
- command: `${ubtPath} ${cmdArgs.join(' ')}`
173
+ command: `${ubtPath} ${quotedArgs.join(' ')}`
109
174
  });
110
175
  }
111
176
  });
112
177
 
113
178
  child.on('error', (err) => {
179
+ const quotedArgs = cmdArgs.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
180
+
114
181
  resolve({
115
182
  success: false,
116
183
  error: 'SPAWN_FAILED',
117
184
  message: `Failed to spawn UnrealBuildTool: ${err.message}`,
118
- command: `${ubtPath} ${cmdArgs.join(' ')}`
185
+ command: `${ubtPath} ${quotedArgs.join(' ')}`
119
186
  });
120
187
  });
121
188
  });
122
189
  }
123
190
  default:
124
- // Fallback to automation bridge if we add more actions later that are bridge-supported
125
191
  const res = await executeAutomationRequest(tools, 'manage_pipeline', { ...args, subAction: action }, 'Automation bridge not available for manage_pipeline');
126
192
  return cleanObject(res);
127
193
  }
@@ -59,9 +59,9 @@ describe('sanitizePath', () => {
59
59
  });
60
60
 
61
61
  it('sanitizes path segments with dots', () => {
62
- const result = sanitizePath('/Game/../MyAsset');
63
- // Each segment is sanitized, .. becomes a segment that gets sanitized
64
- expect(result).toContain('/');
62
+ expect(() => sanitizePath('/Game/../MyAsset')).toThrow(
63
+ 'Path traversal (..) is not allowed'
64
+ );
65
65
  });
66
66
  });
67
67
 
@@ -43,7 +43,7 @@ const testCases = [
43
43
  scenario: "Edge: Very long safe command",
44
44
  toolName: "system_control",
45
45
  arguments: { action: "console_command", command: "stat fps; stat gpu; stat memory" },
46
- expected: "success"
46
+ expected: "blocked|command_blocked|blocked for safety"
47
47
  },
48
48
  {
49
49
  scenario: "Warning: Unknown command",
@@ -20,6 +20,66 @@ let serverArgs = process.env.UNREAL_MCP_SERVER_ARGS ? process.env.UNREAL_MCP_SER
20
20
  const serverCwd = process.env.UNREAL_MCP_SERVER_CWD ?? repoRoot;
21
21
  const serverEnv = Object.assign({}, process.env);
22
22
 
23
+ const DEFAULT_RESPONSE_LOG_MAX_CHARS = 6000; // default max chars
24
+ const RESPONSE_LOGGING_ENABLED = process.env.UNREAL_MCP_TEST_LOG_RESPONSES !== '0';
25
+
26
+ function clampString(value, maxChars) {
27
+ if (typeof value !== 'string') return '';
28
+ if (value.length <= maxChars) return value;
29
+ return value.slice(0, maxChars) + `\n... (truncated, ${value.length - maxChars} chars omitted)`;
30
+ }
31
+
32
+ function tryParseJson(text) {
33
+ if (typeof text !== 'string') return null;
34
+ try {
35
+ return JSON.parse(text);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export function normalizeMcpResponse(response) {
42
+ const normalized = {
43
+ isError: Boolean(response?.isError),
44
+ structuredContent: response?.structuredContent ?? null,
45
+ contentText: '',
46
+ content: response?.content ?? undefined
47
+ };
48
+
49
+ if (normalized.structuredContent === null && Array.isArray(response?.content)) {
50
+ for (const entry of response.content) {
51
+ if (entry?.type !== 'text' || typeof entry.text !== 'string') continue;
52
+ const parsed = tryParseJson(entry.text);
53
+ if (parsed !== null) {
54
+ normalized.structuredContent = parsed;
55
+ break;
56
+ }
57
+ }
58
+ }
59
+
60
+ if (Array.isArray(response?.content) && response.content.length > 0) {
61
+ normalized.contentText = response.content
62
+ .map((entry) => (entry && typeof entry.text === 'string' ? entry.text : ''))
63
+ .filter((text) => text.length > 0)
64
+ .join('\n');
65
+ }
66
+
67
+ return normalized;
68
+ }
69
+
70
+ function logMcpResponse(toolName, normalizedResponse) {
71
+ const maxChars = Number(process.env.UNREAL_MCP_TEST_RESPONSE_MAX_CHARS ?? DEFAULT_RESPONSE_LOG_MAX_CHARS);
72
+ const payload = {
73
+ isError: normalizedResponse.isError,
74
+ structuredContent: normalizedResponse.structuredContent,
75
+ contentText: normalizedResponse.contentText,
76
+ content: normalizedResponse.content
77
+ };
78
+ const json = JSON.stringify(payload, null, 2);
79
+ console.log(`[MCP RESPONSE] ${toolName}:`);
80
+ console.log(clampString(json, Number.isFinite(maxChars) && maxChars > 0 ? maxChars : DEFAULT_RESPONSE_LOG_MAX_CHARS));
81
+ }
82
+
23
83
  function formatResultLine(testCase, status, detail, durationMs) {
24
84
  const durationText = typeof durationMs === 'number' ? ` (${durationMs.toFixed(1)} ms)` : '';
25
85
  return `[${status.toUpperCase()}] ${testCase.scenario}${durationText}${detail ? ` => ${detail}` : ''}`;
@@ -515,13 +575,13 @@ export async function runToolTests(toolName, testCases) {
515
575
  }
516
576
  }
517
577
  const normalizedResponse = { ...response, structuredContent };
578
+ if (RESPONSE_LOGGING_ENABLED) {
579
+ logMcpResponse(testCase.toolName + " :: " + testCase.scenario, normalizeMcpResponse(normalizedResponse));
580
+ }
518
581
  const { passed, reason } = evaluateExpectation(testCase, normalizedResponse);
519
582
 
520
583
  if (!passed) {
521
584
  console.log(`[FAILED] ${testCase.scenario} (${durationMs.toFixed(1)} ms) => ${reason}`);
522
- if (normalizedResponse) {
523
- console.log(`[DEBUG] Full response for ${testCase.scenario}:`, JSON.stringify(normalizedResponse, null, 2));
524
- }
525
585
  results.push({
526
586
  scenario: testCase.scenario,
527
587
  toolName: testCase.toolName,