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