diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 5595cf9..96a9b2b 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@currents/mcp", - "version": "2.1.2", + "version": "2.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@currents/mcp", - "version": "2.1.2", + "version": "2.1.3", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index ef84091..3ce662e 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -3,14 +3,33 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CURRENTS_API_KEY } from "./lib/env.js"; import { logger } from "./lib/logger.js"; +// Actions tools +import { listActionsTool } from "./tools/actions/list-actions.js"; +import { createActionTool } from "./tools/actions/create-action.js"; +import { getActionTool } from "./tools/actions/get-action.js"; +import { updateActionTool } from "./tools/actions/update-action.js"; +import { deleteActionTool } from "./tools/actions/delete-action.js"; +import { enableActionTool } from "./tools/actions/enable-action.js"; +import { disableActionTool } from "./tools/actions/disable-action.js"; +// Projects tools import { getProjectsTool } from "./tools/projects/get-projects.js"; +import { getProjectTool } from "./tools/projects/get-project.js"; +import { getProjectInsightsTool } from "./tools/projects/get-project-insights.js"; +// Runs tools +import { getRunsTool } from "./tools/runs/get-runs.js"; import { getRunDetailsTool } from "./tools/runs/get-run.js"; +import { findRunTool } from "./tools/runs/find-run.js"; +import { cancelRunTool } from "./tools/runs/cancel-run.js"; +import { resetRunTool } from "./tools/runs/reset-run.js"; +import { deleteRunTool } from "./tools/runs/delete-run.js"; +import { cancelRunByGithubCITool } from "./tools/runs/cancel-run-github-ci.js"; +// Specs tools import { getSpecFilesPerformanceTool } from "./tools/specs/get-spec-files-performance.js"; import { getSpecInstancesTool } from "./tools/specs/get-spec-instances.js"; +// Tests tools import { getTestResultsTool } from "./tools/tests/get-test-results.js"; import { getTestsPerformanceTool } from "./tools/tests/get-tests-performance.js"; import { getTestSignatureTool } from "./tools/tests/get-tests-signature.js"; -import { getRunsTool } from "./tools/runs/get-runs.js"; if (CURRENTS_API_KEY === "") { logger.error("CURRENTS_API_KEY env variable is not set."); @@ -21,13 +40,79 @@ const server = new McpServer({ version: "1.0.0", }); +// Actions API tools +server.tool( + "currents-list-actions", + "List all actions for a project with optional filtering. Actions are rules that automatically modify test behavior (skip, quarantine, tag). Supports filtering by status (active/disabled/archived/expired) and search by name. Requires a projectId.", + listActionsTool.schema, + listActionsTool.handler +); + +server.tool( + "currents-create-action", + "Create a new action for a project. Actions define rules that automatically skip, quarantine, or tag tests based on conditions like test title, file path, git branch, etc. Requires projectId, name, action array, and matcher object.", + createActionTool.schema, + createActionTool.handler +); + +server.tool( + "currents-get-action", + "Get a single action by ID. The actionId is globally unique, so projectId is not required. Returns full action details including matcher conditions and current status.", + getActionTool.schema, + getActionTool.handler +); + +server.tool( + "currents-update-action", + "Update an existing action. The actionId is globally unique. You can update name, description, action array, matcher, or expiration date. All fields are optional.", + updateActionTool.schema, + updateActionTool.handler +); + +server.tool( + "currents-delete-action", + "Delete (archive) an action. This is a soft delete - the action will be marked as archived but not permanently removed. The actionId is globally unique.", + deleteActionTool.schema, + deleteActionTool.handler +); + +server.tool( + "currents-enable-action", + "Enable a disabled action. Changes the action status from disabled to active, making it apply to matching tests again. The actionId is globally unique.", + enableActionTool.schema, + enableActionTool.handler +); + +server.tool( + "currents-disable-action", + "Disable an active action. Changes the action status to disabled, temporarily preventing it from applying to tests. The actionId is globally unique.", + disableActionTool.schema, + disableActionTool.handler +); + +// Projects API tools server.tool( "currents-get-projects", - "Retrieves a list of all projects available in the Currents platform. This is a prerequisite for using any other tools that require project-specific information.", + "Retrieves projects available in the Currents platform. Supports cursor-based pagination with limit, starting_after, ending_before parameters, or set fetchAll=true for automatic pagination. This is a prerequisite for using any other tools that require project-specific information.", getProjectsTool.schema, getProjectsTool.handler ); +server.tool( + "currents-get-project", + "Get a single project by ID. Returns project details including name, creation date, failFast setting, inactivity timeout, and default branch name.", + getProjectTool.schema, + getProjectTool.handler +); + +server.tool( + "currents-get-project-insights", + "Get aggregated run and test metrics for a project within a date range. Returns overall metrics and timeline data with configurable resolution (1h/1d/1w). Supports filtering by tags, branches, groups, and authors. Requires projectId, date_start, and date_end.", + getProjectInsightsTool.schema, + getProjectInsightsTool.handler +); + +// Runs API tools server.tool( "currents-get-runs", "Retrieves a list of runs for a specific project with optional filtering. Supports filtering by branch, tags (with AND/OR operators), status (PASSED/FAILED/RUNNING/FAILING), completion state, date range, commit author, and search by ciBuildId or commit message. Requires a projectId. If the projectId is not known, first call 'currents-get-projects' and ask the user to select the project.", @@ -42,6 +127,42 @@ server.tool( getRunDetailsTool.handler ); +server.tool( + "currents-find-run", + "Find a run by query parameters. Returns the most recent completed run matching the criteria. Can search by ciBuildId (exact match) or by branch/tags. Supports pwLastRun flag for Playwright last run info. Requires projectId.", + findRunTool.schema, + findRunTool.handler +); + +server.tool( + "currents-cancel-run", + "Cancel a run in progress. This will stop the run and mark it as cancelled. Requires runId.", + cancelRunTool.schema, + cancelRunTool.handler +); + +server.tool( + "currents-reset-run", + "Reset failed spec files in a run to allow re-execution. Requires runId and machineId array (1-63 machine IDs). Optionally supports batched orchestration.", + resetRunTool.schema, + resetRunTool.handler +); + +server.tool( + "currents-delete-run", + "Delete a run and all associated data. This is a permanent deletion. Requires runId.", + deleteRunTool.schema, + deleteRunTool.handler +); + +server.tool( + "currents-cancel-run-github-ci", + "Cancel a run by GitHub Actions workflow run ID and attempt number. Optionally scope by projectId or ciBuildId. Requires githubRunId and githubRunAttempt.", + cancelRunByGithubCITool.schema, + cancelRunByGithubCITool.handler +); + +// Specs API tools server.tool( "currents-get-spec-instance", "Retrieves debugging data from a specific execution of a test spec file by instanceId.", @@ -56,6 +177,7 @@ server.tool( getSpecFilesPerformanceTool.handler ); +// Tests API tools server.tool( "currents-get-tests-performance", "Retrieves aggregated test metrics for a specific project within a date range. Supports ordering by failures, passes, flakiness, duration, executions, title, and various delta metrics. Supports filtering by spec name, test title, tags, branches, groups, authors, minimum executions, and test state. Requires a projectId. If the projectId is not known, first call 'currents-get-projects' and ask the user to select the project.", diff --git a/mcp-server/src/lib/request.ts b/mcp-server/src/lib/request.ts index bee8218..d9c2afb 100644 --- a/mcp-server/src/lib/request.ts +++ b/mcp-server/src/lib/request.ts @@ -56,6 +56,66 @@ export async function postApi(path: string, body: B): Promise { } } +export async function putApi(path: string, body?: B): Promise { + const headers = { + "User-Agent": USER_AGENT, + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer " + CURRENTS_API_KEY, + }; + + try { + const response = await fetch(`${CURRENTS_API_URL}${path}`, { + method: "PUT", + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + logger.error(`HTTP error! status: ${response.status}`); + logger.error(response); + return null; + } + return (await response.json()) as T; + } catch (error: any) { + logger.error("Error making Currents PUT request:", error.toString()); + return null; + } +} + +export async function deleteApi(path: string): Promise { + const headers = { + "User-Agent": USER_AGENT, + Accept: "application/json", + Authorization: "Bearer " + CURRENTS_API_KEY, + }; + + try { + const response = await fetch(`${CURRENTS_API_URL}${path}`, { + method: "DELETE", + headers, + }); + if (!response.ok) { + logger.error(`HTTP error! status: ${response.status}`); + logger.error(response); + return null; + } + // Handle 204 No Content responses (common for DELETE operations) + if (response.status === 204 || response.headers.get("content-length") === "0") { + return {} as T; + } + // Check if response has content before parsing JSON + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return (await response.json()) as T; + } + // If no JSON content, return empty object + return {} as T; + } catch (error: any) { + logger.error("Error making Currents DELETE request:", error.toString()); + return null; + } +} + export async function fetchCursorBasedPaginatedApi( path: string ): Promise { diff --git a/mcp-server/src/tools/actions/create-action.ts b/mcp-server/src/tools/actions/create-action.ts new file mode 100644 index 0000000..da5d10b --- /dev/null +++ b/mcp-server/src/tools/actions/create-action.ts @@ -0,0 +1,156 @@ +import { z } from "zod"; +import { postApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +// Define condition type and operator enums +const ConditionType = z.enum([ + "testId", + "project", + "title", + "file", + "git_branch", + "git_authorName", + "git_authorEmail", + "git_remoteOrigin", + "git_message", + "error_message", + "titlePath", + "annotation", + "tag", +]); + +const ConditionOperator = z.enum([ + "eq", + "neq", + "any", + "empty", + "in", + "notIn", + "inc", + "notInc", + "incAll", + "notIncAll", +]); + +// Define rule action schemas +const RuleActionSkip = z.object({ + op: z.literal("skip"), +}); + +const RuleActionQuarantine = z.object({ + op: z.literal("quarantine"), +}); + +const RuleActionTag = z.object({ + op: z.literal("tag"), + details: z.object({ + tags: z.array(z.string()).max(10).describe("Tags to add to matching tests"), + }), +}); + +const RuleAction = z.union([RuleActionSkip, RuleActionQuarantine, RuleActionTag]); + +// Define matcher condition schema +const RuleMatcherCondition = z.object({ + type: ConditionType, + op: ConditionOperator, + value: z.union([z.string(), z.array(z.string())]).optional().nullable(), +}); + +// Define matcher schema +const RuleMatcher = z.object({ + op: z.enum(["AND", "OR"]).describe("How to combine multiple conditions"), + cond: z.array(RuleMatcherCondition).min(1).describe("List of conditions to match"), +}); + +const zodSchema = z.object({ + projectId: z + .string() + .describe("The project ID to create the action for."), + name: z + .string() + .min(1) + .max(255) + .describe("Human-readable name for the action."), + description: z + .string() + .max(1000) + .optional() + .nullable() + .describe("Optional description for the action."), + action: z + .array(RuleAction) + .min(1) + .describe("Actions to perform when conditions match."), + matcher: RuleMatcher.describe("Matcher defining which tests this action applies to."), + expiresAfter: z + .string() + .optional() + .nullable() + .describe("Optional expiration date in ISO 8601 format."), +}); + +interface CreateActionRequest { + name: string; + description?: string | null; + action: any[]; + matcher: any; + expiresAfter?: string | null; +} + +interface ActionResponse { + status: string; + data: any; +} + +const handler = async ({ + projectId, + name, + description, + action, + matcher, + expiresAfter, +}: z.infer) => { + logger.info(`Creating action for project ${projectId}: ${name}`); + + const body: CreateActionRequest = { + name, + description, + action, + matcher, + expiresAfter, + }; + + const queryParams = new URLSearchParams(); + queryParams.append("projectId", projectId); + + const data = await postApi( + `/actions?${queryParams.toString()}`, + body + ); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to create action", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const createActionTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/actions/delete-action.ts b/mcp-server/src/tools/actions/delete-action.ts new file mode 100644 index 0000000..821df76 --- /dev/null +++ b/mcp-server/src/tools/actions/delete-action.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { deleteApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + actionId: z.string().describe("The action ID to delete (archive)."), +}); + +const handler = async ({ actionId }: z.infer) => { + logger.info(`Deleting (archiving) action ${actionId}`); + + const data = await deleteApi(`/actions/${actionId}`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to delete action", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const deleteActionTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/actions/disable-action.ts b/mcp-server/src/tools/actions/disable-action.ts new file mode 100644 index 0000000..8fd0082 --- /dev/null +++ b/mcp-server/src/tools/actions/disable-action.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { putApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + actionId: z.string().describe("The action ID to disable."), +}); + +const handler = async ({ actionId }: z.infer) => { + logger.info(`Disabling action ${actionId}`); + + const data = await putApi(`/actions/${actionId}/disable`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to disable action", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const disableActionTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/actions/enable-action.ts b/mcp-server/src/tools/actions/enable-action.ts new file mode 100644 index 0000000..874b954 --- /dev/null +++ b/mcp-server/src/tools/actions/enable-action.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { putApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + actionId: z.string().describe("The action ID to enable."), +}); + +const handler = async ({ actionId }: z.infer) => { + logger.info(`Enabling action ${actionId}`); + + const data = await putApi(`/actions/${actionId}/enable`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to enable action", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const enableActionTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/actions/get-action.ts b/mcp-server/src/tools/actions/get-action.ts new file mode 100644 index 0000000..21fa586 --- /dev/null +++ b/mcp-server/src/tools/actions/get-action.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { fetchApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + actionId: z.string().describe("The action ID to fetch."), +}); + +const handler = async ({ actionId }: z.infer) => { + logger.info(`Fetching action ${actionId}`); + + const data = await fetchApi(`/actions/${actionId}`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to retrieve action", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const getActionTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/actions/list-actions.ts b/mcp-server/src/tools/actions/list-actions.ts new file mode 100644 index 0000000..b9cad6e --- /dev/null +++ b/mcp-server/src/tools/actions/list-actions.ts @@ -0,0 +1,65 @@ +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 fetch actions from."), + status: z + .array(z.enum(["active", "disabled", "archived", "expired"])) + .optional() + .describe("Filter actions by status (can be specified multiple times)."), + search: z + .string() + .optional() + .describe("Search actions by name."), +}); + +const handler = async ({ + projectId, + status, + search, +}: z.infer) => { + const queryParams = new URLSearchParams(); + queryParams.append("projectId", projectId); + + if (status && status.length > 0) { + status.forEach((s) => queryParams.append("status", s)); + } + + if (search) { + queryParams.append("search", search); + } + + logger.info( + `Fetching actions for project ${projectId} with query params: ${queryParams.toString()}` + ); + + const data = await fetchApi(`/actions?${queryParams.toString()}`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to retrieve actions", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const listActionsTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/actions/update-action.ts b/mcp-server/src/tools/actions/update-action.ts new file mode 100644 index 0000000..e83d4e9 --- /dev/null +++ b/mcp-server/src/tools/actions/update-action.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; +import { putApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +// Define condition type and operator enums +const ConditionType = z.enum([ + "testId", + "project", + "title", + "file", + "git_branch", + "git_authorName", + "git_authorEmail", + "git_remoteOrigin", + "git_message", + "error_message", + "titlePath", + "annotation", + "tag", +]); + +const ConditionOperator = z.enum([ + "eq", + "neq", + "any", + "empty", + "in", + "notIn", + "inc", + "notInc", + "incAll", + "notIncAll", +]); + +// Define rule action schemas +const RuleActionSkip = z.object({ + op: z.literal("skip"), +}); + +const RuleActionQuarantine = z.object({ + op: z.literal("quarantine"), +}); + +const RuleActionTag = z.object({ + op: z.literal("tag"), + details: z.object({ + tags: z.array(z.string()).max(10).describe("Tags to add to matching tests"), + }), +}); + +const RuleAction = z.union([RuleActionSkip, RuleActionQuarantine, RuleActionTag]); + +// Define matcher condition schema +const RuleMatcherCondition = z.object({ + type: ConditionType, + op: ConditionOperator, + value: z.union([z.string(), z.array(z.string())]).optional().nullable(), +}); + +// Define matcher schema +const RuleMatcher = z.object({ + op: z.enum(["AND", "OR"]).describe("How to combine multiple conditions"), + cond: z.array(RuleMatcherCondition).min(1).describe("List of conditions to match"), +}); + +const zodSchema = z.object({ + actionId: z.string().describe("The action ID to update."), + name: z + .string() + .min(1) + .max(255) + .optional() + .describe("Human-readable name for the action."), + description: z + .string() + .max(1000) + .optional() + .nullable() + .describe("Optional description for the action."), + action: z + .array(RuleAction) + .min(1) + .optional() + .describe("Actions to perform when conditions match."), + matcher: RuleMatcher.optional().describe("Matcher defining which tests this action applies to."), + expiresAfter: z + .string() + .optional() + .nullable() + .describe("Optional expiration date in ISO 8601 format."), +}); + +interface UpdateActionRequest { + name?: string; + description?: string | null; + action?: any[]; + matcher?: any; + expiresAfter?: string | null; +} + +interface ActionResponse { + status: string; + data: any; +} + +const handler = async ({ + actionId, + name, + description, + action, + matcher, + expiresAfter, +}: z.infer) => { + logger.info(`Updating action ${actionId}`); + + // Validate that at least one update field is provided + if ( + name === undefined && + description === undefined && + action === undefined && + matcher === undefined && + expiresAfter === undefined + ) { + return { + content: [ + { + type: "text" as const, + text: "Error: At least one field to update must be provided (name, description, action, matcher, or expiresAfter).", + }, + ], + }; + } + + const body: UpdateActionRequest = {}; + + if (name !== undefined) body.name = name; + if (description !== undefined) body.description = description; + if (action !== undefined) body.action = action; + if (matcher !== undefined) body.matcher = matcher; + if (expiresAfter !== undefined) body.expiresAfter = expiresAfter; + + const data = await putApi( + `/actions/${actionId}`, + body + ); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to update action", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const updateActionTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/projects/get-project-insights.ts b/mcp-server/src/tools/projects/get-project-insights.ts new file mode 100644 index 0000000..2601acd --- /dev/null +++ b/mcp-server/src/tools/projects/get-project-insights.ts @@ -0,0 +1,100 @@ +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 fetch insights for."), + date_start: z + .string() + .describe("Start date in ISO 8601 format."), + date_end: z + .string() + .describe("End date in ISO 8601 format."), + resolution: z + .enum(["1h", "1d", "1w"]) + .optional() + .describe("Time resolution for histogram data. Defaults to '1d'."), + tags: z + .array(z.string()) + .optional() + .describe("Filter by tags (can be specified multiple times)."), + branches: z + .array(z.string()) + .optional() + .describe("Filter by branches (can be specified multiple times)."), + groups: z + .array(z.string()) + .optional() + .describe("Filter by groups (can be specified multiple times)."), + authors: z + .array(z.string()) + .optional() + .describe("Filter by git authors (can be specified multiple times)."), +}); + +const handler = async ({ + projectId, + date_start, + date_end, + resolution = "1d", + tags, + branches, + groups, + authors, +}: z.infer) => { + const queryParams = new URLSearchParams(); + queryParams.append("date_start", date_start); + queryParams.append("date_end", date_end); + queryParams.append("resolution", resolution); + + if (tags && tags.length > 0) { + tags.forEach((t) => queryParams.append("tags", t)); + } + + if (branches && branches.length > 0) { + branches.forEach((b) => queryParams.append("branches", b)); + } + + if (groups && groups.length > 0) { + groups.forEach((g) => queryParams.append("groups", g)); + } + + if (authors && authors.length > 0) { + authors.forEach((a) => queryParams.append("authors", a)); + } + + logger.info( + `Fetching insights for project ${projectId} with query params: ${queryParams.toString()}` + ); + + const data = await fetchApi( + `/projects/${projectId}/insights?${queryParams.toString()}` + ); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to retrieve project insights", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const getProjectInsightsTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/projects/get-project.ts b/mcp-server/src/tools/projects/get-project.ts new file mode 100644 index 0000000..e1b9183 --- /dev/null +++ b/mcp-server/src/tools/projects/get-project.ts @@ -0,0 +1,38 @@ +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 fetch details for."), +}); + +const handler = async ({ projectId }: z.infer) => { + logger.info(`Fetching project ${projectId}`); + + const data = await fetchApi(`/projects/${projectId}`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to retrieve project", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const getProjectTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/projects/get-projects.test.ts b/mcp-server/src/tools/projects/get-projects.test.ts index 1c915e8..eb4ebc9 100644 --- a/mcp-server/src/tools/projects/get-projects.test.ts +++ b/mcp-server/src/tools/projects/get-projects.test.ts @@ -19,7 +19,7 @@ describe("getProjectsTool", () => { mockProjects ); - const result = await getProjectsTool.handler(); + const result = await getProjectsTool.handler({ fetchAll: true }); expect(result).toEqual({ content: [ @@ -37,7 +37,7 @@ describe("getProjectsTool", () => { it("should return error message when API request fails", async () => { vi.spyOn(request, "fetchCursorBasedPaginatedApi").mockResolvedValue(null); - const result = await getProjectsTool.handler(); + const result = await getProjectsTool.handler({ fetchAll: true }); expect(result).toEqual({ content: [ diff --git a/mcp-server/src/tools/projects/get-projects.ts b/mcp-server/src/tools/projects/get-projects.ts index 9456982..39a2077 100644 --- a/mcp-server/src/tools/projects/get-projects.ts +++ b/mcp-server/src/tools/projects/get-projects.ts @@ -1,10 +1,80 @@ import { z } from "zod"; -import { fetchCursorBasedPaginatedApi } from "../../lib/request.js"; +import { fetchApi, fetchCursorBasedPaginatedApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; -const zodSchema = z.object({}); +const zodSchema = z.object({ + limit: z + .number() + .max(100) + .optional() + .describe("Maximum number of items to return (default: 10, max: 100)."), + starting_after: z + .string() + .optional() + .describe("Cursor for pagination. Returns items after this cursor value."), + ending_before: z + .string() + .optional() + .describe("Cursor for pagination. Returns items before this cursor value."), + fetchAll: z + .boolean() + .optional() + .describe("If true, fetches all projects using automatic pagination. Ignores limit, starting_after, and ending_before."), +}); -const handler = async () => { - const data = await fetchCursorBasedPaginatedApi("/projects"); +const handler = async ({ + limit, + starting_after, + ending_before, + fetchAll = false, +}: z.infer) => { + // If fetchAll is true, use the automatic pagination + if (fetchAll) { + logger.info("Fetching all projects with automatic pagination"); + const data = await fetchCursorBasedPaginatedApi("/projects"); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to retrieve projects", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; + } + + // Otherwise, use manual pagination with parameters + 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); + } + + const queryString = queryParams.toString(); + const path = queryString ? `/projects?${queryString}` : "/projects"; + + logger.info(`Fetching projects with query params: ${queryString}`); + + const data = await fetchApi(path); if (!data) { return { diff --git a/mcp-server/src/tools/runs/cancel-run-github-ci.ts b/mcp-server/src/tools/runs/cancel-run-github-ci.ts new file mode 100644 index 0000000..5a89ee4 --- /dev/null +++ b/mcp-server/src/tools/runs/cancel-run-github-ci.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { putApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + githubRunId: z + .string() + .describe("GitHub Actions workflow run ID."), + githubRunAttempt: z + .number() + .describe("GitHub Actions workflow run attempt number."), + projectId: z + .string() + .optional() + .describe("Optional project ID to scope the cancellation."), + ciBuildId: z + .string() + .optional() + .describe("Optional CI build ID to scope the cancellation."), +}); + +interface CancelRunGithubCIRequest { + githubRunId: string; + githubRunAttempt: number; + projectId?: string; + ciBuildId?: string; +} + +interface RunCancellationResponse { + status: string; + data: any; +} + +const handler = async ({ + githubRunId, + githubRunAttempt, + projectId, + ciBuildId, +}: z.infer) => { + logger.info( + `Cancelling run by GitHub CI: workflow ${githubRunId}, attempt ${githubRunAttempt}` + ); + + const body: CancelRunGithubCIRequest = { + githubRunId, + githubRunAttempt, + }; + + if (projectId) { + body.projectId = projectId; + } + + if (ciBuildId) { + body.ciBuildId = ciBuildId; + } + + const data = await putApi( + `/runs/cancel-ci/github`, + body + ); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to cancel run by GitHub CI", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const cancelRunByGithubCITool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/runs/cancel-run.ts b/mcp-server/src/tools/runs/cancel-run.ts new file mode 100644 index 0000000..e535eba --- /dev/null +++ b/mcp-server/src/tools/runs/cancel-run.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { putApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + runId: z.string().describe("The run ID to cancel."), +}); + +const handler = async ({ runId }: z.infer) => { + logger.info(`Cancelling run ${runId}`); + + const data = await putApi(`/runs/${runId}/cancel`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to cancel run", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const cancelRunTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/runs/delete-run.ts b/mcp-server/src/tools/runs/delete-run.ts new file mode 100644 index 0000000..6b46635 --- /dev/null +++ b/mcp-server/src/tools/runs/delete-run.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { deleteApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + runId: z.string().describe("The run ID to delete."), +}); + +const handler = async ({ runId }: z.infer) => { + logger.info(`Deleting run ${runId}`); + + const data = await deleteApi(`/runs/${runId}`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to delete run", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const deleteRunTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/runs/find-run.ts b/mcp-server/src/tools/runs/find-run.ts new file mode 100644 index 0000000..87242dc --- /dev/null +++ b/mcp-server/src/tools/runs/find-run.ts @@ -0,0 +1,81 @@ +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 search for runs in."), + ciBuildId: z + .string() + .optional() + .describe("The CI build ID. If provided, returns the run with this exact ciBuildId."), + branch: z + .string() + .optional() + .describe("Git branch name. Used when ciBuildId is not provided."), + tag: z + .array(z.string()) + .optional() + .describe("Run tags to filter by (can be specified multiple times)."), + pwLastRun: z + .boolean() + .optional() + .describe("If true, includes information about failed tests from the last run (Playwright only)."), +}); + +const handler = async ({ + projectId, + ciBuildId, + branch, + tag, + pwLastRun, +}: z.infer) => { + const queryParams = new URLSearchParams(); + queryParams.append("projectId", projectId); + + if (ciBuildId) { + queryParams.append("ciBuildId", ciBuildId); + } + + if (branch) { + queryParams.append("branch", branch); + } + + if (tag && tag.length > 0) { + tag.forEach((t) => queryParams.append("tag", t)); + } + + if (pwLastRun !== undefined) { + queryParams.append("pwLastRun", pwLastRun.toString()); + } + + logger.info(`Finding run with query params: ${queryParams.toString()}`); + + const data = await fetchApi(`/runs/find?${queryParams.toString()}`); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to find run", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const findRunTool = { + schema: zodSchema.shape, + handler, +}; diff --git a/mcp-server/src/tools/runs/reset-run.ts b/mcp-server/src/tools/runs/reset-run.ts new file mode 100644 index 0000000..ee6d12c --- /dev/null +++ b/mcp-server/src/tools/runs/reset-run.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { putApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z.object({ + runId: z.string().describe("The run ID to reset."), + machineId: z + .array(z.string()) + .min(1) + .max(63) + .describe("Machine ID(s) to reset."), + isBatchedOr8n: z + .boolean() + .optional() + .describe("Whether to use batched orchestration."), +}); + +interface ResetRunRequest { + machineId: string[]; + isBatchedOr8n?: boolean; +} + +interface ResetRunResponse { + status: string; + data: any; +} + +const handler = async ({ + runId, + machineId, + isBatchedOr8n, +}: z.infer) => { + logger.info(`Resetting run ${runId} for machines: ${machineId.join(", ")}`); + + const body: ResetRunRequest = { + machineId, + }; + + if (isBatchedOr8n !== undefined) { + body.isBatchedOr8n = isBatchedOr8n; + } + + const data = await putApi( + `/runs/${runId}/reset`, + body + ); + + if (!data) { + return { + content: [ + { + type: "text" as const, + text: "Failed to reset run", + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +}; + +export const resetRunTool = { + schema: zodSchema.shape, + handler, +};