@testchimp/cli 0.1.83 → 0.1.85

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.
@@ -1,9 +1,13 @@
1
1
  import { normalizeScope } from "./normalize.js";
2
+ import { postIngress, TestChimpHttpError } from "./client.js";
2
3
  import { runProvisionEphemeralEnvironmentAndWait } from "./ephemeralWait.js";
3
4
  import * as S from "./schemas.js";
4
5
  import { resolveGitHeadSha } from "./gitSha.js";
5
6
  import { parseDateBoundToMillis } from "./dates.js";
6
7
  import { buildAgentTraceabilityPayload, resolveToolchainVersions } from "./agentTraceability.js";
8
+ export const BOT_ACK_PATH = "/bot/events/ack";
9
+ export const BOT_ACK_UNSUPPORTED_MESSAGE = "This TestChimp deployment does not support bot acks yet (ingress returned 404 for /bot/events/ack). " +
10
+ "Upgrade the TestChimp platform, or ask your admin to enable QA bots.";
7
11
  function platformToProtoEnum(platform) {
8
12
  switch (platform) {
9
13
  case "ios":
@@ -1519,13 +1523,13 @@ export const TOOL_DEFINITIONS = [
1519
1523
  },
1520
1524
  {
1521
1525
  kebab: "get-git-folder-mapping",
1522
- description: "Return git provider, repository, and mapped plans/tests folder paths for the project.",
1526
+ description: "Return git provider, repository, mapped plans/tests folder paths, and plans branch (empty = repository default) for the project.",
1523
1527
  inputSchema: S.emptyInput,
1524
1528
  execute: async (_args, { postMcp }) => postMcp("/api/mcp/get_git_folder_mapping", {}),
1525
1529
  },
1526
1530
  {
1527
1531
  kebab: "update-git-folder-mapping",
1528
- description: "Update mapped plans/tests folder paths (and optional repository) on the platform. Agent scaffolds folders in a PR; this records platform mapping.",
1532
+ description: "Update mapped plans/tests folder paths (and optional repository / plans branch) on the platform. Agent scaffolds folders in a PR; this records platform mapping.",
1529
1533
  inputSchema: S.updateGitFolderMappingInput,
1530
1534
  execute: async (args, { postMcp }) => {
1531
1535
  const a = args;
@@ -1539,6 +1543,8 @@ export const TOOL_DEFINITIONS = [
1539
1543
  body.plans_folder_path = plans;
1540
1544
  if (repo)
1541
1545
  body.repository_full_name = repo;
1546
+ if (a.plansBranch !== undefined)
1547
+ body.plansBranch = a.plansBranch.trim();
1542
1548
  return postMcp("/api/mcp/update_git_folder_mapping", body);
1543
1549
  },
1544
1550
  },
@@ -1616,6 +1622,112 @@ export const TOOL_DEFINITIONS = [
1616
1622
  });
1617
1623
  },
1618
1624
  },
