diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d50b3af..c12f7c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,10 @@ jobs: working-directory: ./mcp-server run: npm ci + - name: Build + working-directory: ./mcp-server + run: npm run build + - name: Run tests working-directory: ./mcp-server run: npm run test:run diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index d97934f..4e75f25 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased (2026-04-20) + +### Added + +* MCP tool `currents-get-context` for `GET /context` (test failure context for AI debugging). + +### Fixed + +* `currents-list-affected-tests`: serialize `action_type` query params as `action_type[]` to match OpenAPI. +* `currents-update-webhook`: require at least one updatable field so the HTTP request body is never empty (matches OpenAPI `requestBody.required: true`). +* `postApi`: treat HTTP 201 and empty JSON bodies like other successful POST responses. + ## [2.2.5](https://github.com/currents-dev/currents-mcp/compare/v2.2.4...v2.2.5) (2026-02-05) ### Bug Fixes @@ -10,7 +22,9 @@ ### Documentation -* add comprehensive parity analysis documentation ([#47](https://github.com/currents-dev/currents-mcp/pull/47))## [2.2.4](https://github.com/currents-dev/currents-mcp/compare/v2.2.3...v2.2.4) (2026-01-27) +* add comprehensive parity analysis documentation ([#47](https://github.com/currents-dev/currents-mcp/pull/47)) + +## [2.2.4](https://github.com/currents-dev/currents-mcp/compare/v2.2.3...v2.2.4) (2026-01-27) ## [2.2.3](https://github.com/currents-dev/currents-mcp/compare/v2.2.1...v2.2.3) (2026-01-27) diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index ae1e088..0aae149 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -35,6 +35,7 @@ import { getTestsPerformanceTool } from "./tools/tests/get-tests-performance.js" import { getTestSignatureTool } from "./tools/tests/get-tests-signature.js"; // Errors tools import { getErrorsExplorerTool } from "./tools/errors/get-errors-explorer.js"; +import { getContextTool } from "./tools/context/get-context.js"; // Webhooks tools import { listWebhooksTool } from "./tools/webhooks/list-webhooks.js"; import { createWebhookTool } from "./tools/webhooks/create-webhook.js"; @@ -103,7 +104,7 @@ server.tool( server.tool( "currents-list-affected-tests", - "List tests affected by actions (quarantine, skip, tag) for a project within a date range. Returns aggregated data grouped by test signature. Supports filtering by action types, action ID, status, and search. Requires projectId, date_start, and date_end. (Preview endpoint: fields and path may change.)", + "List tests affected by actions (quarantine, skip, tag) for a project within a date range. Returns aggregated data grouped by test signature. Supports filtering by action types, action ID, status, and search. Requires projectId, date_start, and date_end.", listAffectedTestsTool.schema, listAffectedTestsTool.handler ); @@ -239,6 +240,13 @@ server.tool( getErrorsExplorerTool.handler ); +server.tool( + "currents-get-context", + "Retrieve structured test failure context for AI debugging (GET /context). Supports run-level (run_id only), instance-level (run_id + instance_id), or test-level (instance_id + test_id). Optional format json|md, detail default|compact|summary, and pagination limit/page for run/instance levels.", + getContextTool.schema, + getContextTool.handler +); + // Webhooks API tools server.tool( "currents-list-webhooks", diff --git a/mcp-server/src/lib/request.ts b/mcp-server/src/lib/request.ts index d9c2afb..b0ade34 100644 --- a/mcp-server/src/lib/request.ts +++ b/mcp-server/src/lib/request.ts @@ -49,7 +49,14 @@ export async function postApi(path: string, body: B): Promise { logger.error(response); return null; } - return (await response.json()) as T; + if (response.status === 204 || response.headers.get("content-length") === "0") { + return {} as T; + } + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return (await response.json()) as T; + } + return {} as T; } catch (error: any) { logger.error("Error making Currents POST request:", error.toString()); return null; diff --git a/mcp-server/src/tools/actions/list-affected-tests.test.ts b/mcp-server/src/tools/actions/list-affected-tests.test.ts new file mode 100644 index 0000000..df963db --- /dev/null +++ b/mcp-server/src/tools/actions/list-affected-tests.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as request from "../../lib/request.js"; +import { listAffectedTestsTool } from "./list-affected-tests.js"; + +vi.mock("../../lib/request.js"); + +describe("listAffectedTestsTool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("serializes action_type as action_type[] per OpenAPI", async () => { + vi.spyOn(request, "fetchApi").mockResolvedValue({ status: "OK", data: [] }); + + await listAffectedTestsTool.handler({ + projectId: "p1", + date_start: "2026-01-01", + date_end: "2026-01-02", + action_type: ["skip", "tag"], + }); + + expect(request.fetchApi).toHaveBeenCalledWith( + expect.stringContaining("action_type%5B%5D=skip") + ); + expect(request.fetchApi).toHaveBeenCalledWith( + expect.stringContaining("action_type%5B%5D=tag") + ); + }); +}); diff --git a/mcp-server/src/tools/actions/list-affected-tests.ts b/mcp-server/src/tools/actions/list-affected-tests.ts index fabd3bd..c438242 100644 --- a/mcp-server/src/tools/actions/list-affected-tests.ts +++ b/mcp-server/src/tools/actions/list-affected-tests.ts @@ -74,7 +74,7 @@ const handler = async ({ } if (action_type && action_type.length > 0) { - action_type.forEach((t) => queryParams.append("action_type", t)); + action_type.forEach((t) => queryParams.append("action_type[]", t)); } if (action_id) { diff --git a/mcp-server/src/tools/actions/update-action.ts b/mcp-server/src/tools/actions/update-action.ts index e83d4e9..32ec8f3 100644 --- a/mcp-server/src/tools/actions/update-action.ts +++ b/mcp-server/src/tools/actions/update-action.ts @@ -132,7 +132,7 @@ const handler = async ({ } const body: UpdateActionRequest = {}; - + if (name !== undefined) body.name = name; if (description !== undefined) body.description = description; if (action !== undefined) body.action = action; diff --git a/mcp-server/src/tools/context/get-context.test.ts b/mcp-server/src/tools/context/get-context.test.ts new file mode 100644 index 0000000..a866d30 --- /dev/null +++ b/mcp-server/src/tools/context/get-context.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../lib/env.js", () => ({ + CURRENTS_API_KEY: "k", + CURRENTS_API_URL: "https://api.test.com/v1", +})); + +const { getContextTool } = await import("./get-context.js"); + +describe("getContextTool", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ status: "OK", data: { level: "run" } }), + }) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("calls GET /context with query params for run-level", async () => { + await getContextTool.handler({ + run_id: "run-1", + format: "json", + detail: "default", + limit: 10, + page: 0, + }); + + expect(fetch).toHaveBeenCalledWith( + "https://api.test.com/v1/context?run_id=run-1&format=json&detail=default&limit=10&page=0", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer k", + Accept: "application/json", + }), + }) + ); + }); + + it("rejects test_id without instance_id", async () => { + const result = await getContextTool.handler({ + run_id: "run-1", + test_id: "t1", + } as any); + + expect(result.content[0].type).toBe("text"); + expect(String((result.content[0] as { text: string }).text)).toContain( + "Invalid parameters" + ); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/mcp-server/src/tools/context/get-context.ts b/mcp-server/src/tools/context/get-context.ts new file mode 100644 index 0000000..7ffbcef --- /dev/null +++ b/mcp-server/src/tools/context/get-context.ts @@ -0,0 +1,205 @@ +import { z } from "zod"; +import { CURRENTS_API_KEY, CURRENTS_API_URL } from "../../lib/env.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z + .object({ + run_id: z + .string() + .optional() + .describe( + "Run identifier. Required for run-level (run_id only) and instance-level (run_id + instance_id, no test_id). Omit for test-level (instance_id + test_id)." + ), + instance_id: z + .string() + .optional() + .describe( + "Instance identifier. Required for instance-level and test-level. Omit for run-level (use run_id only)." + ), + test_id: z + .string() + .optional() + .describe( + "Test identifier. When set, selects test-level detail and requires instance_id. run_id is not required in this case." + ), + attempt: z + .number() + .int() + .optional() + .describe("Attempt number (0-indexed); defaults to latest."), + format: z + .enum(["json", "md"]) + .optional() + .describe("Response format. Falls back to Accept header when absent. Defaults to json."), + detail: z + .enum(["default", "compact", "summary"]) + .optional() + .describe( + "Controls output verbosity. default returns all available data; compact omits full steps and limits assets; summary minimizes output. Defaults to default." + ), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe( + "Maximum number of failed tests per page (run-level and instance-level only). Default 10." + ), + page: z + .number() + .int() + .min(0) + .optional() + .describe( + "Page number for failed tests pagination, 0-indexed (run-level and instance-level only). Default 0." + ), + max_length: z + .number() + .int() + .min(1) + .max(50000) + .optional() + .describe( + "Truncate markdown response to this character limit (only applies when format=md)." + ), + }) + .superRefine((val, ctx) => { + const { run_id, instance_id, test_id } = val; + if (test_id) { + if (!instance_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "test_id requires instance_id", + path: ["instance_id"], + }); + } + return; + } + if (instance_id) { + if (!run_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "instance-level context requires run_id when test_id is omitted", + path: ["run_id"], + }); + } + return; + } + if (!run_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "run-level context requires run_id", + path: ["run_id"], + }); + } + }); + +const handler = async (args: z.infer) => { + const parsed = zodSchema.safeParse(args); + if (!parsed.success) { + const message = parsed.error.issues + .map((i) => `${i.path.join(".") || "input"}: ${i.message}`) + .join("; "); + return { + content: [ + { + type: "text" as const, + text: `Invalid parameters: ${message}`, + }, + ], + }; + } + + const { + run_id, + instance_id, + test_id, + attempt, + format = "json", + detail = "default", + limit = 10, + page = 0, + max_length, + } = parsed.data; + + const queryParams = new URLSearchParams(); + if (run_id) queryParams.append("run_id", run_id); + if (instance_id) queryParams.append("instance_id", instance_id); + if (test_id) queryParams.append("test_id", test_id); + if (attempt !== undefined) queryParams.append("attempt", attempt.toString()); + queryParams.append("format", format); + queryParams.append("detail", detail); + queryParams.append("limit", limit.toString()); + queryParams.append("page", page.toString()); + if (max_length !== undefined) { + queryParams.append("max_length", max_length.toString()); + } + + const accept = + format === "md" ? "text/markdown, application/json;q=0.1" : "application/json"; + + const headers: Record = { + "User-Agent": "currents-app/1.0", + Accept: accept, + Authorization: "Bearer " + CURRENTS_API_KEY, + }; + + const url = `${CURRENTS_API_URL}/context?${queryParams.toString()}`; + logger.info(`Fetching failure context: ${url}`); + + try { + const response = await fetch(url, { headers }); + if (!response.ok) { + logger.error(`HTTP error! status: ${response.status}`); + return { + content: [ + { + type: "text" as const, + text: `Failed to retrieve context (HTTP ${response.status})`, + }, + ], + }; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; + } + + const text = await response.text(); + return { + content: [ + { + type: "text" as const, + text, + }, + ], + }; + } catch (error: unknown) { + logger.error( + `Error making Currents context request: ${String(error)}` + ); + return { + content: [ + { + type: "text" as const, + text: "Failed to retrieve context", + }, + ], + }; + } +}; + +export const getContextTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/webhooks/update-webhook.ts b/mcp-server/src/tools/webhooks/update-webhook.ts index 1187a45..ab92c33 100644 --- a/mcp-server/src/tools/webhooks/update-webhook.ts +++ b/mcp-server/src/tools/webhooks/update-webhook.ts @@ -55,6 +55,17 @@ const handler = async ({ body.label = label; } + if (Object.keys(body).length === 0) { + return { + content: [ + { + type: "text" as const, + text: "Error: At least one field to update must be provided (url, headers, hookEvents, or label).", + }, + ], + }; + } + logger.info(`Updating webhook ${hookId}`); const data = await putApi(`/webhooks/${hookId}`, body); diff --git a/mcp-server/src/tools/webhooks/webhooks.test.ts b/mcp-server/src/tools/webhooks/webhooks.test.ts index 7bbbcac..d4b8ae9 100644 --- a/mcp-server/src/tools/webhooks/webhooks.test.ts +++ b/mcp-server/src/tools/webhooks/webhooks.test.ts @@ -283,14 +283,7 @@ describe("updateWebhookTool", () => { }); }); - it("should update webhook with hookId only (no changes)", async () => { - const mockResponse = { - hookId: "webhook-123", - url: "https://example.com/webhook", - }; - - vi.spyOn(request, "putApi").mockResolvedValue(mockResponse); - + it("should reject update with hookId only (OpenAPI requires request body)", async () => { const result = await updateWebhookTool.handler({ hookId: "webhook-123", }); @@ -299,11 +292,11 @@ describe("updateWebhookTool", () => { content: [ { type: "text", - text: JSON.stringify(mockResponse, null, 2), + text: "Error: At least one field to update must be provided (url, headers, hookEvents, or label).", }, ], }); - expect(request.putApi).toHaveBeenCalledWith("/webhooks/webhook-123", {}); + expect(request.putApi).not.toHaveBeenCalled(); }); it("should return error message when API request fails", async () => {