@testdriverai/mcp 7.11.6-test → 7.11.7-canary

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.
@@ -13,6 +13,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
13
13
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
14
14
  import { randomUUID } from "crypto";
15
15
  import * as http from "http";
16
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
16
17
  import * as Sentry from "@sentry/node";
17
18
  import * as fs from "fs";
18
19
  import * as os from "os";
@@ -23,7 +24,6 @@ import * as core from "./core/actions.js";
23
24
  import { NoActiveSessionError } from "./core/actions.js";
24
25
  import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
25
26
  import { SessionStartInputSchema } from "./provision-types.js";
26
- import { sessionManager } from "./session.js";
27
27
  // =============================================================================
28
28
  // Sentry
29
29
  // =============================================================================
@@ -181,17 +181,24 @@ const RESOURCE_URI = "ui://testdriver/mcp-app.html";
181
181
  // Resource URI base for serving screenshot blobs (with dynamic IDs)
182
182
  const SCREENSHOT_RESOURCE_BASE = "screenshot://testdriver/screenshot";
183
183
  const CROPPED_IMAGE_RESOURCE_BASE = "screenshot://testdriver/cropped";
184
- // Map of image ID -> image data
185
- const imageStore = new Map();
186
- // Counter for generating unique image IDs
187
- let imageIdCounter = 0;
188
184
  // Maximum number of images to store (to prevent memory leaks)
189
185
  const MAX_STORED_IMAGES = 100;
186
+ function imageStoreState() {
187
+ const adapter = core.getAdapterState();
188
+ let state = adapter.imageStore;
189
+ if (!state) {
190
+ state = { images: new Map(), counter: 0 };
191
+ adapter.imageStore = state;
192
+ }
193
+ return state;
194
+ }
190
195
  /**
191
196
  * Store an image and return its unique resource URI
192
197
  */
