@testdriverai/mcp 7.9.116-test → 7.9.118-test

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.
@@ -10,15 +10,19 @@ process.env.TD_DEBUG = "true";
10
10
  import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
11
11
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
14
+ import { randomUUID } from "crypto";
15
+ import * as http from "http";
13
16
  import * as Sentry from "@sentry/node";
14
17
  import * as fs from "fs";
15
18
  import * as os from "os";
16
19
  import * as path from "path";
17
20
  import { fileURLToPath, pathToFileURL } from "url";
18
21
  import { z } from "zod";
19
- import { generateActionCode } from "./codegen.js";
22
+ import * as core from "./core/actions.js";
23
+ import { NoActiveSessionError } from "./core/actions.js";
20
24
  import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
21
- import { getProvisionOptions, SessionStartInputSchema } from "./provision-types.js";
25
+ import { SessionStartInputSchema } from "./provision-types.js";
22
26
  import { sessionManager } from "./session.js";
23
27
  // =============================================================================
24
28
  // Sentry
@@ -177,10 +181,6 @@ const RESOURCE_URI = "ui://testdriver/mcp-app.html";
177
181
  // Resource URI base for serving screenshot blobs (with dynamic IDs)
178
182
  const SCREENSHOT_RESOURCE_BASE = "screenshot://testdriver/screenshot";
179
183
  const CROPPED_IMAGE_RESOURCE_BASE = "screenshot://testdriver/cropped";
180
- // SDK instance (will be initialized on session start)
181
- let sdk = null;
182
- // Last screenshot base64 for check comparisons
183
- let lastScreenshotBase64 = null;
184
184
  // Map of image ID -> image data
185
185
  const imageStore = new Map();
186
186
  // Counter for generating unique image IDs
@@ -229,42 +229,39 @@ function getSessionData(session) {
229
229
  };
230
230
  }
231
231
  /**
232
- * Check if session is ready for use - returns error result if not
233
- * This helper provides clear, actionable error messages for the AI
234
- *
235
- * Auto-extends the session on each successful check to prevent expiry during active use
232
+ * Map a {@link NoActiveSessionError} thrown by the core into the exact same
233
+ * "no/expired session" tool result the server's old `requireActiveSession`
234
+ * produced. Kept byte-identical so external behavior is unchanged.
236
235
  */
237
- function requireActiveSession() {
238
- const session = sessionManager.getCurrentSession();
239
- // No session ever created
240
- if (!sdk || !session) {
241
- return {
242
- valid: false,
243
- error: createToolResult(false, "ERROR: No active session. You must call session_start first to create a sandbox before using any other tools.", {
244
- error: "NO_SESSION",
245
- action: "session_start",
246
- message: "No sandbox session exists. Call session_start to create one."
247
- })
248
- };
249
- }
250
- // Session exists but has expired
251
- if (!sessionManager.isSessionValid(session.sessionId)) {
252
- // Clear the SDK reference since the sandbox is no longer available
253
- sdk = null;
254
- return {
255
- valid: false,
256
- error: createToolResult(false, "ERROR: Session has expired or timed out. The sandbox is no longer available. You must call session_start again to create a new sandbox session before continuing.", {
257
- error: "SESSION_EXPIRED",
258
- action: "session_start",
259
- message: "The previous sandbox session has expired. Call session_start to create a new one.",
260
- expiredSessionId: session.sessionId
261
- })
262
- };
236
+ function noSessionResult(err) {
237
+ if (err.code === "SESSION_EXPIRED") {
238
+ return createToolResult(false, "ERROR: Session has expired or timed out. The sandbox is no longer available. You must call session_start again to create a new sandbox session before continuing.", {
239
+ error: "SESSION_EXPIRED",
240
+ action: "session_start",
241
+ message: "The previous sandbox session has expired. Call session_start to create a new one.",
242
+ expiredSessionId: err.expiredSessionId,
243
+ });
263
244
  }
264
- // Auto-extend session on each command to prevent expiry during active use
265
- // This resets the expiry timer back to the original keepAlive duration
266
- sessionManager.refreshSession(session.sessionId);
267
- return { valid: true };
245
+ return createToolResult(false, "ERROR: No active session. You must call session_start first to create a sandbox before using any other tools.", {
246
+ error: "NO_SESSION",
247
+ action: "session_start",
248
+ message: "No sandbox session exists. Call session_start to create one.",
249
+ });
250
+ }
251
+ /**
252
+ * Map a core {@link ActionResult} into an MCP CallToolResult, storing images as
253
+ * resource URIs (the core returns BARE base64, which is what `storeImage` wants).
254
+ */
255
+ function resultToMcp(r) {
256
+ const data = { ...r.data };
257
+ for (const img of r.images ?? []) {
258
+ const uri = storeImage(img.base64, img.kind);
259
+ if (img.kind === "cropped")
260
+ data.croppedImageResourceUri = uri;
261
+ else
262
+ data.screenshotResourceUri = uri;
263
+ }
264
+ return createToolResult(r.ok, r.text, data, r.code);
268
265
  }
269
266
  const DEFAULT_HEARTBEAT_MS = 3000;
270
267
  function makeProgressReporter(extra) {
@@ -416,9 +413,6 @@ const server = isSentryEnabled()
416
413
  name: "testdriver",
417
414
  version: version,
418
415
  });
419
- // Element reference storage (for click/hover after find)
420
- // Stores actual Element instances - no raw coordinates as input
421
- const elementRefs = new Map();
422
416
  // =============================================================================
423
417
  // Register UI Resource
424
418
  // =============================================================================
@@ -533,203 +527,53 @@ Debug mode (connect to existing sandbox):
533
527
  sandboxId: params.sandboxId,
534
528
  });
535
529
  try {
536
- // Validate required fields for specific provision types (unless connecting to existing sandbox)
537
- if (!params.sandboxId) {
538
- if (params.type === "installer" && !params.installerUrl) {
539
- return createToolResult(false, "installer type requires 'installerUrl' parameter", { error: "Missing required parameter: installerUrl" });
540
- }
541
- if (params.type === "electron" && !params.appPath) {
542
- return createToolResult(false, "electron type requires 'appPath' parameter", { error: "Missing required parameter: appPath" });
543
- }
544
- }
545
- // Create new session
546
- const newSession = sessionManager.createSession({
547
- os: resolvedOs,
548
- keepAlive: params.keepAlive,
549
- testFile: params.testFile,
550
- });
551
- logger.debug("session_start: Session created", { sessionId: newSession.sessionId });
552
- // Determine API root
553
- const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
554
- logger.debug("session_start: Using API root", { apiRoot });
555
- // Initialize SDK
556
- logger.debug("session_start: Initializing SDK");
557
- const TestDriverSDK = (await import("../../sdk.js")).default;
558
- // Determine preview mode from environment variable
559
- // TD_PREVIEW can be "ide", "browser", or "none"
560
- // Default to "ide" so the live preview shows within the IDE
561
- const previewMode = process.env.TD_PREVIEW || "ide";
562
- logger.debug("session_start: Preview mode", { preview: previewMode });
563
- // Get IP from params or environment (for self-hosted instances)
564
- const instanceIp = params.ip || process.env.TD_IP;
565
- // Get API key - check multiple sources for GitHub Copilot coding agent compatibility
566
- // 1. TD_API_KEY (standard environment variable)
567
- // 2. COPILOT_MCP_TD_API_KEY (fallback for GitHub Copilot coding agent)
568
- const apiKey = process.env.TD_API_KEY || process.env.COPILOT_MCP_TD_API_KEY || "";
569
- if (!apiKey) {
570
- logger.error("session_start: No API key found", {
571
- hasTD_API_KEY: !!process.env.TD_API_KEY,
572
- hasCOPILOT_MCP_TD_API_KEY: !!process.env.COPILOT_MCP_TD_API_KEY,
573
- availableEnvVars: Object.keys(process.env).filter(k => k.includes('TD') || k.includes('COPILOT_MCP'))
574
- });
575
- return createToolResult(false, "No API key found. Please set TD_API_KEY or COPILOT_MCP_TD_API_KEY environment variable.", {
576
- error: "Missing API key",
577
- hint: "For GitHub Copilot coding agent, create a Copilot environment secret named COPILOT_MCP_TD_API_KEY"
578
- });
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) }));
579
541
  }
