@testdriverai/mcp 7.10.0-canary.0 → 7.10.1-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.
Files changed (196) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/agent/index.js +116 -71
  3. package/agent/interface.js +13 -1
  4. package/agent/lib/commands.js +35 -15
  5. package/agent/lib/provision-commands.js +92 -11
  6. package/agent/lib/sandbox.js +263 -30
  7. package/agent/lib/sdk.js +16 -12
  8. package/agent/lib/system.js +107 -31
  9. package/ai/.claude-plugin/plugin.json +89 -4
  10. package/ai/agents/testdriver.md +4 -8
  11. package/ai/skills/testdriver-assert/SKILL.md +0 -1
  12. package/ai/skills/testdriver-aws-setup/SKILL.md +1 -1
  13. package/ai/skills/testdriver-caching/SKILL.md +13 -6
  14. package/ai/skills/testdriver-ci-cd/SKILL.md +57 -14
  15. package/ai/skills/testdriver-client/SKILL.md +10 -6
  16. package/ai/skills/testdriver-customizing-devices/SKILL.md +7 -5
  17. package/ai/skills/testdriver-dashcam/SKILL.md +36 -3
  18. package/ai/skills/testdriver-debugging-with-screenshots/SKILL.md +28 -4
  19. package/ai/skills/testdriver-device-config/SKILL.md +5 -5
  20. package/ai/skills/testdriver-find/SKILL.md +50 -11
  21. package/ai/skills/testdriver-generating-tests/SKILL.md +163 -8
  22. package/ai/skills/testdriver-hosted/SKILL.md +1 -1
  23. package/ai/skills/testdriver-machine-setup/SKILL.md +331 -0
  24. package/ai/skills/testdriver-making-assertions/SKILL.md +78 -2
  25. package/ai/skills/testdriver-mcp-workflow/SKILL.md +2 -2
  26. package/ai/skills/testdriver-parse/SKILL.md +1 -1
  27. package/ai/skills/testdriver-performing-actions/SKILL.md +97 -3
  28. package/ai/skills/testdriver-provision/SKILL.md +0 -20
  29. package/ai/skills/testdriver-quickstart/SKILL.md +299 -21
  30. package/ai/skills/testdriver-reusable-code/SKILL.md +3 -3
  31. package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
  32. package/ai/skills/testdriver-screenshot/SKILL.md +5 -5
  33. package/ai/skills/testdriver-scroll/SKILL.md +30 -160
  34. package/ai/skills/testdriver-test-writer/SKILL.md +3 -5
  35. package/ai/skills/testdriver-testdriver/SKILL.md +3 -7
  36. package/ai/skills/testdriver-wait/SKILL.md +1 -1
  37. package/{docs/v7/ai.mdx → ai/skills/testdriver:ai/SKILL.md} +3 -4
  38. package/ai/skills/testdriver:assert/SKILL.md +315 -0
  39. package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
  40. package/ai/skills/testdriver:cache/SKILL.md +221 -0
  41. package/ai/skills/testdriver:caching/SKILL.md +124 -0
  42. package/ai/skills/testdriver:captcha/SKILL.md +158 -0
  43. package/ai/skills/testdriver:ci-cd/SKILL.md +659 -0
  44. package/ai/skills/testdriver:click/SKILL.md +286 -0
  45. package/ai/skills/testdriver:client/SKILL.md +477 -0
  46. package/ai/skills/testdriver:customizing-devices/SKILL.md +319 -0
  47. package/ai/skills/testdriver:dashcam/SKILL.md +451 -0
  48. package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +415 -0
  49. package/{docs/v7/device-config.mdx → ai/skills/testdriver:device-config/SKILL.md} +8 -8
  50. package/ai/skills/testdriver:double-click/SKILL.md +102 -0
  51. package/ai/skills/testdriver:elements/SKILL.md +605 -0
  52. package/ai/skills/testdriver:enterprise/SKILL.md +7 -0
  53. package/ai/skills/testdriver:errors/SKILL.md +246 -0
  54. package/ai/skills/testdriver:events/SKILL.md +356 -0
  55. package/ai/skills/testdriver:exec/SKILL.md +317 -0
  56. package/ai/skills/testdriver:find/SKILL.md +860 -0
  57. package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
  58. package/ai/skills/testdriver:generating-tests/SKILL.md +25 -0
  59. package/ai/skills/testdriver:hosted/SKILL.md +156 -0
  60. package/ai/skills/testdriver:hover/SKILL.md +278 -0
  61. package/{docs/v7/locating-elements.mdx → ai/skills/testdriver:locating-elements/SKILL.md} +3 -3
  62. package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
  63. package/ai/skills/testdriver:mcp/SKILL.md +7 -0
  64. package/ai/skills/testdriver:mouse-down/SKILL.md +161 -0
  65. package/ai/skills/testdriver:mouse-up/SKILL.md +164 -0
  66. package/ai/skills/testdriver:parse/SKILL.md +236 -0
  67. package/ai/skills/testdriver:performing-actions/SKILL.md +53 -0
  68. package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
  69. package/ai/skills/testdriver:provision/SKILL.md +331 -0
  70. package/ai/skills/testdriver:quickstart/SKILL.md +172 -0
  71. package/ai/skills/testdriver:redraw/SKILL.md +214 -0
  72. package/ai/skills/testdriver:reusable-code/SKILL.md +249 -0
  73. package/ai/skills/testdriver:right-click/SKILL.md +123 -0
  74. package/{docs/v7/running-tests.mdx → ai/skills/testdriver:running-tests/SKILL.md} +4 -4
  75. package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
  76. package/ai/skills/testdriver:screenshots/SKILL.md +184 -0
  77. package/ai/skills/testdriver:scroll/SKILL.md +205 -0
  78. package/ai/skills/testdriver:secrets/SKILL.md +115 -0
  79. package/ai/skills/testdriver:self-hosted/SKILL.md +147 -0
  80. package/ai/skills/testdriver:test-results-json/SKILL.md +257 -0
  81. package/ai/skills/testdriver:testdriver/SKILL.md +624 -0
  82. package/ai/skills/testdriver:type/SKILL.md +357 -0
  83. package/ai/skills/testdriver:variables/SKILL.md +111 -0
  84. package/ai/skills/testdriver:wait/SKILL.md +50 -0
  85. package/{docs/v7/waiting-for-elements.mdx → ai/skills/testdriver:waiting-for-elements/SKILL.md} +3 -3
  86. package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
  87. package/debugger/index.html +12 -0
  88. package/docs/_data/examples-manifest.json +54 -46
  89. package/docs/_data/examples-manifest.schema.json +12 -2
  90. package/docs/_scripts/extract-example-urls.js +21 -3
  91. package/docs/_scripts/generate-examples.js +64 -16
  92. package/docs/changelog.mdx +29 -2
  93. package/docs/claude-mcp-plugin.mdx +65 -18
  94. package/docs/docs.json +16 -33
  95. package/docs/snippets/tests/scroll-yaml.mdx +2 -2
  96. package/docs/v6/commands/scroll-until-image.mdx +1 -1
  97. package/docs/v6/commands/scroll-until-text.mdx +2 -2
  98. package/docs/v6/commands/scroll.mdx +2 -2
  99. package/docs/v7/_drafts/agents.mdx +0 -4
  100. package/docs/v7/_drafts/best-practices.mdx +0 -12
  101. package/docs/v7/_drafts/commands/scroll-until-image.mdx +1 -1
  102. package/docs/v7/_drafts/commands/scroll-until-text.mdx +2 -2
  103. package/docs/v7/_drafts/commands/scroll.mdx +1 -1
  104. package/docs/v7/_drafts/configuration.mdx +0 -12
  105. package/docs/v7/_drafts/faq.mdx +2 -10
  106. package/docs/v7/_drafts/readme.mdx +0 -1
  107. package/docs/v7/_drafts/troubleshooting.mdx +1 -4
  108. package/docs/v7/assert.mdx +0 -1
  109. package/docs/v7/aws-setup.mdx +1 -1
  110. package/docs/v7/caching.mdx +14 -10
  111. package/docs/v7/ci-cd.mdx +98 -14
  112. package/docs/v7/client.mdx +10 -6
  113. package/docs/v7/copilot/auto-healing.mdx +167 -18
  114. package/docs/v7/copilot/running-tests.mdx +915 -54
  115. package/docs/v7/customizing-devices.mdx +7 -5
  116. package/docs/v7/dashcam.mdx +36 -3
  117. package/docs/v7/debugging-with-screenshots.mdx +31 -7
  118. package/docs/v7/examples/ai.mdx +13 -8
  119. package/docs/v7/examples/assert.mdx +1 -1
  120. package/docs/v7/examples/chrome-extension.mdx +30 -13
  121. package/docs/v7/examples/element-not-found.mdx +1 -1
  122. package/docs/v7/examples/findall-coffee-icons.mdx +3 -2
  123. package/docs/v7/examples/formatted-logging.mdx +74 -0
  124. package/docs/v7/examples/hover-image.mdx +1 -1
  125. package/docs/v7/examples/hover-text-with-description.mdx +2 -2
  126. package/docs/v7/examples/hover-text.mdx +1 -1
  127. package/docs/v7/examples/installer.mdx +1 -1
  128. package/docs/v7/examples/launch-vscode-linux.mdx +27 -27
  129. package/docs/v7/examples/match-image.mdx +1 -1
  130. package/docs/v7/examples/parse.mdx +3 -2
  131. package/docs/v7/examples/press-keys.mdx +2 -2
  132. package/docs/v7/examples/scroll-keyboard.mdx +2 -2
  133. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  134. package/docs/v7/examples/scroll.mdx +1 -1
  135. package/docs/v7/examples/type.mdx +1 -1
  136. package/docs/v7/find.mdx +50 -11
  137. package/docs/v7/generating-tests.mdx +166 -10
  138. package/docs/v7/hosted.mdx +1 -1
  139. package/docs/v7/making-assertions.mdx +81 -4
  140. package/docs/v7/parse.mdx +1 -1
  141. package/docs/v7/performing-actions.mdx +100 -5
  142. package/docs/v7/provision.mdx +0 -20
  143. package/docs/v7/quickstart.mdx +299 -21
  144. package/docs/v7/reusable-code.mdx +3 -3
  145. package/docs/v7/screenshot.mdx +5 -5
  146. package/docs/v7/scroll.mdx +30 -160
  147. package/docs/v7/wait.mdx +1 -1
  148. package/examples/chrome-extension.test.mjs +29 -12
  149. package/examples/config.mjs +1 -1
  150. package/examples/launch-vscode-linux.test.mjs +25 -25
  151. package/interfaces/cli/commands/init.js +84 -2
  152. package/interfaces/vitest-plugin.mjs +51 -61
  153. package/lib/core/Dashcam.js +43 -2
  154. package/lib/core/index.d.ts +13 -1
  155. package/lib/github-comment.mjs +11 -5
  156. package/lib/init-project.js +98 -44
  157. package/lib/install-clients.js +470 -0
  158. package/lib/provision.js +749 -0
  159. package/lib/resolve-channel.js +2 -2
  160. package/lib/sentry.js +15 -2
  161. package/lib/vitest/hooks.mjs +44 -34
  162. package/mcp-server/README.md +32 -0
  163. package/mcp-server/dist/core/actions.d.ts +176 -0
  164. package/mcp-server/dist/core/actions.js +753 -0
  165. package/mcp-server/dist/env-utils.d.ts +45 -0
  166. package/mcp-server/dist/env-utils.js +63 -0
  167. package/mcp-server/dist/provision-types.d.ts +6 -2
  168. package/mcp-server/dist/provision-types.js +3 -1
  169. package/mcp-server/dist/server.mjs +449 -751
  170. package/mcp-server/package-lock.json +384 -1
  171. package/mcp-server/package.json +5 -2
  172. package/mcp-server/src/core/actions.ts +912 -0
  173. package/mcp-server/src/env-utils.test.ts +82 -0
  174. package/mcp-server/src/env-utils.ts +77 -0
  175. package/mcp-server/src/provision-types.ts +4 -1
  176. package/mcp-server/src/server.ts +533 -954
  177. package/mcp-server/tsconfig.json +1 -1
  178. package/mcp-server/vitest.config.ts +7 -0
  179. package/package.json +15 -3
  180. package/sdk.d.ts +11 -9
  181. package/sdk.js +116 -746
  182. package/setup/aws/spawn-runner.sh +24 -3
  183. package/docs/v7/_drafts/caching-ai.mdx +0 -215
  184. package/docs/v7/_drafts/caching.mdx +0 -366
  185. package/docs/v7/_drafts/prompt-cache.mdx +0 -200
  186. package/docs/v7/copilot/creating-tests.mdx +0 -156
  187. package/docs/v7/copilot/github.mdx +0 -143
  188. package/docs/v7/copilot/setup.mdx +0 -143
  189. package/docs/v7/examples/exec-output.mdx +0 -84
  190. package/docs/v7/examples/exec-pwsh.mdx +0 -82
  191. package/docs/v7/examples/focus-window.mdx +0 -61
  192. package/docs/v7/examples/scroll-until-image.mdx +0 -82
  193. package/docs/v7/examples/windows-installer.mdx +0 -95
  194. package/examples/formatted-logging.test.mjs +0 -27
  195. package/examples/match-image.test.mjs +0 -55
  196. package/manual/exec-stream-logs.test.mjs +0 -25
