@testdriverai/agent 7.11.146-test → 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.
- package/docs/claude-mcp-plugin.mdx +37 -0
- package/mcp-server/Dockerfile +29 -0
- package/mcp-server/dist/http-auth.d.ts +38 -0
- package/mcp-server/dist/http-auth.js +135 -0
- package/mcp-server/dist/server.mjs +174 -2
- package/mcp-server/package-lock.json +13 -4
- package/mcp-server/package.json +1 -0
- package/mcp-server/src/http-auth.ts +169 -0
- package/mcp-server/src/server.ts +192 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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": "^
|
|
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": "
|
|
2767
|
-
"resolved": "https://registry.npmjs.org/jose/-/jose-
|
|
2768
|
-
"integrity": "sha512-
|
|
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"
|
package/mcp-server/package.json
CHANGED
|
@@ -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
|
+
}
|
package/mcp-server/src/server.ts
CHANGED
|
@@ -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
|
-
|
|
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 });
|