Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 124 additions & 2 deletions mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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.",
Expand All @@ -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.",
Expand All @@ -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.",
Expand Down
50 changes: 50 additions & 0 deletions mcp-server/src/lib/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,56 @@ export async function postApi<T, B>(path: string, body: B): Promise<T | null> {
}
}

export async function putApi<T, B>(path: string, body?: B): Promise<T | null> {
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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: deleteApi unconditionally calls response.json() which will throw an error if the API returns 204 No Content (a common response for DELETE operations). This would cause successful deletions to be incorrectly reported as failures. Consider checking if the response has content before parsing JSON.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcp-server/src/lib/request.ts, line 78:

<comment>`deleteApi` unconditionally calls `response.json()` which will throw an error if the API returns 204 No Content (a common response for DELETE operations). This would cause successful deletions to be incorrectly reported as failures. Consider checking if the response has content before parsing JSON.</comment>

<file context>
@@ -56,6 +56,56 @@ export async function postApi<T, B>(path: string, body: B): Promise<T | null> {
+      logger.error(response);
+      return null;
+    }
+    return (await response.json()) as T;
+  } catch (error: any) {
+    logger.error("Error making Currents PUT request:", error.toString());
</file context>

} catch (error: any) {
logger.error("Error making Currents PUT request:", error.toString());
return null;
}
}

export async function deleteApi<T>(path: string): Promise<T | null> {
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;
}
return (await response.json()) as T;
} catch (error: any) {
logger.error("Error making Currents DELETE request:", error.toString());
return null;
}
}

export async function fetchCursorBasedPaginatedApi<T>(
path: string
): Promise<T[] | null> {
Expand Down
156 changes: 156 additions & 0 deletions mcp-server/src/tools/actions/create-action.ts
Original file line number Diff line number Diff line change
@@ -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<typeof zodSchema>) => {
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<ActionResponse, CreateActionRequest>(
`/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,
};
Loading