@@ -10,14 +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";
20
- import { getProvisionOptions, SessionStartInputSchema } from "./provision-types.js";
22
+ import * as core from "./core/actions.js";
23
+ import { NoActiveSessionError } from "./core/actions.js";
24
+ import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
25
+ import { SessionStartInputSchema } from "./provision-types.js";
21
26
  import { sessionManager } from "./session.js";
22
27
  // =============================================================================
23
28
  // Sentry
@@ -47,9 +52,7 @@ function resolveChannel(ver) {
47
52
  return "stable";
48
53
  }
49
54
  function resolveSentryEnvironment(ver) {
50
- if (process.env.TD_ENV && VALID_ENVS.has(process.env.TD_ENV))
51
- return process.env.TD_ENV;
52
- return CHANNEL_TO_ENV[resolveChannel(ver)] || "production";
55
+ return resolveChannel(ver);
53
56
  }
54
57
  const activeChannel = resolveChannel(version);
55
58
  const sentryEnvironment = resolveSentryEnvironment(version);
@@ -66,8 +69,8 @@ if (isSentryEnabled()) {
66
69
  "https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760",
67
70
  environment: sentryEnvironment,
68
71
  release: version,
69
- sampleRate: 1.0,
70
- tracesSampleRate: 1.0,
72
+ sampleRate: 0.01,
73
+ tracesSampleRate: 0.01,
71
74
  sendDefaultPii: true,
72
75
  integrations: [Sentry.httpIntegration(), Sentry.nodeContextIntegration()],
73
76
  initialScope: {
@@ -178,10 +181,6 @@ const RESOURCE_URI = "ui://testdriver/mcp-app.html";
178
181
  // Resource URI base for serving screenshot blobs (with dynamic IDs)
179
182
  const SCREENSHOT_RESOURCE_BASE = "screenshot://testdriver/screenshot";
180
183
  const CROPPED_IMAGE_RESOURCE_BASE = "screenshot://testdriver/cropped";
181
- // SDK instance (will be initialized on session start)
182
- let sdk = null;
183
- // Last screenshot base64 for check comparisons
184
- let lastScreenshotBase64 = null;
185
184
  // Map of image ID -> image data
186
185
  const imageStore = new Map();
187
186
  // Counter for generating unique image IDs
@@ -230,42 +229,135 @@ function getSessionData(session) {
230
229
  };
231
230
  }
232
231
  /**
233
- * Check if session is ready for use - returns error result if not
234
- * This helper provides clear, actionable error messages for the AI
235
- *
236
- * 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.
237
235
  */
238
- function requireActiveSession() {
239
- const session = sessionManager.getCurrentSession();
240
- // No session ever created
241
- if (!sdk || !session) {
242
- return {
243
- valid: false,
244
- error: createToolResult(false, "ERROR: No active session. You must call session_start first to create a sandbox before using any other tools.", {
245
- error: "NO_SESSION",
246
- action: "session_start",
247
- message: "No sandbox session exists. Call session_start to create one."
248
- })
249
- };
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
+ });
250
244
  }
251
- // Session exists but has expired
252
- if (!sessionManager.isSessionValid(session.sessionId)) {
253
- // Clear the SDK reference since the sandbox is no longer available
254
- sdk = null;
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);
265
+ }
266
+ const DEFAULT_HEARTBEAT_MS = 3000;
267
+ function makeProgressReporter(extra) {
268
+ const progressToken = extra?._meta?.progressToken;
269
+ // No token → client did not opt into progress. Return a no-op reporter.
270
+ if (progressToken === undefined || progressToken === null) {
255
271
  return {
256
- valid: false,
257
- 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.", {
258
- error: "SESSION_EXPIRED",
259
- action: "session_start",
260
- message: "The previous sandbox session has expired. Call session_start to create a new one.",
261
- expiredSessionId: session.sessionId
262
- })
272
+ report: () => { },
273
+ heartbeat: () => () => { },
263
274
  };
264
275
  }