580
- logger.debug("session_start: API key found", {
581
- source: process.env.TD_API_KEY ? "TD_API_KEY" : "COPILOT_MCP_TD_API_KEY",
582
- keyPrefix: apiKey.substring(0, 7) + "..."
583
- });
584
- sdk = new TestDriverSDK(apiKey, {
585
- os: resolvedOs,
586
- logging: false,
587
- apiRoot,
588
- preview: previewMode,
589
- ip: instanceIp,
590
- e2bTemplateId: resolvedE2bTemplateId,
591
- });
592
- // Handle sandboxId mode - connect to existing sandbox (debug-on-failure mode)
593
- if (params.sandboxId) {
594
- logger.info("session_start: Connecting to existing sandbox (debug mode)", { sandboxId: params.sandboxId });
595
- const stopHeartbeat = progress.heartbeat(`Connecting to existing sandbox ${params.sandboxId}...`);
596
- try {
597
- await raceAbort(extra.signal, "session_start", sdk.connect({
598
- sandboxId: params.sandboxId,
599
- keepAlive: params.keepAlive,
600
- }));
601
- }
602
- finally {
603
- stopHeartbeat();
604
- }
605
- // Get sandbox ID
606
- const instance = sdk.getInstance();
607
- logger.info("session_start: Connected to existing sandbox", { instanceId: instance?.instanceId });
608
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
609
- // Set Sentry context for error tracking
610
- setSessionContext(newSession.sessionId, instance?.instanceId);
611
- // Capture screenshot of current state
612
- logger.debug("session_start: Capturing screenshot of existing sandbox");
613
- progress.report("Capturing screenshot...");
614
- const screenshotBase64 = await raceAbort(extra.signal, "session_start", sdk.agent.system.captureScreenBase64(1, false, true));
615
- let screenshotResourceUri;
616
- if (screenshotBase64) {
617
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
618
- lastScreenshotBase64 = screenshotBase64;
619
- }
620
- const duration = Date.now() - startTime;
621
- logger.info("session_start: Connected to existing sandbox", { duration, sessionId: newSession.sessionId, sandboxId: params.sandboxId });
622
- return createToolResult(true, `Connected to existing sandbox (debug mode)
623
- Session: ${newSession.sessionId}
624
- Sandbox: ${params.sandboxId}
625
- Expires in: ${Math.round(params.keepAlive / 1000)}s
626
-
627
- You are now connected to the sandbox in its current state. Use find, click, type, etc. to interact.`, {
628
- action: "session_start",
629
- sessionId: newSession.sessionId,
630
- sandboxId: params.sandboxId,
631
- debugMode: true,
632
- screenshotResourceUri,
633
- duration
634
- }, "// Connected to existing sandbox - no provision code needed");
542
+ finally {
543
+ stopHeartbeat();
635
544
  }
636
- // Connect to sandbox
637
- if (instanceIp) {
638
- logger.info("session_start: Connecting to self-hosted instance...", { ip: instanceIp });
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);
639
549
  }
640
- else {
641
- logger.info("session_start: Connecting to cloud sandbox...");
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 } });
642
554
  }
643
- {
644
- const stopHeartbeat = progress.heartbeat(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
645
- try {
646
- await raceAbort(extra.signal, "session_start", sdk.connect({
647
- reconnect: params.reconnect,
648
- keepAlive: params.keepAlive,
649
- ip: instanceIp,
650
- }));
651
- }
652
- finally {
653
- stopHeartbeat();
654
- }
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;
655
564
  }
656
- // Get sandbox ID
657
- const instance = sdk.getInstance();
658
- logger.info("session_start: Connected to sandbox", { instanceId: instance?.instanceId });
659
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
660
- // Set Sentry context for error tracking
661
- setSessionContext(newSession.sessionId, instance?.instanceId);
662
- // Get provision-specific options
663
- const provisionOptions = getProvisionOptions(params);
664
- let provisionCmd = "";
665
- // Provisioning can take tens of seconds (downloading installers, booting
666
- // apps). Heartbeat so the client's idle timeout keeps resetting.
667
- const stopProvisionHeartbeat = progress.heartbeat(`Provisioning ${params.type}...`);
668
- try {
669
- // Provision based on type
670
- switch (params.type) {
671
- case "chrome": {
672
- const chromeOpts = provisionOptions;
673
- logger.info("session_start: Provisioning Chrome", { url: chromeOpts.url });
674
- await raceAbort(extra.signal, "session_start", sdk.provision.chrome(chromeOpts));
675
- provisionCmd = "provision.chrome";
676
- logger.debug("session_start: Chrome provisioned");
677
- break;
678
- }
679
- case "chromeExtension": {
680
- const extOpts = provisionOptions;
681
- logger.info("session_start: Provisioning Chrome Extension", { extensionPath: extOpts.extensionPath, extensionId: extOpts.extensionId });
682
- await raceAbort(extra.signal, "session_start", sdk.provision.chromeExtension(extOpts));
683
- provisionCmd = "provision.chromeExtension";
684
- logger.debug("session_start: Chrome Extension provisioned");
685
- break;
686
- }
687
- case "vscode": {
688
- const vscodeOpts = provisionOptions;
689
- logger.info("session_start: Provisioning VS Code", { workspace: vscodeOpts.workspace });
690
- await raceAbort(extra.signal, "session_start", sdk.provision.vscode(vscodeOpts));
691
- provisionCmd = "provision.vscode";
692
- logger.debug("session_start: VS Code provisioned");
693
- break;
694
- }
695
- case "installer": {
696
- const installerOpts = provisionOptions;
697
- logger.info("session_start: Provisioning installer", { url: installerOpts.url });
698
- await raceAbort(extra.signal, "session_start", sdk.provision.installer(installerOpts));
699
- provisionCmd = "provision.installer";
700
- logger.debug("session_start: Installer provisioned");
701
- break;
702
- }
703
- case "electron": {
704
- const electronOpts = provisionOptions;
705
- logger.info("session_start: Provisioning Electron", { appPath: electronOpts.appPath });
706
- await raceAbort(extra.signal, "session_start", sdk.provision.electron(electronOpts));
707
- provisionCmd = "provision.electron";
708
- logger.debug("session_start: Electron app provisioned");
709
- break;
710
- }
711
- }
712
- }
713
- finally {
714
- stopProvisionHeartbeat();
715
- }
716
- // Capture initial screenshot after provisioning
717
- logger.debug("session_start: Capturing initial screenshot");
718
- progress.report("Capturing screenshot...");
719
- const screenshotBase64 = await raceAbort(extra.signal, "session_start", sdk.agent.system.captureScreenBase64(1, false, true));
720
- let screenshotResourceUri;
721
- if (screenshotBase64) {
722
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
723
- lastScreenshotBase64 = screenshotBase64;
565
+ if (result.data.debugMode) {
566
+ // Debug (existing-sandbox) success text — preserve original wording.
567
+ const text = `Connected to existing sandbox (debug mode)
568
+ Session: ${result.data.sessionId}
569
+ Sandbox: ${result.data.sandboxId}
570
+ Expires in: ${Math.round(params.keepAlive / 1000)}s
571
+
572
+ 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);
724
574
  }
725
- const duration = Date.now() - startTime;
726
- logger.info("session_start: Completed", { duration, sessionId: newSession.sessionId, selfHosted: !!instanceIp });
727
- // Generate the code for this provision action
728
- const generatedCode = generateActionCode(provisionCmd, provisionOptions);
729
- // Build debugger URL for the session
730
- const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
731
- const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
732
- return createToolResult(true, `Session started: ${newSession.sessionId}\nConnection: ${connectionType}\nType: ${params.type}\nSandbox: ${instance?.instanceId}\nExpires in: ${Math.round(params.keepAlive / 1000)}s
575
+ // Normal provisioning success append the EXACT dependency guidance block.
576
+ const text = `${result.text}
733
577
 
734
578
  IMPORTANT - If creating a new test project, use these EXACT dependencies in package.json:
735
579
  {
@@ -741,16 +585,8 @@ IMPORTANT - If creating a new test project, use these EXACT dependencies in pack
741
585
  "scripts": {
742
586
  "test": "vitest"
743
587
  }
744
- }`, {
745
- action: "session_start",
746
- sessionId: newSession.sessionId,
747
- provisionType: params.type,
748
- selfHosted: !!instanceIp,
749
- instanceIp: instanceIp || undefined,
750
- debuggerUrl,
751
- screenshotResourceUri,
752
- duration
753
- }, generatedCode);
588
+ }`;
589
+ return createToolResult(true, text, data, result.code);
754
590
  }
755
591
  catch (error) {
756
592
  // On client cancellation, tear down the half-provisioned session so we
@@ -759,7 +595,7 @@ IMPORTANT - If creating a new test project, use these EXACT dependencies in pack
759
595
  if (error instanceof ToolAbortError) {
760
596
  logger.info("session_start: Cancelled by client, tearing down session");
761
597
  try {
762
- await sdk?.disconnect?.();
598
+ await core.disconnect();
763
599
  }
764
600
  catch (cleanupErr) {
765
601
  logger.warn("session_start: Cleanup after cancel failed", { error: String(cleanupErr) });
@@ -778,20 +614,15 @@ server.registerTool("session_status", {
778
614
  }, async () => {
779
615
  const startTime = Date.now();
780
616
  logger.info("session_status: Checking");
781
- const session = sessionManager.getCurrentSession();
782
- if (!session) {
783
- logger.warn("session_status: No active session");
784
- return createToolResult(false, "No active session", { error: "No active session. Call session_start first." });
785
- }
786
- const summary = sessionManager.getSessionSummary(session.sessionId);
617
+ const result = core.sessionStatus();
787
618
  const duration = Date.now() - startTime;
788
619
  logger.info("session_status: Completed", {
789
- sessionId: session.sessionId,
790
- status: session.status,
791
- timeRemaining: summary?.timeRemaining,
792
- duration
620
+ ok: result.ok,
621
+ sessionId: result.data.sessionId,
622
+ status: result.data.status,
623
+ duration,
793
624
  });
794
- return createToolResult(true, `Session: ${session.sessionId}\nStatus: ${session.status}\nTime remaining: ${Math.round((summary?.timeRemaining || 0) / 1000)}s`, { action: "session_status", ...summary, sessionId: session.sessionId, status: session.status, duration });
625
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
795
626
  });
796
627
  // Session Extend
797
628
  server.registerTool("session_extend", {
@@ -801,19 +632,18 @@ server.registerTool("session_extend", {
801
632
  }),
802
633
  }, async (params) => {
803
634
  logger.info("session_extend: Extending", { additionalMs: params.additionalMs });
804
- const session = sessionManager.getCurrentSession();
805
- if (!session) {
635
+ const result = core.sessionExtend(params.additionalMs);
636
+ if (!result.ok) {
806
637
  logger.warn("session_extend: No active session");
807
638
  return { content: [{ type: "text", text: "No active session" }] };
808
639
  }
809
- sessionManager.extendSession(session.sessionId, params.additionalMs);
810
- const newExpiry = sessionManager.getTimeRemaining(session.sessionId);
811
- logger.info("session_extend: Extended", { sessionId: session.sessionId, newExpiry });
640
+ logger.info("session_extend: Extended", { newExpiry: result.data.newExpiry });
641
+ // Preserve the original plain content shape (not via createToolResult).
812
642
  return {
813
643
  content: [
814
644
  {
815
645
  type: "text",
816
- text: `Session extended by ${params.additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
646
+ text: result.text,
817
647
  },
818
648
  ],
