@testdriverai/agent 7.11.146-canary → 7.11.147-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.
@@ -65,6 +65,43 @@ This is the same config the plugin wires up for you — the plugin just bundles
65
65
 
66
66
  ---
67
67
 
68
+ ## Remote (hosted URL) install — no CLI, browser login
69
+
70
+ TestDriver also runs a **hosted MCP server** you can connect to with just a URL. There is no `npx` command and no API key to paste: the server speaks OAuth 2.1, so your client opens a browser, you sign in with TestDriver, and the tools appear. It exposes the **full live tool set** (`session_start`, `find`, `click`, `type`, `assert`, …) plus the read-only data tools, all scoped to your team.
71
+
72
+ Hosted endpoint:
73
+
74
+ ```text
75
+ https://mcp.testdriver.ai/mcp
76
+ ```
77
+
78
+ The server advertises its authorization server (Auth0) via [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) protected-resource metadata at:
79
+
80
+ ```text
81
+ https://mcp.testdriver.ai/.well-known/oauth-protected-resource
82
+ ```
83
+
84
+ Spec-compliant clients discover and complete the OAuth flow automatically:
85
+
86
+ - **Claude** — add a custom connector pointing at the URL above; Claude runs the browser login for you.
87
+ - **VS Code / Copilot** — add a remote MCP server entry with the URL; VS Code handles the OAuth handshake.
88
+ - **ChatGPT** — add it as a connector by URL.
89
+ - **Any spec-compliant client** — point it at the URL; it will read the metadata, sign you in, and connect.
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "testdriver": {
95
+ "url": "https://mcp.testdriver.ai/mcp"
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ Each connection gets its own isolated sandbox, so multiple people (or multiple chats) can run tests at the same time without interfering. The API-key paths above still work for automation and CI.
102
+
103
+ ---
104
+
68
105
  ## Observing test runs via HTTP MCP
69
106
 
70
107
  TestDriver also exposes **test results and analytics** over an HTTP MCP endpoint, so Claude Code (or any MCP-compatible client) can inspect your test runs, failures, and filters without provisioning a sandbox.
@@ -0,0 +1,29 @@
1
+ FROM node:22-alpine AS builder
2
+
3
+ WORKDIR /app
4
+
5
+ COPY sdk/mcp-server/package*.json ./
6
+ RUN npm ci --ignore-scripts
7
+
8
+ COPY sdk/mcp-server/ ./
9
+ RUN npm run build:server
10
+
11
+ FROM node:22-alpine AS runtime
12
+
13
+ WORKDIR /app
14
+
15
+ COPY sdk/mcp-server/package*.json ./
16
+ RUN npm ci --ignore-scripts --omit-dev
17
+
18
+ COPY --from=builder /app/dist ./dist
19
+
20
+ ENV NODE_ENV=production
21
+ ENV PORT=8080
22
+ # Hosted Streamable HTTP transport with Auth0 OAuth enforcement, bound for Fly.
23
+ ENV TD_MCP_TRANSPORT=http
24
+ ENV TD_MCP_AUTH=oauth
25
+ ENV TD_MCP_HOST=0.0.0.0
26
+
27
+ EXPOSE 8080
28
+
29
+ CMD ["node", "dist/server.mjs"]
@@ -0,0 +1,38 @@
1
+ /**
2
+ * http-auth.ts
3
+ *
4
+ * OAuth 2.0 Resource Server helpers for the standalone TestDriver MCP server's
5
+ * Streamable HTTP transport. Identity is delegated to Auth0 — this server only
6
+ * *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience and
7
+ * advertises Auth0 as the authorization server via RFC 9728 metadata.
8
+ *
9
+ * When OAuth is disabled (no `TD_MCP_AUTH=oauth`), everything here is a no-op so
10
+ * local stdio / trusted-network usage is unchanged.
11
+ */
12
+ import type { IncomingMessage } from "http";
13
+ import { type JWTPayload } from "jose";
14
+ /** Whether OAuth enforcement is enabled for the HTTP transport. */
15
+ export declare function isOAuthEnabled(): boolean;
16
+ /** RFC 9728 protected resource metadata document. */
17
+ export declare function protectedResourceMetadata(req: IncomingMessage, mcpPath: string): Record<string, unknown>;
18
+ /** Build the `WWW-Authenticate` challenge header value. */
19
+ export declare function wwwAuthenticate(req: IncomingMessage, opts?: {
20
+ error?: string;
21
+ description?: string;
22
+ }): string;
23
+ /** Result of an authentication attempt. */
24
+ export type AuthResult = {
25
+ ok: true;
26
+ claims: JWTPayload;
27
+ token: string;
28
+ } | {
29
+ ok: false;
30
+ status: number;
31
+ error: string;
32
+ description: string;
33
+ };
34
+ /**
35
+ * Authenticate an incoming request against Auth0. Returns the verified claims
36
+ * on success, or an error descriptor the caller turns into a 401 challenge.
37
+ */
38
+ export declare function authenticate(req: IncomingMessage): Promise<AuthResult>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * http-auth.ts
3
+ *
4
+ * OAuth 2.0 Resource Server helpers for the standalone TestDriver MCP server's
5
+ * Streamable HTTP transport. Identity is delegated to Auth0 — this server only
6
+ * *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience and
7
+ * advertises Auth0 as the authorization server via RFC 9728 metadata.
8
+ *
9
+ * When OAuth is disabled (no `TD_MCP_AUTH=oauth`), everything here is a no-op so
10
+ * local stdio / trusted-network usage is unchanged.
11
+ */
12
+ import { createRemoteJWKSet, jwtVerify } from "jose";
13
+ /** Whether OAuth enforcement is enabled for the HTTP transport. */
14
+ export function isOAuthEnabled() {
15
+ return (process.env.TD_MCP_AUTH || "").toLowerCase() === "oauth";
16
+ }
17
+ /** Canonical resource identifier (RFC 8707) = the Auth0 API audience. */
18
+ const MCP_AUDIENCE = process.env.TD_AUTH0_AUDIENCE || "https://api.testdriver.ai";
19
+ /** Auth0 issuer(s) trusted to mint tokens. Comma-separated env override. */
20
+ const TRUSTED_ISSUERS = (process.env.TD_MCP_TRUSTED_ISSUERS ||
21
+ (process.env.AUTH0_DOMAIN
22
+ ? `https://${String(process.env.AUTH0_DOMAIN)
23
+ .replace(/^https?:\/\//, "")
24
+ .replace(/\/$/, "")}/`
25
+ : "https://replayable.us.auth0.com/"))
26
+ .split(",")
27
+ .map((s) => s.trim())
28
+ .filter(Boolean);
29
+ // One remote JWKS per issuer; jose caches keys and handles rotation.
30
+ const jwksByIssuer = new Map();
31
+ function jwksForIssuer(issuer) {
32
+ let jwks = jwksByIssuer.get(issuer);
33
+ if (!jwks) {
34
+ jwks = createRemoteJWKSet(new URL(`${issuer.replace(/\/$/, "")}/.well-known/jwks.json`));
35
+ jwksByIssuer.set(issuer, jwks);
36
+ }
37
+ return jwks;
38
+ }
39
+ /** Scheme the client used to reach us (honours a TLS-terminating proxy). */
40
+ function requestScheme(req) {
41
+ const forwarded = req.headers["x-forwarded-proto"];
42
+ if (forwarded) {
43
+ return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
44
+ }
45
+ const socket = req.socket;
46
+ return socket && socket.encrypted ? "https" : "http";
47
+ }
48
+ /** The public URL clients use to reach this server's MCP endpoint. */
49
+ function resourceUrl(req, mcpPath) {
50
+ if (process.env.TD_MCP_RESOURCE) {
51
+ return process.env.TD_MCP_RESOURCE;
52
+ }
53
+ const host = req.headers["x-forwarded-host"] || req.headers.host;
54
+ return `${requestScheme(req)}://${host}${mcpPath}`;
55
+ }
56
+ /** RFC 9728 protected resource metadata document. */
57
+ export function protectedResourceMetadata(req, mcpPath) {
58
+ return {
59
+ resource: resourceUrl(req, mcpPath),
60
+ authorization_servers: TRUSTED_ISSUERS.map((iss) => iss.replace(/\/$/, "")),
61
+ bearer_methods_supported: ["header"],
62
+ scopes_supported: ["openid", "profile", "email"],
63
+ resource_documentation: "https://docs.testdriver.ai",
64
+ };
65
+ }
66
+ /** Build the `WWW-Authenticate` challenge header value. */
67
+ export function wwwAuthenticate(req, opts) {
68
+ const host = req.headers["x-forwarded-host"] || req.headers.host;
69
+ const metadataUrl = `${requestScheme(req)}://${host}/.well-known/oauth-protected-resource`;
70
+ let challenge = `Bearer resource_metadata="${metadataUrl}"`;
71
+ if (opts?.error) {
72
+ challenge += `, error="${opts.error}"`;
73
+ }
74
+ if (opts?.description) {
75
+ challenge += `, error_description="${opts.description}"`;
76
+ }
77
+ return challenge;
78
+ }
79
+ /** Extract a Bearer token from the Authorization header, or null. */
80
+ function bearerFromRequest(req) {
81
+ const auth = req.headers.authorization;
82
+ if (!auth) {
83
+ return null;
84
+ }
85
+ const m = /^Bearer\s+(.+)$/i.exec(auth);
86
+ return m ? m[1].trim() : null;
87
+ }
88
+ /**
89
+ * Authenticate an incoming request against Auth0. Returns the verified claims
90
+ * on success, or an error descriptor the caller turns into a 401 challenge.
91
+ */
92
+ export async function authenticate(req) {
93
+ const token = bearerFromRequest(req);
94
+ if (!token) {
95
+ return {
96
+ ok: false,
97
+ status: 401,
98
+ error: "invalid_request",
99
+ description: "Authentication required",
100
+ };
101
+ }
102
+ // Read the unverified issuer to select the right JWKS, then verify.
103
+ let issuer;
104
+ try {
105
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
106
+ issuer = payload.iss;
107
+ }
108
+ catch {
109
+ issuer = undefined;
110
+ }
111
+ if (!issuer || !TRUSTED_ISSUERS.includes(issuer)) {
112
+ return {
113
+ ok: false,
114
+ status: 401,
115
+ error: "invalid_token",
116
+ description: "Untrusted or missing token issuer",
117
+ };
118
+ }
119
+ try {
120
+ const { payload } = await jwtVerify(token, jwksForIssuer(issuer), {
121
+ issuer,
122
+ audience: MCP_AUDIENCE,
123
+ algorithms: ["RS256"],
124
+ });
125
+ return { ok: true, claims: payload, token };
126
+ }
127
+ catch {
128
+ return {
129
+ ok: false,
130
+ status: 401,
131
+ error: "invalid_token",
132
+ description: "Invalid or expired access token",
133
+ };
134
+ }
135
+ }
@@ -23,6 +23,7 @@ import { z } from "zod";
23
23
  import * as core from "./core/actions.js";
24
24
  import { NoActiveSessionError } from "./core/actions.js";
25
25
  import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
26
+ import { authenticate, isOAuthEnabled, protectedResourceMetadata, wwwAuthenticate } from "./http-auth.js";
26
27
  import { SessionStartInputSchema } from "./provision-types.js";
27
28
  // =============================================================================
28
29
  // Sentry
@@ -192,6 +193,12 @@ function imageStoreState() {
192
193
  }
193
194
  return state;
194
195
  }
