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