265
- // Auto-extend session on each command to prevent expiry during active use
266
- // This resets the expiry timer back to the original keepAlive duration
267
- sessionManager.refreshSession(session.sessionId);
268
- return { valid: true };
276
+ let progress = 0;
277
+ const send = (message) => {
278
+ progress += 1;
279
+ // Fire-and-forget: a failed notification must never break the tool call.
280
+ void extra
281
+ .sendNotification({
282
+ method: "notifications/progress",
283
+ params: { progressToken, progress, message },
284
+ })
285
+ .catch((err) => {
286
+ logger.debug("progress: sendNotification failed", { error: String(err) });
287
+ });
288
+ };
289
+ return {
290
+ report: (message) => send(message),
291
+ heartbeat: (message, intervalMs = DEFAULT_HEARTBEAT_MS) => {
292
+ send(message);
293
+ const timer = setInterval(() => send(message), intervalMs);
294
+ // Don't let the heartbeat keep the event loop alive on its own.
295
+ timer.unref?.();
296
+ return () => clearInterval(timer);
297
+ },
298
+ };
299
+ }
300
+ // =============================================================================
301
+ // Cancellation (MCP `notifications/cancelled` → `extra.signal`)
302
+ // =============================================================================
303
+ /** Thrown when a tool call is aborted by the client. */
304
+ class ToolAbortError extends Error {
305
+ constructor(tool) {
306
+ super(`${tool} was cancelled by the client`);
307
+ this.name = "ToolAbortError";
308
+ }
309
+ }
310
+ /** Reject as soon as `signal` aborts. Used to race against long SDK calls. */
311
+ function rejectOnAbort(signal, tool) {
312
+ if (!signal) {
313
+ // Never-resolving promise with a no-op cleanup — Promise.race ignores it.
314
+ return { promise: new Promise(() => { }), cleanup: () => { } };
315
+ }
316
+ let onAbort = () => { };
317
+ const promise = new Promise((_, reject) => {
318
+ onAbort = () => reject(new ToolAbortError(tool));
319
+ if (signal.aborted) {
320
+ onAbort();
321
+ }
322
+ else {
323
+ signal.addEventListener("abort", onAbort, { once: true });
324
+ }
325
+ });
326
+ return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
327
+ }
328
+ /**
329
+ * Run a long-running SDK call, but settle as soon as the client aborts.
330
+ *
331
+ * The wrapped SDK methods are not themselves signal-aware, so on abort the
332
+ * underlying work keeps running to completion in the background — but the tool
333
+ * call returns promptly with a `ToolAbortError` instead of blocking the client.
334
+ * Callers that hold cleanable resources (e.g. `session_start`) should catch
335
+ * `ToolAbortError` and tear them down.
336
+ */
337
+ async function raceAbort(signal, tool, work) {
338
+ if (signal?.aborted) {
339
+ throw new ToolAbortError(tool);
340
+ }
341
+ const { promise, cleanup } = rejectOnAbort(signal, tool);
342
+ try {
343
+ return await Promise.race([work, promise]);
344
+ }
345
+ finally {
346
+ cleanup();
347
+ }
348
+ }
349
+ /**
350
+ * If `error` is a client cancellation, return a "cancelled" tool result so the
351
+ * caller can `return` it; otherwise return null so normal error handling (log +
352
+ * Sentry + rethrow) proceeds. Keeps abort out of error reporting — a user
353
+ * cancelling is not a failure.
354
+ */
355
+ function cancelledResultOrNull(error, tool) {
356
+ if (error instanceof ToolAbortError) {
357
+ logger.info(`${tool}: Cancelled by client`);
358
+ return createToolResult(false, `${tool} was cancelled.`, { action: tool, cancelled: true });
359
+ }
360
+ return null;
269
361
  }