196
+ /** Per-session team API key resolved from the caller's OAuth token (hosted mode).
197
+ * Null when unset, so session_start falls back to the process-wide env key. */
198
+ function sessionApiKey() {
199
+ const key = core.getAdapterState().apiKey;
200
+ return typeof key === "string" && key.length > 0 ? key : undefined;
201
+ }
195
202
  /**
196
203
  * Store an image and return its unique resource URI
197
204
  */
@@ -565,7 +572,7 @@ Debug mode (connect to existing sandbox):
565
572
  : "Starting session...");
566
573
  let result;
567
574
  try {
568
- result = await raceAbort(extra.signal, "session_start", core.sessionStart(params, { os: resolvedOs, e2bTemplateId: resolvedE2bTemplateId }, { onProgress: (m) => progress.report(m) }));
575
+ result = await raceAbort(extra.signal, "session_start", core.sessionStart(params, { os: resolvedOs, e2bTemplateId: resolvedE2bTemplateId, apiKey: sessionApiKey() }, { onProgress: (m) => progress.report(m) }));
569
576
  }
570
577
  finally {
571
578
  stopHeartbeat();
@@ -1564,8 +1571,79 @@ Learn more at https://docs.testdriver.ai/v7/getting-started/
1564
1571
  throw error;
1565
1572
  }
