Skip to content

Commit 006d7ec

Browse files
manoj-k04claude
andcommitted
feat(test-management): add listTestPlans and getTestPlan tools
Expose Test Plans as first-class objects in the MCP so agents can retrieve a plan by TP-* identifier instead of falling back to test runs. getTestPlan chains the plan-details and linked-runs endpoints into a unified response with metadata, linked test runs, total test-case count across runs, and a derived run-state summary — suitable for generating test documentation and QA status reports. listTestPlans covers discovery when the identifier is not known. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a54179e commit 006d7ec

4 files changed

Lines changed: 452 additions & 1 deletion

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { apiClient } from "../../lib/apiClient.js";
2+
import { z } from "zod";
3+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4+
import { formatAxiosError } from "../../lib/error.js";
5+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
6+
import { BrowserStackConfig } from "../../lib/types.js";
7+
import { getTMBaseURL } from "../../lib/tm-base-url.js";
8+
9+
/**
10+
* Schema for fetching a single test plan by identifier, including its linked test runs.
11+
*/
12+
export const GetTestPlanSchema = z.object({
13+
project_identifier: z
14+
.string()
15+
.describe(
16+
"Identifier of the project (starts with PR- followed by a number).",
17+
),
18+
test_plan_identifier: z
19+
.string()
20+
.describe(
21+
"Identifier of the test plan (starts with TP- followed by a number).",
22+
),
23+
});
24+
25+
export type GetTestPlanArgs = z.infer<typeof GetTestPlanSchema>;
26+
27+
interface TestPlan {
28+
identifier: string;
29+
name: string;
30+
active_state: string;
31+
description: string | null;
32+
project_id: string;
33+
start_date: string | null;
34+
end_date: string | null;
35+
created_at: string;
36+
test_runs_count?: { active: number; closed: number };
37+
test_runs?: Array<{ identifier: string; name: string }>;
38+
links?: Record<string, string>;
39+
}
40+
41+
interface LinkedTestRun {
42+
identifier: string;
43+
name: string;
44+
run_state: string;
45+
active_state: string;
46+
assignee?: string | null;
47+
description?: string | null;
48+
created_at: string;
49+
project_id: string;
50+
test_cases_count: number;
51+
}
52+
53+
/**
54+
* Fetches a test plan by identifier and its linked test runs, returning a unified view
55+
* suitable for generating documentation (metadata + linked runs + status summary + case count).
56+
*/
57+
export async function getTestPlan(
58+
args: GetTestPlanArgs,
59+
config: BrowserStackConfig,
60+
): Promise<CallToolResult> {
61+
try {
62+
const tmBaseUrl = await getTMBaseURL(config);
63+
const projectId = encodeURIComponent(args.project_identifier);
64+
const planId = encodeURIComponent(args.test_plan_identifier);
65+
66+
const authString = getBrowserStackAuth(config);
67+
const [username, password] = authString.split(":");
68+
const authHeader =
69+
"Basic " + Buffer.from(`${username}:${password}`).toString("base64");
70+
71+
const planResp = await apiClient.get({
72+
url: `${tmBaseUrl}/api/v2/projects/${projectId}/test-plans/${planId}`,
73+
headers: { Authorization: authHeader },
74+
});
75+
76+
if (!planResp.data?.success) {
77+
return {
78+
content: [
79+
{
80+
type: "text",
81+
text: `Failed to fetch test plan: ${JSON.stringify(planResp.data)}`,
82+
},
83+
],
84+
isError: true,
85+
};
86+
}
87+
88+
const plan: TestPlan = planResp.data.test_plan;
89+
90+
const runsResp = await apiClient.get({
91+
url: `${tmBaseUrl}/api/v2/projects/${projectId}/test-plans/${planId}/test-runs`,
92+
headers: { Authorization: authHeader },
93+
});
94+
95+
const runs: LinkedTestRun[] = runsResp.data?.success
96+
? (runsResp.data.test_runs ?? [])
97+
: [];
98+
99+
const statusSummary: Record<string, number> = {};
100+
let totalCases = 0;
101+
for (const run of runs) {
102+
statusSummary[run.run_state] = (statusSummary[run.run_state] ?? 0) + 1;
103+
totalCases += run.test_cases_count ?? 0;
104+
}
105+
106+
const header = [
107+
`Test Plan ${plan.identifier}: ${plan.name}`,
108+
`Status: ${plan.active_state}`,
109+
plan.description ? `Description: ${plan.description}` : null,
110+
plan.start_date || plan.end_date
111+
? `Dates: ${plan.start_date ?? "—"}${plan.end_date ?? "—"}`
112+
: null,
113+
`Linked runs: ${runs.length} (plan counts — active ${plan.test_runs_count?.active ?? 0} / closed ${plan.test_runs_count?.closed ?? 0})`,
114+
`Total test cases across runs: ${totalCases}`,
115+
Object.keys(statusSummary).length > 0
116+
? `Run-state breakdown: ${Object.entries(statusSummary)
117+
.map(([s, n]) => `${s}=${n}`)
118+
.join(", ")}`
119+
: null,
120+
]
121+
.filter(Boolean)
122+
.join("\n");
123+
124+
const runsBlock = runs.length
125+
? "\n\nLinked test runs:\n" +
126+
runs
127+
.map(
128+
(r) =>
129+
`• ${r.identifier}: ${r.name} [${r.run_state}] — ${r.test_cases_count} case(s)${r.assignee ? ` (assignee: ${r.assignee})` : ""}`,
130+
)
131+
.join("\n")
132+
: "\n\nNo test runs linked to this plan.";
133+
134+
return {
135+
content: [
136+
{ type: "text", text: header + runsBlock },
137+
{
138+
type: "text",
139+
text: JSON.stringify(
140+
{
141+
test_plan: plan,
142+
linked_test_runs: runs,
143+
status_summary: statusSummary,
144+
total_test_cases: totalCases,
145+
},
146+
null,
147+
2,
148+
),
149+
},
150+
],
151+
};
152+
} catch (err) {
153+
return formatAxiosError(err, "Failed to fetch test plan");
154+
}
155+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { apiClient } from "../../lib/apiClient.js";
2+
import { z } from "zod";
3+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4+
import { formatAxiosError } from "../../lib/error.js";
5+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
6+
import { BrowserStackConfig } from "../../lib/types.js";
7+
import { getTMBaseURL } from "../../lib/tm-base-url.js";
8+
9+
/**
10+
* Schema for listing test plans in a BrowserStack Test Management project.
11+
*/
12+
export const ListTestPlansSchema = z.object({
13+
project_identifier: z
14+
.string()
15+
.describe(
16+
"Identifier of the project to fetch test plans from (starts with PR- followed by a number).",
17+
),
18+
p: z.number().optional().describe("Page number."),
19+
});
20+
21+
export type ListTestPlansArgs = z.infer<typeof ListTestPlansSchema>;
22+
23+
interface TestPlanListItem {
24+
identifier: string;
25+
name: string;
26+
active_state: string;
27+
description: string | null;
28+
project_id: string;
29+
start_date: string | null;
30+
end_date: string | null;
31+
created_at: string;
32+
test_runs_count?: { active: number; closed: number };
33+
}
34+
35+
/**
36+
* Lists test plans for a project in BrowserStack Test Management.
37+
*/
38+
export async function listTestPlans(
39+
args: ListTestPlansArgs,
40+
config: BrowserStackConfig,
41+
): Promise<CallToolResult> {
42+
try {
43+
const params = new URLSearchParams();
44+
if (args.p !== undefined) params.append("p", args.p.toString());
45+
46+
const tmBaseUrl = await getTMBaseURL(config);
47+
const projectId = encodeURIComponent(args.project_identifier);
48+
const url = `${tmBaseUrl}/api/v2/projects/${projectId}/test-plans?${params.toString()}`;
49+
50+
const authString = getBrowserStackAuth(config);
51+
const [username, password] = authString.split(":");
52+
const resp = await apiClient.get({
53+
url,
54+
headers: {
55+
Authorization:
56+
"Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
57+
},
58+
});
59+
60+
const data = resp.data;
61+
if (!data.success) {
62+
return {
63+
content: [
64+
{
65+
type: "text",
66+
text: `Failed to list test plans: ${JSON.stringify(data)}`,
67+
},
68+
],
69+
isError: true,
70+
};
71+
}
72+
73+
const plans: TestPlanListItem[] = data.test_plans ?? [];
74+
const info = data.info ?? {};
75+
const count = info.count ?? plans.length;
76+
77+
if (plans.length === 0) {
78+
return {
79+
content: [
80+
{
81+
type: "text",
82+
text: `No test plans found in project ${args.project_identifier}.`,
83+
},
84+
],
85+
};
86+
}
87+
88+
const summary = plans
89+
.map(
90+
(p) =>
91+
`• ${p.identifier}: ${p.name} [${p.active_state}] — ${p.test_runs_count?.active ?? 0} active / ${p.test_runs_count?.closed ?? 0} closed run(s)`,
92+
)
93+
.join("\n");
94+
95+
return {
96+
content: [
97+
{
98+
type: "text",
99+
text: `Found ${count} test plan(s) in project ${args.project_identifier}:\n\n${summary}`,
100+
},
101+
{ type: "text", text: JSON.stringify(plans, null, 2) },
102+
],
103+
};
104+
} catch (err) {
105+
return formatAxiosError(err, "Failed to list test plans");
106+
}
107+
}

src/tools/testmanagement.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ import {
5757
createLCASteps,
5858
CreateLCAStepsSchema,
5959
} from "./testmanagement-utils/create-lca-steps.js";
60+
61+
import {
62+
listTestPlans,
63+
ListTestPlansSchema,
64+
} from "./testmanagement-utils/list-testplans.js";
65+
66+
import {
67+
getTestPlan,
68+
GetTestPlanSchema,
69+
} from "./testmanagement-utils/get-testplan.js";
70+
6071
import { BrowserStackConfig } from "../lib/types.js";
6172

6273
//TODO: Moving the traceMCP and catch block to the parent(server) function
@@ -432,6 +443,72 @@ export async function createLCAStepsTool(
432443
}
433444
}
434445