270
362
  /**
271
363
  * Create tool result with structured content for MCP App
@@ -321,9 +413,6 @@ const server = isSentryEnabled()
321
413
  name: "testdriver",
322
414
  version: version,
323
415
  });
324
- // Element reference storage (for click/hover after find)
325
- // Stores actual Element instances - no raw coordinates as input
326
- const elementRefs = new Map();
327
416
  // =============================================================================
328
417
  // Register UI Resource
329
418
  // =============================================================================
@@ -412,188 +501,79 @@ Debug mode (connect to existing sandbox):
412
501
  - Use this to interactively debug failed tests without re-running from scratch`,
413
502
  inputSchema: SessionStartInputSchema,
414
503
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
415
- }, async (params) => {
504
+ }, async (params, extra) => {
416
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
+ }
417
522
  logger.info("session_start: Starting", {
418
523
  type: params.type,
419
524
  url: params.url,
420
- os: params.os,
525
+ os: resolvedOs,
421
526
  reconnect: params.reconnect,
422
527
  sandboxId: params.sandboxId,
423
528
  });
424
529
  try {
425
- // Validate required fields for specific provision types (unless connecting to existing sandbox)
426
- if (!params.sandboxId) {
427
- if (params.type === "installer" && !params.installerUrl) {
428
- return createToolResult(false, "installer type requires 'installerUrl' parameter", { error: "Missing required parameter: installerUrl" });
429
- }
430
- if (params.type === "electron" && !params.appPath) {
431
- return createToolResult(false, "electron type requires 'appPath' parameter", { error: "Missing required parameter: appPath" });
432
- }
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) }));
433
541
  }
434
- // Create new session
435
- const newSession = sessionManager.createSession({
436
- os: params.os,
437
- keepAlive: params.keepAlive,
438
- testFile: params.testFile,
439
- });
440
- logger.debug("session_start: Session created", { sessionId: newSession.sessionId });
441
- // Determine API root
442
- const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
443
- logger.debug("session_start: Using API root", { apiRoot });
444
- // Initialize SDK
445
- logger.debug("session_start: Initializing SDK");
446
- const TestDriverSDK = (await import("../../sdk.js")).default;
447
- // Determine preview mode from environment variable
448
- // TD_PREVIEW can be "ide", "browser", or "none"
449
- // Default to "ide" so the live preview shows within the IDE
450
- const previewMode = process.env.TD_PREVIEW || "ide";
451
- logger.debug("session_start: Preview mode", { preview: previewMode });
452
- // Get IP from params or environment (for self-hosted instances)
453
- const instanceIp = params.ip || process.env.TD_IP;
454
- // Get API key - check multiple sources for GitHub Copilot coding agent compatibility
455
- // 1. TD_API_KEY (standard environment variable)
456
- // 2. COPILOT_MCP_TD_API_KEY (fallback for GitHub Copilot coding agent)
457
- const apiKey = process.env.TD_API_KEY || process.env.COPILOT_MCP_TD_API_KEY || "";
458
- if (!apiKey) {
459
- logger.error("session_start: No API key found", {
460
- hasTD_API_KEY: !!process.env.TD_API_KEY,
461
- hasCOPILOT_MCP_TD_API_KEY: !!process.env.COPILOT_MCP_TD_API_KEY,
462
- availableEnvVars: Object.keys(process.env).filter(k => k.includes('TD') || k.includes('COPILOT_MCP'))
463
- });
464
- return createToolResult(false, "No API key found. Please set TD_API_KEY or COPILOT_MCP_TD_API_KEY environment variable.", {
465
- error: "Missing API key",
466
- hint: "For GitHub Copilot coding agent, create a Copilot environment secret named COPILOT_MCP_TD_API_KEY"
467
- });
542
+ finally {
543
+ stopHeartbeat();
468
544
  }
469
- logger.debug("session_start: API key found", {
470
- source: process.env.TD_API_KEY ? "TD_API_KEY" : "COPILOT_MCP_TD_API_KEY",
471
- keyPrefix: apiKey.substring(0, 7) + "..."
472
- });
473
- sdk = new TestDriverSDK(apiKey, {
474
- os: params.os,
475
- logging: false,
476
- apiRoot,
477
- preview: previewMode,
478
- ip: instanceIp,
479
- });
480
- // Handle sandboxId mode - connect to existing sandbox (debug-on-failure mode)
481
- if (params.sandboxId) {
482
- logger.info("session_start: Connecting to existing sandbox (debug mode)", { sandboxId: params.sandboxId });
483
- await sdk.connect({
484
- sandboxId: params.sandboxId,
485
- keepAlive: params.keepAlive,
486
- });
487
- // Get sandbox ID
488
- const instance = sdk.getInstance();
489
- logger.info("session_start: Connected to existing sandbox", { instanceId: instance?.instanceId });
490
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
491
- // Set Sentry context for error tracking
492
- setSessionContext(newSession.sessionId, instance?.instanceId);
493
- // Capture screenshot of current state
494
- logger.debug("session_start: Capturing screenshot of existing sandbox");
495
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
496
- let screenshotResourceUri;
497
- if (screenshotBase64) {
498
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
499
- lastScreenshotBase64 = screenshotBase64;
500
- }
501
- const duration = Date.now() - startTime;
502
- logger.info("session_start: Connected to existing sandbox", { duration, sessionId: newSession.sessionId, sandboxId: params.sandboxId });
503
- return createToolResult(true, `Connected to existing sandbox (debug mode)
504
- Session: ${newSession.sessionId}
505
- Sandbox: ${params.sandboxId}
506
- Expires in: ${Math.round(params.keepAlive / 1000)}s
507
-
508
- You are now connected to the sandbox in its current state. Use find, click, type, etc. to interact.`, {
509
- action: "session_start",
510
- sessionId: newSession.sessionId,
511
- sandboxId: params.sandboxId,
512
- debugMode: true,
513
- screenshotResourceUri,
514
- duration
515
- }, "// Connected to existing sandbox - no provision code needed");
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);
516
549
  }
517
- // Connect to sandbox
518
- if (instanceIp) {
519
- logger.info("session_start: Connecting to self-hosted instance...", { ip: instanceIp });
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 } });
520
554
  }
521
- else {
522
- logger.info("session_start: Connecting to cloud sandbox...");
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;
523
564
  }
524
- await sdk.connect({
525
- reconnect: params.reconnect,
526
- keepAlive: params.keepAlive,
527
- ip: instanceIp,
528
- });
529
- // Get sandbox ID
530
- const instance = sdk.getInstance();
531
- logger.info("session_start: Connected to sandbox", { instanceId: instance?.instanceId });
532
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
533
- // Set Sentry context for error tracking
534
- setSessionContext(newSession.sessionId, instance?.instanceId);
535
- // Get provision-specific options
536
- const provisionOptions = getProvisionOptions(params);
537
- let provisionCmd = "";
538
- // Provision based on type
539
- switch (params.type) {
540
- case "chrome": {
541
- const chromeOpts = provisionOptions;
542
- logger.info("session_start: Provisioning Chrome", { url: chromeOpts.url });
543
- await sdk.provision.chrome(chromeOpts);
544
- provisionCmd = "provision.chrome";
545
- logger.debug("session_start: Chrome provisioned");
546
- break;
547
- }
548
- case "chromeExtension": {
549
- const extOpts = provisionOptions;
550
- logger.info("session_start: Provisioning Chrome Extension", { extensionPath: extOpts.extensionPath, extensionId: extOpts.extensionId });
551
- await sdk.provision.chromeExtension(extOpts);
552
- provisionCmd = "provision.chromeExtension";
553
- logger.debug("session_start: Chrome Extension provisioned");
554
- break;
555
- }
556
- case "vscode": {
557
- const vscodeOpts = provisionOptions;
558
- logger.info("session_start: Provisioning VS Code", { workspace: vscodeOpts.workspace });
559
- await sdk.provision.vscode(vscodeOpts);
560
- provisionCmd = "provision.vscode";
561
- logger.debug("session_start: VS Code provisioned");
562
- break;
563
- }
564
- case "installer": {
565
- const installerOpts = provisionOptions;
566
- logger.info("session_start: Provisioning installer", { url: installerOpts.url });
567
- await sdk.provision.installer(installerOpts);
568
- provisionCmd = "provision.installer";
569
- logger.debug("session_start: Installer provisioned");
570
- break;
571
- }
572
- case "electron": {
573
- const electronOpts = provisionOptions;
574
- logger.info("session_start: Provisioning Electron", { appPath: electronOpts.appPath });
575
- await sdk.provision.electron(electronOpts);
576
- provisionCmd = "provision.electron";
577
- logger.debug("session_start: Electron app provisioned");
578
- break;
579
- }
580
- }
581
- // Capture initial screenshot after provisioning
582
- logger.debug("session_start: Capturing initial screenshot");
583
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
584
- let screenshotResourceUri;
585
- if (screenshotBase64) {
586
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
587
- 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);
588
574
  }
589
- const duration = Date.now() - startTime;
590
- logger.info("session_start: Completed", { duration, sessionId: newSession.sessionId, selfHosted: !!instanceIp });
591
- // Generate the code for this provision action
592
- const generatedCode = generateActionCode(provisionCmd, provisionOptions);
593
- // Build debugger URL for the session
594
- const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
595
- const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
596
- 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}
597
577
 
598
578
  IMPORTANT - If creating a new test project, use these EXACT dependencies in package.json:
599
579
  {
@@ -605,18 +585,23 @@ IMPORTANT - If creating a new test project, use these EXACT dependencies in pack
605
585
  "scripts": {
606
586
  "test": "vitest"
607
587
  }
608
- }`, {
609
- action: "session_start",
610
- sessionId: newSession.sessionId,
611
- provisionType: params.type,
612
- selfHosted: !!instanceIp,
613
- instanceIp: instanceIp || undefined,
614
- debuggerUrl,
615
- screenshotResourceUri,
616
- duration
617
- }, generatedCode);
588
+ }`;
589
+ return createToolResult(true, text, data, result.code);
618
590
  }
619
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");
597
+ try {
598
+ await core.disconnect();
599
+ }
600
+ catch (cleanupErr) {
601
+ logger.warn("session_start: Cleanup after cancel failed", { error: String(cleanupErr) });
602
+ }
603
+ return createToolResult(false, "Session start was cancelled.", { action: "session_start", cancelled: true });
604
+ }
620
605
  logger.error("session_start: Failed", { error: String(error) });
621
606
  captureException(error, { tags: { tool: "session_start" }, extra: { params } });
622
607
  throw error;
@@ -629,20 +614,15 @@ server.registerTool("session_status", {
629
614
  }, async () => {
630
615
  const startTime = Date.now();
631
616
  logger.info("session_status: Checking");
632
- const session = sessionManager.getCurrentSession();
633
- if (!session) {
634
- logger.warn("session_status: No active session");
635
- return createToolResult(false, "No active session", { error: "No active session. Call session_start first." });
636
- }
637
- const summary = sessionManager.getSessionSummary(session.sessionId);
617
+ const result = core.sessionStatus();
638
618
  const duration = Date.now() - startTime;
639
619
  logger.info("session_status: Completed", {
640
- sessionId: session.sessionId,
641
- status: session.status,
642
- timeRemaining: summary?.timeRemaining,
643
- duration
620
+ ok: result.ok,
621
+ sessionId: result.data.sessionId,
622
+ status: result.data.status,
623
+ duration,
644
624
  });
645
- 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 } });
646
626
  });
647
627
  // Session Extend
648
628
  server.registerTool("session_extend", {
@@ -652,19 +632,18 @@ server.registerTool("session_extend", {
652
632
  }),
653
633
  }, async (params) => {
654
634
  logger.info("session_extend: Extending", { additionalMs: params.additionalMs });
655
- const session = sessionManager.getCurrentSession();
656
- if (!session) {
635
+ const result = core.sessionExtend(params.additionalMs);
636
+ if (!result.ok) {
657
637
  logger.warn("session_extend: No active session");
658
638
  return { content: [{ type: "text", text: "No active session" }] };
659
639
  }
660
- sessionManager.extendSession(session.sessionId, params.additionalMs);
661
- const newExpiry = sessionManager.getTimeRemaining(session.sessionId);
662
- 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).
663
642
  return {
664
643
  content: [
665
644
  {
666
645
  type: "text",
667
- text: `Session extended by ${params.additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
646
+ text: result.text,
668
647
  },
669
648
  ],
670
649
  };
@@ -678,97 +657,32 @@ registerAppTool(server, "find", {
678
657
  timeout: z.number().optional().describe("Timeout in ms for polling"),
679
658
  }),
680
659
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
681
- }, async (params) => {
660
+ }, async (params, extra) => {
682
661
  const startTime = Date.now();
662
+ const progress = makeProgressReporter(extra);
683
663
  logger.info("find: Starting", { description: params.description, timeout: params.timeout });
684
- const sessionCheck = requireActiveSession();
685
- if (!sessionCheck.valid) {
686
- logger.warn("find: No active session");
687
- return sessionCheck.error;
688
- }
689
664
  try {
690
665
  logger.debug("find: Calling SDK find");
691
- const element = await sdk.find(params.description, params.timeout ? { timeout: params.timeout } : undefined);
692
- const found = element.found();
693
- const coords = element.getCoordinates();
694
- // Store element ref for later use (stores actual Element instance)
695
- const elementRef = `el-${Date.now()}`;
696
- if (found && coords) {
697
- elementRefs.set(elementRef, {
698
- element: element, // Store the actual Element instance
699
- description: params.description,
700
- coords: {
701
- x: coords.x,
702
- y: coords.y,
703
- centerX: coords.centerX,
704
- centerY: coords.centerY,
705
- },
706
- });
707
- logger.info("find: Element found", {
708
- description: params.description,
709
- coords: { x: coords.centerX, y: coords.centerY },
710
- confidence: element.confidence,
711
- elementRef
712
- });
666
+ const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
667
+ let result;
668
+ try {
669
+ result = await raceAbort(extra.signal, "find", core.find(params.description, params.timeout));
713
670
  }
714
- else {
715
- logger.warn("find: Element not found", { description: params.description });
671
+ finally {
672
+ stopHeartbeat();
716
673
  }
717
- // Return raw SDK response directly
718
- const rawResponse = element._response || {};
719
674
  const duration = Date.now() - startTime;
720
- // Store cropped image for resource serving (instead of inline data URL)
721
- let croppedImageResourceUri;
722
- let screenshotResourceUri;
723
- const croppedImage = rawResponse.croppedImage;
724
- if (croppedImage) {
725
- const imageData = croppedImage.startsWith('data:')
726
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
727
- : croppedImage;
728
- croppedImageResourceUri = storeImage(imageData, "cropped");
729
- // Remove croppedImage from response to avoid context bloat
730
- delete rawResponse.croppedImage;
731
- }
732
- else if (!found) {
733
- // Element not found and no cropped image - capture a fresh screenshot
734
- // so the user can see what's currently visible on screen
735
- try {
736
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
737
- if (screenshotBase64) {
738
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
739
- logger.debug("find: Captured screenshot for not-found state");
740
- }
741
- }
742
- catch (e) {
743
- logger.warn("find: Failed to capture screenshot for not-found state", { error: String(e) });
744
- }
745
- }
746
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
747
- delete rawResponse.extractedText;
748
- delete rawResponse.pixelDiffImage;
749
- // Generate code for this find action
750
- const generatedCode = found ? generateActionCode("find", { description: params.description }) : undefined;
751
- // Build element info for display (cropped image is always centered on element)
752
- const elementInfo = found ? {
753
- description: params.description,
754
- centerX: coords?.centerX,
755
- centerY: coords?.centerY,
756
- confidence: element.confidence,
757
- ref: elementRef,
758
- } : undefined;
759
- return createToolResult(found, found
760
- ? `Found: "${params.description}" at (${rawResponse.coordinates?.x}, ${rawResponse.coordinates?.y})\nRef: ${elementRef}`
761
- : `Element not found: "${params.description}"`, {
762
- ...rawResponse,
763
- action: "find",
764
- element: elementInfo,
765
- ref: elementRef,
766
- croppedImageResourceUri,
767
- screenshotResourceUri,
768
- duration,
769
- }, generatedCode);
675
+ logger.info("find: Completed", { description: params.description, found: result.ok, duration });
676
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
770
677
  }
771
678
  catch (error) {
679
+ if (error instanceof NoActiveSessionError) {
680
+ logger.warn("find: No active session");
681
+ return noSessionResult(error);
682
+ }
683
+ const cancelled = cancelledResultOrNull(error, "find");
684
+ if (cancelled)
685
+ return cancelled;
772
686
  logger.error("find: Failed", { error: String(error), description: params.description });
773
687
  captureException(error, { tags: { tool: "find" }, extra: { description: params.description } });
774
688
  throw error;
@@ -783,101 +697,32 @@ registerAppTool(server, "findall", {
783
697
  timeout: z.number().optional().describe("Timeout in ms for polling"),
784
698
  }),
785
699
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
786
- }, async (params) => {
700
+ }, async (params, extra) => {
787
701
  const startTime = Date.now();
702
+ const progress = makeProgressReporter(extra);
788
703
  logger.info("findall: Starting", { description: params.description, timeout: params.timeout });
789
- const sessionCheck = requireActiveSession();
790
- if (!sessionCheck.valid) {
791
- logger.warn("findall: No active session");
792
- return sessionCheck.error;
793
- }
794
704
  try {
795
705
  logger.debug("findall: Calling SDK findAll");
796
- const elements = await sdk.findAll(params.description, params.timeout ? { timeout: params.timeout } : undefined);
797
- const count = elements.length;
798
- // Store element refs for later use
799
- const refs = [];
800
- const elementInfos = [];
801
- for (let i = 0; i < elements.length; i++) {
802
- const element = elements[i];
803
- const coords = element.getCoordinates();
804
- const elementRef = `el-${Date.now()}-${i}`;
805
- if (coords) {
806
- elementRefs.set(elementRef, {
807
- element: element,
808
- description: `${params.description} [${i}]`,
809
- coords: {
810
- x: coords.x,
811
- y: coords.y,
812
- centerX: coords.centerX,
813
- centerY: coords.centerY,
814
- },
815
- });
816
- refs.push(elementRef);
817
- elementInfos.push({
818
- ref: elementRef,
819
- x: coords.x,
820
- y: coords.y,
821
- centerX: coords.centerX,
822
- centerY: coords.centerY,
823
- confidence: element.confidence,
824
- });
825
- }
826
- }
827
- logger.info("findall: Elements found", {
828
- description: params.description,
829
- count,
830
- refs
831
- });
832
- // Get the first element's response for the image (shows all highlights)
833
- const rawResponse = elements[0]?._response || {};
834
- const duration = Date.now() - startTime;
835
- // Store cropped image for resource serving (instead of inline data URL)
836
- let croppedImageResourceUri;
837
- let screenshotResourceUri;
838
- const croppedImage = rawResponse.croppedImage;
839
- if (croppedImage) {
840
- const imageData = croppedImage.startsWith('data:')
841
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
842
- : croppedImage;
843
- croppedImageResourceUri = storeImage(imageData, "cropped");
844
- // Remove croppedImage from response to avoid context bloat
845
- delete rawResponse.croppedImage;
706
+ const stopHeartbeat = progress.heartbeat(`Looking for all "${params.description}"...`);
707
+ let result;
708
+ try {
709
+ result = await raceAbort(extra.signal, "findall", core.findAll(params.description, params.timeout));
846
710
  }
847
- else if (count === 0) {
848
- // No elements found and no cropped image - capture a fresh screenshot
849
- // so the user can see what's currently visible on screen
850
- try {
851
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
852
- if (screenshotBase64) {
853
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
854
- logger.debug("findall: Captured screenshot for not-found state");
855
- }
856
- }
857
- catch (e) {
858
- logger.warn("findall: Failed to capture screenshot for not-found state", { error: String(e) });
859
- }
711
+ finally {
712
+ stopHeartbeat();
860
713
  }
861
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
862
- delete rawResponse.extractedText;
863
- delete rawResponse.pixelDiffImage;
864
- // Generate code for this findall action
865
- const generatedCode = count > 0 ? generateActionCode("findall", { description: params.description }) : undefined;
866
- // Build refs list for text output
867
- const refsList = refs.map((ref, i) => ` [${i}] ${ref}`).join('\n');
868
- return createToolResult(count > 0, count > 0
869
- ? `Found ${count} elements matching "${params.description}":\n${refsList}`
870
- : `No elements found matching: "${params.description}"`, {
871
- ...rawResponse,
872
- count,
873
- refs,
874
- elements: elementInfos,
875
- croppedImageResourceUri,
876
- screenshotResourceUri,
877
- duration,
878
- }, generatedCode);
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 } });
879
717
  }
880
718
  catch (error) {
719
+ if (error instanceof NoActiveSessionError) {
720
+ logger.warn("findall: No active session");
721
+ return noSessionResult(error);
722
+ }
723
+ const cancelled = cancelledResultOrNull(error, "findall");
724
+ if (cancelled)
725
+ return cancelled;
881
726
  logger.error("findall: Failed", { error: String(error), description: params.description });
882
727
  captureException(error, { tags: { tool: "findall" }, extra: { description: params.description } });
883
728
  throw error;
@@ -895,58 +740,19 @@ registerAppTool(server, "click", {
895
740
  }, async (params) => {
896
741
  const startTime = Date.now();
897
742
  logger.info("click: Starting", { elementRef: params.elementRef, action: params.action });
898
- const sessionCheck = requireActiveSession();
899
- if (!sessionCheck.valid) {
900
- logger.warn("click: No active session");
901
- return sessionCheck.error;
902
- }
903
- // Look up the element reference
904
- const ref = elementRefs.get(params.elementRef);
905
- if (!ref) {
906
- logger.warn("click: Element reference not found", { elementRef: params.elementRef });
907
- return createToolResult(false, `Element reference "${params.elementRef}" not found. Use 'find' first to locate the element.`, { error: "Element reference not found" });
908
- }
909
- const { element, description, coords } = ref;
910
743
  try {
911
- logger.debug("click: Executing click on element", { description, action: params.action });
912
- // Use the Element's click method instead of raw coordinates
913
- if (params.action === "click") {
914
- await element.click();
915
- }
916
- else if (params.action === "double-click") {
917
- await element.doubleClick();
918
- }
919
- else if (params.action === "right-click") {
920
- await element.rightClick();
921
- }
922
- // Capture screenshot after click to show result
923
- logger.debug("click: Capturing screenshot after click");
924
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
925
- let screenshotResourceUri;
926
- if (screenshotBase64) {
927
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
928
- lastScreenshotBase64 = screenshotBase64;
929
- }
930
- const rawResponse = element._response || {};
931
- // Remove large data from response to reduce context bloat
932
- delete rawResponse.croppedImage;
933
- delete rawResponse.extractedText;
934
- 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);
935
746
  const duration = Date.now() - startTime;
936
- logger.info("click: Completed", { description, duration });
937
- // Generate code for this click action
938
- const generatedCode = generateActionCode("click", { action: params.action });
939
- return createToolResult(true, `Clicked on "${description}"`, {
940
- ...rawResponse,
941
- action: "click",
942
- clickAction: params.action,
943
- clickPosition: coords,
944
- screenshotResourceUri,
945
- duration
946
- }, generatedCode);
747
+ logger.info("click: Completed", { elementRef: params.elementRef, duration });
748
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
947
749
  }
948
750
  catch (error) {
949
- 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 });
950
756
  captureException(error, { tags: { tool: "click" }, extra: { elementRef: params.elementRef, action: params.action } });
951
757
  throw error;
952
758
  }
@@ -962,47 +768,19 @@ registerAppTool(server, "hover", {
962
768
  }, async (params) => {
963
769
  const startTime = Date.now();
964
770
  logger.info("hover: Starting", { elementRef: params.elementRef });
965
- const sessionCheck = requireActiveSession();
966
- if (!sessionCheck.valid) {
967
- logger.warn("hover: No active session");
968
- return sessionCheck.error;
969
- }
970
- // Look up the element reference
971
- const ref = elementRefs.get(params.elementRef);
972
- if (!ref) {
973
- logger.warn("hover: Element reference not found", { elementRef: params.elementRef });
974
- return createToolResult(false, `Element reference "${params.elementRef}" not found. Use 'find' first to locate the element.`, { error: "Element reference not found" });
975
- }
976
- const { element, description, coords } = ref;
977
771
  try {
978
- logger.debug("hover: Executing hover on element", { description });
979
- await element.hover();
980
- // Capture screenshot after hover to show result
981
- logger.debug("hover: Capturing screenshot after hover");
982
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
983
- let screenshotResourceUri;
984
- if (screenshotBase64) {
985
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
986
- lastScreenshotBase64 = screenshotBase64;
987
- }
988
- const rawResponse = element._response || {};
989
- // Remove large data from response to reduce context bloat
990
- delete rawResponse.croppedImage;
991
- delete rawResponse.extractedText;
992
- delete rawResponse.pixelDiffImage;
772
+ logger.debug("hover: Executing hover on element", { elementRef: params.elementRef });
773
+ const result = await core.hover(params.elementRef);
993
774
  const duration = Date.now() - startTime;
994
- logger.info("hover: Completed", { description, duration });
995
- // Generate code for this hover action
996
- const generatedCode = generateActionCode("hover", {});
997
- return createToolResult(true, `Hovered over "${description}"`, {
998
- ...rawResponse,
999
- action: "hover",
1000
- screenshotResourceUri,
1001
- duration
1002
- }, generatedCode);
775
+ logger.info("hover: Completed", { elementRef: params.elementRef, duration });
776
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1003
777
  }
1004
778
  catch (error) {
1005
- 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 });
1006
784
  captureException(error, { tags: { tool: "hover" }, extra: { elementRef: params.elementRef } });
1007
785
  throw error;
1008
786
  }
@@ -1016,21 +794,18 @@ server.registerTool("wait", {
1016
794
  }, async (params) => {
1017
795
  const startTime = Date.now();
1018
796
  logger.info("wait: Starting", { timeout: params.timeout });
1019
- const sessionCheck = requireActiveSession();
1020
- if (!sessionCheck.valid) {
1021
- logger.warn("wait: No active session");
1022
- return sessionCheck.error;
1023
- }
1024
797
  try {
1025
798
  logger.debug("wait: Waiting", { timeout: params.timeout });
1026
- await sdk.wait(params.timeout);
799
+ const result = await core.wait(params.timeout);
1027
800
  const duration = Date.now() - startTime;
1028
801
  logger.info("wait: Completed", { timeout: params.timeout, duration });
1029
- // Generate code for this wait action
1030
- const generatedCode = generateActionCode("wait", { timeout: params.timeout });
1031
- return createToolResult(true, `Waited for ${params.timeout}ms`, { action: "wait", timeout: params.timeout, duration }, generatedCode);
802
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1032
803
  }
1033
804
  catch (error) {
805
+ if (error instanceof NoActiveSessionError) {
806
+ logger.warn("wait: No active session");
807
+ return noSessionResult(error);
808
+ }
1034
809
  logger.error("wait: Failed", { error: String(error) });
1035
810
  captureException(error, { tags: { tool: "wait" }, extra: { timeout: params.timeout } });
1036
811
  throw error;
@@ -1045,21 +820,18 @@ server.registerTool("focus_application", {
1045
820
  }, async (params) => {
1046
821
  const startTime = Date.now();
1047
822
  logger.info("focus_application: Starting", { name: params.name });
1048
- const sessionCheck = requireActiveSession();
1049
- if (!sessionCheck.valid) {
1050
- logger.warn("focus_application: No active session");
1051
- return sessionCheck.error;
1052
- }
1053
823
  try {
1054
824
  logger.debug("focus_application: Focusing", { name: params.name });
1055
- await sdk.focusApplication(params.name);
825
+ const result = await core.focusApplication(params.name);
1056
826
  const duration = Date.now() - startTime;
1057
827
  logger.info("focus_application: Completed", { name: params.name, duration });
1058
- // Generate code for this focus action
1059
- const generatedCode = generateActionCode("focus_application", { name: params.name });
1060
- return createToolResult(true, `Focused application: "${params.name}"`, { action: "focus", name: params.name, duration }, generatedCode);
828
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1061
829
  }
1062
830
  catch (error) {
831
+ if (error instanceof NoActiveSessionError) {
832
+ logger.warn("focus_application: No active session");
833
+ return noSessionResult(error);
834
+ }
1063
835
  logger.error("focus_application: Failed", { error: String(error), name: params.name });
1064
836
  captureException(error, { tags: { tool: "focus_application" }, extra: { name: params.name } });
1065
837
  throw error;
@@ -1074,124 +846,32 @@ registerAppTool(server, "find_and_click", {
1074
846
  action: z.enum(["click", "double-click", "right-click"]).default("click"),
1075
847
  }),
1076
848
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1077
- }, async (params) => {
849
+ }, async (params, extra) => {
1078
850
  const startTime = Date.now();
851
+ const progress = makeProgressReporter(extra);
1079
852
  logger.info("find_and_click: Starting", { description: params.description, action: params.action });
1080
- const sessionCheck = requireActiveSession();
1081
- if (!sessionCheck.valid) {
1082
- logger.warn("find_and_click: No active session");
1083
- return sessionCheck.error;
1084
- }
1085
853
  try {
1086
854
  logger.debug("find_and_click: Finding element");
1087
- const element = await sdk.find(params.description);
1088
- const found = element.found();
1089
- if (!found) {
1090
- logger.warn("find_and_click: Element not found", { description: params.description });
1091
- // Capture screenshot to show current state even when element not found
1092
- const rawResponse = element._response || {};
1093
- const duration = Date.now() - startTime;
1094
- // Store cropped image (screenshot) for resource serving
1095
- let croppedImageResourceUri;
1096
- let screenshotResourceUri;
1097
- const croppedImage = rawResponse.croppedImage;
1098
- if (croppedImage) {
1099
- const imageData = croppedImage.startsWith('data:')
1100
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
1101
- : croppedImage;
1102
- croppedImageResourceUri = storeImage(imageData, "screenshot");
1103
- delete rawResponse.croppedImage;
1104
- }
1105
- else {
1106
- // No cropped image - capture a fresh screenshot so the user can see
1107
- // what's currently visible on screen when element was not found
1108
- try {
1109
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
1110
- if (screenshotBase64) {
1111
- screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
1112
- logger.debug("find_and_click: Captured screenshot for not-found state");
1113
- }
1114
- }
1115
- catch (e) {
1116
- logger.warn("find_and_click: Failed to capture screenshot for not-found state", { error: String(e) });
1117
- }
1118
- }
1119
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
1120
- delete rawResponse.extractedText;
1121
- delete rawResponse.pixelDiffImage;
1122
- return createToolResult(false, `Element not found: "${params.description}"`, {
1123
- ...rawResponse,
1124
- action: "find_and_click",
1125
- error: "Element not found",
1126
- croppedImageResourceUri,
1127
- screenshotResourceUri,
1128
- duration
1129
- });
855
+ const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
856
+ let result;
857
+ try {
858
+ result = await raceAbort(extra.signal, "find_and_click", core.findAndClick(params.description, params.action));
1130
859
  }
1131
- const coords = element.getCoordinates();
1132
- // Store element ref for later use (in case user wants to interact again)
1133
- const elementRef = `el-${Date.now()}`;
1134
- if (coords) {
1135
- elementRefs.set(elementRef, {
1136
- element: element,
1137
- description: params.description,
1138
- coords: {
1139
- x: coords.x,
1140
- y: coords.y,
1141
- centerX: coords.centerX,
1142
- centerY: coords.centerY,
1143
- },
1144
- });
860
+ finally {
861
+ stopHeartbeat();
1145
862
  }
1146
- logger.debug("find_and_click: Element found, clicking", { action: params.action, elementRef });
1147
- if (params.action === "click") {
1148
- await element.click();
1149
- }
1150
- else if (params.action === "double-click") {
1151
- await element.doubleClick();
1152
- }
1153
- else if (params.action === "right-click") {
1154
- await element.rightClick();
1155
- }
1156
- // Return raw SDK response directly
1157
- const rawResponse = element._response || {};
1158
863
  const duration = Date.now() - startTime;
1159
- // Store cropped image for resource serving (instead of inline data URL)
1160
- let croppedImageResourceUri;
1161
- const croppedImage = rawResponse.croppedImage;
1162
- if (croppedImage) {
1163
- const imageData = croppedImage.startsWith('data:')
1164
- ? croppedImage.replace(/^data:image\/\w+;base64,/, '')
1165
- : croppedImage;
1166
- croppedImageResourceUri = storeImage(imageData, "cropped");
1167
- // Remove croppedImage from response to avoid context bloat
1168
- delete rawResponse.croppedImage;
1169
- }
1170
- // Remove extractedText and pixelDiffImage from response to reduce context bloat
1171
- delete rawResponse.extractedText;
1172
- delete rawResponse.pixelDiffImage;
1173
- // Generate code for this find_and_click action
1174
- const generatedCode = generateActionCode("find_and_click", { description: params.description, action: params.action });
1175
- // Build element info for display (match find action format)
1176
- const elementInfo = coords ? {
1177
- description: params.description,
1178
- centerX: coords.centerX,
1179
- centerY: coords.centerY,
1180
- confidence: element.confidence,
1181
- ref: elementRef,
1182
- } : undefined;
1183
- return createToolResult(true, `Found and clicked: "${params.description}" at (${rawResponse.coordinates?.x}, ${rawResponse.coordinates?.y})\nRef: ${elementRef}`, {
1184
- ...rawResponse,
1185
- action: "find_and_click",
1186
- element: elementInfo,
1187
- ref: elementRef,
1188
- clickAction: params.action,
1189
- clickPosition: coords ? { x: coords.centerX, y: coords.centerY } : undefined,
1190
- croppedImageResourceUri,
1191
- duration,
1192
- }, generatedCode);
864
+ logger.info("find_and_click: Completed", { description: params.description, found: result.ok, duration });
865
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1193
866
  }
1194
867
  catch (error) {
868
+ if (error instanceof NoActiveSessionError) {
869
+ logger.warn("find_and_click: No active session");
870
+ return noSessionResult(error);
871
+ }
872
+ const cancelled = cancelledResultOrNull(error, "find_and_click");
873
+ if (cancelled)
874
+ return cancelled;
1195
875
  logger.error("find_and_click: Failed", { error: String(error), description: params.description });
1196
876
  captureException(error, { tags: { tool: "find_and_click" }, extra: { description: params.description, action: params.action } });
1197
877
  throw error;
@@ -1208,21 +888,18 @@ server.registerTool("type", {
1208
888
  }, async (params) => {
1209
889
  const startTime = Date.now();
1210
890
  logger.info("type: Starting", { textLength: params.text.length, secret: params.secret });
1211
- const sessionCheck = requireActiveSession();
1212
- if (!sessionCheck.valid) {
1213
- logger.warn("type: No active session");
1214
- return sessionCheck.error;
1215
- }
1216
891
  try {
1217
892
  logger.debug("type: Typing text");
1218
- await sdk.type(params.text, { secret: params.secret, delay: params.delay });
893
+ const result = await core.type(params.text, params.secret, params.delay);
1219
894
  const duration = Date.now() - startTime;
1220
895
  logger.info("type: Completed", { duration });
1221
- // Generate code for this type action
1222
- const generatedCode = generateActionCode("type", { text: params.text, secret: params.secret });
1223
- 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 } });
1224
897
  }
1225
898
  catch (error) {
899
+ if (error instanceof NoActiveSessionError) {
900
+ logger.warn("type: No active session");
901
+ return noSessionResult(error);
902
+ }
1226
903
  logger.error("type: Failed", { error: String(error) });
1227
904
  captureException(error, { tags: { tool: "type" }, extra: { textLength: params.text.length, secret: params.secret } });
1228
905
  throw error;
@@ -1237,21 +914,18 @@ server.registerTool("press_keys", {
1237
914
  }, async (params) => {
1238
915
  const startTime = Date.now();
1239
916
  logger.info("press_keys: Starting", { keys: params.keys });
1240
- const sessionCheck = requireActiveSession();
1241
- if (!sessionCheck.valid) {
1242
- logger.warn("press_keys: No active session");
1243
- return sessionCheck.error;
1244
- }
1245
917
  try {
1246
918
  logger.debug("press_keys: Pressing keys");
1247
- await sdk.pressKeys(params.keys);
919
+ const result = await core.pressKeys(params.keys);
1248
920
  const duration = Date.now() - startTime;
1249
921
  logger.info("press_keys: Completed", { keys: params.keys, duration });
1250
- // Generate code for this press_keys action
1251
- const generatedCode = generateActionCode("press_keys", { keys: params.keys });
1252
- return createToolResult(true, `Pressed keys: ${params.keys.join(" + ")}`, { action: "press_keys", keys: params.keys, duration }, generatedCode);
922
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1253
923
  }
1254
924
  catch (error) {
925
+ if (error instanceof NoActiveSessionError) {
926
+ logger.warn("press_keys: No active session");
927
+ return noSessionResult(error);
928
+ }
1255
929
  logger.error("press_keys: Failed", { error: String(error), keys: params.keys });
1256
930
  captureException(error, { tags: { tool: "press_keys" }, extra: { keys: params.keys } });
1257
931
  throw error;
@@ -1267,21 +941,18 @@ server.registerTool("scroll", {
1267
941
  }, async (params) => {
1268
942
  const startTime = Date.now();
1269
943
  logger.info("scroll: Starting", { direction: params.direction, amount: params.amount });
1270
- const sessionCheck = requireActiveSession();
1271
- if (!sessionCheck.valid) {
1272
- logger.warn("scroll: No active session");
1273
- return sessionCheck.error;
1274
- }
1275
944
  try {
1276
945
  logger.debug("scroll: Scrolling");
1277
- await sdk.scroll(params.direction, params.amount ? { amount: params.amount } : undefined);
946
+ const result = await core.scroll(params.direction, params.amount);
1278
947
  const duration = Date.now() - startTime;
1279
948
  logger.info("scroll: Completed", { direction: params.direction, duration });
1280
- // Generate code for this scroll action
1281
- const generatedCode = generateActionCode("scroll", { direction: params.direction, amount: params.amount });
1282
- 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 } });
1283
950
  }
1284
951
  catch (error) {
952
+ if (error instanceof NoActiveSessionError) {
953
+ logger.warn("scroll: No active session");
954
+ return noSessionResult(error);
955
+ }
1285
956
  logger.error("scroll: Failed", { error: String(error), direction: params.direction });
1286
957
  captureException(error, { tags: { tool: "scroll" }, extra: { direction: params.direction, amount: params.amount } });
1287
958
  throw error;
@@ -1302,21 +973,18 @@ Unlike 'check' which is for your understanding during development, 'assert' crea
1302
973
  }, async (params) => {
1303
974
  const startTime = Date.now();
1304
975
  logger.info("assert: Starting", { assertion: params.assertion });
1305
- const sessionCheck = requireActiveSession();
1306
- if (!sessionCheck.valid) {
1307
- logger.warn("assert: No active session");
1308
- return sessionCheck.error;
1309
- }
1310
976
  try {
1311
977
  logger.debug("assert: Running assertion");
1312
- const result = await sdk.assert(params.assertion);
978
+ const result = await core.assert(params.assertion);
1313
979
  const duration = Date.now() - startTime;
1314
- logger.info("assert: Completed", { assertion: params.assertion, passed: result, duration });
1315
- // Generate code for this assert action
1316
- const generatedCode = generateActionCode("assert", { assertion: params.assertion });
1317
- 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 } });
1318
982
  }
1319
983
  catch (error) {
984
+ if (error instanceof NoActiveSessionError) {
985
+ logger.warn("assert: No active session");
986
+ return noSessionResult(error);
987
+ }
1320
988
  logger.error("assert: Failed", { error: String(error), assertion: params.assertion });
1321
989
  captureException(error, { tags: { tool: "assert" }, extra: { assertion: params.assertion } });
1322
990
  throw error;
@@ -1349,29 +1017,24 @@ You can optionally provide a reference image URI to compare against a previous s
1349
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."),
1350
1018
  }),
1351
1019
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1352
- }, async (params) => {
1020
+ }, async (params, extra) => {
1353
1021
  const startTime = Date.now();
1022
+ const progress = makeProgressReporter(extra);
1354
1023
  logger.info("check: Starting", { task: params.task, hasReferenceImageUri: !!params.referenceImageUri });
1355
- const sessionCheck = requireActiveSession();
1356
- if (!sessionCheck.valid) {
1357
- logger.warn("check: No active session");
1358
- return sessionCheck.error;
1359
- }
1360
1024
  try {
1361
- // Capture current screenshot
1362
- logger.debug("check: Capturing current screenshot");
1363
- const currentScreenshot = await sdk.agent.system.captureScreenBase64(1, false, true);
1364
- // Use provided reference image URI, last screenshot as "before" state, or current if no previous screenshot
1365
- 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;
1366
1029
  if (params.referenceImageUri) {
1367
1030
  // Extract image ID from URI (e.g., "screenshot://testdriver/screenshot/screenshot-1" -> "screenshot-1")
1368
- const uriParts = params.referenceImageUri.split('/');
1031
+ const uriParts = params.referenceImageUri.split("/");
1369
1032
  const imageId = uriParts[uriParts.length - 1];
1370
1033
  logger.info("check: Looking up reference image", {
1371
1034
  referenceImageUri: params.referenceImageUri,
1372
1035
  extractedImageId: imageId,
1373
1036
  imageStoreSize: imageStore.size,
1374
- availableKeys: Array.from(imageStore.keys())
1037
+ availableKeys: Array.from(imageStore.keys()),
1375
1038
  });
1376
1039
  const storedImage = getStoredImage(imageId);
1377
1040
  if (storedImage) {
@@ -1379,72 +1042,40 @@ You can optionally provide a reference image URI to compare against a previous s
1379
1042
  imageId,
1380
1043
  dataLength: storedImage.data?.length,
1381
1044
  type: storedImage.type,
1382
- hasData: !!storedImage.data
1045
+ hasData: !!storedImage.data,
1383
1046
  });
1384
- beforeScreenshot = storedImage.data;
1047
+ referenceImage = storedImage.data;
1385
1048
  }
1386
1049
  else {
1387
1050
  logger.warn("check: Reference image NOT found in store, falling back to last screenshot", {
1388
1051
  referenceImageUri: params.referenceImageUri,
1389
1052
  imageId,
1390
1053
  imageStoreSize: imageStore.size,
1391
- availableKeys: Array.from(imageStore.keys())
1054
+ availableKeys: Array.from(imageStore.keys()),
1392
1055
  });
1393
- beforeScreenshot = lastScreenshotBase64 || currentScreenshot;
1394
1056
  }
1395
1057
  }
1396
- else {
1397
- beforeScreenshot = lastScreenshotBase64 || currentScreenshot;
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));
1398
1063
  }
1399
- // Update last screenshot for next check
1400
- lastScreenshotBase64 = currentScreenshot;
1401
- // Get system state
1402
- const mousePosition = await sdk.agent.system.getMousePosition();
1403
- const activeWindow = await sdk.agent.system.activeWin();
1404
- // Call the check endpoint
1405
- logger.info("check: Calling check API endpoint", {
1406
- hasLastScreenshot: beforeScreenshot !== currentScreenshot,
1407
- usingReferenceImageUri: !!params.referenceImageUri,
1408
- beforeScreenshotLength: beforeScreenshot?.length || 0,
1409
- currentScreenshotLength: currentScreenshot?.length || 0,
1410
- beforeScreenshotPreview: beforeScreenshot?.substring(0, 50),
1411
- currentScreenshotPreview: currentScreenshot?.substring(0, 50)
1412
- });
1413
- const response = await sdk.agent.sdk.req("check", {
1414
- tasks: [params.task],
1415
- images: [beforeScreenshot, currentScreenshot],
1416
- mousePosition,
1417
- activeWindow,
1418
- });
1419
- const aiResponse = response.data;
1420
- // Store screenshot for resource serving
1421
- let screenshotResourceUri;
1422
- if (currentScreenshot) {
1423
- screenshotResourceUri = storeImage(currentScreenshot, "screenshot");
1064
+ finally {
1065
+ stopHeartbeat();
1424
1066
  }
1425
- // Determine if the check passed based on the AI response
1426
- // The AI typically returns markdown with its analysis
1427
- // We consider it "complete" if the response doesn't contain code blocks (indicating more work needed)
1428
- const hasCodeBlocks = aiResponse && (aiResponse.includes("```yml") ||
1429
- aiResponse.includes("```yaml") ||
1430
- aiResponse.includes("- command:"));
1431
- const isComplete = !hasCodeBlocks;
1432
1067
  const duration = Date.now() - startTime;
1433
- logger.info("check: Completed", { task: params.task, complete: isComplete, duration });
1434
- // Note: check doesn't generate code - it's for AI understanding, not test recording
1435
- return createToolResult(isComplete, isComplete
1436
- ? `✓ Task appears complete: "${params.task}"\n\nAI Analysis:\n${aiResponse}`
1437
- : `⚠ Task may not be complete: "${params.task}"\n\nAI Analysis:\n${aiResponse}`, {
1438
- action: "check",
1439
- task: params.task,
1440
- complete: isComplete,
1441
- success: isComplete,
1442
- aiResponse,
1443
- screenshotResourceUri,
1444
- duration
1445
- });
1068
+ logger.info("check: Completed", { task: params.task, complete: result.ok, duration });
1069
+ return resultToMcp({ ...result, data: { ...result.data, duration } });
1446
1070
  }
1447
1071
  catch (error) {
1072
+ if (error instanceof NoActiveSessionError) {
1073
+ logger.warn("check: No active session");
1074
+ return noSessionResult(error);
1075
+ }
1076
+ const cancelled = cancelledResultOrNull(error, "check");
1077
+ if (cancelled)
1078
+ return cancelled;
1448
1079
  logger.error("check: Failed", { error: String(error), task: params.task });
1449
1080
  captureException(error, { tags: { tool: "check" }, extra: { task: params.task } });
1450
1081
  throw error;
@@ -1461,21 +1092,18 @@ server.registerTool("exec", {
1461
1092
  }, async (params) => {
1462
1093
  const startTime = Date.now();
1463
1094
  logger.info("exec: Starting", { language: params.language, codeLength: params.code.length, timeout: params.timeout });
1464
- const sessionCheck = requireActiveSession();
1465
- if (!sessionCheck.valid) {
1466
- logger.warn("exec: No active session");
1467
- return sessionCheck.error;
1468
- }
1469
1095
  try {
1470
1096
  logger.debug("exec: Executing code", { language: params.language });
1471
- const output = await sdk.exec(params.language, params.code, params.timeout);
1097
+ const result = await core.exec(params.language, params.code, params.timeout);
1472
1098
  const duration = Date.now() - startTime;
1473
- logger.info("exec: Completed", { language: params.language, outputLength: output?.length || 0, duration });
1474
- // Generate code for this exec action
1475
- const generatedCode = generateActionCode("exec", { language: params.language, code: params.code, timeout: params.timeout });
1476
- 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 } });
1477
1101
  }
1478
1102
  catch (error) {
1103
+ if (error instanceof NoActiveSessionError) {
1104
+ logger.warn("exec: No active session");
1105
+ return noSessionResult(error);
1106
+ }
1479
1107
  logger.error("exec: Failed", { error: String(error), language: params.language });
1480
1108
  captureException(error, { tags: { tool: "exec" }, extra: { language: params.language, codeLength: params.code.length } });
1481
1109
  throw error;
@@ -1811,30 +1439,25 @@ Only use 'screenshot' when you explicitly want to show something to the human us
1811
1439
  }, async () => {
1812
1440
  const startTime = Date.now();
1813
1441
  logger.info("screenshot: Starting");
1814
- const sessionCheck = requireActiveSession();
1815
- if (!sessionCheck.valid) {
1816
- logger.warn("screenshot: No active session");
1817
- return sessionCheck.error;
1818
- }
1819
1442
  try {
1820
- // Capture full screen screenshot
1821
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
1822
- let screenshotResourceUri;
1823
- if (screenshotBase64) {
1824
- // Store raw base64 for the resource blob with unique ID
1825
- 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");
1826
1450
  }
1827
1451
  const duration = Date.now() - startTime;
1828
- logger.info("screenshot: Completed", { duration, hasImage: !!screenshotBase64 });
1829
- // Only send the resource URI - the MCP app will fetch the image via resources/read
1830
- // This keeps the base64 image data OUT of AI context
1831
- return createToolResult(true, "Screenshot captured and displayed to user", {
1832
- action: "screenshot",
1833
- screenshotResourceUri,
1834
- duration
1835
- });
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);
1836
1455
  }
1837
1456
  catch (error) {
1457
+ if (error instanceof NoActiveSessionError) {
1458
+ logger.warn("screenshot: No active session");
1459
+ return noSessionResult(error);
1460
+ }
1838
1461
  logger.error("screenshot: Failed", { error: String(error) });
1839
1462
  return createToolResult(false, `Screenshot failed: ${error}`, { error: String(error) });
1840
1463
  }
@@ -1860,6 +1483,7 @@ API Key: The apiKey parameter is optional. If not provided, you'll need to manua
1860
1483
  directory: z.string().optional().describe("Target directory (defaults to current working directory)"),
1861
1484
  apiKey: z.string().optional().describe("TestDriver API key (will be saved to .env)"),
1862
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."),
1863
1487
  }),
1864
1488
  }, async (params) => {
1865
1489
  const startTime = Date.now();
@@ -1874,6 +1498,7 @@ API Key: The apiKey parameter is optional. If not provided, you'll need to manua
1874
1498
  targetDir,
1875
1499
  apiKey: params.apiKey,
1876
1500
  skipInstall: params.skipInstall,
1501
+ skipSampleTest: params.skipSampleTest,
1877
1502
  });
1878
1503
  const duration = Date.now() - startTime;
1879
1504
  logger.info("init: Completed", { targetDir, duration, success: result.success });
@@ -1913,14 +1538,87 @@ Learn more at https://docs.testdriver.ai/v7/getting-started/
1913
1538
  throw error;
1914
1539
  }
1915
1540
  });
1541
+ // =============================================================================
1542
+ // HTTP transport (Streamable HTTP, no auth)
1543
+ // =============================================================================
1544
+ /**
1545
+ * Serve the MCP server over Streamable HTTP with no authentication.
1546
+ *
1547
+ * 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.
1553
+ *
1554
+ * No auth is applied here by design (the connection is expected to be local-only
1555
+ * or otherwise protected outside the MCP layer). Do not expose this on a public
1556
+ * network without putting a real authenticating proxy in front of it.
1557
+ */
1558
+ async function startHttpServer() {
1559
+ const host = process.env.TD_MCP_HOST || "127.0.0.1";
1560
+ const port = Number(process.env.TD_MCP_PORT || process.env.PORT || 8788);
1561
+ 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);
1567
+ 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) });
1585
+ captureException(error, { tags: { phase: "http" } });
1586
+ if (!res.headersSent) {
1587
+ res.writeHead(500, { "content-type": "application/json" });
1588
+ res.end(JSON.stringify({ error: "Internal server error" }));
1589
+ }
1590
+ });
1591
+ });
1592
+ await new Promise((resolve) => httpServer.listen(port, host, resolve));
1593
+ logger.info("TestDriver MCP Server running on Streamable HTTP", {
1594
+ url: `http://${host}:${port}${mcpPath}`,
1595
+ });
1596
+ const shutdown = async () => {
1597
+ logger.info("Shutting down MCP Server");
1598
+ httpServer.close();
1599
+ await transport.close().catch(() => { });
1600
+ await flushSentry();
1601
+ process.exit(0);
1602
+ };
1603
+ process.on("SIGINT", shutdown);
1604
+ process.on("SIGTERM", shutdown);
1605
+ }
1916
1606
  // Start the server
1917
1607
  async function main() {
1608
+ // Transport selection: TD_MCP_TRANSPORT=http serves Streamable HTTP (used by
1609
+ // the eve agent); anything else (default) uses stdio for local CLI clients.
1610
+ const transportMode = (process.env.TD_MCP_TRANSPORT || "stdio").toLowerCase();
1918
1611
  logger.info("Starting TestDriver MCP Server", {
1919
1612
  version,
1613
+ transport: transportMode,
1920
1614
  logLevel: process.env.TD_LOG_LEVEL || "INFO",
1921
1615
  distDir: DIST_DIR,
1922
1616
  sentryEnabled: isSentryEnabled(),
1923
1617
  });
1618
+ if (transportMode === "http") {
1619
+ await startHttpServer();
1620
+ return;
1621
+ }
1924
1622
  const transport = new StdioServerTransport();
1925
1623
  await server.connect(transport);
1926
1624
  logger.info("TestDriver MCP Server running on stdio");