1625
+ {
1626
+ kebab: "get-my-tasks",
1627
+ description: "List a team member's open QA work in this project: manual test scenarios assigned to them in test runs " +
1628
+ "(with testRunUrl), issues assigned to them (status, severity, due date, issueUrl), and SmartTests they " +
1629
+ "authored that still await verification. With an OAuth token the user is the token's user; with an API key " +
1630
+ "pass userId. Use for daily reminders and 'what should I work on' questions.",
1631
+ inputSchema: S.getMyTasksInput,
1632
+ execute: async (args, { postMcp }) => {
1633
+ const a = args;
1634
+ return postMcp("/api/mcp/get_my_tasks", a.userId ? { userId: a.userId.trim() } : {});
1635
+ },
1636
+ },
1637
+ {
1638
+ kebab: "list-tests-awaiting-verification",
1639
+ description: "List SmartTests whose latest executions still need human verification before they earn a verified badge " +
1640
+ "(testId, testName, filePath, scenarioId / scenarioOrdinalId / scenarioTitle, verificationStatus NOT_VERIFIED " +
1641
+ "or VERIFIED_STALE, reportedAtMillis, workflowExecutionId). " +
1642
+ "Optional userId narrows to tests authored for that user (defaults to the OAuth token's user); optional limit. " +
1643
+ "Use after writing E2E tests to prompt the user to verify the executions.",
1644
+ inputSchema: S.listTestsAwaitingVerificationInput,
1645
+ execute: async (args, { postMcp }) => {
1646
+ const a = args;
1647
+ const body = {};
1648
+ if (a.userId)
1649
+ body.userId = a.userId.trim();
1650
+ if (a.limit != null)
1651
+ body.limit = a.limit;
1652
+ return postMcp("/api/mcp/list_tests_awaiting_verification", body);
1653
+ },
1654
+ },
1655
+ {
1656
+ kebab: "get-qa-posture",
1657
+ description: "Project-wide QA posture snapshot: releases (version, lifecycleStatus, due date, passed / failed / blocked / " +
1658
+ "not-attempted counts), issues (active, inProgress, blocked, openBySeverity), activeTestRuns with result " +
1659
+ "counts, and testsAwaitingVerificationCount. Use for weekly " +
1660
+ "QA digests and release-health questions; summarise for the reader's role rather than dumping raw JSON.",
1661
+ inputSchema: S.emptyInput,
1662
+ execute: async (_args, { postMcp }) => postMcp("/api/mcp/get_qa_posture", {}),
1663
+ },
1664
+ {
1665
+ kebab: "get-bot-compat",
1666
+ description: "Return the minimum testchimp skill version, minimum @testchimp/cli version, and the bot event schema " +
1667
+ "version this TestChimp deployment requires from QA bots. Call on bot startup / daily; if the local skill or " +
1668
+ "CLI is older, ask the user to approve an upgrade.",
1669
+ inputSchema: S.emptyInput,
1670
+ execute: async (_args, { postMcp }) => postMcp("/api/mcp/get_bot_compat", {}),
1671
+ },
1672
+ {
1673
+ kebab: "get-bot-profile",
1674
+ description: "Fetch this QA bot's registration: botId, project, the team member it represents (userId), platform, status, " +
1675
+ "role, responsibilities, capabilities, event subscriptions, paused flag, and webhook health. botId defaults to " +
1676
+ "the bot-id header (TESTCHIMP_BOT_ID) or the OAuth token's bot. Use to confirm identity before onboarding and " +
1677
+ "to check paused / capabilities before running routines.",
1678
+ inputSchema: S.getBotProfileInput,
1679
+ execute: async (args, { postMcp }) => {
1680
+ const a = args;
1681
+ return postMcp("/api/mcp/get_bot_profile", a.botId ? { botId: a.botId } : {});
1682
+ },
1683
+ },
1684
+ {
1685
+ kebab: "register-bot-profile",
1686
+ description: "Register or replace this QA bot's profile and event subscriptions atomically (mutating — confirm with the user " +
1687
+ "first). role: QA_LEAD | PM | QA_ENGINEER | DEVELOPER. responsibilities: the user's own words. capabilities: " +
1688
+ "REQUIREMENTS_UPDATE, E2E_AUTHORING, ISSUE_FIX, MANUAL_TEST_COORDINATION, TEST_BATCH_FIX, QA_POSTURE. " +
1689
+ "subscriptions: [{eventType, filters:[{field, op:'eq', value}]}] where value may be 'me' (e.g. git-push " +
1690
+ "author=me, issue-assigned assignee=me, e2e-batch-completed). Derive subscriptions from capabilities per the " +
1691
+ "testchimp skill's bot onboarding guide. botId defaults to the bot-id header / OAuth token.",
1692
+ inputSchema: S.registerBotProfileInput,
1693
+ execute: async (args, { postMcp }) => {
1694
+ const a = args;
1695
+ const body = {
1696
+ role: a.role,
1697
+ responsibilities: a.responsibilities?.trim() ?? "",
1698
+ capabilities: a.capabilities,
1699
+ subscriptions: a.subscriptions.map((s) => ({
1700
+ eventType: s.eventType.trim(),
1701
+ filters: (s.filters ?? []).map((f) => ({ field: f.field.trim(), op: f.op, value: f.value.trim() })),
1702
+ })),
1703
+ };
1704
+ if (a.botId)
1705
+ body.botId = a.botId;
1706
+ return postMcp("/bots/register_profile", body);
1707
+ },
1708
+ },
1709
+ {
1710
+ kebab: "ack-bot-events",
1711
+ description: "Acknowledge QA-bot webhook events by eventId (1-100 per call) so TestChimp stops redelivering them. Ack every " +
1712
+ "event you handled or decided to ignore (including expired / irrelevant ones); re-acking is safe. Pass the " +
1713
+ "delivery's ackUrl when present (defaults to the ingress /bot/events/ack). Returns per-id status: " +
1714
+ "BOT_ACK_ACCEPTED / BOT_ACK_ACKED / BOT_ACK_ALREADY_ACKED / BOT_ACK_EXPIRED_RECORDED are fine; " +
1715
+ "BOT_ACK_UNKNOWN_EVENT / BOT_ACK_NOT_A_TARGET / BOT_ACK_MISSING_BOT_ID indicate a wrong id or bot identity.",
1716
+ inputSchema: S.ackBotEventsInput,
1717
+ execute: async (args, ctx) => {
1718
+ const a = args;
1719
+ const post = ctx.postIngress ?? postIngress;
1720
+ const eventIds = Array.from(new Set(a.eventIds));
1721
+ try {
1722
+ return await post(a.ackUrl?.trim() || BOT_ACK_PATH, { eventIds });
1723
+ }
1724
+ catch (e) {
1725
+ if (e instanceof TestChimpHttpError && e.status === 404)
1726
+ throw new Error(BOT_ACK_UNSUPPORTED_MESSAGE);
1727
+ throw e;
1728
+ }
1729
+ },
1730
+ },
1619
1731
  ];
