@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.
- package/.claude-plugin/marketplace.json +17 -0
- package/agent/index.js +116 -71
- package/agent/interface.js +13 -1
- package/agent/lib/commands.js +35 -15
- package/agent/lib/provision-commands.js +92 -11
- package/agent/lib/sandbox.js +263 -30
- package/agent/lib/sdk.js +16 -12
- package/agent/lib/system.js +107 -31
- package/ai/.claude-plugin/plugin.json +89 -4
- package/ai/agents/testdriver.md +4 -8
- package/ai/skills/testdriver-assert/SKILL.md +0 -1
- package/ai/skills/testdriver-aws-setup/SKILL.md +1 -1
- package/ai/skills/testdriver-caching/SKILL.md +13 -6
- package/ai/skills/testdriver-ci-cd/SKILL.md +57 -14
- package/ai/skills/testdriver-client/SKILL.md +10 -6
- package/ai/skills/testdriver-customizing-devices/SKILL.md +7 -5
- package/ai/skills/testdriver-dashcam/SKILL.md +36 -3
- package/ai/skills/testdriver-debugging-with-screenshots/SKILL.md +28 -4
- package/ai/skills/testdriver-device-config/SKILL.md +5 -5
- package/ai/skills/testdriver-find/SKILL.md +50 -11
- package/ai/skills/testdriver-generating-tests/SKILL.md +163 -8
- package/ai/skills/testdriver-hosted/SKILL.md +1 -1
- package/ai/skills/testdriver-machine-setup/SKILL.md +331 -0
- package/ai/skills/testdriver-making-assertions/SKILL.md +78 -2
- package/ai/skills/testdriver-mcp-workflow/SKILL.md +2 -2
- package/ai/skills/testdriver-parse/SKILL.md +1 -1
- package/ai/skills/testdriver-performing-actions/SKILL.md +97 -3
- package/ai/skills/testdriver-provision/SKILL.md +0 -20
- package/ai/skills/testdriver-quickstart/SKILL.md +299 -21
- package/ai/skills/testdriver-reusable-code/SKILL.md +3 -3
- package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
- package/ai/skills/testdriver-screenshot/SKILL.md +5 -5
- package/ai/skills/testdriver-scroll/SKILL.md +30 -160
- package/ai/skills/testdriver-test-writer/SKILL.md +3 -5
- package/ai/skills/testdriver-testdriver/SKILL.md +3 -7
- package/ai/skills/testdriver-wait/SKILL.md +1 -1
- package/{docs/v7/ai.mdx → ai/skills/testdriver:ai/SKILL.md} +3 -4
- package/ai/skills/testdriver:assert/SKILL.md +315 -0
- package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
- package/ai/skills/testdriver:cache/SKILL.md +221 -0
- package/ai/skills/testdriver:caching/SKILL.md +124 -0
- package/ai/skills/testdriver:captcha/SKILL.md +158 -0
- package/ai/skills/testdriver:ci-cd/SKILL.md +659 -0
- package/ai/skills/testdriver:click/SKILL.md +286 -0
- package/ai/skills/testdriver:client/SKILL.md +477 -0
- package/ai/skills/testdriver:customizing-devices/SKILL.md +319 -0
- package/ai/skills/testdriver:dashcam/SKILL.md +451 -0
- package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +415 -0
- package/{docs/v7/device-config.mdx → ai/skills/testdriver:device-config/SKILL.md} +8 -8
- package/ai/skills/testdriver:double-click/SKILL.md +102 -0
- package/ai/skills/testdriver:elements/SKILL.md +605 -0
- package/ai/skills/testdriver:enterprise/SKILL.md +7 -0
- package/ai/skills/testdriver:errors/SKILL.md +246 -0
- package/ai/skills/testdriver:events/SKILL.md +356 -0
- package/ai/skills/testdriver:exec/SKILL.md +317 -0
- package/ai/skills/testdriver:find/SKILL.md +860 -0
- package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
- package/ai/skills/testdriver:generating-tests/SKILL.md +25 -0
- package/ai/skills/testdriver:hosted/SKILL.md +156 -0
- package/ai/skills/testdriver:hover/SKILL.md +278 -0
- package/{docs/v7/locating-elements.mdx → ai/skills/testdriver:locating-elements/SKILL.md} +3 -3
- package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
- package/ai/skills/testdriver:mcp/SKILL.md +7 -0
- package/ai/skills/testdriver:mouse-down/SKILL.md +161 -0
- package/ai/skills/testdriver:mouse-up/SKILL.md +164 -0
- package/ai/skills/testdriver:parse/SKILL.md +236 -0
- package/ai/skills/testdriver:performing-actions/SKILL.md +53 -0
- package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
- package/ai/skills/testdriver:provision/SKILL.md +331 -0
- package/ai/skills/testdriver:quickstart/SKILL.md +172 -0
- package/ai/skills/testdriver:redraw/SKILL.md +214 -0
- package/ai/skills/testdriver:reusable-code/SKILL.md +249 -0
- package/ai/skills/testdriver:right-click/SKILL.md +123 -0
- package/{docs/v7/running-tests.mdx → ai/skills/testdriver:running-tests/SKILL.md} +4 -4
- package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
- package/ai/skills/testdriver:screenshots/SKILL.md +184 -0
- package/ai/skills/testdriver:scroll/SKILL.md +205 -0
- package/ai/skills/testdriver:secrets/SKILL.md +115 -0
- package/ai/skills/testdriver:self-hosted/SKILL.md +147 -0
- package/ai/skills/testdriver:test-results-json/SKILL.md +257 -0
- package/ai/skills/testdriver:testdriver/SKILL.md +624 -0
- package/ai/skills/testdriver:type/SKILL.md +357 -0
- package/ai/skills/testdriver:variables/SKILL.md +111 -0
- package/ai/skills/testdriver:wait/SKILL.md +50 -0
- package/{docs/v7/waiting-for-elements.mdx → ai/skills/testdriver:waiting-for-elements/SKILL.md} +3 -3
- package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
- package/debugger/index.html +12 -0
- package/docs/_data/examples-manifest.json +54 -46
- package/docs/_data/examples-manifest.schema.json +12 -2
- package/docs/_scripts/extract-example-urls.js +21 -3
- package/docs/_scripts/generate-examples.js +64 -16
- package/docs/changelog.mdx +29 -2
- package/docs/claude-mcp-plugin.mdx +65 -18
- package/docs/docs.json +16 -33
- package/docs/snippets/tests/scroll-yaml.mdx +2 -2
- package/docs/v6/commands/scroll-until-image.mdx +1 -1
- package/docs/v6/commands/scroll-until-text.mdx +2 -2
- package/docs/v6/commands/scroll.mdx +2 -2
- package/docs/v7/_drafts/agents.mdx +0 -4
- package/docs/v7/_drafts/best-practices.mdx +0 -12
- package/docs/v7/_drafts/commands/scroll-until-image.mdx +1 -1
- package/docs/v7/_drafts/commands/scroll-until-text.mdx +2 -2
- package/docs/v7/_drafts/commands/scroll.mdx +1 -1
- package/docs/v7/_drafts/configuration.mdx +0 -12
- package/docs/v7/_drafts/faq.mdx +2 -10
- package/docs/v7/_drafts/readme.mdx +0 -1
- package/docs/v7/_drafts/troubleshooting.mdx +1 -4
- package/docs/v7/assert.mdx +0 -1
- package/docs/v7/aws-setup.mdx +1 -1
- package/docs/v7/caching.mdx +14 -10
- package/docs/v7/ci-cd.mdx +98 -14
- package/docs/v7/client.mdx +10 -6
- package/docs/v7/copilot/auto-healing.mdx +167 -18
- package/docs/v7/copilot/running-tests.mdx +915 -54
- package/docs/v7/customizing-devices.mdx +7 -5
- package/docs/v7/dashcam.mdx +36 -3
- package/docs/v7/debugging-with-screenshots.mdx +31 -7
- package/docs/v7/examples/ai.mdx +13 -8
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +30 -13
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/findall-coffee-icons.mdx +3 -2
- package/docs/v7/examples/formatted-logging.mdx +74 -0
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text-with-description.mdx +2 -2
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +27 -27
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/parse.mdx +3 -2
- package/docs/v7/examples/press-keys.mdx +2 -2
- package/docs/v7/examples/scroll-keyboard.mdx +2 -2
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/find.mdx +50 -11
- package/docs/v7/generating-tests.mdx +166 -10
- package/docs/v7/hosted.mdx +1 -1
- package/docs/v7/making-assertions.mdx +81 -4
- package/docs/v7/parse.mdx +1 -1
- package/docs/v7/performing-actions.mdx +100 -5
- package/docs/v7/provision.mdx +0 -20
- package/docs/v7/quickstart.mdx +299 -21
- package/docs/v7/reusable-code.mdx +3 -3
- package/docs/v7/screenshot.mdx +5 -5
- package/docs/v7/scroll.mdx +30 -160
- package/docs/v7/wait.mdx +1 -1
- package/examples/chrome-extension.test.mjs +29 -12
- package/examples/config.mjs +1 -1
- package/examples/launch-vscode-linux.test.mjs +25 -25
- package/interfaces/cli/commands/init.js +84 -2
- package/interfaces/vitest-plugin.mjs +51 -61
- package/lib/core/Dashcam.js +43 -2
- package/lib/core/index.d.ts +13 -1
- package/lib/github-comment.mjs +11 -5
- package/lib/init-project.js +98 -44
- package/lib/install-clients.js +470 -0
- package/lib/provision.js +749 -0
- package/lib/resolve-channel.js +2 -2
- package/lib/sentry.js +15 -2
- package/lib/vitest/hooks.mjs +44 -34
- package/mcp-server/README.md +32 -0
- package/mcp-server/dist/core/actions.d.ts +176 -0
- package/mcp-server/dist/core/actions.js +753 -0
- package/mcp-server/dist/env-utils.d.ts +45 -0
- package/mcp-server/dist/env-utils.js +63 -0
- package/mcp-server/dist/provision-types.d.ts +6 -2
- package/mcp-server/dist/provision-types.js +3 -1
- package/mcp-server/dist/server.mjs +449 -751
- package/mcp-server/package-lock.json +384 -1
- package/mcp-server/package.json +5 -2
- package/mcp-server/src/core/actions.ts +912 -0
- package/mcp-server/src/env-utils.test.ts +82 -0
- package/mcp-server/src/env-utils.ts +77 -0
- package/mcp-server/src/provision-types.ts +4 -1
- package/mcp-server/src/server.ts +533 -954
- package/mcp-server/tsconfig.json +1 -1
- package/mcp-server/vitest.config.ts +7 -0
- package/package.json +15 -3
- package/sdk.d.ts +11 -9
- package/sdk.js +116 -746
- package/setup/aws/spawn-runner.sh +24 -3
- package/docs/v7/_drafts/caching-ai.mdx +0 -215
- package/docs/v7/_drafts/caching.mdx +0 -366
- package/docs/v7/_drafts/prompt-cache.mdx +0 -200
- package/docs/v7/copilot/creating-tests.mdx +0 -156
- package/docs/v7/copilot/github.mdx +0 -143
- package/docs/v7/copilot/setup.mdx +0 -143
- package/docs/v7/examples/exec-output.mdx +0 -84
- package/docs/v7/examples/exec-pwsh.mdx +0 -82
- package/docs/v7/examples/focus-window.mdx +0 -61
- package/docs/v7/examples/scroll-until-image.mdx +0 -82
- package/docs/v7/examples/windows-installer.mdx +0 -95
- package/examples/formatted-logging.test.mjs +0 -27
- package/examples/match-image.test.mjs +0 -55
- 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
|
|
20
|
-
import {
|
|
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
|
-
|
|
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:
|
|
70
|
-
tracesSampleRate:
|
|
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
|
-
*
|
|
234
|
-
*
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
525
|
+
os: resolvedOs,
|
|
421
526
|
reconnect: params.reconnect,
|
|
422
527
|
sandboxId: params.sandboxId,
|
|
423
528
|
});
|
|
424
529
|
try {
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
518
|
-
if (
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
duration
|
|
620
|
+
ok: result.ok,
|
|
621
|
+
sessionId: result.data.sessionId,
|
|
622
|
+
status: result.data.status,
|
|
623
|
+
duration,
|
|
644
624
|
});
|
|
645
|
-
return
|
|
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
|
|
656
|
-
if (!
|
|
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
|
-
|
|
661
|
-
|
|
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:
|
|
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
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
715
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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", {
|
|
912
|
-
|
|
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", {
|
|
937
|
-
|
|
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
|
-
|
|
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", {
|
|
979
|
-
await
|
|
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", {
|
|
995
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
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:
|
|
1434
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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");
|