1566
1573
  });
1574
+ // =============================================================================
1575
+ // Data tools — read TestDriver run history via the backend REST API, scoped to
1576
+ // the caller's team by their OAuth token (hosted mode) or env API key.
1577
+ // =============================================================================
1578
+ server.registerTool("list_test_runs", {
1579
+ description: "List recent TestDriver test runs for the current team.",
1580
+ inputSchema: z.object({
1581
+ page: z.number().optional().describe("Page number (1-indexed)"),
1582
+ limit: z.number().optional().describe("Maximum number of test runs to return"),
1583
+ status: z.string().optional().describe("Filter by test run status"),
1584
+ platform: z.string().optional().describe("Filter by platform"),
1585
+ branch: z.string().optional().describe("Filter by git branch"),
1586
+ testFile: z.string().optional().describe("Filter by test file path"),
1587
+ search: z.string().optional().describe("Search by suite name substring"),
1588
+ }),
1589
+ }, async (params) => {
1590
+ try {
1591
+ const data = await backendGet("/api/v1/testdriver/test-runs-list", params);
1592
+ return createToolResult(true, JSON.stringify(data, null, 2), { action: "list_test_runs" });
1593
+ }
1594
+ catch (error) {
1595
+ logger.error("list_test_runs: Failed", { error: String(error) });
1596
+ captureException(error, { tags: { tool: "list_test_runs" } });
1597
+ return createToolResult(false, `Failed to list test runs: ${String(error)}`, { action: "list_test_runs" });
1598
+ }
1599
+ });
1600
+ server.registerTool("get_test_run_detail", {
1601
+ description: "Get details for a single TestDriver test run, including its test cases.",
1602
+ inputSchema: z.object({
1603
+ id: z.string().describe("The test run id"),
1604
+ }),
1605
+ }, async (params) => {
1606
+ try {
1607
+ const data = await backendGet("/api/v1/testdriver/test-run-detail", { id: params.id });
1608
+ return createToolResult(true, JSON.stringify(data, null, 2), { action: "get_test_run_detail" });
1609
+ }
1610
+ catch (error) {
1611
+ logger.error("get_test_run_detail: Failed", { error: String(error) });
1612
+ captureException(error, { tags: { tool: "get_test_run_detail" } });
1613
+ return createToolResult(false, `Failed to get test run detail: ${String(error)}`, { action: "get_test_run_detail" });
1614
+ }
1615
+ });
1567
1616
  return server;
1568
1617
  }
1618
+ /**
1619
+ * Call a backend REST endpoint as the current session's team. Prefers the
1620
+ * caller's OAuth Bearer (hosted mode); otherwise sends the env API key via
1621
+ * `X-Api-Key` (stdio / local mode).
1622
+ */
1623
+ async function backendGet(pathname, query) {
1624
+ const apiRoot = (process.env.TD_API_ROOT || "https://api.testdriver.ai").replace(/\/$/, "");
1625
+ const url = new URL(`${apiRoot}${pathname}`);
1626
+ for (const [k, v] of Object.entries(query)) {
1627
+ if (v !== undefined && v !== null && v !== "") {
1628
+ url.searchParams.set(k, String(v));
1629
+ }
1630
+ }
1631
+ const adapter = core.getAdapterState();
1632
+ const headers = { accept: "application/json" };
1633
+ const token = adapter.oauthToken;
1634
+ const apiKey = adapter.apiKey || process.env.TD_API_KEY || process.env.COPILOT_MCP_TD_API_KEY;
1635
+ if (typeof token === "string" && token) {
1636
+ headers.authorization = `Bearer ${token}`;
1637
+ }
1638
+ else if (typeof apiKey === "string" && apiKey) {
1639
+ headers["x-api-key"] = apiKey;
1640
+ }
1641
+ const resp = await fetch(url.toString(), { headers });
1642
+ if (!resp.ok) {
1643
+ throw new Error(`backend ${pathname} returned ${resp.status}`);
1644
+ }
1645
+ return resp.json();
1646
+ }
1569
1647
  /** Read and JSON-parse a request body. Returns undefined for an empty/invalid
1570
1648
  * body (GET/DELETE carry none) so callers can treat "no body" uniformly. */