1620
1732
  const TOOL_BY_KEBAB = new Map(TOOL_DEFINITIONS.map((t) => [t.kebab, t]));
1621
1733
  export function getToolDefinition(kebab) {
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { TestChimpHttpError } from "./client.js";
3
4
  import { getToolDefinition, runTool } from "./tools.js";
4
5
  const runtimeObservation = {
5
6
  windowStartMillis: "1789497600000",
@@ -163,3 +164,96 @@ describe("list-meeting-filter-options", () => {
163
164
  assert.deepEqual(request, { path: "/api/mcp/list_meeting_filter_options", body: {} });
164
165
  });
165
166
  });
167
+ describe("ack-bot-events", () => {
168
+ it("posts deduped eventIds to the delivery ackUrl", async () => {
169
+ let request;
170
+ const response = JSON.stringify({ results: [{ eventId: "e1", status: "BOT_ACK_ACKED" }] });
171
+ const result = await runTool("ack-bot-events", { eventIds: ["e1", "e1", "e2"], ackUrl: "https://ingress.testchimp.io/bot/events/ack" }, {
172
+ postMcp: async () => assert.fail("ack must not go to featureservice"),
173
+ postIngress: async (pathOrUrl, body) => {
174
+ request = { pathOrUrl, body };
175
+ return response;
176
+ },
177
+ });
178
+ assert.deepEqual(request, {
179
+ pathOrUrl: "https://ingress.testchimp.io/bot/events/ack",
180
+ body: { eventIds: ["e1", "e2"] },
181
+ });
182
+ assert.equal(result, response);
183
+ });
184
+ it("defaults to the ingress ack path", async () => {
185
+ let target;
186
+ await runTool("ack-bot-events", { eventIds: ["e1"] }, {
187
+ postMcp: async () => "{}",
188
+ postIngress: async (pathOrUrl) => {
189
+ target = pathOrUrl;
190
+ return "{}";
191
+ },
192
+ });
193
+ assert.equal(target, "/bot/events/ack");
194
+ });
195
+ it("explains a 404 from an older backend", async () => {
196
+ await assert.rejects(runTool("ack-bot-events", { eventIds: ["e1"] }, {
197
+ postMcp: async () => "{}",
198
+ postIngress: async () => {
199
+ throw new TestChimpHttpError(404, "Not Found", "");
200
+ },
201
+ }), /does not support bot acks yet/);
202
+ });
203
+ it("validates eventIds count and characters", async () => {
204
+ const ctx = { postMcp: async () => "{}", postIngress: async () => "{}" };
205
+ await assert.rejects(runTool("ack-bot-events", { eventIds: [] }, ctx), /Invalid input/);
206
+ const tooMany = Array.from({ length: 101 }, (_, i) => `e${i}`);
207
+ await assert.rejects(runTool("ack-bot-events", { eventIds: tooMany }, ctx), /Invalid input/);
208
+ await assert.rejects(runTool("ack-bot-events", { eventIds: ["é"] }, ctx), /Invalid input/);
209
+ });
210
+ });
211
+ describe("bot tools", () => {
212
+ async function capture(kebab, args) {
213
+ let request;
214
+ await runTool(kebab, args, {
215
+ postMcp: async (path, body) => {
216
+ request = { path, body };
217
+ return "{}";
218
+ },
219
+ });
220
+ return request;
221
+ }
222
+ it("maps read tools to their endpoints", async () => {
223
+ assert.deepEqual(await capture("get-my-tasks", { userId: "u1" }), {
224
+ path: "/api/mcp/get_my_tasks",
225
+ body: { userId: "u1" },
226
+ });
227
+ assert.deepEqual(await capture("get-my-tasks", {}), { path: "/api/mcp/get_my_tasks", body: {} });
228
+ assert.deepEqual(await capture("list-tests-awaiting-verification", { limit: 5 }), {
229
+ path: "/api/mcp/list_tests_awaiting_verification",
230
+ body: { limit: 5 },
231
+ });
232
+ assert.deepEqual(await capture("get-qa-posture", {}), { path: "/api/mcp/get_qa_posture", body: {} });
233
+ assert.deepEqual(await capture("get-bot-compat", {}), { path: "/api/mcp/get_bot_compat", body: {} });
234
+ assert.deepEqual(await capture("get-bot-profile", {}), { path: "/api/mcp/get_bot_profile", body: {} });
235
+ });
236
+ it("registers a bot profile with default filter op", async () => {
237
+ assert.deepEqual(await capture("register-bot-profile", {
238
+ role: "QA_ENGINEER",
239
+ responsibilities: " checkout flows ",
240
+ capabilities: ["E2E_AUTHORING", "TEST_BATCH_FIX"],
241
+ subscriptions: [
242
+ { eventType: "git-push", filters: [{ field: "author", value: "me" }] },
243
+ { eventType: "e2e-batch-completed" },
244
+ ],
245
+ }), {
246
+ path: "/bots/register_profile",
247
+ body: {
248
+ role: "QA_ENGINEER",
249
+ responsibilities: "checkout flows",
250
+ capabilities: ["E2E_AUTHORING", "TEST_BATCH_FIX"],
251
+ subscriptions: [
252
+ { eventType: "git-push", filters: [{ field: "author", op: "eq", value: "me" }] },
253
+ { eventType: "e2e-batch-completed", filters: [] },
254
+ ],
255
+ },
256
+ });
257
+ await assert.rejects(capture("register-bot-profile", { role: "CEO" }), /Invalid input/);
258
+ });
259
+ });
@@ -0,0 +1,14 @@
1
+ import { type Server } from "node:http";
2
+ export declare const MAX_MCP_BODY_BYTES: number;
3
+ export interface McpHttpServerOptions {
4
+ port: number;
5
+ host: string;
6
+ }
7
+ /**
8
+ * Unverified peek at a JWT `exp` so expired access tokens get an HTTP 401 (which makes MCP clients
9
+ * refresh) instead of a tool-level error. Signature verification stays with TestChimp.
10
+ */
11
+ export declare function isExpiredJwt(token: string, nowSeconds?: number): boolean;
12
+ /** Remote MCP (Streamable HTTP, stateless). Each caller's bearer is forwarded to TestChimp; env credentials are never used. */
13
+ export declare function createMcpHttpServer(): Server;
14
+ export declare function runMcpHttpServer(options: McpHttpServerOptions): Promise<Server>;
@@ -0,0 +1,169 @@
1
+ import { createServer } from "node:http";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { getBackendUrl, runWithRequestAuth } from "../core/client.js";
4
+ import { createMcpServer } from "./server.js";
5
+ export const MAX_MCP_BODY_BYTES = 1024 * 1024;
6
+ const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
7
+ class BodyTooLargeError extends Error {
8
+ }
9
+ function firstHeader(value) {
10
+ return Array.isArray(value) ? value[0] : value;
11
+ }
12
+ /** Public origin of this server: TESTCHIMP_MCP_PUBLIC_URL, else derived from the request (Cloud Run sets X-Forwarded-Proto). */
13
+ function publicBaseUrl(req) {
14
+ const configured = process.env.TESTCHIMP_MCP_PUBLIC_URL?.trim();
15
+ if (configured)
16
+ return configured.replace(/\/+$/, "").replace(/\/mcp$/, "");
17
+ const proto = firstHeader(req.headers["x-forwarded-proto"])?.split(",")[0].trim() === "https" ? "https" : "http";
18
+ const host = firstHeader(req.headers["x-forwarded-host"])?.split(",")[0].trim() || req.headers.host || "localhost";
19
+ return `${proto}://${host}`;
20
+ }
21
+ function authorizationServer() {
22
+ return process.env.TESTCHIMP_OAUTH_ISSUER?.trim().replace(/\/+$/, "") || getBackendUrl();
23
+ }
24
+ function sendJson(res, status, body, headers = {}) {
25
+ const payload = JSON.stringify(body);
26
+ res.writeHead(status, {
27
+ "Content-Type": "application/json",
28
+ "Content-Length": Buffer.byteLength(payload),
29
+ ...headers,
30
+ });
31
+ res.end(payload);
32
+ }
33
+ function jsonRpcError(res, status, message, headers = {}) {
34
+ sendJson(res, status, { jsonrpc: "2.0", error: { code: -32000, message }, id: null }, headers);
35
+ }
36
+ function bearerToken(req) {
37
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(firstHeader(req.headers.authorization) ?? "");
38
+ return match?.[1];
39
+ }
40
+ /**
41
+ * Unverified peek at a JWT `exp` so expired access tokens get an HTTP 401 (which makes MCP clients
42
+ * refresh) instead of a tool-level error. Signature verification stays with TestChimp.
43
+ */
44
+ export function isExpiredJwt(token, nowSeconds = Math.floor(Date.now() / 1000)) {
45
+ const parts = token.split(".");
46
+ if (parts.length !== 3)
47
+ return false;
48
+ try {
49
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
50
+ return typeof payload.exp === "number" && payload.exp <= nowSeconds;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ async function readJsonBody(req) {
57
+ const chunks = [];
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
61
+ size += buf.length;
62
+ if (size > MAX_MCP_BODY_BYTES)
63
+ throw new BodyTooLargeError();
64
+ chunks.push(buf);
65
+ }
66
+ const text = Buffer.concat(chunks).toString("utf8");
67
+ return text.trim() === "" ? undefined : JSON.parse(text);
68
+ }
69
+ async function handleMcpPost(req, res) {
70
+ const token = bearerToken(req);
71
+ if (!token) {
72
+ const metadataUrl = `${publicBaseUrl(req)}${PROTECTED_RESOURCE_PATH}`;
73
+ jsonRpcError(res, 401, "Unauthorized: Authorization: Bearer <token> is required", {
74
+ "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`,
75
+ });
76
+ return;
77
+ }
78
+ if (isExpiredJwt(token)) {
79
+ const metadataUrl = `${publicBaseUrl(req)}${PROTECTED_RESOURCE_PATH}`;
80
+ jsonRpcError(res, 401, "Unauthorized: access token expired", {
81
+ "WWW-Authenticate": `Bearer error="invalid_token", error_description="expired", resource_metadata="${metadataUrl}"`,
82
+ });
83
+ return;
84
+ }
85
+ const declaredLength = Number(firstHeader(req.headers["content-length"]) ?? 0);
86
+ if (declaredLength > MAX_MCP_BODY_BYTES) {
87
+ jsonRpcError(res, 413, "Request body too large");
88
+ return;
89
+ }
90
+ let body;
91
+ try {
92
+ body = await readJsonBody(req);
93
+ }
94
+ catch (e) {
95
+ if (e instanceof BodyTooLargeError)
96
+ jsonRpcError(res, 413, "Request body too large");
97
+ else
98
+ jsonRpcError(res, 400, "Parse error: request body must be JSON");
99
+ return;
100
+ }
101
+ const server = createMcpServer();
102
+ const transport = new StreamableHTTPServerTransport({
103
+ sessionIdGenerator: undefined,
104
+ enableJsonResponse: true,
105
+ });
106
+ res.on("close", () => {
107
+ void transport.close();
108
+ void server.close();
109
+ });
110
+ const botId = firstHeader(req.headers["bot-id"])?.trim() || undefined;
111
+ await runWithRequestAuth({ bearerToken: token, botId, isolated: true }, async () => {
112
+ await server.connect(transport);
113
+ await transport.handleRequest(req, res, body);
114
+ });
115
+ }
116
+ async function route(req, res) {
117
+ const path = new URL(req.url ?? "/", "http://localhost").pathname.replace(/\/+$/, "") || "/";
118
+ if (path === "/healthz") {
119
+ sendJson(res, 200, { status: "ok" });
120
+ return;
121
+ }
122
+ if (path === PROTECTED_RESOURCE_PATH || path === `${PROTECTED_RESOURCE_PATH}/mcp`) {
123
+ if (req.method !== "GET" && req.method !== "HEAD") {
124
+ sendJson(res, 405, { error: "Method not allowed" }, { Allow: "GET" });
125
+ return;
126
+ }
127
+ // RFC 9728 protected-resource metadata keys are spec-mandated snake_case.
128
+ sendJson(res, 200, {
129
+ resource: `${publicBaseUrl(req)}/mcp`,
130
+ authorization_servers: [authorizationServer()],
131
+ bearer_methods_supported: ["header"],
132
+ scopes_supported: ["testchimp"],
133
+ });
134
+ return;
135
+ }
136
+ if (path === "/mcp") {
137
+ if (req.method === "POST") {
138
+ await handleMcpPost(req, res);
139
+ return;
140
+ }
141
+ jsonRpcError(res, 405, "Method not allowed (stateless server: use POST)", { Allow: "POST" });
142
+ return;
143
+ }
144
+ sendJson(res, 404, { error: "Not found" });
145
+ }
146
+ /** Remote MCP (Streamable HTTP, stateless). Each caller's bearer is forwarded to TestChimp; env credentials are never used. */
147
+ export function createMcpHttpServer() {
148
+ return createServer((req, res) => {
149
+ route(req, res).catch((e) => {
150
+ console.error(`[testchimp mcp] request failed: ${e instanceof Error ? e.message : String(e)}`);
151
+ if (!res.headersSent)
152
+ jsonRpcError(res, 500, "Internal server error");
153
+ else
154
+ res.end();
155
+ });
156
+ });
157
+ }
158
+ export async function runMcpHttpServer(options) {
159
+ const server = createMcpHttpServer();
160
+ await new Promise((resolve, reject) => {
161
+ server.once("error", reject);
162
+ server.listen(options.port, options.host, () => resolve());
163
+ });
164
+ console.error(`[testchimp mcp] Streamable HTTP listening on http://${options.host}:${options.port}/mcp`);
165
+ const shutdown = () => server.close(() => process.exit(0));
166
+ process.once("SIGTERM", shutdown);
167
+ process.once("SIGINT", shutdown);
168
+ return server;
169
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, afterEach, before, describe, it } from "node:test";
3
+ import { createMcpHttpServer, isExpiredJwt } from "./httpServer.js";
4
+ const originalFetch = globalThis.fetch;
5
+ const originalEnv = { ...process.env };
6
+ let server;
7
+ let baseUrl;
8
+ before(async () => {
9
+ server = createMcpHttpServer();
10
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
11
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
12
+ });
13
+ after(async () => {
14
+ await new Promise((resolve) => server.close(() => resolve()));
15
+ });
16
+ afterEach(() => {
17
+ globalThis.fetch = originalFetch;
18
+ process.env = { ...originalEnv };
19
+ });
20
+ describe("isExpiredJwt", () => {
21
+ const jwt = (payload) => `e30.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.sig`;
22
+ it("flags expired tokens and passes live or opaque ones", () => {
23
+ assert.equal(isExpiredJwt(jwt({ exp: 100 }), 200), true);
24
+ assert.equal(isExpiredJwt(jwt({ exp: 300 }), 200), false);
25
+ assert.equal(isExpiredJwt(jwt({}), 200), false);
26
+ assert.equal(isExpiredJwt("opaque-token", 200), false);
27
+ });
28
+ });
29
+ describe("remote MCP over HTTP", () => {
30
+ it("serves /healthz", async () => {
31
+ const res = await originalFetch(`${baseUrl}/healthz`);
32
+ assert.equal(res.status, 200);
33
+ });
34
+ it("rejects /mcp without a bearer and points at the resource metadata", async () => {
35
+ process.env.TESTCHIMP_MCP_PUBLIC_URL = "https://mcp.example.com";
36
+ const res = await originalFetch(`${baseUrl}/mcp`, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
40
+ });
41
+ assert.equal(res.status, 401);
42
+ assert.equal(res.headers.get("www-authenticate"), 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"');
43
+ });
44
+ it("returns 405 for GET /mcp in stateless mode", async () => {
45
+ const res = await originalFetch(`${baseUrl}/mcp`, { headers: { Authorization: "Bearer t" } });
46
+ assert.equal(res.status, 405);
47
+ });
48
+ it("publishes RFC 9728 protected-resource metadata", async () => {
49
+ process.env.TESTCHIMP_BACKEND_URL = "https://featureservice-staging.testchimp.io";
50
+ delete process.env.TESTCHIMP_MCP_PUBLIC_URL;
51
+ delete process.env.TESTCHIMP_OAUTH_ISSUER;
52
+ for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
53
+ const res = await originalFetch(`${baseUrl}${path}`, { headers: { "X-Forwarded-Proto": "https" } });
54
+ assert.equal(res.status, 200);
55
+ const host = new URL(baseUrl).host;
56
+ assert.deepEqual(await res.json(), {
57
+ resource: `https://${host}/mcp`,
58
+ authorization_servers: ["https://featureservice-staging.testchimp.io"],
59
+ bearer_methods_supported: ["header"],
60
+ scopes_supported: ["testchimp"],
61
+ });
62
+ }
63
+ });
64
+ it("rejects oversized bodies", async () => {
65
+ const res = await originalFetch(`${baseUrl}/mcp`, {
66
+ method: "POST",
67
+ headers: { Authorization: "Bearer t", "Content-Type": "application/json" },
68
+ body: JSON.stringify({ pad: "x".repeat(1024 * 1024 + 10) }),
69
+ });
70
+ assert.equal(res.status, 413);
71
+ });
72
+ it("forwards the caller's bearer (never the server API key) on tool calls", async () => {
73
+ process.env.TESTCHIMP_BACKEND_URL = "https://featureservice.example.com";
74
+ process.env.TESTCHIMP_API_KEY = "server-key";
75
+ process.env.TESTCHIMP_OAUTH_TOKEN = "server-token";
76
+ const upstream = [];
77
+ globalThis.fetch = (async (input, init) => {
78
+ const url = String(input);
79
+ if (url.startsWith(baseUrl))
80
+ return originalFetch(input, init);
81
+ upstream.push({ url, headers: init?.headers });
82
+ return new Response(JSON.stringify({ minSkillVersion: "1.0.53", minCliVersion: "0.1.85", eventSchemaVersion: 1 }));
83
+ });
84
+ const res = await originalFetch(`${baseUrl}/mcp`, {
85
+ method: "POST",
86
+ headers: {
87
+ Authorization: "Bearer caller-jwt",
88
+ "Content-Type": "application/json",
89
+ Accept: "application/json, text/event-stream",
90
+ },
91
+ body: JSON.stringify({
92
+ jsonrpc: "2.0",
93
+ id: 7,
94
+ method: "tools/call",
95
+ params: { name: "get-bot-compat", arguments: {} },
96
+ }),
97
+ });
98
+ assert.equal(res.status, 200);
99
+ const payload = (await res.json());
100
+ assert.equal(JSON.parse(payload.result?.content?.[0]?.text ?? "{}").minCliVersion, "0.1.85");
101
+ assert.deepEqual(upstream, [
102
+ {
103
+ url: "https://featureservice.example.com/api/mcp/get_bot_compat",
104
+ headers: { "Content-Type": "application/json", Authorization: "Bearer caller-jwt" },
105
+ },
106
+ ]);
107
+ });
108
+ });
@@ -1 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /** McpServer with every TestChimp tool registered (shared by stdio and HTTP transports). */
3
+ export declare function createMcpServer(): McpServer;
1
4
  export declare function runMcpServer(): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { postMcp } from "../core/client.js";
