@testdriverai/mcp 7.11.6-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.
@@ -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 { sessionManager, type SessionState } from "./session.js";
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 id = `${type}-${++imageIdCounter}`;
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 imageStore.get(id);
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: sessionManager.getTimeRemaining(session.sessionId),
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 = sessionManager.getCurrentSession();
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 = sessionManager.getCurrentSession();
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
- // Create MCP server wrapped with Sentry for automatic tracing
517
- const server = isSentryEnabled()
518
- ? Sentry.wrapMcpServerWithSentry(
519
- new McpServer({
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
- imageStoreSize: imageStore.size,
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
- imageStoreSize: imageStore.size,
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. The server holds single-session global state (the active `sdk`, element
1883
- * refs, image store), so we mirror that with a single long-lived MCP transport:
1884
- * the first `initialize` request mints a session id and every subsequent request
1885
- * reuses the same `server` + transport. Concurrent sessions are intentionally not
1886
- * supported this server provisions one sandbox at a time.
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
- // One transport for the lifetime of the process (single-session design).
1898
- const transport = new StreamableHTTPServerTransport({
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
- const url = new URL(req.url || "/", `http://${req.headers.host}`);
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
- // Lightweight health check for readiness probes / `eve dev`.
1907
- if (req.method === "GET" && url.pathname === "/health") {
1908
- res.writeHead(200, { "content-type": "application/json" });
1909
- res.end(JSON.stringify({ status: "ok", server: "testdriver", version }));
1910
- return;
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
- if (url.pathname !== mcpPath) {
1914
- res.writeHead(404, { "content-type": "application/json" });
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
- // The Streamable HTTP transport handles POST (requests), GET (SSE stream),
1920
- // and DELETE (session teardown) on the same path. Body parsing is left to
1921
- // the transport so it can read the raw stream.
1922
- transport.handleRequest(req, res).catch((error) => {
1923
- logger.error("http: handleRequest failed", { error: String(error) });
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
- await transport.close().catch(() => {});
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/mcp",
3
- "version": "7.11.6-test",
3
+ "version": "7.11.7-canary",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",