1571
1649
  function readJsonBody(req) {
@@ -1606,6 +1684,46 @@ function readJsonBody(req) {
1606
1684
  * or otherwise protected outside the MCP layer). Do not expose this on a public
1607
1685
  * network without putting a real authenticating proxy in front of it.
1608
1686
  */
1687
+ /**
1688
+ * Exchange a verified Auth0 access token for the caller's native TestDriver team
1689
+ * API key via the backend, so each hosted session provisions under the right
1690
+ * team and quota. Returns null if the exchange fails (caller falls back / errors).
1691
+ */
1692
+ async function exchangeOAuthTokenForApiKey(token) {
1693
+ const apiRoot = process.env.TD_API_ROOT || "https://api.testdriver.ai";
1694
+ try {
1695
+ const resp = await fetch(`${apiRoot.replace(/\/$/, "")}/auth/exchange-oauth-token`, {
1696
+ method: "POST",
1697
+ headers: {
1698
+ "content-type": "application/json",
1699
+ authorization: `Bearer ${token}`,
1700
+ },
1701
+ body: JSON.stringify({}),
1702
+ });
1703
+ if (!resp.ok) {
1704
+ logger.warn("http: exchange-oauth-token failed", { status: resp.status });
1705
+ return null;
1706
+ }
1707
+ const data = (await resp.json());
1708
+ return data.apiKey || null;
1709
+ }
1710
+ catch (error) {
1711
+ logger.error("http: exchange-oauth-token error", { error: String(error) });
1712
+ return null;
1713
+ }
1714
+ }
1715
+ // Fly.io machine that owns this process. When set (hosted, multi-machine), we
1716
+ // tag it into each session id so a request landing on the wrong machine can be
1717
+ // replayed to the owner via the `fly-replay` header — session affinity without a
1718
+ // shared store. Empty locally / single-machine, where every request is already
1719
+ // local.
1720
+ const FLY_MACHINE_ID = process.env.FLY_MACHINE_ID || "";
1721
+ // Session ids are `<uuid>.<machineId>` in multi-machine mode. These split the
1722
+ // two back out; a plain uuid (no dot) means single-machine and owns itself.
1723
+ function sessionOwnerMachine(sid) {
1724
+ const dot = sid.lastIndexOf(".");
1725
+ return dot === -1 ? "" : sid.slice(dot + 1);
1726
+ }
1609
1727
  async function startHttpServer() {
1610
1728
  const host = process.env.TD_MCP_HOST || "127.0.0.1";
1611
1729
  const port = Number(process.env.TD_MCP_PORT || process.env.PORT || 8788);
@@ -1621,11 +1739,44 @@ async function startHttpServer() {
1621
1739
  res.end(JSON.stringify({ status: "ok", server: "testdriver", version, sessions: connections.size }));
1622
1740
  return;
1623
1741
  }
1742
+ // RFC 9728 protected-resource metadata so OAuth clients discover Auth0.
1743
+ // Only advertised when OAuth is enforced. Some clients append the resource
1744
+ // path to the well-known URL, so match both.
1745
+ if (isOAuthEnabled() &&
1746
+ req.method === "GET" &&
1747
+ (url.pathname === "/.well-known/oauth-protected-resource" ||
1748
+ url.pathname === `/.well-known/oauth-protected-resource${mcpPath}`)) {
1749
+ res.writeHead(200, { "content-type": "application/json" });
1750
+ res.end(JSON.stringify(protectedResourceMetadata(req, mcpPath)));
1751
+ return;
1752
+ }
1624
1753
  if (url.pathname !== mcpPath) {
1625
1754
  res.writeHead(404, { "content-type": "application/json" });
1626
1755
  res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
1627
1756
  return;
1628
1757
  }
1758
+ // OAuth resource-server enforcement (only when TD_MCP_AUTH=oauth). When
1759
+ // disabled this is a no-op so local/trusted-network usage is unchanged.
1760
+ let resolvedApiKey = null;
1761
+ let resolvedToken = null;
1762
+ if (isOAuthEnabled()) {
1763
+ const auth = await authenticate(req);
1764
+ if (!auth.ok) {
1765
+ res.writeHead(auth.status, {
1766
+ "content-type": "application/json",
1767
+ "www-authenticate": wwwAuthenticate(req, { error: auth.error, description: auth.description }),
1768
+ });
1769
+ res.end(JSON.stringify({ error: auth.error, error_description: auth.description }));
1770
+ return;
1771
+ }
1772
+ resolvedToken = auth.token;
1773
+ resolvedApiKey = await exchangeOAuthTokenForApiKey(auth.token);
1774
+ if (!resolvedApiKey) {
1775
+ res.writeHead(403, { "content-type": "application/json" });
1776
+ res.end(JSON.stringify({ error: "no_team", error_description: "No TestDriver team is linked to this account" }));
1777
+ return;
1778
+ }
1779
+ }
1629
1780
  const sessionId = req.headers["mcp-session-id"];
1630
1781
  const sid = Array.isArray(sessionId) ? sessionId[0] : sessionId;
1631
1782
  // POST bodies must be parsed here (to detect `initialize` and to hand the
@@ -1635,6 +1786,15 @@ async function startHttpServer() {
1635
1786
  // A brand-new session: only an `initialize` request may create one.
1636
1787
  if (!connection) {
1637
1788
  if (sid) {
1789
+ // The session lives on another Fly machine: ask Fly's proxy to replay
1790
+ // this request there instead of 404ing (which would force a new
1791
+ // sandbox). Only when the owner is a *different* known machine.
1792
+ const owner = sessionOwnerMachine(sid);
1793
+ if (FLY_MACHINE_ID && owner && owner !== FLY_MACHINE_ID) {
1794
+ res.writeHead(409, { "fly-replay": `instance=${owner}` });
1795
+ res.end();
1796
+ return;
1797
+ }
1638
1798
  // Client presented a session id we don't know — it was torn down or is
1639
1799
  // stale. 404 so the client re-initializes (matches SDK stateful mode).
1640
1800
  res.writeHead(404, { "content-type": "application/json" });
@@ -1648,9 +1808,21 @@ async function startHttpServer() {
1648
1808
  }
1649
1809
  // Mint an isolated context + a server bound to it + a transport.
1650
1810
  const ctx = core.createIsolatedContext();
1811
+ // Seed the per-session team key so session_start provisions under the
1812
+ // caller's own team (hosted OAuth mode). Falls back to env when unset.
1813
+ if (resolvedApiKey) {
1814
+ ctx.adapter.apiKey = resolvedApiKey;
1815
+ }
1816
+ // Keep the caller's Bearer so data tools can call the backend REST API as
1817
+ // the authenticated user.
1818
+ if (resolvedToken) {
1819
+ ctx.adapter.oauthToken = resolvedToken;
1820
+ }
1651
1821
  const mcpServer = buildServer();
1652
1822
  const transport = new StreamableHTTPServerTransport({
1653
- sessionIdGenerator: () => randomUUID(),
1823
+ // Tag the owning machine into the id (multi-machine only) so later
1824
+ // requests can be replayed here via `fly-replay`.
1825
+ sessionIdGenerator: () => (FLY_MACHINE_ID ? `${randomUUID()}.${FLY_MACHINE_ID}` : randomUUID()),
1654
1826
  onsessioninitialized: (newId) => {
1655
1827
  connections.set(newId, { transport, server: mcpServer, ctx });
1656
1828
  logger.info("http: MCP session initialized", { sessionId: newId, activeSessions: connections.size });
@@ -11,7 +11,7 @@
11
11
  "@modelcontextprotocol/ext-apps": "^1.0.0",
12
12
  "@modelcontextprotocol/sdk": "^1.24.0",
13
13
  "@sentry/node": "^9.0.0",
14
- "jose": "^6.2.12",
14
+ "jose": "^5.10.0",
15
15
  "semver": "^7.7.4",
16
16
  "zod": "^3.24.0"
17
17
  },
@@ -569,6 +569,15 @@
569
569
  }
570
570
  }
571
571
  },
572
+ "node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
573
+ "version": "6.2.12",
574
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
575
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
576
+ "license": "MIT",
577
+ "funding": {
578
+ "url": "https://github.com/sponsors/panva"
579
+ }
580
+ },
572
581
  "node_modules/@opentelemetry/api": {
573
582
  "version": "1.9.0",
574
583
  "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -2763,9 +2772,9 @@
2763
2772
  "license": "ISC"
2764
2773
  },
2765
2774
  "node_modules/jose": {
2766
- "version": "6.2.12",
2767
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
2768
- "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
2775
+ "version": "5.10.0",
2776
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
2777
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
2769
2778
  "license": "MIT",
2770
2779
  "funding": {
2771
2780
  "url": "https://github.com/sponsors/panva"
@@ -18,6 +18,7 @@
18
18
  "@modelcontextprotocol/ext-apps": "^1.0.0",
19
19
  "@modelcontextprotocol/sdk": "^1.24.0",
20
20
  "@sentry/node": "^9.0.0",
21
+ "jose": "^5.10.0",
21
22
  "semver": "^7.7.4",
22
23
  "zod": "^3.24.0"
23
24
  },
@@ -0,0 +1,169 @@
1
+ /**
2
+ * http-auth.ts
3
+ *
4
+ * OAuth 2.0 Resource Server helpers for the standalone TestDriver MCP server's
5
+ * Streamable HTTP transport. Identity is delegated to Auth0 — this server only
6
+ * *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience and
7
+ * advertises Auth0 as the authorization server via RFC 9728 metadata.
8
+ *
9
+ * When OAuth is disabled (no `TD_MCP_AUTH=oauth`), everything here is a no-op so
10
+ * local stdio / trusted-network usage is unchanged.
11
+ */
12
+
13
+ import type { IncomingMessage } from "http";
14
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
15
+
16
+ /** Whether OAuth enforcement is enabled for the HTTP transport. */
17
+ export function isOAuthEnabled(): boolean {
18
+ return (process.env.TD_MCP_AUTH || "").toLowerCase() === "oauth";
19
+ }
20
+
21
+ /** Canonical resource identifier (RFC 8707) = the Auth0 API audience. */
22
+ const MCP_AUDIENCE =
23
+ process.env.TD_AUTH0_AUDIENCE || "https://api.testdriver.ai";
24
+
25
+ /** Auth0 issuer(s) trusted to mint tokens. Comma-separated env override. */
26
+ const TRUSTED_ISSUERS = (
27
+ process.env.TD_MCP_TRUSTED_ISSUERS ||
28
+ (process.env.AUTH0_DOMAIN
29
+ ? `https://${String(process.env.AUTH0_DOMAIN)
30
+ .replace(/^https?:\/\//, "")
31
+ .replace(/\/$/, "")}/`
32
+ : "https://replayable.us.auth0.com/")
33
+ )
34
+ .split(",")
35
+ .map((s) => s.trim())
36
+ .filter(Boolean);
37
+
38
+ // One remote JWKS per issuer; jose caches keys and handles rotation.
39
+ const jwksByIssuer = new Map<
40
+ string,
41
+ ReturnType<typeof createRemoteJWKSet>
42
+ >();
43
+
44
+ function jwksForIssuer(issuer: string): ReturnType<typeof createRemoteJWKSet> {
45
+ let jwks = jwksByIssuer.get(issuer);
46
+ if (!jwks) {
47
+ jwks = createRemoteJWKSet(
48
+ new URL(`${issuer.replace(/\/$/, "")}/.well-known/jwks.json`),
49
+ );
50
+ jwksByIssuer.set(issuer, jwks);
51
+ }
52
+ return jwks;
53
+ }
54
+
55
+ /** Scheme the client used to reach us (honours a TLS-terminating proxy). */
56
+ function requestScheme(req: IncomingMessage): string {
57
+ const forwarded = req.headers["x-forwarded-proto"];
58
+ if (forwarded) {
59
+ return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
60
+ }
61
+ const socket = req.socket as { encrypted?: boolean } | undefined;
62
+ return socket && socket.encrypted ? "https" : "http";
63
+ }
64
+
65
+ /** The public URL clients use to reach this server's MCP endpoint. */
66
+ function resourceUrl(req: IncomingMessage, mcpPath: string): string {
67
+ if (process.env.TD_MCP_RESOURCE) {
68
+ return process.env.TD_MCP_RESOURCE;
69
+ }
70
+ const host = req.headers["x-forwarded-host"] || req.headers.host;
71
+ return `${requestScheme(req)}://${host}${mcpPath}`;
72
+ }
73
+
74
+ /** RFC 9728 protected resource metadata document. */
75
+ export function protectedResourceMetadata(
76
+ req: IncomingMessage,
77
+ mcpPath: string,
78
+ ): Record<string, unknown> {
79
+ return {
80
+ resource: resourceUrl(req, mcpPath),
81
+ authorization_servers: TRUSTED_ISSUERS.map((iss) => iss.replace(/\/$/, "")),
82
+ bearer_methods_supported: ["header"],
83
+ scopes_supported: ["openid", "profile", "email"],
84
+ resource_documentation: "https://docs.testdriver.ai",
85
+ };
86
+ }
87
+
88
+ /** Build the `WWW-Authenticate` challenge header value. */
89
+ export function wwwAuthenticate(
90
+ req: IncomingMessage,
91
+ opts?: { error?: string; description?: string },
92
+ ): string {
93
+ const host = req.headers["x-forwarded-host"] || req.headers.host;
94
+ const metadataUrl = `${requestScheme(req)}://${host}/.well-known/oauth-protected-resource`;
95
+ let challenge = `Bearer resource_metadata="${metadataUrl}"`;
96
+ if (opts?.error) {
97
+ challenge += `, error="${opts.error}"`;
98
+ }
99
+ if (opts?.description) {
100
+ challenge += `, error_description="${opts.description}"`;
101
+ }
102
+ return challenge;
103
+ }
104
+
105
+ /** Extract a Bearer token from the Authorization header, or null. */
106
+ function bearerFromRequest(req: IncomingMessage): string | null {
107
+ const auth = req.headers.authorization;
108
+ if (!auth) {
109
+ return null;
110
+ }
111
+ const m = /^Bearer\s+(.+)$/i.exec(auth);
112
+ return m ? m[1].trim() : null;
113
+ }
114
+
115
+ /** Result of an authentication attempt. */
116
+ export type AuthResult =
117
+ | { ok: true; claims: JWTPayload; token: string }
118
+ | { ok: false; status: number; error: string; description: string };
119
+
120
+ /**
121
+ * Authenticate an incoming request against Auth0. Returns the verified claims
122
+ * on success, or an error descriptor the caller turns into a 401 challenge.
123
+ */
124
+ export async function authenticate(req: IncomingMessage): Promise<AuthResult> {
125
+ const token = bearerFromRequest(req);
126
+ if (!token) {
127
+ return {
128
+ ok: false,
129
+ status: 401,
130
+ error: "invalid_request",
131
+ description: "Authentication required",
132
+ };
133
+ }
134
+
135
+ // Read the unverified issuer to select the right JWKS, then verify.
136
+ let issuer: string | undefined;
137
+ try {
138
+ const payload = JSON.parse(
139
+ Buffer.from(token.split(".")[1], "base64url").toString("utf-8"),
140
+ );
141
+ issuer = payload.iss;
142
+ } catch {
143
+ issuer = undefined;
144
+ }
145
+ if (!issuer || !TRUSTED_ISSUERS.includes(issuer)) {
146
+ return {
147
+ ok: false,
148
+ status: 401,
149
+ error: "invalid_token",
150
+ description: "Untrusted or missing token issuer",
151
+ };
152
+ }
153
+
154
+ try {
155
+ const { payload } = await jwtVerify(token, jwksForIssuer(issuer), {
156
+ issuer,
157
+ audience: MCP_AUDIENCE,
158
+ algorithms: ["RS256"],
159
+ });
160
+ return { ok: true, claims: payload, token };
161
+ } catch {
162
+ return {
163
+ ok: false,
164
+ status: 401,
165
+ error: "invalid_token",
166
+ description: "Invalid or expired access token",
167
+ };
168
+ }
169
+ }
@@ -29,6 +29,7 @@ import { z } from "zod";
29
29
  import * as core from "./core/actions.js";
30
30
  import { NoActiveSessionError, type ActionResult } from "./core/actions.js";
31
31
  import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
32
+ import { authenticate, isOAuthEnabled, protectedResourceMetadata, wwwAuthenticate } from "./http-auth.js";
32
33
  import { SessionStartInputSchema, type SessionStartInput } from "./provision-types.js";
33
34
  import { type SessionState } from "./session.js";
34
35
 
@@ -253,6 +254,13 @@ function imageStoreState(): ImageStoreState {
253
254
  return state;
254
255
  }
255
256
 
257
+ /** Per-session team API key resolved from the caller's OAuth token (hosted mode).
258
+ * Null when unset, so session_start falls back to the process-wide env key. */
259
+ function sessionApiKey(): string | undefined {
260
+ const key = core.getAdapterState().apiKey;
261
+ return typeof key === "string" && key.length > 0 ? key : undefined;
262
+ }
263
+
256
264
  /**
257
265
  * Store an image and return its unique resource URI
258
266
  */
@@ -736,7 +744,7 @@ Debug mode (connect to existing sandbox):
736
744
  "session_start",
737
745
  core.sessionStart(
738
746
  params,
739
- { os: resolvedOs, e2bTemplateId: resolvedE2bTemplateId },
747
+ { os: resolvedOs, e2bTemplateId: resolvedE2bTemplateId, apiKey: sessionApiKey() },
740
748
  { onProgress: (m) => progress.report(m) }
741
749
  )
742
750
  );
@@ -1911,9 +1919,91 @@ Learn more at https://docs.testdriver.ai/v7/getting-started/
1911
1919
  }
1912
1920
  );
1913
1921
 
1922
+ // =============================================================================
1923
+ // Data tools — read TestDriver run history via the backend REST API, scoped to
1924
+ // the caller's team by their OAuth token (hosted mode) or env API key.
1925
+ // =============================================================================
1926
+
1927
+ server.registerTool(
1928
+ "list_test_runs",
1929
+ {
1930
+ description: "List recent TestDriver test runs for the current team.",
1931
+ inputSchema: z.object({
1932
+ page: z.number().optional().describe("Page number (1-indexed)"),
1933
+ limit: z.number().optional().describe("Maximum number of test runs to return"),
1934
+ status: z.string().optional().describe("Filter by test run status"),
1935
+ platform: z.string().optional().describe("Filter by platform"),
1936
+ branch: z.string().optional().describe("Filter by git branch"),
1937
+ testFile: z.string().optional().describe("Filter by test file path"),
1938
+ search: z.string().optional().describe("Search by suite name substring"),
1939
+ }),
1940
+ },
1941
+ async (params): Promise<CallToolResult> => {
1942
+ try {
1943
+ const data = await backendGet("/api/v1/testdriver/test-runs-list", params as Record<string, unknown>);
1944
+ return createToolResult(true, JSON.stringify(data, null, 2), { action: "list_test_runs" });
1945
+ } catch (error) {
1946
+ logger.error("list_test_runs: Failed", { error: String(error) });
1947
+ captureException(error as Error, { tags: { tool: "list_test_runs" } });
1948
+ return createToolResult(false, `Failed to list test runs: ${String(error)}`, { action: "list_test_runs" });
1949
+ }
1950
+ }
1951
+ );
1952
+
1953
+ server.registerTool(
1954
+ "get_test_run_detail",
1955
+ {
1956
+ description: "Get details for a single TestDriver test run, including its test cases.",
1957
+ inputSchema: z.object({
1958
+ id: z.string().describe("The test run id"),
1959
+ }),
1960
+ },
1961
+ async (params): Promise<CallToolResult> => {
1962
+ try {
1963
+ const data = await backendGet("/api/v1/testdriver/test-run-detail", { id: params.id });
1964
+ return createToolResult(true, JSON.stringify(data, null, 2), { action: "get_test_run_detail" });
1965
+ } catch (error) {
1966
+ logger.error("get_test_run_detail: Failed", { error: String(error) });
1967
+ captureException(error as Error, { tags: { tool: "get_test_run_detail" } });
1968
+ return createToolResult(false, `Failed to get test run detail: ${String(error)}`, { action: "get_test_run_detail" });
1969
+ }
1970
+ }
1971
+ );
1972
+
1914
1973
  return server;
1915
1974
  }