446+
/**
447+
* Lists test plans in a project.
448+
*/
449+
export async function listTestPlansTool(
450+
args: z.infer<typeof ListTestPlansSchema>,
451+
config: BrowserStackConfig,
452+
server: McpServer,
453+
): Promise<CallToolResult> {
454+
try {
455+
trackMCP(
456+
"listTestPlans",
457+
server.server.getClientVersion()!,
458+
undefined,
459+
config,
460+
);
461+
return await listTestPlans(args, config);
462+
} catch (err) {
463+
logger.error("Failed to list test plans: %s", err);
464+
trackMCP("listTestPlans", server.server.getClientVersion()!, err, config);
465+
return {
466+
content: [
467+
{
468+
type: "text",
469+
text: `Failed to list test plans: ${
470+
err instanceof Error ? err.message : "Unknown error"
471+
}. Please open an issue on GitHub if the problem persists`,
472+
},
473+
],
474+
isError: true,
475+
};
476+
}
477+
}
478+
479+
/**
480+
* Fetches a test plan by identifier, with its linked runs and a derived status summary.
481+
*/
482+
export async function getTestPlanTool(
483+
args: z.infer<typeof GetTestPlanSchema>,
484+
config: BrowserStackConfig,
485+
server: McpServer,
486+
): Promise<CallToolResult> {
487+
try {
488+
trackMCP(
489+
"getTestPlan",
490+
server.server.getClientVersion()!,
491+
undefined,
492+
config,
493+
);
494+
return await getTestPlan(args, config);
495+
} catch (err) {
496+
logger.error("Failed to fetch test plan: %s", err);
497+
trackMCP("getTestPlan", server.server.getClientVersion()!, err, config);
498+
return {
499+
content: [
500+
{
501+
type: "text",
502+
text: `Failed to fetch test plan: ${
503+
err instanceof Error ? err.message : "Unknown error"
504+
}. Please open an issue on GitHub if the problem persists`,
505+
},
506+
],
507+
isError: true,
508+
};
509+
}
510+
}
511+
435512
/**
436513
* Registers both project/folder and test-case tools.
437514
*/
@@ -519,5 +596,19 @@ export default function addTestManagementTools(
519596
(args, context) => createLCAStepsTool(args, context, config, server),
520597
);
521598

599+
tools.listTestPlans = server.tool(
600+
"listTestPlans",
601+
"List test plans in a BrowserStack Test Management project. Returns each plan's identifier (TP-*), name, status, description, dates, and active/closed test-run counts. Supports pagination.",
602+
ListTestPlansSchema.shape,
603+
(args) => listTestPlansTool(args, config, server),
604+
);
605+
606+
tools.getTestPlan = server.tool(
607+
"getTestPlan",
608+
"Fetch a test plan by identifier (TP-*) from BrowserStack Test Management. Returns plan metadata, the full list of linked test runs, total test-case count across runs, and a status summary — suitable for generating test documentation or QA status reports.",
609+
GetTestPlanSchema.shape,
610+
(args) => getTestPlanTool(args, config, server),
611+
);
612+
522613
return tools;
523614
}

0 commit comments

Comments
 (0)