@testdriverai/mcp 7.11.6-test → 7.11.7-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/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/mcp-server/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as http from "http";
|
|
|
18
18
|
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
19
19
|
import type { Variables } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
|
|
20
20
|
import type { CallToolResult, ReadResourceResult, ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
21
22
|
import * as Sentry from "@sentry/node";
|
|
22
23
|
import * as fs from "fs";
|
|
23
24
|
import * as os from "os";
|
|
@@ -29,7 +30,7 @@ import * as core from "./core/actions.js";
|
|
|
29
30
|
import { NoActiveSessionError, type ActionResult } from "./core/actions.js";
|
|
30
31
|
import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
|
|
31
32
|
import { SessionStartInputSchema, type SessionStartInput } from "./provision-types.js";
|
|
32
|
-
import {
|
|
33
|
+
import { type SessionState } from "./session.js";
|
|
33
34
|
|
|
34
35
|
// =============================================================================
|
|
35
36
|
// Sentry
|
|
@@ -225,21 +226,41 @@ interface StoredImage {
|
|
|
225
226
|
timestamp: number;
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
// Map of image ID -> image data
|
|
229
|
-
const imageStore = new Map<string, StoredImage>();
|
|
230
|
-
|
|
231
|
-
// Counter for generating unique image IDs
|
|
232
|
-
let imageIdCounter = 0;
|
|
233
|
-
|
|
234
229
|
// Maximum number of images to store (to prevent memory leaks)
|
|
235
230
|
const MAX_STORED_IMAGES = 100;
|
|
236
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Per-connection image store. It lives in the active {@link core.CoreContext}'s
|
|
234
|
+
* adapter scratch space (not a module global), so concurrent HTTP MCP clients
|
|
235
|
+
* don't share — or evict — each other's screenshots. The stdio/eve hosts have a
|
|
236
|
+
* single context, so this behaves exactly like the old module-level Map for them.
|
|
237
|
+
* Resource reads (which also run inside the connection's context) resolve the
|
|
238
|
+
* same per-connection store, so a screenshot URI only resolves for the client
|
|
239
|
+
* that produced it.
|
|
240
|
+
*/
|
|
241
|
+
interface ImageStoreState {
|
|
242
|
+
images: Map<string, StoredImage>;
|
|
243
|
+
counter: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function imageStoreState(): ImageStoreState {
|
|
247
|
+
const adapter = core.getAdapterState();
|
|
248
|
+
let state = adapter.imageStore as ImageStoreState | undefined;
|
|
249
|
+
if (!state) {
|
|
250
|
+
state = { images: new Map(), counter: 0 };
|
|
251
|
+
adapter.imageStore = state;
|
|
252
|
+
}
|
|
253
|
+
return state;
|
|
254
|
+
}
|
|
255
|
+
|
|
237
256
|
/**
|
|
238
257
|
* Store an image and return its unique resource URI
|
|
239
258
|
*/
|
|
240
259
|
function storeImage(data: string, type: "screenshot" | "cropped"): string {
|
|
241
|
-
const
|
|
242
|
-
|
|
260
|
+
const state = imageStoreState();
|
|
261
|
+
const imageStore = state.images;
|
|
262
|
+
const id = `${type}-${++state.counter}`;
|
|
263
|
+
|
|
243
264
|
// Clean up old images if we exceed the limit
|
|
244
265
|
if (imageStore.size >= MAX_STORED_IMAGES) {
|
|
245
266
|
// Remove oldest images (first entries in the map)
|
|
@@ -250,15 +271,15 @@ function storeImage(data: string, type: "screenshot" | "cropped"): string {
|
|
|
250
271
|
}
|
|
251
272
|
logger.debug("storeImage: Cleaned up old images", { removed: entriesToRemove, remaining: imageStore.size });
|
|
252
273
|
}
|
|
253
|
-
|
|
274
|
+
|
|
254
275
|
imageStore.set(id, {
|
|
255
276
|
data,
|
|
256
277
|
type,
|
|
257
278
|
timestamp: Date.now(),
|
|
258
279
|
});
|
|
259
|
-
|
|
280
|
+
|
|
260
281
|
logger.debug("storeImage: Stored image", { id, type, dataLength: data.length });
|
|
261
|
-
|
|
282
|
+
|
|
262
283
|
const base = type === "screenshot" ? SCREENSHOT_RESOURCE_BASE : CROPPED_IMAGE_RESOURCE_BASE;
|
|
263
284
|
return `${base}/${id}`;
|
|
264
285
|
}
|
|
@@ -267,7 +288,13 @@ function storeImage(data: string, type: "screenshot" | "cropped"): string {
|
|
|
267
288
|
* Get an image by its ID
|
|
268
289
|
*/
|
|
269
290
|
function getStoredImage(id: string): StoredImage | undefined {
|
|
270
|
-
return
|
|
291
|
+
return imageStoreState().images.get(id);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Diagnostics for the active connection's image store (used in debug logs). */
|
|
295
|
+
function imageStoreDiagnostics(): { imageStoreSize: number; availableKeys: string[] } {
|
|
296
|
+
const images = imageStoreState().images;
|
|
297
|
+
return { imageStoreSize: images.size, availableKeys: Array.from(images.keys()) };
|
|
271
298
|
}
|
|
272
299
|
|
|
273
300
|
/**
|
|
@@ -277,7 +304,7 @@ function getSessionData(session: SessionState | null) {
|
|
|
277
304
|
if (!session) return { id: null, expiresIn: 0 };
|
|
278
305
|
return {
|
|
279
306
|
id: session.sessionId,
|
|
280
|
-
expiresIn:
|
|
307
|
+
expiresIn: core.getSessionTimeRemaining(session.sessionId),
|
|
281
308
|
};
|
|
282
309
|
}
|
|
283
310
|
|
|
@@ -478,7 +505,7 @@ function createToolResult(
|
|
|
478
505
|
let fullText = textContent;
|
|
479
506
|
if (generatedCode && success) {
|
|
480
507
|
// Get the test file from the current session
|
|
481
|
-
const session =
|
|
508
|
+
const session = core.getCurrentSession();
|
|
482
509
|
const testFile = session?.testFile;
|
|
483
510
|
|
|
484
511
|
if (testFile) {
|
|
@@ -501,7 +528,7 @@ function createToolResult(
|
|
|
501
528
|
// structuredContent goes to UI (includes imageUrl for display)
|
|
502
529
|
// Always include success flag so UI can display correct status indicator
|
|
503
530
|
// Include generatedCode and testFile in structured data so agents can programmatically handle it
|
|
504
|
-
const session =
|
|
531
|
+
const session = core.getCurrentSession();
|
|
505
532
|
return {
|
|
506
533
|
content,
|
|
507
534
|
structuredContent: {
|
|
@@ -513,24 +540,40 @@ function createToolResult(
|
|
|
513
540
|
};
|
|
514
541
|
}
|
|
515
542
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
543
|
+
/**
|
|
544
|
+
* Build a fully-registered MCP server instance (all resources + tools).
|
|
545
|
+
*
|
|
546
|
+
* This used to run once at module load against a single shared `server`. It is
|
|
547
|
+
* now a factory so the HTTP transport can mint one server PER connection — each
|
|
548
|
+
* bound (via the surrounding {@link core.runInContext}) to its own isolated
|
|
549
|
+
* {@link core.CoreContext}. That's what stops one Streamable-HTTP client's tool
|
|
550
|
+
* call from reading another client's sandbox. The stdio host calls this once and
|
|
551
|
+
* runs it over the global context, so its behavior is unchanged.
|
|
552
|
+
*
|
|
553
|
+
* The tool/resource handlers below don't reference any per-connection state
|
|
554
|
+
* directly — they go through the context-aware helpers (`storeImage`,
|
|
555
|
+
* `getStoredImage`, `core.*`), which resolve the active context. So a single
|
|
556
|
+
* copy of the registration code serves every connection correctly.
|
|
557
|
+
*/
|
|
558
|
+
function buildServer(): McpServer {
|
|
559
|
+
// Create MCP server wrapped with Sentry for automatic tracing
|
|
560
|
+
const server = isSentryEnabled()
|
|
561
|
+
? Sentry.wrapMcpServerWithSentry(
|
|
562
|
+
new McpServer({
|
|
563
|
+
name: "testdriver",
|
|
564
|
+
version: version,
|
|
565
|
+
})
|
|
566
|
+
)
|
|
567
|
+
: new McpServer({
|
|
520
568
|
name: "testdriver",
|
|
521
569
|
version: version,
|
|
522
|
-
})
|
|
523
|
-
)
|
|
524
|
-
: new McpServer({
|
|
525
|
-
name: "testdriver",
|
|
526
|
-
version: version,
|
|
527
|
-
});
|
|
570
|
+
});
|
|
528
571
|
|
|
529
|
-
// =============================================================================
|
|
530
|
-
// Register UI Resource
|
|
531
|
-
// =============================================================================
|
|
572
|
+
// =============================================================================
|
|
573
|
+
// Register UI Resource
|
|
574
|
+
// =============================================================================
|
|
532
575
|
|
|
533
|
-
registerAppResource(
|
|
576
|
+
registerAppResource(
|
|
534
577
|
server,
|
|
535
578
|
RESOURCE_URI,
|
|
536
579
|
RESOURCE_URI,
|
|
@@ -1294,8 +1337,7 @@ You can optionally provide a reference image URI to compare against a previous s
|
|
|
1294
1337
|
logger.info("check: Looking up reference image", {
|
|
1295
1338
|
referenceImageUri: params.referenceImageUri,
|
|
1296
1339
|
extractedImageId: imageId,
|
|
1297
|
-
|
|
1298
|
-
availableKeys: Array.from(imageStore.keys()),
|
|
1340
|
+
...imageStoreDiagnostics(),
|
|
1299
1341
|
});
|
|
1300
1342
|
|
|
1301
1343
|
const storedImage = getStoredImage(imageId);
|
|
@@ -1312,8 +1354,7 @@ You can optionally provide a reference image URI to compare against a previous s
|
|
|
1312
1354
|
logger.warn("check: Reference image NOT found in store, falling back to last screenshot", {
|
|
1313
1355
|
referenceImageUri: params.referenceImageUri,
|
|
1314
1356
|
imageId,
|
|
1315
|
-
|
|
1316
|
-
availableKeys: Array.from(imageStore.keys()),
|
|
1357
|
+
...imageStoreDiagnostics(),
|
|
1317
1358
|
});
|
|
1318
1359
|
}
|
|
1319
1360
|
}
|
|
@@ -1870,20 +1911,56 @@ Learn more at https://docs.testdriver.ai/v7/getting-started/
|
|
|
1870
1911
|
}
|
|
1871
1912
|
);
|
|
1872
1913
|
|
|
1914
|
+
return server;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1873
1917
|
|
|
1874
1918
|
// =============================================================================
|
|
1875
1919
|
// HTTP transport (Streamable HTTP, no auth)
|
|
1876
1920
|
// =============================================================================
|
|
1877
1921
|
|
|
1922
|
+
/** A live MCP connection: its transport, the server bound to it, and the
|
|
1923
|
+
* isolated action context every one of its tool calls runs inside. */
|
|
1924
|
+
interface HttpConnection {
|
|
1925
|
+
transport: StreamableHTTPServerTransport;
|
|
1926
|
+
server: McpServer;
|
|
1927
|
+
ctx: core.CoreContext;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** Read and JSON-parse a request body. Returns undefined for an empty/invalid
|
|
1931
|
+
* body (GET/DELETE carry none) so callers can treat "no body" uniformly. */
|
|
1932
|
+
function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
1933
|
+
return new Promise((resolve) => {
|
|
1934
|
+
const chunks: Buffer[] = [];
|
|
1935
|
+
req.on("data", (chunk) => chunks.push(chunk as Buffer));
|
|
1936
|
+
req.on("end", () => {
|
|
1937
|
+
if (chunks.length === 0) return resolve(undefined);
|
|
1938
|
+
try {
|
|
1939
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
1940
|
+
} catch {
|
|
1941
|
+
resolve(undefined);
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
req.on("error", () => resolve(undefined));
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1878
1948
|
/**
|
|
1879
1949
|
* Serve the MCP server over Streamable HTTP with no authentication.
|
|
1880
1950
|
*
|
|
1881
1951
|
* This is what the eve agent (and any other Streamable-HTTP MCP client) connects
|
|
1882
|
-
* to.
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
1952
|
+
* to. Each MCP session gets its OWN transport + server + isolated action context,
|
|
1953
|
+
* keyed by the `mcp-session-id` header:
|
|
1954
|
+
*
|
|
1955
|
+
* - An `initialize` request (no session id yet) mints a fresh connection: a new
|
|
1956
|
+
* isolated {@link core.CoreContext}, a server built over it, and a transport
|
|
1957
|
+
* whose generated session id we store. Every later request carrying that id
|
|
1958
|
+
* routes back to the same connection and runs inside its context via
|
|
1959
|
+
* {@link core.runInContext} — so one client's tool call can only ever touch
|
|
1960
|
+
* its own sandbox / element refs / image store. This is the fix for the
|
|
1961
|
+
* cross-session bleed: state is per-connection, not process-global.
|
|
1962
|
+
* - `DELETE` (or transport close) tears the connection down and drops it from
|
|
1963
|
+
* the map, so a disconnecting client frees its slot and its context is GC'd.
|
|
1887
1964
|
*
|
|
1888
1965
|
* No auth is applied here by design (the connection is expected to be local-only
|
|
1889
1966
|
* or otherwise protected outside the MCP layer). Do not expose this on a public
|
|
@@ -1894,33 +1971,92 @@ async function startHttpServer() {
|
|
|
1894
1971
|
const port = Number(process.env.TD_MCP_PORT || process.env.PORT || 8788);
|
|
1895
1972
|
const mcpPath = process.env.TD_MCP_PATH || "/mcp";
|
|
1896
1973
|
|
|
1897
|
-
//
|
|
1898
|
-
const
|
|
1899
|
-
sessionIdGenerator: () => randomUUID(),
|
|
1900
|
-
});
|
|
1901
|
-
await server.connect(transport);
|
|
1974
|
+
// Live connections keyed by MCP session id. One entry per connected client.
|
|
1975
|
+
const connections = new Map<string, HttpConnection>();
|
|
1902
1976
|
|
|
1903
1977
|
const httpServer = http.createServer((req, res) => {
|
|
1904
|
-
|
|
1978
|
+
void (async () => {
|
|
1979
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1980
|
+
|
|
1981
|
+
// Lightweight health check for readiness probes / `eve dev`.
|
|
1982
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
1983
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1984
|
+
res.end(JSON.stringify({ status: "ok", server: "testdriver", version, sessions: connections.size }));
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1905
1987
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
}
|
|
1988
|
+
if (url.pathname !== mcpPath) {
|
|
1989
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1990
|
+
res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1912
1993
|
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
|
|
1916
|
-
return;
|
|
1917
|
-
}
|
|
1994
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1995
|
+
const sid = Array.isArray(sessionId) ? sessionId[0] : sessionId;
|
|
1918
1996
|
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1997
|
+
// POST bodies must be parsed here (to detect `initialize` and to hand the
|
|
1998
|
+
// transport a pre-parsed body). GET (SSE) and DELETE carry no JSON body.
|
|
1999
|
+
const body = req.method === "POST" ? await readJsonBody(req) : undefined;
|
|
2000
|
+
|
|
2001
|
+
let connection: HttpConnection | undefined = sid ? connections.get(sid) : undefined;
|
|
2002
|
+
|
|
2003
|
+
// A brand-new session: only an `initialize` request may create one.
|
|
2004
|
+
if (!connection) {
|
|
2005
|
+
if (sid) {
|
|
2006
|
+
// Client presented a session id we don't know — it was torn down or is
|
|
2007
|
+
// stale. 404 so the client re-initializes (matches SDK stateful mode).
|
|
2008
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
2009
|
+
res.end(JSON.stringify({ error: "Unknown or expired MCP session", sessionId: sid }));
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
if (!isInitializeRequest(body)) {
|
|
2013
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
2014
|
+
res.end(JSON.stringify({ error: "Bad Request: no MCP session id and not an initialize request" }));
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Mint an isolated context + a server bound to it + a transport.
|
|
2019
|
+
const ctx = core.createIsolatedContext();
|
|
2020
|
+
const mcpServer = buildServer();
|
|
2021
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2022
|
+
sessionIdGenerator: () => randomUUID(),
|
|
2023
|
+
onsessioninitialized: (newId: string) => {
|
|
2024
|
+
connections.set(newId, { transport, server: mcpServer, ctx });
|
|
2025
|
+
logger.info("http: MCP session initialized", { sessionId: newId, activeSessions: connections.size });
|
|
2026
|
+
},
|
|
2027
|
+
});
|
|
2028
|
+
// When the transport closes (client DELETE, network drop, or shutdown),
|
|
2029
|
+
// drop the connection and best-effort tear down its sandbox so a
|
|
2030
|
+
// disconnecting client doesn't leak a live VM.
|
|
2031
|
+
transport.onclose = () => {
|
|
2032
|
+
const closedId = transport.sessionId;
|
|
2033
|
+
if (closedId && connections.delete(closedId)) {
|
|
2034
|
+
logger.info("http: MCP session closed", { sessionId: closedId, activeSessions: connections.size });
|
|
2035
|
+
}
|
|
2036
|
+
// Disconnect the SDK inside this connection's context (best-effort).
|
|
2037
|
+
core.runInContext(ctx, () => { void core.disconnect(); });
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
await mcpServer.connect(transport);
|
|
2041
|
+
connection = { transport, server: mcpServer, ctx };
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// Route the request through THIS connection's transport, inside THIS
|
|
2045
|
+
// connection's action context so every core.* call the handlers make
|
|
2046
|
+
// resolves the right sandbox / refs / image store.
|
|
2047
|
+
const conn = connection;
|
|
2048
|
+
await core.runInContext(conn.ctx, () =>
|
|
2049
|
+
conn.transport.handleRequest(req, res, body),
|
|
2050
|
+
).catch((error: unknown) => {
|
|
2051
|
+
logger.error("http: handleRequest failed", { error: String(error) });
|
|
2052
|
+
captureException(error as Error, { tags: { phase: "http" } });
|
|
2053
|
+
if (!res.headersSent) {
|
|
2054
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2055
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
})().catch((error: unknown) => {
|
|
2059
|
+
logger.error("http: request handling failed", { error: String(error) });
|
|
1924
2060
|
captureException(error as Error, { tags: { phase: "http" } });
|
|
1925
2061
|
if (!res.headersSent) {
|
|
1926
2062
|
res.writeHead(500, { "content-type": "application/json" });
|
|
@@ -1935,9 +2071,12 @@ async function startHttpServer() {
|
|
|
1935
2071
|
});
|
|
1936
2072
|
|
|
1937
2073
|
const shutdown = async () => {
|
|
1938
|
-
logger.info("Shutting down MCP Server");
|
|
2074
|
+
logger.info("Shutting down MCP Server", { activeSessions: connections.size });
|
|
1939
2075
|
httpServer.close();
|
|
1940
|
-
|
|
2076
|
+
for (const conn of connections.values()) {
|
|
2077
|
+
await conn.transport.close().catch(() => {});
|
|
2078
|
+
}
|
|
2079
|
+
connections.clear();
|
|
1941
2080
|
await flushSentry();
|
|
1942
2081
|
process.exit(0);
|
|
1943
2082
|
};
|
|
@@ -1964,6 +2103,10 @@ async function main() {
|
|
|
1964
2103
|
return;
|
|
1965
2104
|
}
|
|
1966
2105
|
|
|
2106
|
+
// stdio: one long-lived server for the process. It runs over the global
|
|
2107
|
+
// action context (no runInContext wrap), so behavior is unchanged — a single
|
|
2108
|
+
// local CLI client, one sandbox at a time, exactly as before.
|
|
2109
|
+
const server = buildServer();
|
|
1967
2110
|
const transport = new StdioServerTransport();
|
|
1968
2111
|
await server.connect(transport);
|
|
1969
2112
|
|