1916
1975
 
1976
+ /**
1977
+ * Call a backend REST endpoint as the current session's team. Prefers the
1978
+ * caller's OAuth Bearer (hosted mode); otherwise sends the env API key via
1979
+ * `X-Api-Key` (stdio / local mode).
1980
+ */
1981
+ async function backendGet(pathname: string, query: Record<string, unknown>): Promise<unknown> {
1982
+ const apiRoot = (process.env.TD_API_ROOT || "https://api.testdriver.ai").replace(/\/$/, "");
1983
+ const url = new URL(`${apiRoot}${pathname}`);
1984
+ for (const [k, v] of Object.entries(query)) {
1985
+ if (v !== undefined && v !== null && v !== "") {
1986
+ url.searchParams.set(k, String(v));
1987
+ }
1988
+ }
1989
+
1990
+ const adapter = core.getAdapterState();
1991
+ const headers: Record<string, string> = { accept: "application/json" };
1992
+ const token = adapter.oauthToken;
1993
+ const apiKey = adapter.apiKey || process.env.TD_API_KEY || process.env.COPILOT_MCP_TD_API_KEY;
1994
+ if (typeof token === "string" && token) {
1995
+ headers.authorization = `Bearer ${token}`;
1996
+ } else if (typeof apiKey === "string" && apiKey) {
1997
+ headers["x-api-key"] = apiKey;
1998
+ }
1999
+
2000
+ const resp = await fetch(url.toString(), { headers });
2001
+ if (!resp.ok) {
2002
+ throw new Error(`backend ${pathname} returned ${resp.status}`);
2003
+ }
2004
+ return resp.json();
2005
+ }
2006
+
1917
2007
 