3
+ import { postIngress, postMcp } from "../core/client.js";
4
4
  import { TOOL_DEFINITIONS, runTool } from "../core/tools.js";
5
5
  import { PACKAGE_VERSION } from "../core/version.js";
6
6
  function textResult(json) {
@@ -8,7 +8,8 @@ function textResult(json) {
8
8
  content: [{ type: "text", text: json }],
9
9
  };
10
10
  }
11
- export async function runMcpServer() {
11
+ /** McpServer with every TestChimp tool registered (shared by stdio and HTTP transports). */
12
+ export function createMcpServer() {
12
13
  const server = new McpServer({ name: "testchimp", version: PACKAGE_VERSION }, { capabilities: { tools: {}, logging: {} } });
13
14
  for (const def of TOOL_DEFINITIONS) {
14
15
  server.registerTool(def.kebab, {
@@ -27,11 +28,16 @@ export async function runMcpServer() {
27
28
  : undefined;
28
29
  const json = await runTool(def.kebab, args ?? {}, {
29
30
  postMcp,
31
+ postIngress,
30
32
  onProgress,
31
33
  });
32
34
  return textResult(json);
33
35
  });
34
36
  }
37
+ return server;
38
+ }
39
+ export async function runMcpServer() {
40
+ const server = createMcpServer();
35
41
  const transport = new StdioServerTransport();
36
42
  await server.connect(transport);
37
43
  }
@@ -0,0 +1,18 @@
1
+ /** Local git inspection for `testchimp workspace map` (same rules as TestChimp Studio folder mapping). */
2
+ /** Normalize a git remote URL to `owner/repo` (or GitLab nested path); host is ignored. */
3
+ export declare function normalizeGitRemoteToFullName(remoteUrl: string | undefined | null): string | null;
4
+ /** Case-insensitive compare of repositoryFullName values. */
5
+ export declare function repositoryFullNamesMatch(expected: string | undefined | null, actual: string | undefined | null): boolean;
6
+ export type LocalGitRepoInspection = {
7
+ selectedPath: string;
8
+ /** Canonical git toplevel, or null if not a work tree. */
9
+ toplevelPath: string | null;
10
+ isRepoRoot: boolean;
11
+ remoteFullNames: string[];
12
+ };
13
+ export declare function inspectLocalGitRepo(folderPath: string): LocalGitRepoInspection;
14
+ /**
15
+ * Throw a user-visible error unless `inspection` is a git work tree; when `expectedRepositoryFullName`
16
+ * is known, also require the repository root and a matching remote.
17
+ */
18
+ export declare function assertFolderMatchesRepo(inspection: LocalGitRepoInspection, expectedRepositoryFullName: string | null): void;