193
198
  function storeImage(data, type) {
194
- const id = `${type}-${++imageIdCounter}`;
199
+ const state = imageStoreState();
200
+ const imageStore = state.images;
201
+ const id = `${type}-${++state.counter}`;
195
202
  // Clean up old images if we exceed the limit
196
203
  if (imageStore.size >= MAX_STORED_IMAGES) {
197
204
  // Remove oldest images (first entries in the map)
@@ -215,7 +222,12 @@ function storeImage(data, type) {
215
222
  * Get an image by its ID
216
223
  */
217
224
  function getStoredImage(id) {
218
- return imageStore.get(id);
225
+ return imageStoreState().images.get(id);
226
+ }
227
+ /** Diagnostics for the active connection's image store (used in debug logs). */
228
+ function imageStoreDiagnostics() {
229
+ const images = imageStoreState().images;
230
+ return { imageStoreSize: images.size, availableKeys: Array.from(images.keys()) };
219
231
  }
220
232
  /**
221
233
  * Get session info for structured content
@@ -225,7 +237,7 @@ function getSessionData(session) {
225
237
  return { id: null, expiresIn: 0 };
226
238
  return {
227
239
  id: session.sessionId,
228
- expiresIn: sessionManager.getTimeRemaining(session.sessionId),
240
+ expiresIn: core.getSessionTimeRemaining(session.sessionId),
229
241
  };
230
242
  }
231
243
  /**
@@ -372,7 +384,7 @@ function createToolResult(success, textContent, structuredData, generatedCode) {
372
384
  let fullText = textContent;
373
385
  if (generatedCode && success) {
374
386
  // Get the test file from the current session
375
- const session = sessionManager.getCurrentSession();
387
+ const session = core.getCurrentSession();
376
388
  const testFile = session?.testFile;
377
389
  if (testFile) {
378
390
  fullText += `\n\n⚠️ ACTION REQUIRED: Append this code to ${testFile}:\n\`\`\`javascript\n${generatedCode}\n\`\`\``;
@@ -392,7 +404,7 @@ function createToolResult(success, textContent, structuredData, generatedCode) {
392
404
  // structuredContent goes to UI (includes imageUrl for display)
393
405
  // Always include success flag so UI can display correct status indicator
394
406
  // Include generatedCode and testFile in structured data so agents can programmatically handle it
395
- const session = sessionManager.getCurrentSession();
407
+ const session = core.getCurrentSession();
396
408
  return {
397
409
  content,
398
410
  structuredContent: {
@@ -403,80 +415,96 @@ function createToolResult(success, textContent, structuredData, generatedCode) {
403
415
  },
404
416
  };
405
417
  }
406
- // Create MCP server wrapped with Sentry for automatic tracing
407
- const server = isSentryEnabled()
408
- ? Sentry.wrapMcpServerWithSentry(new McpServer({
409
- name: "testdriver",
410
- version: version,
411
- }))
412
- : new McpServer({
413
- name: "testdriver",
414
- version: version,
418
+ /**
419
+ * Build a fully-registered MCP server instance (all resources + tools).
420
+ *
421
+ * This used to run once at module load against a single shared `server`. It is
422
+ * now a factory so the HTTP transport can mint one server PER connection — each
423
+ * bound (via the surrounding {@link core.runInContext}) to its own isolated
424
+ * {@link core.CoreContext}. That's what stops one Streamable-HTTP client's tool
425
+ * call from reading another client's sandbox. The stdio host calls this once and
426
+ * runs it over the global context, so its behavior is unchanged.
427
+ *
428
+ * The tool/resource handlers below don't reference any per-connection state
429
+ * directly — they go through the context-aware helpers (`storeImage`,
430
+ * `getStoredImage`, `core.*`), which resolve the active context. So a single
431
+ * copy of the registration code serves every connection correctly.
432
+ */
433
+ function buildServer() {
434
+ // Create MCP server wrapped with Sentry for automatic tracing
435
+ const server = isSentryEnabled()
436
+ ? Sentry.wrapMcpServerWithSentry(new McpServer({
437
+ name: "testdriver",
438
+ version: version,
439
+ }))
440
+ : new McpServer({
441
+ name: "testdriver",
442
+ version: version,
443
+ });
444
+ // =============================================================================
445
+ // Register UI Resource
446
+ // =============================================================================
447
+ registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE, description: "TestDriver Screenshot Viewer UI" }, async () => {
448
+ const htmlPath = path.join(DIST_DIR, "mcp-app.html");
449
+ if (!fs.existsSync(htmlPath)) {
450
+ throw new Error(`UI file not found: ${htmlPath}`);
451
+ }
452
+ const html = fs.readFileSync(htmlPath, "utf-8");
453
+ return {
454
+ contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
455
+ };
415
456
  });
416
- // =============================================================================
417
- // Register UI Resource
418
- // =============================================================================
419
- registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE, description: "TestDriver Screenshot Viewer UI" }, async () => {
420
- const htmlPath = path.join(DIST_DIR, "mcp-app.html");
421
- if (!fs.existsSync(htmlPath)) {
422
- throw new Error(`UI file not found: ${htmlPath}`);
423
- }
424
- const html = fs.readFileSync(htmlPath, "utf-8");
425
- return {
426
- contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
427
- };
428
- });
429
- // Register screenshot resource template for serving binary blobs by ID
430
- server.registerResource("Screenshot", new ResourceTemplate(`${SCREENSHOT_RESOURCE_BASE}/{imageId}`, { list: undefined }), {
431
- description: "Screenshot from TestDriver session served as base64 blob",
432
- mimeType: "image/png",
433
- }, async (uri, variables) => {
434
- const imageId = variables.imageId;
435
- const image = getStoredImage(imageId);
436
- if (!image) {
437
- throw new Error(`Screenshot not found: ${imageId}. It may have been cleaned up.`);
438
- }
439
- logger.debug("screenshot resource: Serving screenshot blob", {
440
- imageId,
441
- blobLength: image.data.length
457
+ // Register screenshot resource template for serving binary blobs by ID
458
+ server.registerResource("Screenshot", new ResourceTemplate(`${SCREENSHOT_RESOURCE_BASE}/{imageId}`, { list: undefined }), {
459
+ description: "Screenshot from TestDriver session served as base64 blob",
460
+ mimeType: "image/png",
461
+ }, async (uri, variables) => {
462
+ const imageId = variables.imageId;
463
+ const image = getStoredImage(imageId);
464
+ if (!image) {
465
+ throw new Error(`Screenshot not found: ${imageId}. It may have been cleaned up.`);
466
+ }
467
+ logger.debug("screenshot resource: Serving screenshot blob", {
468
+ imageId,
469
+ blobLength: image.data.length
470
+ });
471
+ return {
472
+ contents: [{
473
+ uri: uri.href,
474
+ mimeType: "image/png",
475
+ blob: image.data,
476
+ }],
477
+ };
442
478
  });
443
- return {
444
- contents: [{
445
- uri: uri.href,
446
- mimeType: "image/png",
447
- blob: image.data,
448
- }],
449
- };
450
- });
451
- // Register cropped image resource template for serving find operation results by ID
452
- server.registerResource("CroppedImage", new ResourceTemplate(`${CROPPED_IMAGE_RESOURCE_BASE}/{imageId}`, { list: undefined }), {
453
- description: "Cropped image from find operations served as base64 blob",
454
- mimeType: "image/png",
455
- }, async (uri, variables) => {
456
- const imageId = variables.imageId;
457
- const image = getStoredImage(imageId);
458
- if (!image) {
459
- throw new Error(`Cropped image not found: ${imageId}. It may have been cleaned up.`);
460
- }
461
- logger.debug("cropped image resource: Serving cropped image blob", {
462
- imageId,
463
- blobLength: image.data.length
479
+ // Register cropped image resource template for serving find operation results by ID
480
+ server.registerResource("CroppedImage", new ResourceTemplate(`${CROPPED_IMAGE_RESOURCE_BASE}/{imageId}`, { list: undefined }), {
481
+ description: "Cropped image from find operations served as base64 blob",
482
+ mimeType: "image/png",
483
+ }, async (uri, variables) => {
484
+ const imageId = variables.imageId;
485
+ const image = getStoredImage(imageId);
486
+ if (!image) {
487
+ throw new Error(`Cropped image not found: ${imageId}. It may have been cleaned up.`);
488
+ }
489
+ logger.debug("cropped image resource: Serving cropped image blob", {
490
+ imageId,
491
+ blobLength: image.data.length
492
+ });
493
+ return {
494
+ contents: [{
495
+ uri: uri.href,
496
+ mimeType: "image/png",
497
+ blob: image.data,
498
+ }],
499
+ };
464
500
  });
465
- return {
466
- contents: [{
467
- uri: uri.href,
468
- mimeType: "image/png",
469
- blob: image.data,
470
- }],
471
- };
472
- });
473
- // =============================================================================
474
- // Tools
475
- // =============================================================================
476
- // Session Start
477
- registerAppTool(server, "session_start", {
478
- title: "Session Start",
479
- description: `Start a new TestDriver session and provision a sandbox with browser or app.
501
+ // =============================================================================
502
+ // Tools
503
+ // =============================================================================
504
+ // Session Start
505
+ registerAppTool(server, "session_start", {
506
+ title: "Session Start",
507
+ description: `Start a new TestDriver session and provision a sandbox with browser or app.
480
508
 
481
509
  ⚠️ IMPORTANT - Test File Parameter:
482
510
  When 'testFile' is provided, you MUST append the generated code to that file after EVERY successful action.
@@ -499,81 +527,81 @@ Debug mode (connect to existing sandbox):
499
527
  - Provide 'sandboxId' to connect to an existing sandbox (e.g., from a failed test with debugOnFailure: true)
500
528
  - Skips provisioning - connects to sandbox in its current state
501
529
  - Use this to interactively debug failed tests without re-running from scratch`,
502
- inputSchema: SessionStartInputSchema,
503
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
504
- }, async (params, extra) => {
505
- const startTime = Date.now();
506
- const progress = makeProgressReporter(extra);
507
- // Resolve OS with priority: explicit param > TD_OS env var > "linux" default
508
- // This mirrors the behavior of the Vitest hooks (hooks.mjs) which also reads TD_OS
509
- const { os: resolvedOs, warning: osWarning } = resolveOs(params.os);
510
- if (osWarning) {
511
- logger.warn(`session_start: ${osWarning}`);
512
- }
513
- else if (!params.os && resolvedOs !== "linux") {
514
- logger.info("session_start: Using TD_OS environment variable", { os: resolvedOs });
515
- }
516
- // Resolve E2B template ID with priority: explicit param > TD_E2B_TEMPLATE_ID env var
517
- // This mirrors the behavior of the Vitest hooks (hooks.mjs) which also reads TD_E2B_TEMPLATE_ID
518
- const resolvedE2bTemplateId = resolveE2bTemplateId(params.e2bTemplateId);
519
- if (!params.e2bTemplateId && resolvedE2bTemplateId) {
520
- logger.info("session_start: Using TD_E2B_TEMPLATE_ID environment variable", { e2bTemplateId: resolvedE2bTemplateId });
521
- }
522
- logger.info("session_start: Starting", {
523
- type: params.type,
524
- url: params.url,
525
- os: resolvedOs,
526
- reconnect: params.reconnect,
527
- sandboxId: params.sandboxId,
528
- });
529
- try {
530
- // The core owns session creation, SDK init, connect, provisioning and the
531
- // initial screenshot. We keep the abort race + heartbeat scaffolding here:
532
- // a heartbeat ticks while the long provisioning await is in flight, and the
533
- // whole core call is raced against the client's abort signal so cancellation
534
- // still returns promptly. Progress messages from the core are forwarded.
535
- const stopHeartbeat = progress.heartbeat(params.sandboxId
536
- ? `Connecting to existing sandbox ${params.sandboxId}...`
537
- : "Starting session...");
538
- let result;
539
- try {
540
- result = await raceAbort(extra.signal, "session_start", core.sessionStart(params, { os: resolvedOs, e2bTemplateId: resolvedE2bTemplateId }, { onProgress: (m) => progress.report(m) }));
530
+ inputSchema: SessionStartInputSchema,
531
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
532
+ }, async (params, extra) => {
533
+ const startTime = Date.now();
534
+ const progress = makeProgressReporter(extra);
535
+ // Resolve OS with priority: explicit param > TD_OS env var > "linux" default
536
+ // This mirrors the behavior of the Vitest hooks (hooks.mjs) which also reads TD_OS
537
+ const { os: resolvedOs, warning: osWarning } = resolveOs(params.os);
538
+ if (osWarning) {
539
+ logger.warn(`session_start: ${osWarning}`);
541
540
  }
542
- finally {
543
- stopHeartbeat();
541
+ else if (!params.os && resolvedOs !== "linux") {
542
+ logger.info("session_start: Using TD_OS environment variable", { os: resolvedOs });
544
543
  }
545
- const duration = Date.now() - startTime;
546
- // Set Sentry context once the session id is known.
547
- if (result.ok && typeof result.data.sessionId === "string") {
548
- setSessionContext(result.data.sessionId, result.data.sandboxId || undefined);
549
- }
550
- logger.info("session_start: Completed", { duration, ok: result.ok });
551
- if (!result.ok) {
552
- // Validation / missing-key failures: pass through with duration added.
553
- return resultToMcp({ ...result, data: { ...result.data, duration } });
554
- }
555
- // Reproduce the server's exact success output (text + data) which differs
556
- // from the core's neutral text. Images are mapped to resource URIs.
557
- const data = { ...result.data, duration };
558
- for (const img of result.images ?? []) {
559
- const uri = storeImage(img.base64, img.kind);
560
- if (img.kind === "cropped")
561
- data.croppedImageResourceUri = uri;
562
- else
563
- data.screenshotResourceUri = uri;
544
+ // Resolve E2B template ID with priority: explicit param > TD_E2B_TEMPLATE_ID env var
545
+ // This mirrors the behavior of the Vitest hooks (hooks.mjs) which also reads TD_E2B_TEMPLATE_ID
546
+ const resolvedE2bTemplateId = resolveE2bTemplateId(params.e2bTemplateId);
547
+ if (!params.e2bTemplateId && resolvedE2bTemplateId) {
548
+ logger.info("session_start: Using TD_E2B_TEMPLATE_ID environment variable", { e2bTemplateId: resolvedE2bTemplateId });
564
549
  }
565
- if (result.data.debugMode) {
566
- // Debug (existing-sandbox) success text — preserve original wording.
567
- const text = `Connected to existing sandbox (debug mode)
550
+ logger.info("session_start: Starting", {
551
+ type: params.type,
552
+ url: params.url,
553
+ os: resolvedOs,
554
+ reconnect: params.reconnect,
555
+ sandboxId: params.sandboxId,
556
+ });
557
+ try {
558
+ // The core owns session creation, SDK init, connect, provisioning and the
559
+ // initial screenshot. We keep the abort race + heartbeat scaffolding here:
560
+ // a heartbeat ticks while the long provisioning await is in flight, and the
561
+ // whole core call is raced against the client's abort signal so cancellation
562
+ // still returns promptly. Progress messages from the core are forwarded.
563
+ const stopHeartbeat = progress.heartbeat(params.sandboxId
564
+ ? `Connecting to existing sandbox ${params.sandboxId}...`
565
+ : "Starting session...");
566
+ let result;
567
+ try {
568
+ result = await raceAbort(extra.signal, "session_start", core.sessionStart(params, { os: resolvedOs, e2bTemplateId: resolvedE2bTemplateId }, { onProgress: (m) => progress.report(m) }));
569
+ }
570
+ finally {
571
+ stopHeartbeat();
572
+ }
573
+ const duration = Date.now() - startTime;
574
+ // Set Sentry context once the session id is known.
575
+ if (result.ok && typeof result.data.sessionId === "string") {
576
+ setSessionContext(result.data.sessionId, result.data.sandboxId || undefined);
577
+ }
578
+ logger.info("session_start: Completed", { duration, ok: result.ok });
579
+ if (!result.ok) {
580
+ // Validation / missing-key failures: pass through with duration added.
581
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
582
+ }
583
+ // Reproduce the server's exact success output (text + data) which differs
584
+ // from the core's neutral text. Images are mapped to resource URIs.
585
+ const data = { ...result.data, duration };
586
+ for (const img of result.images ?? []) {
587
+ const uri = storeImage(img.base64, img.kind);
588
+ if (img.kind === "cropped")
589
+ data.croppedImageResourceUri = uri;
590
+ else
591
+ data.screenshotResourceUri = uri;
592
+ }
593
+ if (result.data.debugMode) {
594
+ // Debug (existing-sandbox) success text — preserve original wording.
595
+ const text = `Connected to existing sandbox (debug mode)
568
596
  Session: ${result.data.sessionId}
569
597
  Sandbox: ${result.data.sandboxId}
570
598
  Expires in: ${Math.round(params.keepAlive / 1000)}s
571
599
 
572
600
  You are now connected to the sandbox in its current state. Use find, click, type, etc. to interact.`;
573
- return createToolResult(true, text, data, result.code);
574
- }
575
- // Normal provisioning success — append the EXACT dependency guidance block.
576
- const text = `${result.text}
601
+ return createToolResult(true, text, data, result.code);
602
+ }
603
+ // Normal provisioning success — append the EXACT dependency guidance block.
604
+ const text = `${result.text}
577
605
 
578
606
  IMPORTANT - If creating a new test project, use these EXACT dependencies in package.json:
579
607
  {
@@ -586,414 +614,414 @@ IMPORTANT - If creating a new test project, use these EXACT dependencies in pack
586
614
  "test": "vitest"
587
615
  }
588
616
  }`;
589
- return createToolResult(true, text, data, result.code);
590
- }
591
- catch (error) {
592
- // On client cancellation, tear down the half-provisioned session so we
593
- // don't leak a connected sandbox. The underlying SDK call may still be
594
- // running in the background; best-effort cleanup is all we can do.
595
- if (error instanceof ToolAbortError) {
596
- logger.info("session_start: Cancelled by client, tearing down session");
617
+ return createToolResult(true, text, data, result.code);
618
+ }
619
+ catch (error) {
620
+ // On client cancellation, tear down the half-provisioned session so we
621
+ // don't leak a connected sandbox. The underlying SDK call may still be
622
+ // running in the background; best-effort cleanup is all we can do.
623
+ if (error instanceof ToolAbortError) {
624
+ logger.info("session_start: Cancelled by client, tearing down session");
625
+ try {
626
+ await core.disconnect();
627
+ }
628
+ catch (cleanupErr) {
629
+ logger.warn("session_start: Cleanup after cancel failed", { error: String(cleanupErr) });
630
+ }
631
+ return createToolResult(false, "Session start was cancelled.", { action: "session_start", cancelled: true });
632
+ }
633
+ logger.error("session_start: Failed", { error: String(error) });
634
+ captureException(error, { tags: { tool: "session_start" }, extra: { params } });
635
+ throw error;
636
+ }
637
+ });
638
+ // Session Status
639
+ server.registerTool("session_status", {
640
+ description: "Check the current session status and time remaining",
641
+ inputSchema: z.object({}),
642
+ }, async () => {
643
+ const startTime = Date.now();
644
+ logger.info("session_status: Checking");
645
+ const result = core.sessionStatus();
646
+ const duration = Date.now() - startTime;
647
+ logger.info("session_status: Completed", {
648
+ ok: result.ok,
649
+ sessionId: result.data.sessionId,
650
+ status: result.data.status,
651
+ duration,
652
+ });
653
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
654
+ });
655
+ // Session Extend
656
+ server.registerTool("session_extend", {
657
+ description: "Extend the session keepAlive time",
658
+ inputSchema: z.object({
659
+ additionalMs: z.number().default(60000).describe("Additional time in ms"),
660
+ }),
661
+ }, async (params) => {
662
+ logger.info("session_extend: Extending", { additionalMs: params.additionalMs });
663
+ const result = core.sessionExtend(params.additionalMs);
664
+ if (!result.ok) {
665
+ logger.warn("session_extend: No active session");
666
+ return { content: [{ type: "text", text: "No active session" }] };
667
+ }
668
+ logger.info("session_extend: Extended", { newExpiry: result.data.newExpiry });
669
+ // Preserve the original plain content shape (not via createToolResult).
670
+ return {
671
+ content: [
672
+ {
673
+ type: "text",
674
+ text: result.text,
675
+ },
676
+ ],
677
+ };
678
+ });
679
+ // Find Element
680
+ registerAppTool(server, "find", {
681
+ title: "Find Element",
682
+ description: "Find an element on screen by natural language description",
683
+ inputSchema: z.object({
684
+ description: z.string().describe("Natural language description of the element"),
685
+ timeout: z.number().optional().describe("Timeout in ms for polling"),
686
+ }),
687
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
688
+ }, async (params, extra) => {
689
+ const startTime = Date.now();
690
+ const progress = makeProgressReporter(extra);
691
+ logger.info("find: Starting", { description: params.description, timeout: params.timeout });
692
+ try {
693
+ logger.debug("find: Calling SDK find");
694
+ const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
695
+ let result;
597
696
  try {
598
- await core.disconnect();
697
+ result = await raceAbort(extra.signal, "find", core.find(params.description, params.timeout));
599
698
  }
600
- catch (cleanupErr) {
601
- logger.warn("session_start: Cleanup after cancel failed", { error: String(cleanupErr) });
699
+ finally {
700
+ stopHeartbeat();
602
701
  }
603
- return createToolResult(false, "Session start was cancelled.", { action: "session_start", cancelled: true });
702
+ const duration = Date.now() - startTime;
703
+ logger.info("find: Completed", { description: params.description, found: result.ok, duration });
704
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
705
+ }
706
+ catch (error) {
707
+ if (error instanceof NoActiveSessionError) {
708
+ logger.warn("find: No active session");
709
+ return noSessionResult(error);
710
+ }
711
+ const cancelled = cancelledResultOrNull(error, "find");
712
+ if (cancelled)
713
+ return cancelled;
714
+ logger.error("find: Failed", { error: String(error), description: params.description });
715
+ captureException(error, { tags: { tool: "find" }, extra: { description: params.description } });
716
+ throw error;
604
717
  }
605
- logger.error("session_start: Failed", { error: String(error) });
606
- captureException(error, { tags: { tool: "session_start" }, extra: { params } });
607
- throw error;
608
- }
609
- });
610
- // Session Status
611
- server.registerTool("session_status", {
612
- description: "Check the current session status and time remaining",
613
- inputSchema: z.object({}),
614
- }, async () => {
615
- const startTime = Date.now();
616
- logger.info("session_status: Checking");
617
- const result = core.sessionStatus();
618
- const duration = Date.now() - startTime;
619
- logger.info("session_status: Completed", {
620
- ok: result.ok,
621
- sessionId: result.data.sessionId,
622
- status: result.data.status,
623
- duration,
624
718
  });
625
- return resultToMcp({ ...result, data: { ...result.data, duration } });
626
- });
627
- // Session Extend
628
- server.registerTool("session_extend", {
629
- description: "Extend the session keepAlive time",
630
- inputSchema: z.object({
631
- additionalMs: z.number().default(60000).describe("Additional time in ms"),
632
- }),
633
- }, async (params) => {
634
- logger.info("session_extend: Extending", { additionalMs: params.additionalMs });
635
- const result = core.sessionExtend(params.additionalMs);
636
- if (!result.ok) {
637
- logger.warn("session_extend: No active session");
638
- return { content: [{ type: "text", text: "No active session" }] };
639
- }
640
- logger.info("session_extend: Extended", { newExpiry: result.data.newExpiry });
641
- // Preserve the original plain content shape (not via createToolResult).
642
- return {
643
- content: [
644
- {
645
- type: "text",
646
- text: result.text,
647
- },
648
- ],
649
- };
650
- });
651
- // Find Element
652
- registerAppTool(server, "find", {
653
- title: "Find Element",
654
- description: "Find an element on screen by natural language description",
655
- inputSchema: z.object({
656
- description: z.string().describe("Natural language description of the element"),
657
- timeout: z.number().optional().describe("Timeout in ms for polling"),
658
- }),
659
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
660
- }, async (params, extra) => {
661
- const startTime = Date.now();
662
- const progress = makeProgressReporter(extra);
663
- logger.info("find: Starting", { description: params.description, timeout: params.timeout });
664
- try {
665
- logger.debug("find: Calling SDK find");
666
- const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
667
- let result;
719
+ // Find All Elements
720
+ registerAppTool(server, "findall", {
721
+ title: "Find All Elements",
722
+ description: "Find all elements on screen matching a natural language description. Returns an array of element references.",
723
+ inputSchema: z.object({
724
+ description: z.string().describe("Natural language description of the elements to find"),
725
+ timeout: z.number().optional().describe("Timeout in ms for polling"),
726
+ }),
727
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
728
+ }, async (params, extra) => {
729
+ const startTime = Date.now();
730
+ const progress = makeProgressReporter(extra);
731
+ logger.info("findall: Starting", { description: params.description, timeout: params.timeout });
668
732
  try {
669
- result = await raceAbort(extra.signal, "find", core.find(params.description, params.timeout));
733
+ logger.debug("findall: Calling SDK findAll");
734
+ const stopHeartbeat = progress.heartbeat(`Looking for all "${params.description}"...`);
735
+ let result;
736
+ try {
737
+ result = await raceAbort(extra.signal, "findall", core.findAll(params.description, params.timeout));
738
+ }
739
+ finally {
740
+ stopHeartbeat();
741
+ }
742
+ const duration = Date.now() - startTime;
743
+ logger.info("findall: Completed", { description: params.description, count: result.data.count, duration });
744
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
670
745
  }
671
- finally {
672
- stopHeartbeat();
746
+ catch (error) {
747
+ if (error instanceof NoActiveSessionError) {
748
+ logger.warn("findall: No active session");
749
+ return noSessionResult(error);
750
+ }
751
+ const cancelled = cancelledResultOrNull(error, "findall");
752
+ if (cancelled)
753
+ return cancelled;
754
+ logger.error("findall: Failed", { error: String(error), description: params.description });
755
+ captureException(error, { tags: { tool: "findall" }, extra: { description: params.description } });
756
+ throw error;
673
757
  }
674
- const duration = Date.now() - startTime;
675
- logger.info("find: Completed", { description: params.description, found: result.ok, duration });
676
- return resultToMcp({ ...result, data: { ...result.data, duration } });
677
- }
678
- catch (error) {
679
- if (error instanceof NoActiveSessionError) {
680
- logger.warn("find: No active session");
681
- return noSessionResult(error);
758
+ });
759
+ // Click
760
+ registerAppTool(server, "click", {
761
+ title: "Click Element",
762
+ description: "Click on a previously found element. Use 'find' first to locate the element.",
763
+ inputSchema: z.object({
764
+ elementRef: z.string().describe("Reference to previously found element (required). Get this from a 'find' call."),
765
+ action: z.enum(["click", "double-click", "right-click"]).default("click"),
766
+ }),
767
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
768
+ }, async (params) => {
769
+ const startTime = Date.now();
770
+ logger.info("click: Starting", { elementRef: params.elementRef, action: params.action });
771
+ try {
772
+ logger.debug("click: Executing click on element", { elementRef: params.elementRef, action: params.action });
773
+ const result = await core.click(params.elementRef, params.action);
774
+ const duration = Date.now() - startTime;
775
+ logger.info("click: Completed", { elementRef: params.elementRef, duration });
776
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
682
777
  }
683
- const cancelled = cancelledResultOrNull(error, "find");
684
- if (cancelled)
685
- return cancelled;
686
- logger.error("find: Failed", { error: String(error), description: params.description });
687
- captureException(error, { tags: { tool: "find" }, extra: { description: params.description } });
688
- throw error;
689
- }
690
- });
691
- // Find All Elements
692
- registerAppTool(server, "findall", {
693
- title: "Find All Elements",
694
- description: "Find all elements on screen matching a natural language description. Returns an array of element references.",
695
- inputSchema: z.object({
696
- description: z.string().describe("Natural language description of the elements to find"),
697
- timeout: z.number().optional().describe("Timeout in ms for polling"),
698
- }),
699
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
700
- }, async (params, extra) => {
701
- const startTime = Date.now();
702
- const progress = makeProgressReporter(extra);
703
- logger.info("findall: Starting", { description: params.description, timeout: params.timeout });
704
- try {
705
- logger.debug("findall: Calling SDK findAll");
706
- const stopHeartbeat = progress.heartbeat(`Looking for all "${params.description}"...`);
707
- let result;
778
+ catch (error) {
779
+ if (error instanceof NoActiveSessionError) {
780
+ logger.warn("click: No active session");
781
+ return noSessionResult(error);
782
+ }
783
+ logger.error("click: Failed", { error: String(error), elementRef: params.elementRef });
784
+ captureException(error, { tags: { tool: "click" }, extra: { elementRef: params.elementRef, action: params.action } });
785
+ throw error;
786
+ }
787
+ });
788
+ // Hover
789
+ registerAppTool(server, "hover", {
790
+ title: "Hover Element",
791
+ description: "Hover over a previously found element. Use 'find' first to locate the element.",
792
+ inputSchema: z.object({
793
+ elementRef: z.string().describe("Reference to previously found element (required). Get this from a 'find' call."),
794
+ }),
795
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
796
+ }, async (params) => {
797
+ const startTime = Date.now();
798
+ logger.info("hover: Starting", { elementRef: params.elementRef });
708
799
  try {
709
- result = await raceAbort(extra.signal, "findall", core.findAll(params.description, params.timeout));
800
+ logger.debug("hover: Executing hover on element", { elementRef: params.elementRef });
801
+ const result = await core.hover(params.elementRef);
802
+ const duration = Date.now() - startTime;
803
+ logger.info("hover: Completed", { elementRef: params.elementRef, duration });
804
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
710
805
  }
711
- finally {
712
- stopHeartbeat();
806
+ catch (error) {
807
+ if (error instanceof NoActiveSessionError) {
808
+ logger.warn("hover: No active session");
809
+ return noSessionResult(error);
810
+ }
811
+ logger.error("hover: Failed", { error: String(error), elementRef: params.elementRef });
812
+ captureException(error, { tags: { tool: "hover" }, extra: { elementRef: params.elementRef } });
813
+ throw error;
713
814
  }
714
- const duration = Date.now() - startTime;
715
- logger.info("findall: Completed", { description: params.description, count: result.data.count, duration });
716
- return resultToMcp({ ...result, data: { ...result.data, duration } });
717
- }
718
- catch (error) {
719
- if (error instanceof NoActiveSessionError) {
720
- logger.warn("findall: No active session");
721
- return noSessionResult(error);
815
+ });
816
+ // Wait
817
+ server.registerTool("wait", {
818
+ description: "Wait for a specified amount of time",
819
+ inputSchema: z.object({
820
+ timeout: z.number().default(3000).describe("Time to wait in milliseconds (default: 3000)"),
821
+ }),
822
+ }, async (params) => {
823
+ const startTime = Date.now();
824
+ logger.info("wait: Starting", { timeout: params.timeout });
825
+ try {
826
+ logger.debug("wait: Waiting", { timeout: params.timeout });
827
+ const result = await core.wait(params.timeout);
828
+ const duration = Date.now() - startTime;
829
+ logger.info("wait: Completed", { timeout: params.timeout, duration });
830
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
722
831
  }
723
- const cancelled = cancelledResultOrNull(error, "findall");
724
- if (cancelled)
725
- return cancelled;
726
- logger.error("findall: Failed", { error: String(error), description: params.description });
727
- captureException(error, { tags: { tool: "findall" }, extra: { description: params.description } });
728
- throw error;
729
- }
730
- });
731
- // Click
732
- registerAppTool(server, "click", {
733
- title: "Click Element",
734
- description: "Click on a previously found element. Use 'find' first to locate the element.",
735
- inputSchema: z.object({
736
- elementRef: z.string().describe("Reference to previously found element (required). Get this from a 'find' call."),
737
- action: z.enum(["click", "double-click", "right-click"]).default("click"),
738
- }),
739
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
740
- }, async (params) => {
741
- const startTime = Date.now();
742
- logger.info("click: Starting", { elementRef: params.elementRef, action: params.action });
743
- try {
744
- logger.debug("click: Executing click on element", { elementRef: params.elementRef, action: params.action });
745
- const result = await core.click(params.elementRef, params.action);
746
- const duration = Date.now() - startTime;
747
- logger.info("click: Completed", { elementRef: params.elementRef, duration });
748
- return resultToMcp({ ...result, data: { ...result.data, duration } });
749
- }
750
- catch (error) {
751
- if (error instanceof NoActiveSessionError) {
752
- logger.warn("click: No active session");
753
- return noSessionResult(error);
832
+ catch (error) {
833
+ if (error instanceof NoActiveSessionError) {
834
+ logger.warn("wait: No active session");
835
+ return noSessionResult(error);
836
+ }
837
+ logger.error("wait: Failed", { error: String(error) });
838
+ captureException(error, { tags: { tool: "wait" }, extra: { timeout: params.timeout } });
839
+ throw error;
754
840
  }
755
- logger.error("click: Failed", { error: String(error), elementRef: params.elementRef });
756
- captureException(error, { tags: { tool: "click" }, extra: { elementRef: params.elementRef, action: params.action } });
757
- throw error;
758
- }
759
- });
760
- // Hover
761
- registerAppTool(server, "hover", {
762
- title: "Hover Element",
763
- description: "Hover over a previously found element. Use 'find' first to locate the element.",
764
- inputSchema: z.object({
765
- elementRef: z.string().describe("Reference to previously found element (required). Get this from a 'find' call."),
766
- }),
767
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
768
- }, async (params) => {
769
- const startTime = Date.now();
770
- logger.info("hover: Starting", { elementRef: params.elementRef });
771
- try {
772
- logger.debug("hover: Executing hover on element", { elementRef: params.elementRef });
773
- const result = await core.hover(params.elementRef);
774
- const duration = Date.now() - startTime;
775
- logger.info("hover: Completed", { elementRef: params.elementRef, duration });
776
- return resultToMcp({ ...result, data: { ...result.data, duration } });
777
- }
778
- catch (error) {
779
- if (error instanceof NoActiveSessionError) {
780
- logger.warn("hover: No active session");
781
- return noSessionResult(error);
841
+ });
842
+ // Focus Application
843
+ server.registerTool("focus_application", {
844
+ description: "Bring an application window to the foreground",
845
+ inputSchema: z.object({
846
+ name: z.string().describe("Name of the application to focus (e.g., 'Google Chrome', 'Visual Studio Code')"),
847
+ }),
848
+ }, async (params) => {
849
+ const startTime = Date.now();
850
+ logger.info("focus_application: Starting", { name: params.name });
851
+ try {
852
+ logger.debug("focus_application: Focusing", { name: params.name });
853
+ const result = await core.focusApplication(params.name);
854
+ const duration = Date.now() - startTime;
855
+ logger.info("focus_application: Completed", { name: params.name, duration });
856
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
782
857
  }
783
- logger.error("hover: Failed", { error: String(error), elementRef: params.elementRef });
784
- captureException(error, { tags: { tool: "hover" }, extra: { elementRef: params.elementRef } });
785
- throw error;
786
- }
787
- });
788
- // Wait
789
- server.registerTool("wait", {
790
- description: "Wait for a specified amount of time",
791
- inputSchema: z.object({
792
- timeout: z.number().default(3000).describe("Time to wait in milliseconds (default: 3000)"),
793
- }),
794
- }, async (params) => {
795
- const startTime = Date.now();
796
- logger.info("wait: Starting", { timeout: params.timeout });
797
- try {
798
- logger.debug("wait: Waiting", { timeout: params.timeout });
799
- const result = await core.wait(params.timeout);
800
- const duration = Date.now() - startTime;
801
- logger.info("wait: Completed", { timeout: params.timeout, duration });
802
- return resultToMcp({ ...result, data: { ...result.data, duration } });
803
- }
804
- catch (error) {
805
- if (error instanceof NoActiveSessionError) {
806
- logger.warn("wait: No active session");
807
- return noSessionResult(error);
858
+ catch (error) {
859
+ if (error instanceof NoActiveSessionError) {
860
+ logger.warn("focus_application: No active session");
861
+ return noSessionResult(error);
862
+ }
863
+ logger.error("focus_application: Failed", { error: String(error), name: params.name });
864
+ captureException(error, { tags: { tool: "focus_application" }, extra: { name: params.name } });
865
+ throw error;
808
866
  }
809
- logger.error("wait: Failed", { error: String(error) });
810
- captureException(error, { tags: { tool: "wait" }, extra: { timeout: params.timeout } });
811
- throw error;
812
- }
813
- });
814
- // Focus Application
815
- server.registerTool("focus_application", {
816
- description: "Bring an application window to the foreground",
817
- inputSchema: z.object({
818
- name: z.string().describe("Name of the application to focus (e.g., 'Google Chrome', 'Visual Studio Code')"),
819
- }),
820
- }, async (params) => {
821
- const startTime = Date.now();
822
- logger.info("focus_application: Starting", { name: params.name });
823
- try {
824
- logger.debug("focus_application: Focusing", { name: params.name });
825
- const result = await core.focusApplication(params.name);
826
- const duration = Date.now() - startTime;
827
- logger.info("focus_application: Completed", { name: params.name, duration });
828
- return resultToMcp({ ...result, data: { ...result.data, duration } });
829
- }
830
- catch (error) {
831
- if (error instanceof NoActiveSessionError) {
832
- logger.warn("focus_application: No active session");
833
- return noSessionResult(error);
867
+ });
868
+ // Find and Click
869
+ registerAppTool(server, "find_and_click", {
870
+ title: "Find and Click",
871
+ description: "Find an element and click it in one action",
872
+ inputSchema: z.object({
873
+ description: z.string().describe("Natural language description of element"),
874
+ action: z.enum(["click", "double-click", "right-click"]).default("click"),
875
+ }),
876
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
877
+ }, async (params, extra) => {
878
+ const startTime = Date.now();
879
+ const progress = makeProgressReporter(extra);
880
+ logger.info("find_and_click: Starting", { description: params.description, action: params.action });
881
+ try {
882
+ logger.debug("find_and_click: Finding element");
883
+ const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
884
+ let result;
885
+ try {
886
+ result = await raceAbort(extra.signal, "find_and_click", core.findAndClick(params.description, params.action));
887
+ }
888
+ finally {
889
+ stopHeartbeat();
890
+ }
891
+ const duration = Date.now() - startTime;
892
+ logger.info("find_and_click: Completed", { description: params.description, found: result.ok, duration });
893
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
834
894
  }
835
- logger.error("focus_application: Failed", { error: String(error), name: params.name });
836
- captureException(error, { tags: { tool: "focus_application" }, extra: { name: params.name } });
837
- throw error;
838
- }
839
- });
840
- // Find and Click
841
- registerAppTool(server, "find_and_click", {
842
- title: "Find and Click",
843
- description: "Find an element and click it in one action",
844
- inputSchema: z.object({
845
- description: z.string().describe("Natural language description of element"),
846
- action: z.enum(["click", "double-click", "right-click"]).default("click"),
847
- }),
848
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
849
- }, async (params, extra) => {
850
- const startTime = Date.now();
851
- const progress = makeProgressReporter(extra);
852
- logger.info("find_and_click: Starting", { description: params.description, action: params.action });
853
- try {
854
- logger.debug("find_and_click: Finding element");
855
- const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
856
- let result;
895
+ catch (error) {
896
+ if (error instanceof NoActiveSessionError) {
897
+ logger.warn("find_and_click: No active session");
898
+ return noSessionResult(error);
899
+ }
900
+ const cancelled = cancelledResultOrNull(error, "find_and_click");
901
+ if (cancelled)
902
+ return cancelled;
903
+ logger.error("find_and_click: Failed", { error: String(error), description: params.description });
904
+ captureException(error, { tags: { tool: "find_and_click" }, extra: { description: params.description, action: params.action } });
905
+ throw error;
906
+ }
907
+ });
908
+ // Type
909
+ server.registerTool("type", {
910
+ description: "Type text into the currently focused field",
911
+ inputSchema: z.object({
912
+ text: z.string().describe("Text to type"),
913
+ secret: z.boolean().default(false).describe("Whether this is sensitive data"),
914
+ delay: z.number().optional().describe("Delay between keystrokes in ms"),
915
+ }),
916
+ }, async (params) => {
917
+ const startTime = Date.now();
918
+ logger.info("type: Starting", { textLength: params.text.length, secret: params.secret });
857
919
  try {
858
- result = await raceAbort(extra.signal, "find_and_click", core.findAndClick(params.description, params.action));
920
+ logger.debug("type: Typing text");
921
+ const result = await core.type(params.text, params.secret, params.delay);
922
+ const duration = Date.now() - startTime;
923
+ logger.info("type: Completed", { duration });
924
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
859
925
  }
860
- finally {
861
- stopHeartbeat();
926
+ catch (error) {
927
+ if (error instanceof NoActiveSessionError) {
928
+ logger.warn("type: No active session");
929
+ return noSessionResult(error);
930
+ }
931
+ logger.error("type: Failed", { error: String(error) });
932
+ captureException(error, { tags: { tool: "type" }, extra: { textLength: params.text.length, secret: params.secret } });
933
+ throw error;
862
934
  }
863
- const duration = Date.now() - startTime;
864
- logger.info("find_and_click: Completed", { description: params.description, found: result.ok, duration });
865
- return resultToMcp({ ...result, data: { ...result.data, duration } });
866
- }
867
- catch (error) {
868
- if (error instanceof NoActiveSessionError) {
869
- logger.warn("find_and_click: No active session");
870
- return noSessionResult(error);
935
+ });
936
+ // Press Keys
937
+ server.registerTool("press_keys", {
938
+ description: "Press keyboard keys or shortcuts",
939
+ inputSchema: z.object({
940
+ keys: z.array(z.string()).describe("Array of keys to press (e.g., ['ctrl', 'a'])"),
941
+ }),
942
+ }, async (params) => {
943
+ const startTime = Date.now();
944
+ logger.info("press_keys: Starting", { keys: params.keys });
945
+ try {
946
+ logger.debug("press_keys: Pressing keys");
947
+ const result = await core.pressKeys(params.keys);
948
+ const duration = Date.now() - startTime;
949
+ logger.info("press_keys: Completed", { keys: params.keys, duration });
950
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
871
951
  }
872
- const cancelled = cancelledResultOrNull(error, "find_and_click");
873
- if (cancelled)
874
- return cancelled;
875
- logger.error("find_and_click: Failed", { error: String(error), description: params.description });
876
- captureException(error, { tags: { tool: "find_and_click" }, extra: { description: params.description, action: params.action } });
877
- throw error;
878
- }
879
- });
880
- // Type
881
- server.registerTool("type", {
882
- description: "Type text into the currently focused field",
883
- inputSchema: z.object({
884
- text: z.string().describe("Text to type"),
885
- secret: z.boolean().default(false).describe("Whether this is sensitive data"),
886
- delay: z.number().optional().describe("Delay between keystrokes in ms"),
887
- }),
888
- }, async (params) => {
889
- const startTime = Date.now();
890
- logger.info("type: Starting", { textLength: params.text.length, secret: params.secret });
891
- try {
892
- logger.debug("type: Typing text");
893
- const result = await core.type(params.text, params.secret, params.delay);
894
- const duration = Date.now() - startTime;
895
- logger.info("type: Completed", { duration });
896
- return resultToMcp({ ...result, data: { ...result.data, duration } });
897
- }
898
- catch (error) {
899
- if (error instanceof NoActiveSessionError) {
900
- logger.warn("type: No active session");
901
- return noSessionResult(error);
952
+ catch (error) {
953
+ if (error instanceof NoActiveSessionError) {
954
+ logger.warn("press_keys: No active session");
955
+ return noSessionResult(error);
956
+ }
957
+ logger.error("press_keys: Failed", { error: String(error), keys: params.keys });
958
+ captureException(error, { tags: { tool: "press_keys" }, extra: { keys: params.keys } });
959
+ throw error;
902
960
  }
903
- logger.error("type: Failed", { error: String(error) });
904
- captureException(error, { tags: { tool: "type" }, extra: { textLength: params.text.length, secret: params.secret } });
905
- throw error;
906
- }
907
- });
908
- // Press Keys
909
- server.registerTool("press_keys", {
910
- description: "Press keyboard keys or shortcuts",
911
- inputSchema: z.object({
912
- keys: z.array(z.string()).describe("Array of keys to press (e.g., ['ctrl', 'a'])"),
913
- }),
914
- }, async (params) => {
915
- const startTime = Date.now();
916
- logger.info("press_keys: Starting", { keys: params.keys });
917
- try {
918
- logger.debug("press_keys: Pressing keys");
919
- const result = await core.pressKeys(params.keys);
920
- const duration = Date.now() - startTime;
921
- logger.info("press_keys: Completed", { keys: params.keys, duration });
922
- return resultToMcp({ ...result, data: { ...result.data, duration } });
923
- }
924
- catch (error) {
925
- if (error instanceof NoActiveSessionError) {
926
- logger.warn("press_keys: No active session");
927
- return noSessionResult(error);
961
+ });
962
+ // Scroll
963
+ server.registerTool("scroll", {
964
+ description: "Scroll the page or element",
965
+ inputSchema: z.object({
966
+ direction: z.enum(["up", "down", "left", "right"]).default("down"),
967
+ amount: z.number().optional().describe("Amount to scroll in pixels"),
968
+ }),
969
+ }, async (params) => {
970
+ const startTime = Date.now();
971
+ logger.info("scroll: Starting", { direction: params.direction, amount: params.amount });
972
+ try {
973
+ logger.debug("scroll: Scrolling");
974
+ const result = await core.scroll(params.direction, params.amount);
975
+ const duration = Date.now() - startTime;
976
+ logger.info("scroll: Completed", { direction: params.direction, duration });
977
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
928
978
  }
929
- logger.error("press_keys: Failed", { error: String(error), keys: params.keys });
930
- captureException(error, { tags: { tool: "press_keys" }, extra: { keys: params.keys } });
931
- throw error;
932
- }
933
- });
934
- // Scroll
935
- server.registerTool("scroll", {
936
- description: "Scroll the page or element",
937
- inputSchema: z.object({
938
- direction: z.enum(["up", "down", "left", "right"]).default("down"),
939
- amount: z.number().optional().describe("Amount to scroll in pixels"),
940
- }),
941
- }, async (params) => {
942
- const startTime = Date.now();
943
- logger.info("scroll: Starting", { direction: params.direction, amount: params.amount });
944
- try {
945
- logger.debug("scroll: Scrolling");
946
- const result = await core.scroll(params.direction, params.amount);
947
- const duration = Date.now() - startTime;
948
- logger.info("scroll: Completed", { direction: params.direction, duration });
949
- return resultToMcp({ ...result, data: { ...result.data, duration } });
950
- }
951
- catch (error) {
952
- if (error instanceof NoActiveSessionError) {
953
- logger.warn("scroll: No active session");
954
- return noSessionResult(error);
979
+ catch (error) {
980
+ if (error instanceof NoActiveSessionError) {
981
+ logger.warn("scroll: No active session");
982
+ return noSessionResult(error);
983
+ }
984
+ logger.error("scroll: Failed", { error: String(error), direction: params.direction });
985
+ captureException(error, { tags: { tool: "scroll" }, extra: { direction: params.direction, amount: params.amount } });
986
+ throw error;
955
987
  }
956
- logger.error("scroll: Failed", { error: String(error), direction: params.direction });
957
- captureException(error, { tags: { tool: "scroll" }, extra: { direction: params.direction, amount: params.amount } });
958
- throw error;
959
- }
960
- });
961
- // Assert - generates code for test files
962
- server.registerTool("assert", {
963
- description: `Make an AI-powered assertion about the current screen state. GENERATES CODE for the test file.
988
+ });
989
+ // Assert - generates code for test files
990
+ server.registerTool("assert", {
991
+ description: `Make an AI-powered assertion about the current screen state. GENERATES CODE for the test file.
964
992
 
965
993
  Use this when you want a verification step recorded in the generated test. This will add code like:
966
994
  const assertResult = await testdriver.assert("your assertion");
967
995
  expect(assertResult).toBeTruthy();
968
996
 
969
997
  Unlike 'check' which is for your understanding during development, 'assert' creates verification code that runs in CI/CD.`,
970
- inputSchema: z.object({
971
- assertion: z.string().describe("Natural language assertion to verify"),
972
- }),
973
- }, async (params) => {
974
- const startTime = Date.now();
975
- logger.info("assert: Starting", { assertion: params.assertion });
976
- try {
977
- logger.debug("assert: Running assertion");
978
- const result = await core.assert(params.assertion);
979
- const duration = Date.now() - startTime;
980
- logger.info("assert: Completed", { assertion: params.assertion, passed: result.ok, duration });
981
- return resultToMcp({ ...result, data: { ...result.data, duration } });
982
- }
983
- catch (error) {
984
- if (error instanceof NoActiveSessionError) {
985
- logger.warn("assert: No active session");
986
- return noSessionResult(error);
998
+ inputSchema: z.object({
999
+ assertion: z.string().describe("Natural language assertion to verify"),
1000
+ }),
1001
+ }, async (params) => {
1002
+ const startTime = Date.now();
1003
+ logger.info("assert: Starting", { assertion: params.assertion });
1004
+ try {
1005
+ logger.debug("assert: Running assertion");
1006
+ const result = await core.assert(params.assertion);
1007
+ const duration = Date.now() - startTime;
1008
+ logger.info("assert: Completed", { assertion: params.assertion, passed: result.ok, duration });
1009
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
987
1010
  }
988
- logger.error("assert: Failed", { error: String(error), assertion: params.assertion });
989
- captureException(error, { tags: { tool: "assert" }, extra: { assertion: params.assertion } });
990
- throw error;
991
- }
992
- });
993
- // Check - AI uses this to understand the screen state (DOES NOT generate code)
994
- registerAppTool(server, "check", {
995
- title: "Check Screen State",
996
- description: `👁️ THIS IS HOW YOU SEE THE SCREEN. Use this tool whenever you need to understand what's currently displayed.
1011
+ catch (error) {
1012
+ if (error instanceof NoActiveSessionError) {
1013
+ logger.warn("assert: No active session");
1014
+ return noSessionResult(error);
1015
+ }
1016
+ logger.error("assert: Failed", { error: String(error), assertion: params.assertion });
1017
+ captureException(error, { tags: { tool: "assert" }, extra: { assertion: params.assertion } });
1018
+ throw error;
1019
+ }
1020
+ });
1021
+ // Check - AI uses this to understand the screen state (DOES NOT generate code)
1022
+ registerAppTool(server, "check", {
1023
+ title: "Check Screen State",
1024
+ description: `👁️ THIS IS HOW YOU SEE THE SCREEN. Use this tool whenever you need to understand what's currently displayed.
997
1025
 
998
1026
  This tool captures a screenshot and returns AI analysis to YOU. Use it to:
999
1027
  - See what's on the screen right now
@@ -1012,120 +1040,118 @@ Examples:
1012
1040
  Note: This tool does NOT generate test code. Use 'assert' when you want to add a verification step to the test file.
1013
1041
 
1014
1042
  You can optionally provide a reference image URI to compare against a previous state.`,
1015
- inputSchema: z.object({
1016
- task: z.string().describe("The task or condition to verify (e.g., 'Did the login succeed?', 'Is the modal visible?')"),
1017
- referenceImageUri: z.string().optional().describe("Optional screenshot resource URI (e.g., 'screenshot://testdriver/screenshot/screenshot-1') to compare against instead of the automatically captured 'before' screenshot. Use a screenshotResourceUri from a previous action."),
1018
- }),
1019
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1020
- }, async (params, extra) => {
1021
- const startTime = Date.now();
1022
- const progress = makeProgressReporter(extra);
1023
- logger.info("check: Starting", { task: params.task, hasReferenceImageUri: !!params.referenceImageUri });
1024
- try {
1025
- // Resolve the optional reference image from the image store (a server/UI
1026
- // concern). The core handles "last screenshot" / current-screenshot
1027
- // fallback internally when no reference image is passed.
1028
- let referenceImage;
1029
- if (params.referenceImageUri) {
1030
- // Extract image ID from URI (e.g., "screenshot://testdriver/screenshot/screenshot-1" -> "screenshot-1")
1031
- const uriParts = params.referenceImageUri.split("/");
1032
- const imageId = uriParts[uriParts.length - 1];
1033
- logger.info("check: Looking up reference image", {
1034
- referenceImageUri: params.referenceImageUri,
1035
- extractedImageId: imageId,
1036
- imageStoreSize: imageStore.size,
1037
- availableKeys: Array.from(imageStore.keys()),
1038
- });
1039
- const storedImage = getStoredImage(imageId);
1040
- if (storedImage) {
1041
- logger.info("check: Found reference image", {
1042
- imageId,
1043
- dataLength: storedImage.data?.length,
1044
- type: storedImage.type,
1045
- hasData: !!storedImage.data,
1046
- });
1047
- referenceImage = storedImage.data;
1048
- }
1049
- else {
1050
- logger.warn("check: Reference image NOT found in store, falling back to last screenshot", {
1043
+ inputSchema: z.object({
1044
+ task: z.string().describe("The task or condition to verify (e.g., 'Did the login succeed?', 'Is the modal visible?')"),
1045
+ referenceImageUri: z.string().optional().describe("Optional screenshot resource URI (e.g., 'screenshot://testdriver/screenshot/screenshot-1') to compare against instead of the automatically captured 'before' screenshot. Use a screenshotResourceUri from a previous action."),
1046
+ }),
1047
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1048
+ }, async (params, extra) => {
1049
+ const startTime = Date.now();
1050
+ const progress = makeProgressReporter(extra);
1051
+ logger.info("check: Starting", { task: params.task, hasReferenceImageUri: !!params.referenceImageUri });
1052
+ try {
1053
+ // Resolve the optional reference image from the image store (a server/UI
1054
+ // concern). The core handles "last screenshot" / current-screenshot
1055
+ // fallback internally when no reference image is passed.
1056
+ let referenceImage;
1057
+ if (params.referenceImageUri) {
1058
+ // Extract image ID from URI (e.g., "screenshot://testdriver/screenshot/screenshot-1" -> "screenshot-1")
1059
+ const uriParts = params.referenceImageUri.split("/");
1060
+ const imageId = uriParts[uriParts.length - 1];
1061
+ logger.info("check: Looking up reference image", {
1051
1062
  referenceImageUri: params.referenceImageUri,
1052
- imageId,
1053
- imageStoreSize: imageStore.size,
1054
- availableKeys: Array.from(imageStore.keys()),
1063
+ extractedImageId: imageId,
1064
+ ...imageStoreDiagnostics(),
1055
1065
  });
1066
+ const storedImage = getStoredImage(imageId);
1067
+ if (storedImage) {
1068
+ logger.info("check: Found reference image", {
1069
+ imageId,
1070
+ dataLength: storedImage.data?.length,
1071
+ type: storedImage.type,
1072
+ hasData: !!storedImage.data,
1073
+ });
1074
+ referenceImage = storedImage.data;
1075
+ }
1076
+ else {
1077
+ logger.warn("check: Reference image NOT found in store, falling back to last screenshot", {
1078
+ referenceImageUri: params.referenceImageUri,
1079
+ imageId,
1080
+ ...imageStoreDiagnostics(),
1081
+ });
1082
+ }
1083
+ }
1084
+ progress.report("Capturing screenshot...");
1085
+ const stopHeartbeat = progress.heartbeat(`Checking: "${params.task}"...`);
1086
+ let result;
1087
+ try {
1088
+ result = await raceAbort(extra.signal, "check", core.check(params.task, referenceImage));
1089
+ }
1090
+ finally {
1091
+ stopHeartbeat();
1056
1092
  }
1093
+ const duration = Date.now() - startTime;
1094
+ logger.info("check: Completed", { task: params.task, complete: result.ok, duration });
1095
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1057
1096
  }
1058
- progress.report("Capturing screenshot...");
1059
- const stopHeartbeat = progress.heartbeat(`Checking: "${params.task}"...`);
1060
- let result;
1061
- try {
1062
- result = await raceAbort(extra.signal, "check", core.check(params.task, referenceImage));
1097
+ catch (error) {
1098
+ if (error instanceof NoActiveSessionError) {
1099
+ logger.warn("check: No active session");
1100
+ return noSessionResult(error);
1101
+ }
1102
+ const cancelled = cancelledResultOrNull(error, "check");
1103
+ if (cancelled)
1104
+ return cancelled;
1105
+ logger.error("check: Failed", { error: String(error), task: params.task });
1106
+ captureException(error, { tags: { tool: "check" }, extra: { task: params.task } });
1107
+ throw error;
1063
1108
  }
1064
- finally {
1065
- stopHeartbeat();
1109
+ });
1110
+ // Exec
1111
+ server.registerTool("exec", {
1112
+ description: "Execute shell or PowerShell commands in the sandbox",
1113
+ inputSchema: z.object({
1114
+ language: z.enum(["sh", "pwsh"]).default("sh"),
1115
+ code: z.string().describe("Code to execute"),
1116
+ timeout: z.number().default(30000).describe("Timeout in ms"),
1117
+ }),
1118
+ }, async (params) => {
1119
+ const startTime = Date.now();
1120
+ logger.info("exec: Starting", { language: params.language, codeLength: params.code.length, timeout: params.timeout });
1121
+ try {
1122
+ logger.debug("exec: Executing code", { language: params.language });
1123
+ const result = await core.exec(params.language, params.code, params.timeout);
1124
+ const duration = Date.now() - startTime;
1125
+ logger.info("exec: Completed", { language: params.language, outputLength: result.data.output?.length || 0, duration });
1126
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1066
1127
  }
1067
- const duration = Date.now() - startTime;
1068
- logger.info("check: Completed", { task: params.task, complete: result.ok, duration });
1069
- return resultToMcp({ ...result, data: { ...result.data, duration } });
1070
- }
1071
- catch (error) {
1072
- if (error instanceof NoActiveSessionError) {
1073
- logger.warn("check: No active session");
1074
- return noSessionResult(error);
1128
+ catch (error) {
1129
+ if (error instanceof NoActiveSessionError) {
1130
+ logger.warn("exec: No active session");
1131
+ return noSessionResult(error);
1132
+ }
1133
+ logger.error("exec: Failed", { error: String(error), language: params.language });
1134
+ captureException(error, { tags: { tool: "exec" }, extra: { language: params.language, codeLength: params.code.length } });
1135
+ throw error;
1075
1136
  }
1076
- const cancelled = cancelledResultOrNull(error, "check");
1077
- if (cancelled)
1078
- return cancelled;
1079
- logger.error("check: Failed", { error: String(error), task: params.task });
1080
- captureException(error, { tags: { tool: "check" }, extra: { task: params.task } });
1081
- throw error;
1082
- }
1083
- });
1084
- // Exec
1085
- server.registerTool("exec", {
1086
- description: "Execute shell or PowerShell commands in the sandbox",
1087
- inputSchema: z.object({
1088
- language: z.enum(["sh", "pwsh"]).default("sh"),
1089
- code: z.string().describe("Code to execute"),
1090
- timeout: z.number().default(30000).describe("Timeout in ms"),
1091
- }),
1092
- }, async (params) => {
1093
- const startTime = Date.now();
1094
- logger.info("exec: Starting", { language: params.language, codeLength: params.code.length, timeout: params.timeout });
1095
- try {
1096
- logger.debug("exec: Executing code", { language: params.language });
1097
- const result = await core.exec(params.language, params.code, params.timeout);
1098
- const duration = Date.now() - startTime;
1099
- logger.info("exec: Completed", { language: params.language, outputLength: result.data.output?.length || 0, duration });
1100
- return resultToMcp({ ...result, data: { ...result.data, duration } });
1101
- }
1102
- catch (error) {
1103
- if (error instanceof NoActiveSessionError) {
1104
- logger.warn("exec: No active session");
1105
- return noSessionResult(error);
1137
+ });
1138
+ function parseScreenshotFilename(filename) {
1139
+ // Match pattern: 001-click-before-L42-submit-button.png or 001-click-error-L42-submit-button.png
1140
+ const match = filename.match(/^(\d+)-([a-z]+)-(before|after|error)-L(\d+)-(.+)\.png$/i);
1141
+ if (match) {
1142
+ return {
1143
+ sequence: parseInt(match[1], 10),
1144
+ action: match[2].toLowerCase(),
1145
+ phase: match[3].toLowerCase(),
1146
+ lineNumber: parseInt(match[4], 10),
1147
+ description: match[5],
1148
+ };
1106
1149
  }
1107
- logger.error("exec: Failed", { error: String(error), language: params.language });
1108
- captureException(error, { tags: { tool: "exec" }, extra: { language: params.language, codeLength: params.code.length } });
1109
- throw error;
1110
- }
1111
- });
1112
- function parseScreenshotFilename(filename) {
1113
- // Match pattern: 001-click-before-L42-submit-button.png or 001-click-error-L42-submit-button.png
1114
- const match = filename.match(/^(\d+)-([a-z]+)-(before|after|error)-L(\d+)-(.+)\.png$/i);
1115
- if (match) {
1116
- return {
1117
- sequence: parseInt(match[1], 10),
1118
- action: match[2].toLowerCase(),
1119
- phase: match[3].toLowerCase(),
1120
- lineNumber: parseInt(match[4], 10),
1121
- description: match[5],
1122
- };
1150
+ return {};
1123
1151
  }
1124
- return {};
1125
- }
1126
- // List Local Screenshots - lists screenshots saved to .testdriver directory
1127
- server.registerTool("list_local_screenshots", {
1128
- description: `List and filter screenshots saved in the .testdriver directory.
1152
+ // List Local Screenshots - lists screenshots saved to .testdriver directory
1153
+ server.registerTool("list_local_screenshots", {
1154
+ description: `List and filter screenshots saved in the .testdriver directory.
1129
1155
 
1130
1156
  Screenshots from auto-screenshot feature use the format: <seq>-<action>-<phase>-L<line>-<description>.png
1131
1157
  Example: 001-click-before-L42-submit-button.png
@@ -1139,213 +1165,213 @@ This tool supports powerful filtering to find specific screenshots:
1139
1165
  - By sequence number range
1140
1166
 
1141
1167
  Returns a list of screenshot paths that can be viewed with the 'view_local_screenshot' tool.`,
1142
- inputSchema: z.object({
1143
- directory: z.string().optional().describe("Test file or subdirectory to search (e.g., 'login.test', 'mcp-screenshots'). If not provided, searches all."),
1144
- line: z.number().optional().describe("Filter by exact line number from test file (e.g., 42 matches L42)"),
1145
- lineRange: z.object({
1146
- start: z.number().describe("Start line number (inclusive)"),
1147
- end: z.number().describe("End line number (inclusive)"),
1148
- }).optional().describe("Filter by line number range (e.g., { start: 10, end: 20 })"),
1149
- action: z.string().optional().describe("Filter by action type: click, find, type, assert, provision, scroll, hover, etc."),
1150
- phase: z.enum(["before", "after", "error"]).optional().describe("Filter by phase: 'before' (pre-action), 'after' (post-action), or 'error' (when action fails)"),
1151
- pattern: z.string().optional().describe("Regex pattern to match against filename (e.g., 'submit|login' or 'button.*click')"),
1152
- sequence: z.number().optional().describe("Filter by exact sequence number"),
1153
- sequenceRange: z.object({
1154
- start: z.number().describe("Start sequence (inclusive)"),
1155
- end: z.number().describe("End sequence (inclusive)"),
1156
- }).optional().describe("Filter by sequence range (e.g., { start: 1, end: 10 })"),
1157
- limit: z.number().optional().describe("Maximum number of results to return (default: 50)"),
1158
- sortBy: z.enum(["modified", "sequence", "line"]).optional().describe("Sort by: 'modified' (newest first), 'sequence' (execution order), or 'line' (line number). Default: 'modified'"),
1159
- }),
1160
- }, async (params) => {
1161
- const startTime = Date.now();
1162
- logger.info("list_local_screenshots: Starting", { ...params });
1163
- try {
1164
- // Find .testdriver directory - check current working directory and common locations
1165
- const possiblePaths = [
1166
- path.join(process.cwd(), ".testdriver"),
1167
- path.join(os.homedir(), ".testdriver"),
1168
- ];
1169
- let testdriverDir = null;
1170
- for (const p of possiblePaths) {
1171
- if (fs.existsSync(p)) {
1172
- testdriverDir = p;
1173
- break;
1168
+ inputSchema: z.object({
1169
+ directory: z.string().optional().describe("Test file or subdirectory to search (e.g., 'login.test', 'mcp-screenshots'). If not provided, searches all."),
1170
+ line: z.number().optional().describe("Filter by exact line number from test file (e.g., 42 matches L42)"),
1171
+ lineRange: z.object({
1172
+ start: z.number().describe("Start line number (inclusive)"),
1173
+ end: z.number().describe("End line number (inclusive)"),
1174
+ }).optional().describe("Filter by line number range (e.g., { start: 10, end: 20 })"),
1175
+ action: z.string().optional().describe("Filter by action type: click, find, type, assert, provision, scroll, hover, etc."),
1176
+ phase: z.enum(["before", "after", "error"]).optional().describe("Filter by phase: 'before' (pre-action), 'after' (post-action), or 'error' (when action fails)"),
1177
+ pattern: z.string().optional().describe("Regex pattern to match against filename (e.g., 'submit|login' or 'button.*click')"),
1178
+ sequence: z.number().optional().describe("Filter by exact sequence number"),
1179
+ sequenceRange: z.object({
1180
+ start: z.number().describe("Start sequence (inclusive)"),
1181
+ end: z.number().describe("End sequence (inclusive)"),
1182
+ }).optional().describe("Filter by sequence range (e.g., { start: 1, end: 10 })"),
1183
+ limit: z.number().optional().describe("Maximum number of results to return (default: 50)"),
1184
+ sortBy: z.enum(["modified", "sequence", "line"]).optional().describe("Sort by: 'modified' (newest first), 'sequence' (execution order), or 'line' (line number). Default: 'modified'"),
1185
+ }),
1186
+ }, async (params) => {
1187
+ const startTime = Date.now();
1188
+ logger.info("list_local_screenshots: Starting", { ...params });
1189
+ try {
1190
+ // Find .testdriver directory - check current working directory and common locations
1191
+ const possiblePaths = [
1192
+ path.join(process.cwd(), ".testdriver"),
1193
+ path.join(os.homedir(), ".testdriver"),
1194
+ ];
1195
+ let testdriverDir = null;
1196
+ for (const p of possiblePaths) {
1197
+ if (fs.existsSync(p)) {
1198
+ testdriverDir = p;
1199
+ break;
1200
+ }
1174
1201
  }
1175
- }
1176
- if (!testdriverDir) {
1177
- logger.warn("list_local_screenshots: .testdriver directory not found");
1178
- return createToolResult(false, "No .testdriver directory found. Screenshots are saved here during test runs.", { error: "Directory not found" });
1179
- }
1180
- const screenshots = [];
1181
- // Compile regex pattern if provided
1182
- let regexPattern = null;
1183
- if (params.pattern) {
1184
- try {
1185
- regexPattern = new RegExp(params.pattern, "i");
1202
+ if (!testdriverDir) {
1203
+ logger.warn("list_local_screenshots: .testdriver directory not found");
1204
+ return createToolResult(false, "No .testdriver directory found. Screenshots are saved here during test runs.", { error: "Directory not found" });
1186
1205
  }
1187
- catch {
1188
- return createToolResult(false, `Invalid regex pattern: ${params.pattern}`, { error: "Invalid regex" });
1206
+ const screenshots = [];
1207
+ // Compile regex pattern if provided
1208
+ let regexPattern = null;
1209
+ if (params.pattern) {
1210
+ try {
1211
+ regexPattern = new RegExp(params.pattern, "i");
1212
+ }
1213
+ catch {
1214
+ return createToolResult(false, `Invalid regex pattern: ${params.pattern}`, { error: "Invalid regex" });
1215
+ }
1189
1216
  }
1190
- }
1191
- // Function to recursively find PNG files
1192
- const findPngFiles = (dir) => {
1193
- if (!fs.existsSync(dir))
1194
- return;
1195
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1196
- for (const entry of entries) {
1197
- const fullPath = path.join(dir, entry.name);
1198
- if (entry.isDirectory()) {
1199
- // If a specific directory was requested, only search that one
1200
- if (!params.directory || entry.name === params.directory || dir !== testdriverDir) {
1201
- findPngFiles(fullPath);
1217
+ // Function to recursively find PNG files
1218
+ const findPngFiles = (dir) => {
1219
+ if (!fs.existsSync(dir))
1220
+ return;
1221
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1222
+ for (const entry of entries) {
1223
+ const fullPath = path.join(dir, entry.name);
1224
+ if (entry.isDirectory()) {
1225
+ // If a specific directory was requested, only search that one
1226
+ if (!params.directory || entry.name === params.directory || dir !== testdriverDir) {
1227
+ findPngFiles(fullPath);
1228
+ }
1229
+ }
1230
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".png")) {
1231
+ const parsed = parseScreenshotFilename(entry.name);
1232
+ // Apply filters
1233
+ if (params.line !== undefined && parsed.lineNumber !== params.line)
1234
+ continue;
1235
+ if (params.lineRange && (parsed.lineNumber === undefined ||
1236
+ parsed.lineNumber < params.lineRange.start ||
1237
+ parsed.lineNumber > params.lineRange.end))
1238
+ continue;
1239
+ if (params.action && parsed.action !== params.action.toLowerCase())
1240
+ continue;
1241
+ if (params.phase && parsed.phase !== params.phase)
1242
+ continue;
1243
+ if (params.sequence !== undefined && parsed.sequence !== params.sequence)
1244
+ continue;
1245
+ if (params.sequenceRange && (parsed.sequence === undefined ||
1246
+ parsed.sequence < params.sequenceRange.start ||
1247
+ parsed.sequence > params.sequenceRange.end))
1248
+ continue;
1249
+ if (regexPattern && !regexPattern.test(entry.name))
1250
+ continue;
1251
+ const stats = fs.statSync(fullPath);
1252
+ screenshots.push({
1253
+ path: fullPath,
1254
+ name: entry.name,
1255
+ modified: stats.mtime,
1256
+ size: stats.size,
1257
+ parsed,
1258
+ });
1202
1259
  }
1203
1260
  }
1204
- else if (entry.isFile() && entry.name.toLowerCase().endsWith(".png")) {
1205
- const parsed = parseScreenshotFilename(entry.name);
1206
- // Apply filters
1207
- if (params.line !== undefined && parsed.lineNumber !== params.line)
1208
- continue;
1209
- if (params.lineRange && (parsed.lineNumber === undefined ||
1210
- parsed.lineNumber < params.lineRange.start ||
1211
- parsed.lineNumber > params.lineRange.end))
1212
- continue;
1213
- if (params.action && parsed.action !== params.action.toLowerCase())
1214
- continue;
1215
- if (params.phase && parsed.phase !== params.phase)
1216
- continue;
1217
- if (params.sequence !== undefined && parsed.sequence !== params.sequence)
1218
- continue;
1219
- if (params.sequenceRange && (parsed.sequence === undefined ||
1220
- parsed.sequence < params.sequenceRange.start ||
1221
- parsed.sequence > params.sequenceRange.end))
1222
- continue;
1223
- if (regexPattern && !regexPattern.test(entry.name))
1224
- continue;
1225
- const stats = fs.statSync(fullPath);
1226
- screenshots.push({
1227
- path: fullPath,
1228
- name: entry.name,
1229
- modified: stats.mtime,
1230
- size: stats.size,
1231
- parsed,
1232
- });
1233
- }
1261
+ };
1262
+ findPngFiles(testdriverDir);
1263
+ // Sort based on sortBy parameter
1264
+ const sortBy = params.sortBy || "modified";
1265
+ if (sortBy === "modified") {
1266
+ screenshots.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1234
1267
  }
1235
- };
1236
- findPngFiles(testdriverDir);
1237
- // Sort based on sortBy parameter
1238
- const sortBy = params.sortBy || "modified";
1239
- if (sortBy === "modified") {
1240
- screenshots.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1241
- }
1242
- else if (sortBy === "sequence") {
1243
- screenshots.sort((a, b) => (a.parsed.sequence ?? Infinity) - (b.parsed.sequence ?? Infinity));
1244
- }
1245
- else if (sortBy === "line") {
1246
- screenshots.sort((a, b) => (a.parsed.lineNumber ?? Infinity) - (b.parsed.lineNumber ?? Infinity));
1247
- }
1248
- const duration = Date.now() - startTime;
1249
- logger.info("list_local_screenshots: Completed", { count: screenshots.length, duration });
1250
- if (screenshots.length === 0) {
1251
- const filters = [];
1252
- if (params.directory)
1253
- filters.push(`directory=${params.directory}`);
1254
- if (params.line)
1255
- filters.push(`line=${params.line}`);
1256
- if (params.lineRange)
1257
- filters.push(`lineRange=${params.lineRange.start}-${params.lineRange.end}`);
1258
- if (params.action)
1259
- filters.push(`action=${params.action}`);
1260
- if (params.phase)
1261
- filters.push(`phase=${params.phase}`);
1262
- if (params.pattern)
1263
- filters.push(`pattern=${params.pattern}`);
1264
- if (params.sequence)
1265
- filters.push(`sequence=${params.sequence}`);
1266
- if (params.sequenceRange)
1267
- filters.push(`sequenceRange=${params.sequenceRange.start}-${params.sequenceRange.end}`);
1268
- const filterMsg = filters.length > 0 ? ` with filters: ${filters.join(", ")}` : "";
1269
- return createToolResult(true, `No screenshots found in .testdriver directory${filterMsg}.`, {
1268
+ else if (sortBy === "sequence") {
1269
+ screenshots.sort((a, b) => (a.parsed.sequence ?? Infinity) - (b.parsed.sequence ?? Infinity));
1270
+ }
1271
+ else if (sortBy === "line") {
1272
+ screenshots.sort((a, b) => (a.parsed.lineNumber ?? Infinity) - (b.parsed.lineNumber ?? Infinity));
1273
+ }
1274
+ const duration = Date.now() - startTime;
1275
+ logger.info("list_local_screenshots: Completed", { count: screenshots.length, duration });
1276
+ if (screenshots.length === 0) {
1277
+ const filters = [];
1278
+ if (params.directory)
1279
+ filters.push(`directory=${params.directory}`);
1280
+ if (params.line)
1281
+ filters.push(`line=${params.line}`);
1282
+ if (params.lineRange)
1283
+ filters.push(`lineRange=${params.lineRange.start}-${params.lineRange.end}`);
1284
+ if (params.action)
1285
+ filters.push(`action=${params.action}`);
1286
+ if (params.phase)
1287
+ filters.push(`phase=${params.phase}`);
1288
+ if (params.pattern)
1289
+ filters.push(`pattern=${params.pattern}`);
1290
+ if (params.sequence)
1291
+ filters.push(`sequence=${params.sequence}`);
1292
+ if (params.sequenceRange)
1293
+ filters.push(`sequenceRange=${params.sequenceRange.start}-${params.sequenceRange.end}`);
1294
+ const filterMsg = filters.length > 0 ? ` with filters: ${filters.join(", ")}` : "";
1295
+ return createToolResult(true, `No screenshots found in .testdriver directory${filterMsg}.`, {
1296
+ action: "list_local_screenshots",
1297
+ count: 0,
1298
+ directory: testdriverDir,
1299
+ filters: params,
1300
+ duration
1301
+ });
1302
+ }
1303
+ const limit = params.limit || 50;
1304
+ const limitedScreenshots = screenshots.slice(0, limit);
1305
+ // Format the list for display with parsed info
1306
+ const screenshotList = limitedScreenshots.map((s, i) => {
1307
+ const relativePath = path.relative(testdriverDir, s.path);
1308
+ const sizeKB = Math.round(s.size / 1024);
1309
+ const timeAgo = formatTimeAgo(s.modified);
1310
+ // Add parsed info if available
1311
+ const parts = [`${i + 1}. ${relativePath}`];
1312
+ const meta = [];
1313
+ if (s.parsed.lineNumber)
1314
+ meta.push(`L${s.parsed.lineNumber}`);
1315
+ if (s.parsed.action)
1316
+ meta.push(s.parsed.action);
1317
+ if (s.parsed.phase)
1318
+ meta.push(s.parsed.phase);
1319
+ meta.push(`${sizeKB}KB`);
1320
+ meta.push(timeAgo);
1321
+ parts.push(`(${meta.join(", ")})`);
1322
+ return parts.join(" ");
1323
+ }).join("\n");
1324
+ const message = screenshots.length > limit
1325
+ ? `Found ${screenshots.length} screenshots (showing ${limit} results, sorted by ${sortBy}):\n\n${screenshotList}`
1326
+ : `Found ${screenshots.length} screenshot(s) (sorted by ${sortBy}):\n\n${screenshotList}`;
1327
+ return createToolResult(true, message, {
1270
1328
  action: "list_local_screenshots",
1271
- count: 0,
1329
+ count: screenshots.length,
1330
+ returned: limitedScreenshots.length,
1272
1331
  directory: testdriverDir,
1273
1332
  filters: params,
1333
+ sortBy,
1334
+ screenshots: limitedScreenshots.map(s => ({
1335
+ path: s.path,
1336
+ relativePath: path.relative(testdriverDir, s.path),
1337
+ name: s.name,
1338
+ modified: s.modified.toISOString(),
1339
+ sizeBytes: s.size,
1340
+ sequence: s.parsed.sequence,
1341
+ action: s.parsed.action,
1342
+ phase: s.parsed.phase,
1343
+ lineNumber: s.parsed.lineNumber,
1344
+ description: s.parsed.description,
1345
+ })),
1274
1346
  duration
1275
1347
  });
1276
1348
  }
1277
- const limit = params.limit || 50;
1278
- const limitedScreenshots = screenshots.slice(0, limit);
1279
- // Format the list for display with parsed info
1280
- const screenshotList = limitedScreenshots.map((s, i) => {
1281
- const relativePath = path.relative(testdriverDir, s.path);
1282
- const sizeKB = Math.round(s.size / 1024);
1283
- const timeAgo = formatTimeAgo(s.modified);
1284
- // Add parsed info if available
1285
- const parts = [`${i + 1}. ${relativePath}`];
1286
- const meta = [];
1287
- if (s.parsed.lineNumber)
1288
- meta.push(`L${s.parsed.lineNumber}`);
1289
- if (s.parsed.action)
1290
- meta.push(s.parsed.action);
1291
- if (s.parsed.phase)
1292
- meta.push(s.parsed.phase);
1293
- meta.push(`${sizeKB}KB`);
1294
- meta.push(timeAgo);
1295
- parts.push(`(${meta.join(", ")})`);
1296
- return parts.join(" ");
1297
- }).join("\n");
1298
- const message = screenshots.length > limit
1299
- ? `Found ${screenshots.length} screenshots (showing ${limit} results, sorted by ${sortBy}):\n\n${screenshotList}`
1300
- : `Found ${screenshots.length} screenshot(s) (sorted by ${sortBy}):\n\n${screenshotList}`;
1301
- return createToolResult(true, message, {
1302
- action: "list_local_screenshots",
1303
- count: screenshots.length,
1304
- returned: limitedScreenshots.length,
1305
- directory: testdriverDir,
1306
- filters: params,
1307
- sortBy,
1308
- screenshots: limitedScreenshots.map(s => ({
1309
- path: s.path,
1310
- relativePath: path.relative(testdriverDir, s.path),
1311
- name: s.name,
1312
- modified: s.modified.toISOString(),
1313
- sizeBytes: s.size,
1314
- sequence: s.parsed.sequence,
1315
- action: s.parsed.action,
1316
- phase: s.parsed.phase,
1317
- lineNumber: s.parsed.lineNumber,
1318
- description: s.parsed.description,
1319
- })),
1320
- duration
1321
- });
1322
- }
1323
- catch (error) {
1324
- logger.error("list_local_screenshots: Failed", { error: String(error) });
1325
- captureException(error, { tags: { tool: "list_local_screenshots" } });
1326
- throw error;
1349
+ catch (error) {
1350
+ logger.error("list_local_screenshots: Failed", { error: String(error) });
1351
+ captureException(error, { tags: { tool: "list_local_screenshots" } });
1352
+ throw error;
1353
+ }
1354
+ });
1355
+ // Helper to format time ago
1356
+ function formatTimeAgo(date) {
1357
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1358
+ if (seconds < 60)
1359
+ return `${seconds}s ago`;
1360
+ const minutes = Math.floor(seconds / 60);
1361
+ if (minutes < 60)
1362
+ return `${minutes}m ago`;
1363
+ const hours = Math.floor(minutes / 60);
1364
+ if (hours < 24)
1365
+ return `${hours}h ago`;
1366
+ const days = Math.floor(hours / 24);
1367
+ return `${days}d ago`;
1327
1368
  }
1328
- });
1329
- // Helper to format time ago
1330
- function formatTimeAgo(date) {
1331
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1332
- if (seconds < 60)
1333
- return `${seconds}s ago`;
1334
- const minutes = Math.floor(seconds / 60);
1335
- if (minutes < 60)
1336
- return `${minutes}m ago`;
1337
- const hours = Math.floor(minutes / 60);
1338
- if (hours < 24)
1339
- return `${hours}h ago`;
1340
- const days = Math.floor(hours / 24);
1341
- return `${days}d ago`;
1342
- }
1343
- // View Local Screenshot - view a screenshot from .testdriver directory
1344
- // Returns the image so AI clients that support images can see it
1345
- // Also displays to the user via MCP App
1346
- registerAppTool(server, "view_local_screenshot", {
1347
- title: "View Local Screenshot",
1348
- description: `View a screenshot from the .testdriver directory.
1369
+ // View Local Screenshot - view a screenshot from .testdriver directory
1370
+ // Returns the image so AI clients that support images can see it
1371
+ // Also displays to the user via MCP App
1372
+ registerAppTool(server, "view_local_screenshot", {
1373
+ title: "View Local Screenshot",
1374
+ description: `View a screenshot from the .testdriver directory.
1349
1375
 
1350
1376
  Use 'list_local_screenshots' first to see available screenshots, then use this tool to view one.
1351
1377
 
@@ -1356,75 +1382,75 @@ Useful for:
1356
1382
  - Reviewing screenshots from previous test runs
1357
1383
  - Debugging test failures by examining saved screenshots
1358
1384
  - Comparing current screen state to saved screenshots`,
1359
- inputSchema: z.object({
1360
- path: z.string().describe("Full path to the screenshot file (from list_local_screenshots)"),
1361
- }),
1362
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1363
- }, async (params) => {
1364
- const startTime = Date.now();
1365
- logger.info("view_local_screenshot: Starting", { path: params.path });
1366
- try {
1367
- // Validate the path exists and is a PNG
1368
- if (!fs.existsSync(params.path)) {
1369
- logger.warn("view_local_screenshot: File not found", { path: params.path });
1370
- return createToolResult(false, `Screenshot not found: ${params.path}`, { error: "File not found" });
1371
- }
1372
- if (!params.path.toLowerCase().endsWith(".png")) {
1373
- logger.warn("view_local_screenshot: Not a PNG file", { path: params.path });
1374
- return createToolResult(false, "Only PNG files are supported", { error: "Invalid file type" });
1385
+ inputSchema: z.object({
1386
+ path: z.string().describe("Full path to the screenshot file (from list_local_screenshots)"),
1387
+ }),
1388
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1389
+ }, async (params) => {
1390
+ const startTime = Date.now();
1391
+ logger.info("view_local_screenshot: Starting", { path: params.path });
1392
+ try {
1393
+ // Validate the path exists and is a PNG
1394
+ if (!fs.existsSync(params.path)) {
1395
+ logger.warn("view_local_screenshot: File not found", { path: params.path });
1396
+ return createToolResult(false, `Screenshot not found: ${params.path}`, { error: "File not found" });
1397
+ }
1398
+ if (!params.path.toLowerCase().endsWith(".png")) {
1399
+ logger.warn("view_local_screenshot: Not a PNG file", { path: params.path });
1400
+ return createToolResult(false, "Only PNG files are supported", { error: "Invalid file type" });
1401
+ }
1402
+ // Security check - only allow files from .testdriver directory
1403
+ const normalizedPath = path.resolve(params.path);
1404
+ if (!normalizedPath.includes(".testdriver")) {
1405
+ logger.warn("view_local_screenshot: Path not in .testdriver", { path: normalizedPath });
1406
+ return createToolResult(false, "Can only view screenshots from .testdriver directory", { error: "Security: path not allowed" });
1407
+ }
1408
+ // Read the file
1409
+ const imageBuffer = fs.readFileSync(params.path);
1410
+ const imageBase64 = imageBuffer.toString("base64");
1411
+ // Store image for MCP App UI display
1412
+ const screenshotResourceUri = storeImage(imageBase64, "screenshot");
1413
+ const stats = fs.statSync(params.path);
1414
+ const sizeKB = Math.round(stats.size / 1024);
1415
+ const fileName = path.basename(params.path);
1416
+ const duration = Date.now() - startTime;
1417
+ logger.info("view_local_screenshot: Completed", { path: params.path, sizeKB, duration });
1418
+ // Return the image content for AI clients that support images
1419
+ // The content array includes both text and image for maximum compatibility
1420
+ const content = [
1421
+ { type: "text", text: `Screenshot: ${fileName} (${sizeKB}KB)` },
1422
+ {
1423
+ type: "image",
1424
+ data: imageBase64,
1425
+ mimeType: "image/png"
1426
+ },
1427
+ ];
1428
+ return {
1429
+ content,
1430
+ structuredContent: {
1431
+ action: "view_local_screenshot",
1432
+ success: true,
1433
+ path: params.path,
1434
+ fileName,
1435
+ sizeBytes: stats.size,
1436
+ modified: stats.mtime.toISOString(),
1437
+ screenshotResourceUri,
1438
+ duration
1439
+ },
1440
+ };
1375
1441
  }
1376
- // Security check - only allow files from .testdriver directory
1377
- const normalizedPath = path.resolve(params.path);
1378
- if (!normalizedPath.includes(".testdriver")) {
1379
- logger.warn("view_local_screenshot: Path not in .testdriver", { path: normalizedPath });
1380
- return createToolResult(false, "Can only view screenshots from .testdriver directory", { error: "Security: path not allowed" });
1442
+ catch (error) {
1443
+ logger.error("view_local_screenshot: Failed", { error: String(error), path: params.path });
1444
+ captureException(error, { tags: { tool: "view_local_screenshot" }, extra: { path: params.path } });
1445
+ throw error;
1381
1446
  }
1382
- // Read the file
1383
- const imageBuffer = fs.readFileSync(params.path);
1384
- const imageBase64 = imageBuffer.toString("base64");
1385
- // Store image for MCP App UI display
1386
- const screenshotResourceUri = storeImage(imageBase64, "screenshot");
1387
- const stats = fs.statSync(params.path);
1388
- const sizeKB = Math.round(stats.size / 1024);
1389
- const fileName = path.basename(params.path);
1390
- const duration = Date.now() - startTime;
1391
- logger.info("view_local_screenshot: Completed", { path: params.path, sizeKB, duration });
1392
- // Return the image content for AI clients that support images
1393
- // The content array includes both text and image for maximum compatibility
1394
- const content = [
1395
- { type: "text", text: `Screenshot: ${fileName} (${sizeKB}KB)` },
1396
- {
1397
- type: "image",
1398
- data: imageBase64,
1399
- mimeType: "image/png"
1400
- },
1401
- ];
1402
- return {
1403
- content,
1404
- structuredContent: {
1405
- action: "view_local_screenshot",
1406
- success: true,
1407
- path: params.path,
1408
- fileName,
1409
- sizeBytes: stats.size,
1410
- modified: stats.mtime.toISOString(),
1411
- screenshotResourceUri,
1412
- duration
1413
- },
1414
- };
1415
- }
1416
- catch (error) {
1417
- logger.error("view_local_screenshot: Failed", { error: String(error), path: params.path });
1418
- captureException(error, { tags: { tool: "view_local_screenshot" }, extra: { path: params.path } });
1419
- throw error;
1420
- }
1421
- });
1422
- // Screenshot - captures full screen to show user the current state
1423
- // NOTE: This is for SHOWING the user the screen, not for AI understanding.
1424
- // Use 'check' tool for AI to understand screen state.
1425
- registerAppTool(server, "screenshot", {
1426
- title: "Screenshot",
1427
- description: `Display a screenshot to the user. This tool does NOT return the image to you (the AI).
1447
+ });
1448
+ // Screenshot - captures full screen to show user the current state
1449
+ // NOTE: This is for SHOWING the user the screen, not for AI understanding.
1450
+ // Use 'check' tool for AI to understand screen state.
1451
+ registerAppTool(server, "screenshot", {
1452
+ title: "Screenshot",
1453
+ description: `Display a screenshot to the user. This tool does NOT return the image to you (the AI).
1428
1454
 
1429
1455
  ⚠️ IMPORTANT: Do NOT use this tool to understand the screen state. The screenshot is ONLY displayed to the human user - you will NOT receive the image or any analysis.
1430
1456
 
@@ -1434,37 +1460,37 @@ If you need to:
1434
1460
  - Understand the current state → use 'check' instead
1435
1461
 
1436
1462
  Only use 'screenshot' when you explicitly want to show something to the human user without needing to see it yourself.`,
1437
- inputSchema: z.object({}),
1438
- _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1439
- }, async () => {
1440
- const startTime = Date.now();
1441
- logger.info("screenshot: Starting");
1442
- try {
1443
- const result = await core.screenshot();
1444
- // Store the captured image as a resource URI (kept OUT of AI context — the
1445
- // MCP app fetches it via resources/read). Preserve the original user-facing
1446
- // text rather than the core's neutral text.
1447
- const data = { action: "screenshot" };
1448
- for (const img of result.images ?? []) {
1449
- data.screenshotResourceUri = storeImage(img.base64, "screenshot");
1463
+ inputSchema: z.object({}),
1464
+ _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1465
+ }, async () => {
1466
+ const startTime = Date.now();
1467
+ logger.info("screenshot: Starting");
1468
+ try {
1469
+ const result = await core.screenshot();
1470
+ // Store the captured image as a resource URI (kept OUT of AI context — the
1471
+ // MCP app fetches it via resources/read). Preserve the original user-facing
1472
+ // text rather than the core's neutral text.
1473
+ const data = { action: "screenshot" };
1474
+ for (const img of result.images ?? []) {
1475
+ data.screenshotResourceUri = storeImage(img.base64, "screenshot");
1476
+ }
1477
+ const duration = Date.now() - startTime;
1478
+ data.duration = duration;
1479
+ logger.info("screenshot: Completed", { duration, hasImage: (result.images?.length ?? 0) > 0 });
1480
+ return createToolResult(true, "Screenshot captured and displayed to user", data);
1450
1481
  }
1451
- const duration = Date.now() - startTime;
1452
- data.duration = duration;
1453
- logger.info("screenshot: Completed", { duration, hasImage: (result.images?.length ?? 0) > 0 });
1454
- return createToolResult(true, "Screenshot captured and displayed to user", data);
1455
- }
1456
- catch (error) {
1457
- if (error instanceof NoActiveSessionError) {
1458
- logger.warn("screenshot: No active session");
1459
- return noSessionResult(error);
1482
+ catch (error) {
1483
+ if (error instanceof NoActiveSessionError) {
1484
+ logger.warn("screenshot: No active session");
1485
+ return noSessionResult(error);
1486
+ }
1487
+ logger.error("screenshot: Failed", { error: String(error) });
1488
+ return createToolResult(false, `Screenshot failed: ${error}`, { error: String(error) });
1460
1489
  }
1461
- logger.error("screenshot: Failed", { error: String(error) });
1462
- return createToolResult(false, `Screenshot failed: ${error}`, { error: String(error) });
1463
- }
1464
- });
1465
- // Init - Initialize a new TestDriver project
1466
- server.registerTool("init", {
1467
- description: `Initialize a new TestDriver project with Vitest SDK examples.
1490
+ });
1491
+ // Init - Initialize a new TestDriver project
1492
+ server.registerTool("init", {
1493
+ description: `Initialize a new TestDriver project with Vitest SDK examples.
1468
1494
 
1469
1495
  This creates:
1470
1496
  - package.json with proper dependencies
@@ -1479,30 +1505,30 @@ This creates:
1479
1505
  - .env file with API key (if provided)
1480
1506
 
1481
1507
  API Key: The apiKey parameter is optional. If not provided, you'll need to manually add TD_API_KEY to the .env file after initialization. The project structure will still be created successfully.`,
1482
- inputSchema: z.object({
1483
- directory: z.string().optional().describe("Target directory (defaults to current working directory)"),
1484
- apiKey: z.string().optional().describe("TestDriver API key (will be saved to .env)"),
1485
- skipInstall: z.boolean().default(false).describe("Skip npm install step"),
1486
- skipSampleTest: z.boolean().default(false).describe("Skip scaffolding the example test files (tests/example.test.js + tests/login.js). Useful when an agent writes its own tests."),
1487
- }),
1488
- }, async (params) => {
1489
- const startTime = Date.now();
1490
- const targetDir = params.directory ? path.resolve(params.directory) : process.cwd();
1491
- logger.info("init: Starting", { targetDir, hasApiKey: !!params.apiKey, skipInstall: params.skipInstall });
1492
- try {
1493
- // Import the shared init logic (dynamic import for ESM/CJS compatibility)
1494
- const initProjectPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "lib", "init-project.js");
1495
- const { initProject } = await import(pathToFileURL(initProjectPath).href);
1496
- // Run the shared init logic
1497
- const result = await initProject({
1498
- targetDir,
1499
- apiKey: params.apiKey,
1500
- skipInstall: params.skipInstall,
1501
- skipSampleTest: params.skipSampleTest,
1502
- });
1503
- const duration = Date.now() - startTime;
1504
- logger.info("init: Completed", { targetDir, duration, success: result.success });
1505
- const nextSteps = `
1508
+ inputSchema: z.object({
1509
+ directory: z.string().optional().describe("Target directory (defaults to current working directory)"),
1510
+ apiKey: z.string().optional().describe("TestDriver API key (will be saved to .env)"),
1511
+ skipInstall: z.boolean().default(false).describe("Skip npm install step"),
1512
+ skipSampleTest: z.boolean().default(false).describe("Skip scaffolding the example test files (tests/example.test.js + tests/login.js). Useful when an agent writes its own tests."),
1513
+ }),
1514
+ }, async (params) => {
1515
+ const startTime = Date.now();
1516
+ const targetDir = params.directory ? path.resolve(params.directory) : process.cwd();
1517
+ logger.info("init: Starting", { targetDir, hasApiKey: !!params.apiKey, skipInstall: params.skipInstall });
1518
+ try {
1519
+ // Import the shared init logic (dynamic import for ESM/CJS compatibility)
1520
+ const initProjectPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "lib", "init-project.js");
1521
+ const { initProject } = await import(pathToFileURL(initProjectPath).href);
1522
+ // Run the shared init logic
1523
+ const result = await initProject({
1524
+ targetDir,
1525
+ apiKey: params.apiKey,
1526
+ skipInstall: params.skipInstall,
1527
+ skipSampleTest: params.skipSampleTest,
1528
+ });
1529
+ const duration = Date.now() - startTime;
1530
+ logger.info("init: Completed", { targetDir, duration, success: result.success });
1531
+ const nextSteps = `
1506
1532
 
1507
1533
  📚 Next steps:
1508
1534
 
@@ -1520,36 +1546,61 @@ API Key: The apiKey parameter is optional. If not provided, you'll need to manua
1520
1546
 
1521
1547
  Learn more at https://docs.testdriver.ai/v7/getting-started/
1522
1548
  `;
1523
- const allMessages = [...result.results, ...result.errors.map((e) => `⚠️ ${e}`)];
1524
- return createToolResult(result.success, result.success
1525
- ? `✅ TestDriver project initialized successfully!\n\n${allMessages.join("\n")}${nextSteps}`
1526
- : `⚠️ TestDriver project initialization completed with errors:\n\n${allMessages.join("\n")}`, {
1527
- action: "init",
1528
- targetDir,
1529
- filesCreated: result.results.length,
1530
- hasApiKey: !!params.apiKey,
1531
- errors: result.errors,
1532
- duration
1549
+ const allMessages = [...result.results, ...result.errors.map((e) => `⚠️ ${e}`)];
1550
+ return createToolResult(result.success, result.success
1551
+ ? `✅ TestDriver project initialized successfully!\n\n${allMessages.join("\n")}${nextSteps}`
1552
+ : `⚠️ TestDriver project initialization completed with errors:\n\n${allMessages.join("\n")}`, {
1553
+ action: "init",
1554
+ targetDir,
1555
+ filesCreated: result.results.length,
1556
+ hasApiKey: !!params.apiKey,
1557
+ errors: result.errors,
1558
+ duration
1559
+ });
1560
+ }
1561
+ catch (error) {
1562
+ logger.error("init: Failed", { error: String(error), targetDir });
1563
+ captureException(error, { tags: { tool: "init" }, extra: { targetDir } });
1564
+ throw error;
1565
+ }
1566
+ });
1567
+ return server;
1568
+ }
1569
+ /** Read and JSON-parse a request body. Returns undefined for an empty/invalid
1570
+ * body (GET/DELETE carry none) so callers can treat "no body" uniformly. */
1571
+ function readJsonBody(req) {
1572
+ return new Promise((resolve) => {
1573
+ const chunks = [];
1574
+ req.on("data", (chunk) => chunks.push(chunk));
1575
+ req.on("end", () => {
1576
+ if (chunks.length === 0)
1577
+ return resolve(undefined);
1578
+ try {
1579
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
1580
+ }
1581
+ catch {
1582
+ resolve(undefined);
1583
+ }
1533
1584
  });
1534
- }
1535
- catch (error) {
1536
- logger.error("init: Failed", { error: String(error), targetDir });
1537
- captureException(error, { tags: { tool: "init" }, extra: { targetDir } });
1538
- throw error;
1539
- }
1540
- });
1541
- // =============================================================================
1542
- // HTTP transport (Streamable HTTP, no auth)
1543
- // =============================================================================
1585
+ req.on("error", () => resolve(undefined));
1586
+ });
1587
+ }
1544
1588
  /**
1545
1589
  * Serve the MCP server over Streamable HTTP with no authentication.
1546
1590
  *
1547
1591
  * This is what the eve agent (and any other Streamable-HTTP MCP client) connects
1548
- * to. The server holds single-session global state (the active `sdk`, element
1549
- * refs, image store), so we mirror that with a single long-lived MCP transport:
1550
- * the first `initialize` request mints a session id and every subsequent request
1551
- * reuses the same `server` + transport. Concurrent sessions are intentionally not
1552
- * supported this server provisions one sandbox at a time.
1592
+ * to. Each MCP session gets its OWN transport + server + isolated action context,
1593
+ * keyed by the `mcp-session-id` header:
1594
+ *
1595
+ * - An `initialize` request (no session id yet) mints a fresh connection: a new
1596
+ * isolated {@link core.CoreContext}, a server built over it, and a transport
1597
+ * whose generated session id we store. Every later request carrying that id
1598
+ * routes back to the same connection and runs inside its context via
1599
+ * {@link core.runInContext} — so one client's tool call can only ever touch
1600
+ * its own sandbox / element refs / image store. This is the fix for the
1601
+ * cross-session bleed: state is per-connection, not process-global.
1602
+ * - `DELETE` (or transport close) tears the connection down and drops it from
1603
+ * the map, so a disconnecting client frees its slot and its context is GC'd.
1553
1604
  *
1554
1605
  * No auth is applied here by design (the connection is expected to be local-only
1555
1606
  * or otherwise protected outside the MCP layer). Do not expose this on a public
@@ -1559,29 +1610,80 @@ async function startHttpServer() {
1559
1610
  const host = process.env.TD_MCP_HOST || "127.0.0.1";
1560
1611
  const port = Number(process.env.TD_MCP_PORT || process.env.PORT || 8788);
1561
1612
  const mcpPath = process.env.TD_MCP_PATH || "/mcp";
1562
- // One transport for the lifetime of the process (single-session design).
1563
- const transport = new StreamableHTTPServerTransport({
1564
- sessionIdGenerator: () => randomUUID(),
1565
- });
1566
- await server.connect(transport);
1613
+ // Live connections keyed by MCP session id. One entry per connected client.
1614
+ const connections = new Map();
1567
1615
  const httpServer = http.createServer((req, res) => {
1568
- const url = new URL(req.url || "/", `http://${req.headers.host}`);
1569
- // Lightweight health check for readiness probes / `eve dev`.
1570
- if (req.method === "GET" && url.pathname === "/health") {
1571
- res.writeHead(200, { "content-type": "application/json" });
1572
- res.end(JSON.stringify({ status: "ok", server: "testdriver", version }));
1573
- return;
1574
- }
1575
- if (url.pathname !== mcpPath) {
1576
- res.writeHead(404, { "content-type": "application/json" });
1577
- res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
1578
- return;
1579
- }
1580
- // The Streamable HTTP transport handles POST (requests), GET (SSE stream),
1581
- // and DELETE (session teardown) on the same path. Body parsing is left to
1582
- // the transport so it can read the raw stream.
1583
- transport.handleRequest(req, res).catch((error) => {
1584
- logger.error("http: handleRequest failed", { error: String(error) });
1616
+ void (async () => {
1617
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
1618
+ // Lightweight health check for readiness probes / `eve dev`.
1619
+ if (req.method === "GET" && url.pathname === "/health") {
1620
+ res.writeHead(200, { "content-type": "application/json" });
1621
+ res.end(JSON.stringify({ status: "ok", server: "testdriver", version, sessions: connections.size }));
1622
+ return;
1623
+ }
1624
+ if (url.pathname !== mcpPath) {
1625
+ res.writeHead(404, { "content-type": "application/json" });
1626
+ res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
1627
+ return;
1628
+ }
1629
+ const sessionId = req.headers["mcp-session-id"];
1630
+ const sid = Array.isArray(sessionId) ? sessionId[0] : sessionId;
1631
+ // POST bodies must be parsed here (to detect `initialize` and to hand the
1632
+ // transport a pre-parsed body). GET (SSE) and DELETE carry no JSON body.
1633
+ const body = req.method === "POST" ? await readJsonBody(req) : undefined;
1634
+ let connection = sid ? connections.get(sid) : undefined;
1635
+ // A brand-new session: only an `initialize` request may create one.
1636
+ if (!connection) {
1637
+ if (sid) {
1638
+ // Client presented a session id we don't know — it was torn down or is
1639
+ // stale. 404 so the client re-initializes (matches SDK stateful mode).
1640
+ res.writeHead(404, { "content-type": "application/json" });
1641
+ res.end(JSON.stringify({ error: "Unknown or expired MCP session", sessionId: sid }));
1642
+ return;
1643
+ }
1644
+ if (!isInitializeRequest(body)) {
1645
+ res.writeHead(400, { "content-type": "application/json" });
1646
+ res.end(JSON.stringify({ error: "Bad Request: no MCP session id and not an initialize request" }));
1647
+ return;
1648
+ }
1649
+ // Mint an isolated context + a server bound to it + a transport.
1650
+ const ctx = core.createIsolatedContext();
1651
+ const mcpServer = buildServer();
1652
+ const transport = new StreamableHTTPServerTransport({
1653
+ sessionIdGenerator: () => randomUUID(),
1654
+ onsessioninitialized: (newId) => {
1655
+ connections.set(newId, { transport, server: mcpServer, ctx });
1656
+ logger.info("http: MCP session initialized", { sessionId: newId, activeSessions: connections.size });
1657
+ },
1658
+ });
1659
+ // When the transport closes (client DELETE, network drop, or shutdown),
1660
+ // drop the connection and best-effort tear down its sandbox so a
1661
+ // disconnecting client doesn't leak a live VM.
1662
+ transport.onclose = () => {
1663
+ const closedId = transport.sessionId;
1664
+ if (closedId && connections.delete(closedId)) {
1665
+ logger.info("http: MCP session closed", { sessionId: closedId, activeSessions: connections.size });
1666
+ }
1667
+ // Disconnect the SDK inside this connection's context (best-effort).
1668
+ core.runInContext(ctx, () => { void core.disconnect(); });
1669
+ };
1670
+ await mcpServer.connect(transport);
1671
+ connection = { transport, server: mcpServer, ctx };
1672
+ }
1673
+ // Route the request through THIS connection's transport, inside THIS
1674
+ // connection's action context so every core.* call the handlers make
1675
+ // resolves the right sandbox / refs / image store.
1676
+ const conn = connection;
1677
+ await core.runInContext(conn.ctx, () => conn.transport.handleRequest(req, res, body)).catch((error) => {
1678
+ logger.error("http: handleRequest failed", { error: String(error) });
1679
+ captureException(error, { tags: { phase: "http" } });
1680
+ if (!res.headersSent) {
1681
+ res.writeHead(500, { "content-type": "application/json" });
1682
+ res.end(JSON.stringify({ error: "Internal server error" }));
1683
+ }
1684
+ });
1685
+ })().catch((error) => {
1686
+ logger.error("http: request handling failed", { error: String(error) });
1585
1687
  captureException(error, { tags: { phase: "http" } });
1586
1688
  if (!res.headersSent) {
1587
1689
  res.writeHead(500, { "content-type": "application/json" });
@@ -1594,9 +1696,12 @@ async function startHttpServer() {
1594
1696
  url: `http://${host}:${port}${mcpPath}`,
1595
1697
  });
1596
1698
  const shutdown = async () => {
1597
- logger.info("Shutting down MCP Server");
1699
+ logger.info("Shutting down MCP Server", { activeSessions: connections.size });
1598
1700
  httpServer.close();
1599
- await transport.close().catch(() => { });
1701
+ for (const conn of connections.values()) {
1702
+ await conn.transport.close().catch(() => { });
1703
+ }
1704
+ connections.clear();
1600
1705
  await flushSentry();
1601
1706
  process.exit(0);
1602
1707
  };
@@ -1619,6 +1724,10 @@ async function main() {
1619
1724
  await startHttpServer();
1620
1725
  return;
1621
1726
  }
1727
+ // stdio: one long-lived server for the process. It runs over the global
1728
+ // action context (no runInContext wrap), so behavior is unchanged — a single
1729
+ // local CLI client, one sandbox at a time, exactly as before.
1730
+ const server = buildServer();
1622
1731
  const transport = new StdioServerTransport();
1623
1732
  await server.connect(transport);
1624
1733
  logger.info("TestDriver MCP Server running on stdio");