819
649
  };
@@ -831,101 +661,25 @@ registerAppTool(server, "find", {
831
661
  const startTime = Date.now();
832
662
  const progress = makeProgressReporter(extra);
833
663
  logger.info("find: Starting", { description: params.description, timeout: params.timeout });
834
- const sessionCheck = requireActiveSession();
835
- if (!sessionCheck.valid) {
836
- logger.warn("find: No active session");
837
- return sessionCheck.error;
838
- }
839
664
  try {
840
665
  logger.debug("find: Calling SDK find");
841
666
  const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
842
- let element;
667
+ let result;
843
668
  try {
844
- element = await raceAbort(extra.signal, "find", sdk.find(params.description, params.timeout ? { timeout: params.timeout } : undefined));
669
+ result = await raceAbort(extra.signal, "find", core.find(params.description, params.timeout));
845
670
  }
846
671
  finally {
847
672
  stopHeartbeat();
848
673
  }
849
- const found = element.found();
850
- const coords = element.getCoordinates();
851
- // Store element ref for later use (stores actual Element instance)
852
- const elementRef = `el-${Date.now()}`;
853
- if (found && coords) {
854
- elementRefs.set(elementRef, {
855
- element: element, // Store the actual Element instance
856
- description: params.description,
857
- coords: {
858
- x: coords.x,
859
- y: coords.y,
860
- centerX: coords.centerX,
861
- centerY: coords.centerY,
862
- },
863
- });
864
- logger.info("find: Element found", {
865
- description: params.description,
866
- coords: { x: coords.centerX, y: coords.centerY },
867
- confidence: element.confidence,
868
- elementRef
869
- });
870
- }
871
- else {
872
- logger.warn("find: Element not found", { description: params.description });
873
- }
874
- // Return raw SDK response directly
875
- const rawResponse = element._response || {};
876
674
  const duration = Date.now() - startTime;
877
- // Store cropped image for resource serving (instead of inline data URL)
878
- let croppedImageResourceUri;
879
- let screenshotResourceUri;
880
- const croppedImage = rawResponse.croppedImage;
881
- if (croppedImage) {
882
- const imageData = croppedImage.startsWith('data:')
883
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
884
- : croppedImage;
885
- croppedImageResourceUri = storeImage(imageData, "cropped");
886
- // Remove croppedImage from response to avoid context bloat
887
- delete rawResponse.croppedImage;
888
- }
889
- else if (!found) {
890
- // Element not found and no cropped image - capture a fresh screenshot
891
- // so the user can see what's currently visible on screen
892
- try {
893
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
894
- if (screenshotBase64) {
895
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
896
- logger.debug("find: Captured screenshot for not-found state");
897
- }
898
- }
899
- catch (e) {
900
- logger.warn("find: Failed to capture screenshot for not-found state", { error: String(e) });
901
- }
902
- }
903
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
904
- delete rawResponse.extractedText;
905
- delete rawResponse.pixelDiffImage;
906
- // Generate code for this find action
907
- const generatedCode = found ? generateActionCode("find", { description: params.description }) : undefined;
908
- // Build element info for display (cropped image is always centered on element)
909
- const elementInfo = found ? {
910
- description: params.description,
911
- centerX: coords?.centerX,
912
- centerY: coords?.centerY,
913
- confidence: element.confidence,
914
- ref: elementRef,
915
- } : undefined;
916
- return createToolResult(found, found
917
- ? `Found: "${params.description}" at (${rawResponse.coordinates?.x}, ${rawResponse.coordinates?.y})\nRef: ${elementRef}`
918
- : `Element not found: "${params.description}"`, {
919
- ...rawResponse,
920
- action: "find",
921
- element: elementInfo,
922
- ref: elementRef,
923
- croppedImageResourceUri,
924
- screenshotResourceUri,
925
- duration,
926
- }, generatedCode);
675
+ logger.info("find: Completed", { description: params.description, found: result.ok, duration });
676
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
927
677
  }
928
678
  catch (error) {
679
+ if (error instanceof NoActiveSessionError) {
680
+ logger.warn("find: No active session");
681
+ return noSessionResult(error);
682
+ }
929
683
  const cancelled = cancelledResultOrNull(error, "find");
930
684
  if (cancelled)
931
685
  return cancelled;
@@ -947,105 +701,25 @@ registerAppTool(server, "findall", {
947
701
  const startTime = Date.now();
948
702
  const progress = makeProgressReporter(extra);
949
703
  logger.info("findall: Starting", { description: params.description, timeout: params.timeout });
950
- const sessionCheck = requireActiveSession();
951
- if (!sessionCheck.valid) {
952
- logger.warn("findall: No active session");
953
- return sessionCheck.error;
954
- }
955
704
  try {
956
705
  logger.debug("findall: Calling SDK findAll");
957
706
  const stopHeartbeat = progress.heartbeat(`Looking for all "${params.description}"...`);
958
- let elements;
707
+ let result;
959
708
  try {
960
- elements = await raceAbort(extra.signal, "findall", sdk.findAll(params.description, params.timeout ? { timeout: params.timeout } : undefined));
709
+ result = await raceAbort(extra.signal, "findall", core.findAll(params.description, params.timeout));
961
710
  }
962
711
  finally {
963
712
  stopHeartbeat();
964
713
  }
965
- const count = elements.length;
966
- // Store element refs for later use
967
- const refs = [];
968
- const elementInfos = [];
969
- for (let i = 0; i < elements.length; i++) {
970
- const element = elements[i];
971
- const coords = element.getCoordinates();
972
- const elementRef = `el-${Date.now()}-${i}`;
973
- if (coords) {
974
- elementRefs.set(elementRef, {
975
- element: element,
976
- description: `${params.description} [${i}]`,
977
- coords: {
978
- x: coords.x,
979
- y: coords.y,
980
- centerX: coords.centerX,
981
- centerY: coords.centerY,
982
- },
983
- });
984
- refs.push(elementRef);
985
- elementInfos.push({
986
- ref: elementRef,
987
- x: coords.x,
988
- y: coords.y,
989
- centerX: coords.centerX,
990
- centerY: coords.centerY,
991
- confidence: element.confidence,
992
- });
993
- }
994
- }
995
- logger.info("findall: Elements found", {
996
- description: params.description,
997
- count,
998
- refs
999
- });
1000
- // Get the first element's response for the image (shows all highlights)
1001
- const rawResponse = elements[0]?._response || {};
1002
714
  const duration = Date.now() - startTime;
1003
- // Store cropped image for resource serving (instead of inline data URL)
1004
- let croppedImageResourceUri;
1005
- let screenshotResourceUri;
1006
- const croppedImage = rawResponse.croppedImage;
1007
- if (croppedImage) {
1008
- const imageData = croppedImage.startsWith('data:')
1009
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
1010
- : croppedImage;
1011
- croppedImageResourceUri = storeImage(imageData, "cropped");
1012
- // Remove croppedImage from response to avoid context bloat
1013
- delete rawResponse.croppedImage;
1014
- }
1015
- else if (count === 0) {
1016
- // No elements found and no cropped image - capture a fresh screenshot
1017
- // so the user can see what's currently visible on screen
1018
- try {
1019
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
1020
- if (screenshotBase64) {
1021
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
1022
- logger.debug("findall: Captured screenshot for not-found state");
1023
- }
1024
- }
1025
- catch (e) {
1026
- logger.warn("findall: Failed to capture screenshot for not-found state", { error: String(e) });
1027
- }
1028
- }
1029
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
1030
- delete rawResponse.extractedText;
1031
- delete rawResponse.pixelDiffImage;
1032
- // Generate code for this findall action
1033
- const generatedCode = count > 0 ? generateActionCode("findall", { description: params.description }) : undefined;
1034
- // Build refs list for text output
1035
- const refsList = refs.map((ref, i) => ` [${i}] ${ref}`).join('\n');
1036
- return createToolResult(count > 0, count > 0
1037
- ? `Found ${count} elements matching "${params.description}":\n${refsList}`
1038
- : `No elements found matching: "${params.description}"`, {
1039
- ...rawResponse,
1040
- count,
1041
- refs,
1042
- elements: elementInfos,
1043
- croppedImageResourceUri,
1044
- screenshotResourceUri,
1045
- duration,
1046
- }, generatedCode);
715
+ logger.info("findall: Completed", { description: params.description, count: result.data.count, duration });
716
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1047
717
  }
1048
718
  catch (error) {
719
+ if (error instanceof NoActiveSessionError) {
720
+ logger.warn("findall: No active session");
721
+ return noSessionResult(error);
722
+ }
1049
723
  const cancelled = cancelledResultOrNull(error, "findall");
1050
724
  if (cancelled)
1051
725
  return cancelled;
@@ -1066,58 +740,19 @@ registerAppTool(server, "click", {
1066
740
  }, async (params) => {
1067
741
  const startTime = Date.now();
1068
742
  logger.info("click: Starting", { elementRef: params.elementRef, action: params.action });
1069
- const sessionCheck = requireActiveSession();
1070
- if (!sessionCheck.valid) {
1071
- logger.warn("click: No active session");
1072
- return sessionCheck.error;
1073
- }
1074
- // Look up the element reference
1075
- const ref = elementRefs.get(params.elementRef);
1076
- if (!ref) {
1077
- logger.warn("click: Element reference not found", { elementRef: params.elementRef });
1078
- return createToolResult(false, `Element reference "${params.elementRef}" not found. Use 'find' first to locate the element.`, { error: "Element reference not found" });
1079
- }
1080
- const { element, description, coords } = ref;
1081
743
  try {
1082
- logger.debug("click: Executing click on element", { description, action: params.action });
1083
- // Use the Element's click method instead of raw coordinates
1084
- if (params.action === "click") {
1085
- await element.click();
1086
- }
1087
- else if (params.action === "double-click") {
1088
- await element.doubleClick();
1089
- }
1090
- else if (params.action === "right-click") {
1091
- await element.rightClick();
1092
- }
1093
- // Capture screenshot after click to show result
1094
- logger.debug("click: Capturing screenshot after click");
1095
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
1096
- let screenshotResourceUri;
1097
- if (screenshotBase64) {
1098
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
1099
- lastScreenshotBase64 = screenshotBase64;
1100
- }
1101
- const rawResponse = element._response || {};
1102
- // Remove large data from response to reduce context bloat
1103
- delete rawResponse.croppedImage;
1104
- delete rawResponse.extractedText;
1105
- delete rawResponse.pixelDiffImage;
744
+ logger.debug("click: Executing click on element", { elementRef: params.elementRef, action: params.action });
745
+ const result = await core.click(params.elementRef, params.action);
1106
746
  const duration = Date.now() - startTime;
1107
- logger.info("click: Completed", { description, duration });
1108
- // Generate code for this click action
1109
- const generatedCode = generateActionCode("click", { action: params.action });
1110
- return createToolResult(true, `Clicked on "${description}"`, {
1111
- ...rawResponse,
1112
- action: "click",
1113
- clickAction: params.action,
1114
- clickPosition: coords,
1115
- screenshotResourceUri,
1116
- duration
1117
- }, generatedCode);
747
+ logger.info("click: Completed", { elementRef: params.elementRef, duration });
748
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1118
749
  }
1119
750
  catch (error) {
1120
- logger.error("click: Failed", { error: String(error), description });
751
+ if (error instanceof NoActiveSessionError) {
752
+ logger.warn("click: No active session");
753
+ return noSessionResult(error);
754
+ }
755
+ logger.error("click: Failed", { error: String(error), elementRef: params.elementRef });
1121
756
  captureException(error, { tags: { tool: "click" }, extra: { elementRef: params.elementRef, action: params.action } });
1122
757
  throw error;
1123
758
  }
@@ -1133,47 +768,19 @@ registerAppTool(server, "hover", {
1133
768
  }, async (params) => {
1134
769
  const startTime = Date.now();
1135
770
  logger.info("hover: Starting", { elementRef: params.elementRef });
1136
- const sessionCheck = requireActiveSession();
1137
- if (!sessionCheck.valid) {
1138
- logger.warn("hover: No active session");
1139
- return sessionCheck.error;
1140
- }
1141
- // Look up the element reference
1142
- const ref = elementRefs.get(params.elementRef);
1143
- if (!ref) {
1144
- logger.warn("hover: Element reference not found", { elementRef: params.elementRef });
1145
- return createToolResult(false, `Element reference "${params.elementRef}" not found. Use 'find' first to locate the element.`, { error: "Element reference not found" });
1146
- }
1147
- const { element, description, coords } = ref;
1148
771
  try {
1149
- logger.debug("hover: Executing hover on element", { description });
1150
- await element.hover();
1151
- // Capture screenshot after hover to show result
1152
- logger.debug("hover: Capturing screenshot after hover");
1153
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
1154
- let screenshotResourceUri;
1155
- if (screenshotBase64) {
1156
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
1157
- lastScreenshotBase64 = screenshotBase64;
1158
- }
1159
- const rawResponse = element._response || {};
1160
- // Remove large data from response to reduce context bloat
1161
- delete rawResponse.croppedImage;
1162
- delete rawResponse.extractedText;
1163
- delete rawResponse.pixelDiffImage;
772
+ logger.debug("hover: Executing hover on element", { elementRef: params.elementRef });
773
+ const result = await core.hover(params.elementRef);
1164
774
  const duration = Date.now() - startTime;
1165
- logger.info("hover: Completed", { description, duration });
1166
- // Generate code for this hover action
1167
- const generatedCode = generateActionCode("hover", {});
1168
- return createToolResult(true, `Hovered over "${description}"`, {
1169
- ...rawResponse,
1170
- action: "hover",
1171
- screenshotResourceUri,
1172
- duration
1173
- }, generatedCode);
775
+ logger.info("hover: Completed", { elementRef: params.elementRef, duration });
776
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1174
777
  }
1175
778
  catch (error) {
1176
- logger.error("hover: Failed", { error: String(error), description });
779
+ if (error instanceof NoActiveSessionError) {
780
+ logger.warn("hover: No active session");
781
+ return noSessionResult(error);
782
+ }
783
+ logger.error("hover: Failed", { error: String(error), elementRef: params.elementRef });
1177
784
  captureException(error, { tags: { tool: "hover" }, extra: { elementRef: params.elementRef } });
1178
785
  throw error;
1179
786
  }
@@ -1187,21 +794,18 @@ server.registerTool("wait", {
1187
794
  }, async (params) => {
1188
795
  const startTime = Date.now();
1189
796
  logger.info("wait: Starting", { timeout: params.timeout });
1190
- const sessionCheck = requireActiveSession();
1191
- if (!sessionCheck.valid) {
1192
- logger.warn("wait: No active session");
1193
- return sessionCheck.error;
1194
- }
1195
797
  try {
1196
798
  logger.debug("wait: Waiting", { timeout: params.timeout });
1197
- await sdk.wait(params.timeout);
799
+ const result = await core.wait(params.timeout);
1198
800
  const duration = Date.now() - startTime;
1199
801
  logger.info("wait: Completed", { timeout: params.timeout, duration });
1200
- // Generate code for this wait action
1201
- const generatedCode = generateActionCode("wait", { timeout: params.timeout });
1202
- return createToolResult(true, `Waited for ${params.timeout}ms`, { action: "wait", timeout: params.timeout, duration }, generatedCode);
802
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1203
803
  }
1204
804
  catch (error) {
805
+ if (error instanceof NoActiveSessionError) {
806
+ logger.warn("wait: No active session");
807
+ return noSessionResult(error);
808
+ }
1205
809
  logger.error("wait: Failed", { error: String(error) });
1206
810
  captureException(error, { tags: { tool: "wait" }, extra: { timeout: params.timeout } });
1207
811
  throw error;
@@ -1216,21 +820,18 @@ server.registerTool("focus_application", {
1216
820
  }, async (params) => {
1217
821
  const startTime = Date.now();
1218
822
  logger.info("focus_application: Starting", { name: params.name });
1219
- const sessionCheck = requireActiveSession();
1220
- if (!sessionCheck.valid) {
1221
- logger.warn("focus_application: No active session");
1222
- return sessionCheck.error;
1223
- }
1224
823
  try {
1225
824
  logger.debug("focus_application: Focusing", { name: params.name });
1226
- await sdk.focusApplication(params.name);
825
+ const result = await core.focusApplication(params.name);
1227
826
  const duration = Date.now() - startTime;
1228
827
  logger.info("focus_application: Completed", { name: params.name, duration });
1229
- // Generate code for this focus action
1230
- const generatedCode = generateActionCode("focus_application", { name: params.name });
1231
- return createToolResult(true, `Focused application: "${params.name}"`, { action: "focus", name: params.name, duration }, generatedCode);
828
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1232
829
  }
1233
830
  catch (error) {
831
+ if (error instanceof NoActiveSessionError) {
832
+ logger.warn("focus_application: No active session");
833
+ return noSessionResult(error);
834
+ }
1234
835
  logger.error("focus_application: Failed", { error: String(error), name: params.name });
1235
836
  captureException(error, { tags: { tool: "focus_application" }, extra: { name: params.name } });
1236
837
  throw error;
@@ -1249,128 +850,25 @@ registerAppTool(server, "find_and_click", {
1249
850
  const startTime = Date.now();
1250
851
  const progress = makeProgressReporter(extra);
1251
852
  logger.info("find_and_click: Starting", { description: params.description, action: params.action });
1252
- const sessionCheck = requireActiveSession();
1253
- if (!sessionCheck.valid) {
1254
- logger.warn("find_and_click: No active session");
1255
- return sessionCheck.error;
1256
- }
1257
853
  try {
1258
854
  logger.debug("find_and_click: Finding element");
1259
855
  const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
1260
- let element;
856
+ let result;
1261
857
  try {
1262
- element = await raceAbort(extra.signal, "find_and_click", sdk.find(params.description));
858
+ result = await raceAbort(extra.signal, "find_and_click", core.findAndClick(params.description, params.action));
1263
859
  }
1264
860
  finally {
1265
861
  stopHeartbeat();
1266
862
  }
1267
- const found = element.found();
1268
- if (!found) {
1269
- logger.warn("find_and_click: Element not found", { description: params.description });
1270
- // Capture screenshot to show current state even when element not found
1271
- const rawResponse = element._response || {};
1272
- const duration = Date.now() - startTime;
1273
- // Store cropped image (screenshot) for resource serving
1274
- let croppedImageResourceUri;
1275
- let screenshotResourceUri;
1276
- const croppedImage = rawResponse.croppedImage;
1277
- if (croppedImage) {
1278
- const imageData = croppedImage.startsWith('data:')
1279
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
1280
- : croppedImage;
1281
- croppedImageResourceUri = storeImage(imageData, "screenshot");
1282
- delete rawResponse.croppedImage;
1283
- }
1284
- else {
1285
- // No cropped image - capture a fresh screenshot so the user can see
1286
- // what's currently visible on screen when element was not found
1287
- try {
1288
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
1289
- if (screenshotBase64) {
1290
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
1291
- logger.debug("find_and_click: Captured screenshot for not-found state");
1292
- }
1293
- }
1294
- catch (e) {
1295
- logger.warn("find_and_click: Failed to capture screenshot for not-found state", { error: String(e) });
1296
- }
1297
- }
1298
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
1299
- delete rawResponse.extractedText;
1300
- delete rawResponse.pixelDiffImage;
1301
- return createToolResult(false, `Element not found: "${params.description}"`, {
1302
- ...rawResponse,
1303
- action: "find_and_click",
1304
- error: "Element not found",
1305
- croppedImageResourceUri,
1306
- screenshotResourceUri,
1307
- duration
1308
- });
1309
- }
1310
- const coords = element.getCoordinates();
1311
- // Store element ref for later use (in case user wants to interact again)
1312
- const elementRef = `el-${Date.now()}`;
1313
- if (coords) {
1314
- elementRefs.set(elementRef, {
1315
- element: element,
1316
- description: params.description,
1317
- coords: {
1318
- x: coords.x,
1319
- y: coords.y,
1320
- centerX: coords.centerX,
1321
- centerY: coords.centerY,
1322
- },
1323
- });
1324
- }
1325
- logger.debug("find_and_click: Element found, clicking", { action: params.action, elementRef });
1326
- if (params.action === "click") {
1327
- await element.click();
1328
- }
1329
- else if (params.action === "double-click") {
1330
- await element.doubleClick();
1331
- }
1332
- else if (params.action === "right-click") {
1333
- await element.rightClick();
1334
- }
1335
- // Return raw SDK response directly
1336
- const rawResponse = element._response || {};
1337
863
  const duration = Date.now() - startTime;
1338
- // Store cropped image for resource serving (instead of inline data URL)
1339
- let croppedImageResourceUri;
1340
- const croppedImage = rawResponse.croppedImage;
1341
- if (croppedImage) {
1342
- const imageData = croppedImage.startsWith('data:')
1343
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
1344
- : croppedImage;
1345
- croppedImageResourceUri = storeImage(imageData, "cropped");
1346
- // Remove croppedImage from response to avoid context bloat
1347
- delete rawResponse.croppedImage;
1348
- }
1349
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
1350
- delete rawResponse.extractedText;
1351
- delete rawResponse.pixelDiffImage;
1352
- // Generate code for this find_and_click action
1353
- const generatedCode = generateActionCode("find_and_click", { description: params.description, action: params.action });
1354
- // Build element info for display (match find action format)
1355
- const elementInfo = coords ? {
1356
- description: params.description,
1357
- centerX: coords.centerX,
1358
- centerY: coords.centerY,
1359
- confidence: element.confidence,
1360
- ref: elementRef,
1361
- } : undefined;
1362
- return createToolResult(true, `Found and clicked: "${params.description}" at (${rawResponse.coordinates?.x}, ${rawResponse.coordinates?.y})\nRef: ${elementRef}`, {
1363
- ...rawResponse,
1364
- action: "find_and_click",
1365
- element: elementInfo,
1366
- ref: elementRef,
1367
- clickAction: params.action,
1368
- clickPosition: coords ? { x: coords.centerX, y: coords.centerY } : undefined,
1369
- croppedImageResourceUri,
1370
- duration,
1371
- }, generatedCode);
864
+ logger.info("find_and_click: Completed", { description: params.description, found: result.ok, duration });
865
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1372
866
  }
1373
867
  catch (error) {
868
+ if (error instanceof NoActiveSessionError) {
869
+ logger.warn("find_and_click: No active session");
870
+ return noSessionResult(error);
871
+ }
1374
872
  const cancelled = cancelledResultOrNull(error, "find_and_click");
1375
873
  if (cancelled)
1376
874
  return cancelled;
@@ -1390,21 +888,18 @@ server.registerTool("type", {
1390
888
  }, async (params) => {
1391
889
  const startTime = Date.now();
1392
890
  logger.info("type: Starting", { textLength: params.text.length, secret: params.secret });
1393
- const sessionCheck = requireActiveSession();
1394
- if (!sessionCheck.valid) {
1395
- logger.warn("type: No active session");
1396
- return sessionCheck.error;
1397
- }
1398
891
  try {
1399
892
  logger.debug("type: Typing text");
1400
- await sdk.type(params.text, { secret: params.secret, delay: params.delay });
893
+ const result = await core.type(params.text, params.secret, params.delay);
1401
894
  const duration = Date.now() - startTime;
1402
895
  logger.info("type: Completed", { duration });
1403
- // Generate code for this type action
1404
- const generatedCode = generateActionCode("type", { text: params.text, secret: params.secret });
1405
- return createToolResult(true, `Typed: ${params.secret ? "[secret text]" : `"${params.text}"`}`, { action: "type", text: params.secret ? "[SECRET]" : params.text, duration }, generatedCode);
896
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1406
897
  }
1407
898
  catch (error) {
899
+ if (error instanceof NoActiveSessionError) {
900
+ logger.warn("type: No active session");
901
+ return noSessionResult(error);
902
+ }
1408
903
  logger.error("type: Failed", { error: String(error) });
1409
904
  captureException(error, { tags: { tool: "type" }, extra: { textLength: params.text.length, secret: params.secret } });
1410
905
  throw error;
@@ -1419,21 +914,18 @@ server.registerTool("press_keys", {
1419
914
  }, async (params) => {
1420
915
  const startTime = Date.now();
1421
916
  logger.info("press_keys: Starting", { keys: params.keys });
1422
- const sessionCheck = requireActiveSession();
1423
- if (!sessionCheck.valid) {
1424
- logger.warn("press_keys: No active session");
1425
- return sessionCheck.error;
1426
- }
1427
917
  try {
1428
918
  logger.debug("press_keys: Pressing keys");
1429
- await sdk.pressKeys(params.keys);
919
+ const result = await core.pressKeys(params.keys);
1430
920
  const duration = Date.now() - startTime;
1431
921
  logger.info("press_keys: Completed", { keys: params.keys, duration });
1432
- // Generate code for this press_keys action
1433
- const generatedCode = generateActionCode("press_keys", { keys: params.keys });
1434
- return createToolResult(true, `Pressed keys: ${params.keys.join(" + ")}`, { action: "press_keys", keys: params.keys, duration }, generatedCode);
922
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1435
923
  }
1436
924
  catch (error) {
925
+ if (error instanceof NoActiveSessionError) {
926
+ logger.warn("press_keys: No active session");
927
+ return noSessionResult(error);
928
+ }
1437
929
  logger.error("press_keys: Failed", { error: String(error), keys: params.keys });
1438
930
  captureException(error, { tags: { tool: "press_keys" }, extra: { keys: params.keys } });
1439
931
  throw error;
@@ -1449,21 +941,18 @@ server.registerTool("scroll", {
1449
941
  }, async (params) => {
1450
942
  const startTime = Date.now();
1451
943
  logger.info("scroll: Starting", { direction: params.direction, amount: params.amount });
1452
- const sessionCheck = requireActiveSession();
1453
- if (!sessionCheck.valid) {
1454
- logger.warn("scroll: No active session");
1455
- return sessionCheck.error;
1456
- }
1457
944
  try {
1458
945
  logger.debug("scroll: Scrolling");
1459
- await sdk.scroll(params.direction, params.amount ? { amount: params.amount } : undefined);
946
+ const result = await core.scroll(params.direction, params.amount);
1460
947
  const duration = Date.now() - startTime;
1461
948
  logger.info("scroll: Completed", { direction: params.direction, duration });
1462
- // Generate code for this scroll action
1463
- const generatedCode = generateActionCode("scroll", { direction: params.direction, amount: params.amount });
1464
- return createToolResult(true, `Scrolled ${params.direction}${params.amount ? ` by ${params.amount}px` : ""}`, { action: "scroll", scrollDirection: params.direction, direction: params.direction, amount: params.amount, duration }, generatedCode);
949
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1465
950
  }
1466
951
  catch (error) {
952
+ if (error instanceof NoActiveSessionError) {
953
+ logger.warn("scroll: No active session");
954
+ return noSessionResult(error);
955
+ }
1467
956
  logger.error("scroll: Failed", { error: String(error), direction: params.direction });
1468
957
  captureException(error, { tags: { tool: "scroll" }, extra: { direction: params.direction, amount: params.amount } });
1469
958
  throw error;
@@ -1484,21 +973,18 @@ Unlike 'check' which is for your understanding during development, 'assert' crea
1484
973
  }, async (params) => {
1485
974
  const startTime = Date.now();
1486
975
  logger.info("assert: Starting", { assertion: params.assertion });
1487
- const sessionCheck = requireActiveSession();
1488
- if (!sessionCheck.valid) {
1489
- logger.warn("assert: No active session");
1490
- return sessionCheck.error;
1491
- }
1492
976
  try {
1493
977
  logger.debug("assert: Running assertion");
1494
- const result = await sdk.assert(params.assertion);
978
+ const result = await core.assert(params.assertion);
1495
979
  const duration = Date.now() - startTime;
1496
- logger.info("assert: Completed", { assertion: params.assertion, passed: result, duration });
1497
- // Generate code for this assert action
1498
- const generatedCode = generateActionCode("assert", { assertion: params.assertion });
1499
- return createToolResult(result, result ? `✓ Assertion passed: "${params.assertion}"` : `✗ Assertion failed: "${params.assertion}"`, { action: "assert", assertion: params.assertion, passed: result, success: result, duration }, generatedCode);
980
+ logger.info("assert: Completed", { assertion: params.assertion, passed: result.ok, duration });
981
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1500
982
  }
1501
983
  catch (error) {
984
+ if (error instanceof NoActiveSessionError) {
985
+ logger.warn("assert: No active session");
986
+ return noSessionResult(error);
987
+ }
1502
988
  logger.error("assert: Failed", { error: String(error), assertion: params.assertion });
1503
989
  captureException(error, { tags: { tool: "assert" }, extra: { assertion: params.assertion } });
1504
990
  throw error;
@@ -1535,27 +1021,20 @@ You can optionally provide a reference image URI to compare against a previous s
1535
1021
  const startTime = Date.now();
1536
1022
  const progress = makeProgressReporter(extra);
1537
1023
  logger.info("check: Starting", { task: params.task, hasReferenceImageUri: !!params.referenceImageUri });
1538
- const sessionCheck = requireActiveSession();
1539
- if (!sessionCheck.valid) {
1540
- logger.warn("check: No active session");
1541
- return sessionCheck.error;
1542
- }
1543
1024
  try {
1544
- // Capture current screenshot
1545
- logger.debug("check: Capturing current screenshot");
1546
- progress.report("Capturing screenshot...");
1547
- const currentScreenshot = await sdk.agent.system.captureScreenBase64(1, false, true);
1548
- // Use provided reference image URI, last screenshot as "before" state, or current if no previous screenshot
1549
- let beforeScreenshot;
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;
1550
1029
  if (params.referenceImageUri) {
1551
1030
  // Extract image ID from URI (e.g., "screenshot://testdriver/screenshot/screenshot-1" -> "screenshot-1")
1552
- const uriParts = params.referenceImageUri.split('/');
1031
+ const uriParts = params.referenceImageUri.split("/");
1553
1032
  const imageId = uriParts[uriParts.length - 1];
1554
1033
  logger.info("check: Looking up reference image", {
1555
1034
  referenceImageUri: params.referenceImageUri,
1556
1035
  extractedImageId: imageId,
1557
1036
  imageStoreSize: imageStore.size,
1558
- availableKeys: Array.from(imageStore.keys())
1037
+ availableKeys: Array.from(imageStore.keys()),
1559
1038
  });
1560
1039
  const storedImage = getStoredImage(imageId);
1561
1040
  if (storedImage) {
@@ -1563,79 +1042,37 @@ You can optionally provide a reference image URI to compare against a previous s
1563
1042
  imageId,
1564
1043
  dataLength: storedImage.data?.length,
1565
1044
  type: storedImage.type,
1566
- hasData: !!storedImage.data
1045
+ hasData: !!storedImage.data,
1567
1046
  });
1568
- beforeScreenshot = storedImage.data;
1047
+ referenceImage = storedImage.data;
1569
1048
  }
1570
1049
  else {
1571
1050
  logger.warn("check: Reference image NOT found in store, falling back to last screenshot", {
1572
1051
  referenceImageUri: params.referenceImageUri,
1573
1052
  imageId,
1574
1053
  imageStoreSize: imageStore.size,
1575
- availableKeys: Array.from(imageStore.keys())
1054
+ availableKeys: Array.from(imageStore.keys()),
1576
1055
  });
1577
- beforeScreenshot = lastScreenshotBase64 || currentScreenshot;
1578
1056
  }
1579
1057
  }
1580
- else {
1581
- beforeScreenshot = lastScreenshotBase64 || currentScreenshot;
1582
- }
1583
- // Update last screenshot for next check
1584
- lastScreenshotBase64 = currentScreenshot;
1585
- // Get system state
1586
- const mousePosition = await sdk.agent.system.getMousePosition();
1587
- const activeWindow = await sdk.agent.system.activeWin();
1588
- // Call the check endpoint
1589
- logger.info("check: Calling check API endpoint", {
1590
- hasLastScreenshot: beforeScreenshot !== currentScreenshot,
1591
- usingReferenceImageUri: !!params.referenceImageUri,
1592
- beforeScreenshotLength: beforeScreenshot?.length || 0,
1593
- currentScreenshotLength: currentScreenshot?.length || 0,
1594
- beforeScreenshotPreview: beforeScreenshot?.substring(0, 50),
1595
- currentScreenshotPreview: currentScreenshot?.substring(0, 50)
1596
- });
1058
+ progress.report("Capturing screenshot...");
1597
1059
  const stopHeartbeat = progress.heartbeat(`Checking: "${params.task}"...`);
1598
- let response;
1060
+ let result;
1599
1061
  try {
1600
- response = await raceAbort(extra.signal, "check", sdk.agent.sdk.req("check", {
1601
- tasks: [params.task],
1602
- images: [beforeScreenshot, currentScreenshot],
1603
- mousePosition,
1604
- activeWindow,
1605
- }));
1062
+ result = await raceAbort(extra.signal, "check", core.check(params.task, referenceImage));
1606
1063
  }
1607
1064
  finally {
1608
1065
  stopHeartbeat();
1609
1066
  }
1610
- const aiResponse = response.data;
1611
- // Store screenshot for resource serving
1612
- let screenshotResourceUri;
1613
- if (currentScreenshot) {
1614
- screenshotResourceUri = storeImage(currentScreenshot, "screenshot");
1615
- }
1616
- // Determine if the check passed based on the AI response
1617
- // The AI typically returns markdown with its analysis
1618
- // We consider it "complete" if the response doesn't contain code blocks (indicating more work needed)
1619
- const hasCodeBlocks = aiResponse && (aiResponse.includes("```yml") ||
1620
- aiResponse.includes("```yaml") ||
1621
- aiResponse.includes("- command:"));
1622
- const isComplete = !hasCodeBlocks;
1623
1067
  const duration = Date.now() - startTime;
1624
- logger.info("check: Completed", { task: params.task, complete: isComplete, duration });
1625
- // Note: check doesn't generate code - it's for AI understanding, not test recording
1626
- return createToolResult(isComplete, isComplete
1627
- ? `✓ Task appears complete: "${params.task}"\n\nAI Analysis:\n${aiResponse}`
1628
- : `⚠ Task may not be complete: "${params.task}"\n\nAI Analysis:\n${aiResponse}`, {
1629
- action: "check",
1630
- task: params.task,
1631
- complete: isComplete,
1632
- success: isComplete,
1633
- aiResponse,
1634
- screenshotResourceUri,
1635
- duration
1636
- });
1068
+ logger.info("check: Completed", { task: params.task, complete: result.ok, duration });
1069
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1637
1070
  }
1638
1071
  catch (error) {
1072
+ if (error instanceof NoActiveSessionError) {
1073
+ logger.warn("check: No active session");
1074
+ return noSessionResult(error);
1075
+ }
1639
1076
  const cancelled = cancelledResultOrNull(error, "check");
1640
1077
  if (cancelled)
1641
1078
  return cancelled;
@@ -1655,21 +1092,18 @@ server.registerTool("exec", {
1655
1092
  }, async (params) => {
1656
1093
  const startTime = Date.now();
1657
1094
  logger.info("exec: Starting", { language: params.language, codeLength: params.code.length, timeout: params.timeout });
1658
- const sessionCheck = requireActiveSession();
1659
- if (!sessionCheck.valid) {
1660
- logger.warn("exec: No active session");
1661
- return sessionCheck.error;
1662
- }
1663
1095
  try {
1664
1096
  logger.debug("exec: Executing code", { language: params.language });
1665
- const output = await sdk.exec(params.language, params.code, params.timeout);
1097
+ const result = await core.exec(params.language, params.code, params.timeout);
1666
1098
  const duration = Date.now() - startTime;
1667
- logger.info("exec: Completed", { language: params.language, outputLength: output?.length || 0, duration });
1668
- // Generate code for this exec action
1669
- const generatedCode = generateActionCode("exec", { language: params.language, code: params.code, timeout: params.timeout });
1670
- return createToolResult(true, `Executed ${params.language} code:\n${output || "(no output)"}`, { action: "exec", language: params.language, output, duration }, generatedCode);
1099
+ logger.info("exec: Completed", { language: params.language, outputLength: result.data.output?.length || 0, duration });
1100
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1671
1101
  }
1672
1102
  catch (error) {
1103
+ if (error instanceof NoActiveSessionError) {
1104
+ logger.warn("exec: No active session");
1105
+ return noSessionResult(error);
1106
+ }
1673
1107
  logger.error("exec: Failed", { error: String(error), language: params.language });
1674
1108
  captureException(error, { tags: { tool: "exec" }, extra: { language: params.language, codeLength: params.code.length } });
1675
1109
  throw error;
@@ -2005,30 +1439,25 @@ Only use 'screenshot' when you explicitly want to show something to the human us
2005
1439
  }, async () => {
2006
1440
  const startTime = Date.now();
2007
1441
  logger.info("screenshot: Starting");
2008
- const sessionCheck = requireActiveSession();
2009
- if (!sessionCheck.valid) {
2010
- logger.warn("screenshot: No active session");
2011
- return sessionCheck.error;
2012
- }
2013
1442
  try {
2014
- // Capture full screen screenshot
2015
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
2016
- let screenshotResourceUri;
2017
- if (screenshotBase64) {
2018
- // Store raw base64 for the resource blob with unique ID
2019
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
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");
2020
1450
  }
2021
1451
  const duration = Date.now() - startTime;
2022
- logger.info("screenshot: Completed", { duration, hasImage: !!screenshotBase64 });
2023
- // Only send the resource URI - the MCP app will fetch the image via resources/read
2024
- // This keeps the base64 image data OUT of AI context
2025
- return createToolResult(true, "Screenshot captured and displayed to user", {
2026
- action: "screenshot",
2027
- screenshotResourceUri,
2028
- duration
2029
- });
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);
2030
1455
  }
2031
1456
  catch (error) {
1457
+ if (error instanceof NoActiveSessionError) {
1458
+ logger.warn("screenshot: No active session");
1459
+ return noSessionResult(error);
1460
+ }
2032
1461
  logger.error("screenshot: Failed", { error: String(error) });
2033
1462
  return createToolResult(false, `Screenshot failed: ${error}`, { error: String(error) });
2034
1463
  }
@@ -2107,14 +1536,87 @@ Learn more at https://docs.testdriver.ai/v7/getting-started/
2107
1536
  throw error;
2108
1537
  }
2109
1538
  });
1539
+ // =============================================================================
1540
+ // HTTP transport (Streamable HTTP, no auth)
1541
+ // =============================================================================
1542
+ /**
1543
+ * Serve the MCP server over Streamable HTTP with no authentication.
1544
+ *
1545
+ * This is what the eve agent (and any other Streamable-HTTP MCP client) connects
1546
+ * to. The server holds single-session global state (the active `sdk`, element
1547
+ * refs, image store), so we mirror that with a single long-lived MCP transport:
1548
+ * the first `initialize` request mints a session id and every subsequent request
1549
+ * reuses the same `server` + transport. Concurrent sessions are intentionally not
1550
+ * supported — this server provisions one sandbox at a time.
1551
+ *
1552
+ * No auth is applied here by design (the connection is expected to be local-only
1553
+ * or otherwise protected outside the MCP layer). Do not expose this on a public
1554
+ * network without putting a real authenticating proxy in front of it.
1555
+ */
1556
+ async function startHttpServer() {
1557
+ const host = process.env.TD_MCP_HOST || "127.0.0.1";
1558
+ const port = Number(process.env.TD_MCP_PORT || process.env.PORT || 8788);
1559
+ const mcpPath = process.env.TD_MCP_PATH || "/mcp";
1560
+ // One transport for the lifetime of the process (single-session design).
1561
+ const transport = new StreamableHTTPServerTransport({
1562
+ sessionIdGenerator: () => randomUUID(),
1563
+ });
1564
+ await server.connect(transport);
1565
+ const httpServer = http.createServer((req, res) => {
1566
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
1567
+ // Lightweight health check for readiness probes / `eve dev`.
1568
+ if (req.method === "GET" && url.pathname === "/health") {
1569
+ res.writeHead(200, { "content-type": "application/json" });
1570
+ res.end(JSON.stringify({ status: "ok", server: "testdriver", version }));
1571
+ return;
1572
+ }
1573
+ if (url.pathname !== mcpPath) {
1574
+ res.writeHead(404, { "content-type": "application/json" });
1575
+ res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
1576
+ return;
1577
+ }
1578
+ // The Streamable HTTP transport handles POST (requests), GET (SSE stream),
1579
+ // and DELETE (session teardown) on the same path. Body parsing is left to
1580
+ // the transport so it can read the raw stream.
1581
+ transport.handleRequest(req, res).catch((error) => {
1582
+ logger.error("http: handleRequest failed", { error: String(error) });
1583
+ captureException(error, { tags: { phase: "http" } });
1584
+ if (!res.headersSent) {
1585
+ res.writeHead(500, { "content-type": "application/json" });
1586
+ res.end(JSON.stringify({ error: "Internal server error" }));
1587
+ }
1588
+ });
1589
+ });
1590
+ await new Promise((resolve) => httpServer.listen(port, host, resolve));
1591
+ logger.info("TestDriver MCP Server running on Streamable HTTP", {
1592
+ url: `http://${host}:${port}${mcpPath}`,
1593
+ });
1594
+ const shutdown = async () => {
1595
+ logger.info("Shutting down MCP Server");
1596
+ httpServer.close();
1597
+ await transport.close().catch(() => { });
1598
+ await flushSentry();
1599
+ process.exit(0);
1600
+ };
1601
+ process.on("SIGINT", shutdown);
1602
+ process.on("SIGTERM", shutdown);
1603
+ }
2110
1604
  // Start the server
2111
1605
  async function main() {
1606
+ // Transport selection: TD_MCP_TRANSPORT=http serves Streamable HTTP (used by
1607
+ // the eve agent); anything else (default) uses stdio for local CLI clients.
1608
+ const transportMode = (process.env.TD_MCP_TRANSPORT || "stdio").toLowerCase();
2112
1609
  logger.info("Starting TestDriver MCP Server", {
2113
1610
  version,
1611
+ transport: transportMode,
2114
1612
  logLevel: process.env.TD_LOG_LEVEL || "INFO",
2115
1613
  distDir: DIST_DIR,
2116
1614
  sentryEnabled: isSentryEnabled(),
2117
1615
  });
1616
+ if (transportMode === "http") {
1617
+ await startHttpServer();
1618
+ return;
1619
+ }
2118
1620
  const transport = new StdioServerTransport();
2119
1621
  await server.connect(transport);
2120
1622
  logger.info("TestDriver MCP Server running on stdio");