diff --git a/README.md b/README.md index 405a2cb..eabec24 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ Give your AI coding agents full visibility into your CI test results. The Curren | `currents-get-projects` | Retrieves projects available in the Currents platform. | | `currents-get-project` | Get a single project by ID. | | `currents-get-project-insights` | Get aggregated run and test metrics for a project within a date range. | +| `currents-list-pull-requests` | List pull-request cards for a project (runs grouped by meta.pr.id). | +| `currents-list-project-terms` | List cursor-paginated project terms for one type (tag, branch, authorName, etc.). | +| `currents-create-jira-issue` | Create a Jira issue from a run test using the organization Jira integration. | +| `currents-list-jira-projects` | List Jira projects available for the organization integration. | +| `currents-list-jira-issue-types` | List Jira issue types and custom fields for a Jira project. | | `currents-get-runs` | Retrieves a list of runs for a specific project with optional filtering. | | `currents-get-run-details` | Retrieves details of a specific test run. | | `currents-find-run` | Find a run by query parameters. | @@ -40,6 +45,7 @@ Give your AI coding agents full visibility into your CI test results. The Curren | `currents-get-tests-performance` | Retrieves aggregated test metrics for a specific project within a date range. | | `currents-get-tests-signatures` | Generates a unique test signature based on project, spec file path, and test title. | | `currents-get-test-results` | Retrieves historical test execution results for a specific test signature. | +| `currents-get-context` | Get test failure context for AI debugging at run, instance, or test level. | | `currents-get-errors-explorer` | Get aggregated error metrics for a project within a date range. | | `currents-list-webhooks` | List all webhooks for a project. | | `currents-create-webhook` | Create a new webhook for a project. | diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index a661c7e..0589129 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased (2026-05-27) + +### Added + +- MCP tool `currents-get-context` for `GET /context` (registered; failure context for AI debugging). +- MCP tool `currents-list-pull-requests` for `GET /projects/{projectId}/pull-requests`. +- MCP tool `currents-list-project-terms` for `GET /projects/{projectId}/terms/{termType}`. +- MCP tool `currents-create-jira-issue` for `POST /projects/{projectId}/jira/issues`. +- MCP tool `currents-list-jira-projects` for `GET /integrations/jira/projects`. +- MCP tool `currents-list-jira-issue-types` for `GET /integrations/jira/projects/{jiraProjectId}/issue-types`. + +### Fixed + +- `currents-get-context`: export full Zod schema (preserves superRefine validation) for MCP registration. + ## [2.3.1](https://github.com/currents-dev/currents-mcp/compare/v2.3.1-beta.0...v2.3.1) (2026-05-19) - build process improvements, no user-facing changes diff --git a/mcp-server/src/server.ts b/mcp-server/src/server.ts index 1a68292..bfd4da9 100644 --- a/mcp-server/src/server.ts +++ b/mcp-server/src/server.ts @@ -16,10 +16,18 @@ import { getAffectedTestExecutionsTool } from "./tools/actions/get-affected-test import { listActionsTool } from "./tools/actions/list-actions.js"; import { listAffectedTestsTool } from "./tools/actions/list-affected-tests.js"; import { updateActionTool } from "./tools/actions/update-action.js"; +// Context tools +import { getContextTool } from "./tools/context/get-context.js"; +// Integrations tools +import { createJiraIssueFromRunTestTool } from "./tools/integrations/create-jira-issue.js"; +import { listJiraIssueTypesTool } from "./tools/integrations/list-jira-issue-types.js"; +import { listJiraProjectsTool } from "./tools/integrations/list-jira-projects.js"; // Projects tools import { getProjectInsightsTool } from "./tools/projects/get-project-insights.js"; import { getProjectTool } from "./tools/projects/get-project.js"; import { getProjectsTool } from "./tools/projects/get-projects.js"; +import { listProjectPullRequestsTool } from "./tools/projects/list-project-pull-requests.js"; +import { listProjectTermsTool } from "./tools/projects/list-project-terms.js"; // Runs tools import { cancelRunByGithubCITool } from "./tools/runs/cancel-run-github-ci.js"; import { cancelRunTool } from "./tools/runs/cancel-run.js"; @@ -194,6 +202,57 @@ server.registerTool( getProjectInsightsTool.handler, ); + +server.registerTool( + "currents-list-pull-requests", + { + description: + "List pull-request cards for a project (runs grouped by meta.pr.id). Supports cursor pagination, runs_per_pr preview count, and filters by tags, branches, authors, and latest-run status. Requires projectId.", + inputSchema: listProjectPullRequestsTool.schema, + }, + listProjectPullRequestsTool.handler, +); + +server.registerTool( + "currents-list-project-terms", + { + description: + "List cursor-paginated project terms for one type (tag, branch, authorName, etc.). Supports search, sort direction, and starting_after or ending_before cursors. Requires projectId and termType.", + inputSchema: listProjectTermsTool.schema, + }, + listProjectTermsTool.handler, +); + +server.registerTool( + "currents-create-jira-issue", + { + description: + "Create a Jira issue from a run test using the organization Jira integration. Requires projectId, runId, testId, jiraInstallationId, jiraProjectId, and jiraIssueType. Optional customFields array.", + inputSchema: createJiraIssueFromRunTestTool.schema, + }, + createJiraIssueFromRunTestTool.handler, +); + +server.registerTool( + "currents-list-jira-projects", + { + description: + "List Jira projects available for the organization integration. Use returned project IDs as jiraProjectId when creating issues. Requires jira_installation_id.", + inputSchema: listJiraProjectsTool.schema, + }, + listJiraProjectsTool.handler, +); + +server.registerTool( + "currents-list-jira-issue-types", + { + description: + "List Jira issue types and custom fields for a Jira project. Requires jiraProjectId and jira_installation_id.", + inputSchema: listJiraIssueTypesTool.schema, + }, + listJiraIssueTypesTool.handler, +); + // Runs API tools server.registerTool( "currents-get-runs", @@ -317,6 +376,18 @@ server.registerTool( getTestResultsTool.handler, ); + +// Context API tools +server.registerTool( + "currents-get-context", + { + description: + "Get test failure context for AI debugging at run, instance, or test level. Supports json or md format, detail level, and pagination for failed tests. Requires run_id for run-level, or instance_id with optional test_id.", + inputSchema: getContextTool.schema, + }, + getContextTool.handler, +); + // Errors API tools server.registerTool( "currents-get-errors-explorer", diff --git a/mcp-server/src/tools/context/get-context.ts b/mcp-server/src/tools/context/get-context.ts index 7ffbcef..b315848 100644 --- a/mcp-server/src/tools/context/get-context.ts +++ b/mcp-server/src/tools/context/get-context.ts @@ -200,6 +200,6 @@ const handler = async (args: z.infer) => { }; export const getContextTool = { - schema: zodSchema.shape, + schema: zodSchema, handler, }; diff --git a/mcp-server/src/tools/integrations/create-jira-issue.ts b/mcp-server/src/tools/integrations/create-jira-issue.ts new file mode 100644 index 0000000..d62100c --- /dev/null +++ b/mcp-server/src/tools/integrations/create-jira-issue.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { postApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const customFieldSchema = z.object({ + fieldId: z.string().min(1).describe("Jira field ID from issue type discovery."), + value: z.string().min(1).describe("String value for the Jira custom field."), +}); + +const zodSchema = z.object({ + projectId: z.string().describe("Currents project ID."), + runId: z.string().min(1).describe("Currents run ID containing the test."), + testId: z.string().min(1).describe("Test ID within the run."), + jiraInstallationId: z + .string() + .min(1) + .describe("Jira installation ID for the org integration (dashboard Installation ID)."), + jiraProjectId: z.string().min(1).describe("Jira project ID in which to create the issue."), + jiraIssueType: z.string().min(1).describe("Jira issue type ID."), + customFields: z + .array(customFieldSchema) + .optional() + .describe("Optional Jira custom fields for issue creation."), +}); + +const handler = async ({ + projectId, + runId, + testId, + jiraInstallationId, + jiraProjectId, + jiraIssueType, + customFields, +}: z.infer) => { + const body: Record = { + runId, + testId, + jiraInstallationId, + jiraProjectId, + jiraIssueType, + }; + if (customFields?.length) { + body.customFields = customFields; + } + + const path = `/projects/${encodeURIComponent(projectId)}/jira/issues`; + logger.info(`Creating Jira issue: ${path}`); + + const data = await postApi(path, body); + if (!data) { + return { + content: [{ type: "text" as const, text: "Failed to create Jira issue" }], + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +}; + +export const createJiraIssueFromRunTestTool = { + schema: zodSchema, + handler, +}; diff --git a/mcp-server/src/tools/integrations/list-jira-issue-types.ts b/mcp-server/src/tools/integrations/list-jira-issue-types.ts new file mode 100644 index 0000000..fd4a8dd --- /dev/null +++ b/mcp-server/src/tools/integrations/list-jira-issue-types.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { fetchApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + jiraProjectId: z + .string() + .min(1) + .max(128) + .describe("Jira project ID."), + jira_installation_id: z + .string() + .min(1) + .describe("Jira installation ID for the organization integration."), + search: z.string().optional().describe("Search issue types by name."), + page: z + .number() + .int() + .min(0) + .optional() + .describe("Page number for discovery results (default: 0)."), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Maximum issue types per page (default: 50, max: 100)."), +}); + +const handler = async ({ + jiraProjectId, + jira_installation_id, + search, + page, + limit, +}: z.infer) => { + const queryParams = new URLSearchParams(); + queryParams.append("jira_installation_id", jira_installation_id); + if (search) { + queryParams.append("search", search); + } + if (page !== undefined) { + queryParams.append("page", page.toString()); + } + if (limit !== undefined) { + queryParams.append("limit", limit.toString()); + } + + const path = `/integrations/jira/projects/${encodeURIComponent(jiraProjectId)}/issue-types?${queryParams.toString()}`; + logger.info(`Listing Jira issue types: ${path}`); + + const data = await fetchApi(path); + if (!data) { + return { + content: [{ type: "text" as const, text: "Failed to list Jira issue types" }], + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +}; + +export const listJiraIssueTypesTool = { + schema: zodSchema, + handler, +}; diff --git a/mcp-server/src/tools/integrations/list-jira-projects.test.ts b/mcp-server/src/tools/integrations/list-jira-projects.test.ts new file mode 100644 index 0000000..8239ad5 --- /dev/null +++ b/mcp-server/src/tools/integrations/list-jira-projects.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import * as request from "../../lib/request.js"; +import { listJiraProjectsTool } from "./list-jira-projects.js"; + +describe("listJiraProjectsTool", () => { + it("calls GET /integrations/jira/projects with required installation id", async () => { + vi.spyOn(request, "fetchApi").mockResolvedValue({ status: "OK", data: [] }); + + await listJiraProjectsTool.handler({ + jira_installation_id: "inst-1", + search: "core", + page: 1, + limit: 25, + }); + + const url = vi.mocked(request.fetchApi).mock.calls[0][0] as string; + expect(url).toBe( + "/integrations/jira/projects?jira_installation_id=inst-1&search=core&page=1&limit=25" + ); + }); +}); diff --git a/mcp-server/src/tools/integrations/list-jira-projects.ts b/mcp-server/src/tools/integrations/list-jira-projects.ts new file mode 100644 index 0000000..b845851 --- /dev/null +++ b/mcp-server/src/tools/integrations/list-jira-projects.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { fetchApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + jira_installation_id: z + .string() + .min(1) + .describe("Jira installation ID for the organization integration."), + search: z.string().optional().describe("Search Jira projects by name or key."), + page: z + .number() + .int() + .min(0) + .optional() + .describe("Page number for discovery results (default: 0)."), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Maximum projects per page (default: 50, max: 100)."), +}); + +const handler = async ({ + jira_installation_id, + search, + page, + limit, +}: z.infer) => { + const queryParams = new URLSearchParams(); + queryParams.append("jira_installation_id", jira_installation_id); + if (search) { + queryParams.append("search", search); + } + if (page !== undefined) { + queryParams.append("page", page.toString()); + } + if (limit !== undefined) { + queryParams.append("limit", limit.toString()); + } + + const path = `/integrations/jira/projects?${queryParams.toString()}`; + logger.info(`Listing Jira projects: ${path}`); + + const data = await fetchApi(path); + if (!data) { + return { + content: [{ type: "text" as const, text: "Failed to list Jira projects" }], + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +}; + +export const listJiraProjectsTool = { + schema: zodSchema, + handler, +}; diff --git a/mcp-server/src/tools/projects/list-project-pull-requests.test.ts b/mcp-server/src/tools/projects/list-project-pull-requests.test.ts new file mode 100644 index 0000000..4398d60 --- /dev/null +++ b/mcp-server/src/tools/projects/list-project-pull-requests.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import * as request from "../../lib/request.js"; +import { listProjectPullRequestsTool } from "./list-project-pull-requests.js"; + +describe("listProjectPullRequestsTool", () => { + it("serializes query per OpenAPI (repeated status, bracket arrays)", async () => { + vi.spyOn(request, "fetchApi").mockResolvedValue({ status: "OK", data: [] }); + + await listProjectPullRequestsTool.handler({ + projectId: "p1", + limit: 20, + status: ["PASSED", "FAILED"], + tags: ["smoke"], + branches: ["main"], + authors: ["dev@*"], + }); + + const url = vi.mocked(request.fetchApi).mock.calls[0][0] as string; + expect(url).toContain("/projects/p1/pull-requests?"); + expect(url).toContain("limit=20"); + expect(url).toContain("status=PASSED"); + expect(url).toContain("status=FAILED"); + expect(url).toContain("tags%5B%5D=smoke"); + expect(url).toContain("branches%5B%5D=main"); + expect(url).toContain("authors%5B%5D=dev%40"); + }); +}); diff --git a/mcp-server/src/tools/projects/list-project-pull-requests.ts b/mcp-server/src/tools/projects/list-project-pull-requests.ts new file mode 100644 index 0000000..8c91df0 --- /dev/null +++ b/mcp-server/src/tools/projects/list-project-pull-requests.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { fetchApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + projectId: z.string().describe("The project ID to list pull requests for."), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Maximum number of PR cards per page (default: 10, max: 50)."), + starting_after: z + .string() + .optional() + .describe("Cursor for forward pagination."), + ending_before: z + .string() + .optional() + .describe("Cursor for backward pagination."), + runs_per_pr: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe("Number of recent runs to preview per PR card (default: 1, max: 10)."), + tags: z + .array(z.string()) + .optional() + .describe("Filter by run tags (can be specified multiple times)."), + branches: z + .array(z.string()) + .optional() + .describe("Filter by git branch names (can be specified multiple times)."), + authors: z + .array(z.string()) + .optional() + .describe("Filter by git commit author glob patterns (can be specified multiple times)."), + tag_operator: z + .enum(["AND", "OR"]) + .optional() + .describe("Logical operator for tag filtering. AND requires all tags (default), OR requires any tag."), + status: z + .array(z.enum(["PASSED", "FAILED", "RUNNING", "FAILING"])) + .optional() + .describe("Filter PR cards by latest run status (can be specified multiple times)."), +}); + +const handler = async ({ + projectId, + limit, + starting_after, + ending_before, + runs_per_pr, + tags, + branches, + authors, + tag_operator, + status, +}: z.infer) => { + const queryParams = new URLSearchParams(); + + if (limit !== undefined) { + queryParams.append("limit", limit.toString()); + } + if (starting_after) { + queryParams.append("starting_after", starting_after); + } + if (ending_before) { + queryParams.append("ending_before", ending_before); + } + if (runs_per_pr !== undefined) { + queryParams.append("runs_per_pr", runs_per_pr.toString()); + } + if (tags?.length) { + tags.forEach((t) => queryParams.append("tags[]", t)); + } + if (branches?.length) { + branches.forEach((b) => queryParams.append("branches[]", b)); + } + if (authors?.length) { + authors.forEach((a) => queryParams.append("authors[]", a)); + } + if (tag_operator) { + queryParams.append("tag_operator", tag_operator); + } + if (status?.length) { + status.forEach((s) => queryParams.append("status", s)); + } + + const qs = queryParams.toString(); + const path = `/projects/${encodeURIComponent(projectId)}/pull-requests${qs ? `?${qs}` : ""}`; + logger.info(`Fetching pull requests: ${path}`); + + const data = await fetchApi(path); + if (!data) { + return { + content: [{ type: "text" as const, text: "Failed to retrieve pull requests" }], + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +}; + +export const listProjectPullRequestsTool = { + schema: zodSchema, + handler, +}; diff --git a/mcp-server/src/tools/projects/list-project-terms.ts b/mcp-server/src/tools/projects/list-project-terms.ts new file mode 100644 index 0000000..67a758d --- /dev/null +++ b/mcp-server/src/tools/projects/list-project-terms.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { fetchApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const termTypeEnum = z.enum([ + "tag", + "group", + "branch", + "authorName", + "authorEmail", + "framework", + "frameworkVersion", + "clientVersion", + "ann_type", + "ann_desc", +]); + +const zodSchema = z.object({ + projectId: z.string().describe("The project ID."), + termType: termTypeEnum.describe( + "Term kind to list: tag, group, branch, authorName, authorEmail, framework, frameworkVersion, clientVersion, ann_type, or ann_desc." + ), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Maximum items per page (default: 100, max: 100)."), + dir: z + .enum(["asc", "desc"]) + .optional() + .describe("Sort direction by last update time (default: desc)."), + starting_after: z.string().optional().describe("Cursor for forward pagination."), + ending_before: z.string().optional().describe("Cursor for backward pagination."), + search: z + .string() + .max(128) + .optional() + .describe("Case-insensitive search filter for term values."), +}); + +const handler = async ({ + projectId, + termType, + limit, + dir, + starting_after, + ending_before, + search, +}: z.infer) => { + const queryParams = new URLSearchParams(); + + if (limit !== undefined) { + queryParams.append("limit", limit.toString()); + } + if (dir) { + queryParams.append("dir", dir); + } + if (starting_after) { + queryParams.append("starting_after", starting_after); + } + if (ending_before) { + queryParams.append("ending_before", ending_before); + } + if (search) { + queryParams.append("search", search); + } + + const qs = queryParams.toString(); + const path = `/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termType)}${qs ? `?${qs}` : ""}`; + logger.info(`Fetching project terms: ${path}`); + + const data = await fetchApi(path); + if (!data) { + return { + content: [{ type: "text" as const, text: "Failed to retrieve project terms" }], + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +}; + +export const listProjectTermsTool = { + schema: zodSchema, + handler, +};