1918
2008
  // =============================================================================
1919
2009
  // HTTP transport (Streamable HTTP, no auth)
@@ -1966,6 +2056,48 @@ function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
1966
2056
  * or otherwise protected outside the MCP layer). Do not expose this on a public
1967
2057
  * network without putting a real authenticating proxy in front of it.
1968
2058
  */
2059
+ /**
2060
+ * Exchange a verified Auth0 access token for the caller's native TestDriver team
2061
+ * API key via the backend, so each hosted session provisions under the right
2062
+ * team and quota. Returns null if the exchange fails (caller falls back / errors).
2063
+ */
2064
+ async function exchangeOAuthTokenForApiKey(token: string): Promise<string | null> {
2065
+ const apiRoot = process.env.TD_API_ROOT || "https://api.testdriver.ai";
2066
+ try {
2067
+ const resp = await fetch(`${apiRoot.replace(/\/$/, "")}/auth/exchange-oauth-token`, {
2068
+ method: "POST",
2069
+ headers: {
2070
+ "content-type": "application/json",
2071
+ authorization: `Bearer ${token}`,
2072
+ },
2073
+ body: JSON.stringify({}),
2074
+ });
2075
+ if (!resp.ok) {
2076
+ logger.warn("http: exchange-oauth-token failed", { status: resp.status });
2077
+ return null;
2078
+ }
2079
+ const data = (await resp.json()) as { apiKey?: string };
2080
+ return data.apiKey || null;
2081
+ } catch (error) {
2082
+ logger.error("http: exchange-oauth-token error", { error: String(error) });
2083
+ return null;
2084
+ }
2085
+ }
2086
+
2087
+ // Fly.io machine that owns this process. When set (hosted, multi-machine), we
2088
+ // tag it into each session id so a request landing on the wrong machine can be
2089
+ // replayed to the owner via the `fly-replay` header — session affinity without a
2090
+ // shared store. Empty locally / single-machine, where every request is already
2091
+ // local.
2092
+ const FLY_MACHINE_ID = process.env.FLY_MACHINE_ID || "";
2093
+
2094
+ // Session ids are `<uuid>.<machineId>` in multi-machine mode. These split the
2095
+ // two back out; a plain uuid (no dot) means single-machine and owns itself.
2096
+ function sessionOwnerMachine(sid: string): string {
2097
+ const dot = sid.lastIndexOf(".");
2098
+ return dot === -1 ? "" : sid.slice(dot + 1);
2099
+ }
2100
+
1969
2101
  async function startHttpServer() {
1970
2102
  const host = process.env.TD_MCP_HOST || "127.0.0.1";
1971
2103
  const port = Number(process.env.TD_MCP_PORT || process.env.PORT || 8788);
@@ -1985,12 +2117,49 @@ async function startHttpServer() {
1985
2117
  return;
1986
2118
  }
1987
2119
 
2120
+ // RFC 9728 protected-resource metadata so OAuth clients discover Auth0.
2121
+ // Only advertised when OAuth is enforced. Some clients append the resource
2122
+ // path to the well-known URL, so match both.
2123
+ if (
2124
+ isOAuthEnabled() &&
2125
+ req.method === "GET" &&
2126
+ (url.pathname === "/.well-known/oauth-protected-resource" ||
2127
+ url.pathname === `/.well-known/oauth-protected-resource${mcpPath}`)
2128
+ ) {
2129
+ res.writeHead(200, { "content-type": "application/json" });
2130
+ res.end(JSON.stringify(protectedResourceMetadata(req, mcpPath)));
2131
+ return;
2132
+ }
2133
+
1988
2134
  if (url.pathname !== mcpPath) {
1989
2135
  res.writeHead(404, { "content-type": "application/json" });
1990
2136
  res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
1991
2137
  return;
1992
2138
  }
1993
2139
 
2140
+ // OAuth resource-server enforcement (only when TD_MCP_AUTH=oauth). When
2141
+ // disabled this is a no-op so local/trusted-network usage is unchanged.
2142
+ let resolvedApiKey: string | null = null;
2143
+ let resolvedToken: string | null = null;
2144
+ if (isOAuthEnabled()) {
2145
+ const auth = await authenticate(req);
2146
+ if (!auth.ok) {
2147
+ res.writeHead(auth.status, {
2148
+ "content-type": "application/json",
2149
+ "www-authenticate": wwwAuthenticate(req, { error: auth.error, description: auth.description }),
2150
+ });
2151
+ res.end(JSON.stringify({ error: auth.error, error_description: auth.description }));
2152
+ return;
2153
+ }
2154
+ resolvedToken = auth.token;
2155
+ resolvedApiKey = await exchangeOAuthTokenForApiKey(auth.token);
2156
+ if (!resolvedApiKey) {
2157
+ res.writeHead(403, { "content-type": "application/json" });
2158
+ res.end(JSON.stringify({ error: "no_team", error_description: "No TestDriver team is linked to this account" }));
2159
+ return;
2160
+ }
2161
+ }
2162
+
1994
2163
  const sessionId = req.headers["mcp-session-id"];
1995
2164
  const sid = Array.isArray(sessionId) ? sessionId[0] : sessionId;
1996
2165
 
@@ -2003,6 +2172,15 @@ async function startHttpServer() {
2003
2172
  // A brand-new session: only an `initialize` request may create one.
2004
2173
  if (!connection) {
2005
2174
  if (sid) {
2175
+ // The session lives on another Fly machine: ask Fly's proxy to replay
2176
+ // this request there instead of 404ing (which would force a new
2177
+ // sandbox). Only when the owner is a *different* known machine.
2178
+ const owner = sessionOwnerMachine(sid);
2179
+ if (FLY_MACHINE_ID && owner && owner !== FLY_MACHINE_ID) {
2180
+ res.writeHead(409, { "fly-replay": `instance=${owner}` });
2181
+ res.end();
2182
+ return;
2183
+ }
2006
2184
  // Client presented a session id we don't know — it was torn down or is
2007
2185
  // stale. 404 so the client re-initializes (matches SDK stateful mode).
2008
2186
  res.writeHead(404, { "content-type": "application/json" });
@@ -2017,9 +2195,21 @@ async function startHttpServer() {
2017
2195
 
2018
2196
  // Mint an isolated context + a server bound to it + a transport.
2019
2197
  const ctx = core.createIsolatedContext();
2198
+ // Seed the per-session team key so session_start provisions under the
2199
+ // caller's own team (hosted OAuth mode). Falls back to env when unset.
2200
+ if (resolvedApiKey) {
2201
+ ctx.adapter.apiKey = resolvedApiKey;
2202
+ }
2203
+ // Keep the caller's Bearer so data tools can call the backend REST API as
2204
+ // the authenticated user.
2205
+ if (resolvedToken) {
2206
+ ctx.adapter.oauthToken = resolvedToken;
2207
+ }
2020
2208
  const mcpServer = buildServer();
2021
2209
  const transport = new StreamableHTTPServerTransport({
2022
- sessionIdGenerator: () => randomUUID(),
2210
+ // Tag the owning machine into the id (multi-machine only) so later
2211
+ // requests can be replayed here via `fly-replay`.
2212
+ sessionIdGenerator: () => (FLY_MACHINE_ID ? `${randomUUID()}.${FLY_MACHINE_ID}` : randomUUID()),
2023
2213
  onsessioninitialized: (newId: string) => {
2024
2214
  connections.set(newId, { transport, server: mcpServer, ctx });
2025
2215
  logger.info("http: MCP session initialized", { sessionId: newId, activeSessions: connections.size });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/agent",
3
- "version": "7.11.146-canary",
3
+ "version": "7.11.147-test",
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",