Skip to content

Commit a696255

Browse files
authored
feat(mcp): bulk case creation and template selection/listing (#454)
Add two MCP case-authoring capabilities, reusing the existing transactional import service rather than duplicating case-creation logic: - testplanit_cases_create_many creates many cases in one call via a new app/api/projects/[projectId]/cases/bulk-create route that reuses persistGeneratedTestCases (one transaction per folder/state group) and reports per-case success/failure so partial failures are visible. The route authenticates by session or API token with read-only-token enforcement, mirroring the ZenStack RPC handler the MCP server already calls. - testplanit_templates_list returns a project's enabled templates with the case fields each defines. - cases_create and cases_create_many accept an optional templateId (default: the project's first enabled template). Custom fields are resolved and validated against the chosen template — and the case's own template on update — so an out-of-template field returns a clear error instead of being silently dropped. Consolidate to a single bulk-create path by removing the orphaned legacy app/api/repository/import-generated-test-cases route (no product callers; session-only, non-transactional). Its e2e is repointed at the new endpoint and its audit-coverage and OpenAPI references are dropped. Update the MCP docs (overview, configuration, prompts) and the package README for the new tools and the 44-tool count.
1 parent 6ef372c commit a696255

29 files changed

Lines changed: 2213 additions & 1095 deletions

File tree

docs/docs/sdk/mcp-configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Tokens with both scopes set show a **Read-only** badge and an **Agent token** ba
8686

8787
## Tool catalog
8888

89-
The full tool catalog — 28 tools across cases, folders, tags, projects, test runs, results, sessions, findings, code repositories, issues, and repository case links — lives in the [npm package README](https://www.npmjs.com/package/@testplanit/mcp-server). Each tool entry shows its input parameters, output shape, and (where applicable) which killer-app composition it participates in.
89+
The full tool catalog — 44 tools across cases, templates, folders, tags, projects, test runs, results, sessions, findings, milestones, code repositories, issues, and repository case links — lives in the [npm package README](https://www.npmjs.com/package/@testplanit/mcp-server). Each tool entry shows its input parameters, output shape, and (where applicable) which killer-app composition it participates in.
9090

9191
## Troubleshooting
9292

docs/docs/sdk/mcp-overview.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ The TestPlanIt MCP server lets AI agents — Claude Desktop, Cursor, custom MCP-
1010

1111
## What an agent can ask
1212

13-
- List, fetch, create, update, and soft-delete test cases (with steps, custom fields, tags, folder breadcrumb, linked issues, and linked automated tests inline)
13+
- List, fetch, create (one at a time or many in a single bulk call), update, and soft-delete test cases (with steps, custom fields, tags, folder breadcrumb, linked issues, and linked automated tests inline)
14+
- List a project's templates and the case fields each defines, and choose which template a new case uses — custom fields are validated against that template
1415
- List and create test runs, add cases to existing runs, submit test results, and update run state
1516
- List sessions, session results, and session findings — the exploratory testing surface — and create or update sessions
1617
- Create and update milestones, mark them started or complete, and list milestone progress with pooled status rollups inline
@@ -19,7 +20,7 @@ The TestPlanIt MCP server lets AI agents — Claude Desktop, Cursor, custom MCP-
1920
- List code repositories configured in a project (with credentials never returned)
2021
- List folders and tags scoped to a project, with usage counts and tree relationships preserved
2122

22-
See the [npm package README](https://www.npmjs.com/package/@testplanit/mcp-server) for the full tool reference, including request/response schemas for all 42 tools.
23+
See the [npm package README](https://www.npmjs.com/package/@testplanit/mcp-server) for the full tool reference, including request/response schemas for all 44 tools.
2324

2425
## Installation
2526

docs/docs/sdk/mcp-prompts.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,33 @@ Mark the login test case as passed in run 5.
165165
}
166166
```
167167

168+
## "Create these test cases from our checkout spec"
169+
170+
```text
171+
Create these 20 test cases in project Acme under the "Checkout" folder using the
172+
"Regression" template — each has steps and a Priority.
173+
```
174+
175+
**Tool calls (2–3):**
176+
177+
1. (Optional) `testplanit_templates_list({ projectId: <P> })` → lists the project's enabled templates, each with the case fields it defines. The agent picks the right `templateId` and sees which custom fields (e.g. `Priority`) that template accepts.
178+
2. (Optional) `testplanit_folders_list({ projectId: <P> })` → resolves the "Checkout" folder id.
179+
3. `testplanit_cases_create_many({ projectId: <P>, folderId: <id>, templateId: <id>, cases: [ { name, steps, tags, customFields }, ... ] })` → creates all of them in one call. Each case may override `folderId`/`stateName`; `templateId` defaults to the project's first enabled template when omitted.
180+
181+
**What comes back:** a per-case results array so partial failures are visible — each entry is either a success (with the new `caseId`) or an error (with a message). A custom field that isn't part of the chosen template is reported as a per-case error rather than silently dropped.
182+
183+
```json
184+
{
185+
"importedCount": 19,
186+
"failedCount": 1,
187+
"results": [
188+
{ "id": "0", "name": "Guest checkout — valid card", "status": "success", "caseId": 1201 },
189+
{ "id": "1", "name": "Checkout — expired card", "status": "error",
190+
"error": "Custom field(s) not part of template \"Regression\": Severity." }
191+
]
192+
}
193+
```
194+
168195
## See also
169196

170197
- [Overview](./mcp-overview.md) — what the MCP server does

packages/mcp-server/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,17 @@ Your MCP client discovers each tool's full parameters automatically, so the list
9292
| --- | --- |
9393
| `testplanit_cases_list` | List and filter test cases in a project (by folder, tag, name, state, custom field, linked issue, automation, and more). |
9494
| `testplanit_cases_get` | Get a single test case with its fields and steps. |
95-
| `testplanit_cases_create` | Create a test case. |
96-
| `testplanit_cases_update` | Update a test case. |
95+
| `testplanit_cases_create` | Create a test case. Optionally pass `templateId` to choose a template (defaults to the project's first enabled template); custom fields are validated against the chosen template. |
96+
| `testplanit_cases_create_many` | Create many test cases in one call — far faster than per-case creates. Each case takes the same fields as a single create plus optional per-case `folderId`/`stateName`; returns a per-case success/failure result so partial failures are visible. |
97+
| `testplanit_cases_update` | Update a test case. Custom fields are validated against the case's template. |
9798
| `testplanit_cases_delete` | Delete a test case. |
9899

100+
### Templates
101+
102+
| Tool | Description |
103+
| --- | --- |
104+
| `testplanit_templates_list` | List a project's enabled templates, each with the case fields it defines (display name, system name, type, required) — use it to pick a `templateId` for case creation and to learn which custom fields a template accepts. |
105+
99106
### Folders
100107

101108
| Tool | Description |

packages/mcp-server/src/api.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,48 @@ export async function resolveDefaultTemplate(
291291
return templates[0].id;
292292
}
293293

294+
/**
295+
* Resolve the template to use for a case create.
296+
*
297+
* When `templateId` is provided, verify it is an enabled, non-deleted template
298+
* assigned to the project — throwing a 422 with a human-readable message when
299+
* it is not, so the agent gets a clear "that template isn't available here"
300+
* instead of a downstream foreign-key / policy error. When `templateId` is
301+
* omitted, this falls back to `resolveDefaultTemplate` (first enabled template
302+
* assigned to the project).
303+
*/
304+
export async function resolveTemplateForProject(
305+
projectId: number,
306+
env: EnvConfig,
307+
templateId?: number,
308+
): Promise<number> {
309+
if (templateId == null) {
310+
return resolveDefaultTemplate(projectId, env);
311+
}
312+
const templates = await zenstack<{ id: number }[]>(
313+
"templates",
314+
"findMany",
315+
{
316+
where: {
317+
id: templateId,
318+
isDeleted: false,
319+
isEnabled: true,
320+
projects: { some: { projectId } },
321+
},
322+
select: { id: true },
323+
take: 1,
324+
},
325+
env,
326+
);
327+
if (!templates || templates.length === 0) {
328+
throw new TestPlanItHttpError(
329+
`Template ${templateId} is not an enabled template assigned to project ${projectId}. Use testplanit_templates_list to see available templates.`,
330+
{ statusCode: 422 },
331+
);
332+
}
333+
return templates[0].id;
334+
}
335+
294336
/**
295337
* Resolve a workflow state for the CASES scope (NOT runs). Pass `name` to
296338
* select by name; omit to take the first by `order asc`.

packages/mcp-server/src/tools/cases/create.test.ts

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ vi.mock("../../api.js", () => ({
1010
zenstack: vi.fn(),
1111
lookup: vi.fn(),
1212
resolveActiveRepository: vi.fn(),
13-
resolveDefaultTemplate: vi.fn(),
13+
resolveTemplateForProject: vi.fn(),
1414
resolveCaseWorkflowState: vi.fn(),
1515
}));
1616

@@ -38,7 +38,7 @@ import { registerCasesCreate } from "./create.js";
3838
const zenstackMock = vi.mocked(apiModule.zenstack);
3939
const lookupMock = vi.mocked(apiModule.lookup);
4040
const resolveActiveRepositoryMock = vi.mocked(apiModule.resolveActiveRepository);
41-
const resolveDefaultTemplateMock = vi.mocked(apiModule.resolveDefaultTemplate);
41+
const resolveTemplateForProjectMock = vi.mocked(apiModule.resolveTemplateForProject);
4242
const resolveCaseWorkflowStateMock = vi.mocked(apiModule.resolveCaseWorkflowState);
4343
const resolveCustomFieldsMock = vi.mocked(customFieldsModule.resolveCustomFields);
4444
const writeCustomFieldValuesMock = vi.mocked(customFieldsModule.writeCustomFieldValues);
@@ -90,7 +90,7 @@ beforeEach(() => {
9090

9191
// Default happy-path mocks
9292
resolveActiveRepositoryMock.mockResolvedValue(11);
93-
resolveDefaultTemplateMock.mockResolvedValue(22);
93+
resolveTemplateForProjectMock.mockResolvedValue(22);
9494
resolveCaseWorkflowStateMock.mockResolvedValue({ id: 3, name: "Draft" });
9595
resolveCustomFieldsMock.mockResolvedValue([]);
9696
writeCustomFieldValuesMock.mockResolvedValue(undefined);
@@ -181,6 +181,7 @@ describe("testplanit_cases_create", () => {
181181

182182
expect(resolveCustomFieldsMock).toHaveBeenCalledWith(
183183
{ Priority: "High" },
184+
22,
184185
env,
185186
);
186187
expect(writeCustomFieldValuesMock).toHaveBeenCalledWith(
@@ -203,6 +204,104 @@ describe("testplanit_cases_create", () => {
203204
expect(resolveCaseWorkflowStateMock).toHaveBeenCalledWith(7, env, "Active");
204205
});
205206

207+
it("default template: resolveTemplateForProject called with undefined templateId when omitted", async () => {
208+
await callTool({ projectId: 7, folderId: 12, name: "Default template" });
209+
210+
expect(resolveTemplateForProjectMock).toHaveBeenCalledWith(7, env, undefined);
211+
// The create body connects the resolved (default) template id 22.
212+
const createCall = zenstackMock.mock.calls.find((c) => c[1] === "create");
213+
const data = (createCall![2] as { data: Record<string, unknown> }).data;
214+
expect(data.template).toEqual({ connect: { id: 22 } });
215+
});
216+
217+
it("honors an explicit templateId by passing it to resolveTemplateForProject", async () => {
218+
resolveTemplateForProjectMock.mockResolvedValueOnce(55);
219+
220+
await callTool({
221+
projectId: 7,
222+
folderId: 12,
223+
name: "Pick template",
224+
templateId: 55,
225+
});
226+
227+
expect(resolveTemplateForProjectMock).toHaveBeenCalledWith(7, env, 55);
228+
const createCall = zenstackMock.mock.calls.find((c) => c[1] === "create");
229+
const data = (createCall![2] as { data: Record<string, unknown> }).data;
230+
expect(data.template).toEqual({ connect: { id: 55 } });
231+
});
232+
233+
it("surfaces 422 when an explicit templateId is not assigned/enabled for the project", async () => {
234+
resolveTemplateForProjectMock.mockRejectedValueOnce(
235+
new TestPlanItHttpError(
236+
"Template 99 is not an enabled template assigned to project 7.",
237+
{ statusCode: 422 },
238+
),
239+
);
240+
241+
const result = await callTool({
242+
projectId: 7,
243+
folderId: 12,
244+
name: "bad template",
245+
templateId: 99,
246+
});
247+
248+
expect(result.isError).toBe(true);
249+
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text;
250+
expect(text).toContain("Template 99");
251+
});
252+
253+
it("rejects a custom field not on the chosen template (template-scoped resolver throws 422, pre-write)", async () => {
254+
// The template-scoped resolver rejects an out-of-template field.
255+
resolveCustomFieldsMock.mockRejectedValueOnce(
256+
new TestPlanItHttpError(
257+
"Custom field 'Component' is not part of the selected template.",
258+
{ statusCode: 422 },
259+
),
260+
);
261+
262+
const result = await callTool({
263+
projectId: 7,
264+
folderId: 12,
265+
name: "cf not in template",
266+
customFields: { Component: "x" },
267+
});
268+
269+
expect(result.isError).toBe(true);
270+
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text;
271+
expect(text).toContain("Component");
272+
expect(text).toContain("template");
273+
// The case row must NOT have been created — resolution fires pre-write.
274+
expect(zenstackMock.mock.calls.find((c) => c[1] === "create")).toBeUndefined();
275+
});
276+
277+
it("resolves custom fields against the chosen template id, then writes them", async () => {
278+
resolveTemplateForProjectMock.mockResolvedValueOnce(55);
279+
resolveCustomFieldsMock.mockResolvedValueOnce([
280+
{ fieldId: 2, value: "High", name: "Priority" },
281+
]);
282+
283+
const result = await callTool({
284+
projectId: 7,
285+
folderId: 12,
286+
name: "cf in template",
287+
templateId: 55,
288+
customFields: { Priority: "High" },
289+
});
290+
291+
expect(result.isError).toBeFalsy();
292+
// Custom-field resolution is scoped to the resolved template id (55).
293+
expect(resolveCustomFieldsMock).toHaveBeenCalledWith(
294+
{ Priority: "High" },
295+
55,
296+
env,
297+
);
298+
expect(writeCustomFieldValuesMock).toHaveBeenCalledWith(
299+
99,
300+
[{ fieldId: 2, value: "High", name: "Priority" }],
301+
env,
302+
);
303+
});
304+
206305
it("surfaces 422 when no active repository found", async () => {
207306
resolveActiveRepositoryMock.mockRejectedValueOnce(
208307
new TestPlanItHttpError(

packages/mcp-server/src/tools/cases/create.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as z from "zod/v4";
33
import {
44
zenstack,
55
resolveActiveRepository,
6-
resolveDefaultTemplate,
6+
resolveTemplateForProject,
77
resolveCaseWorkflowState,
88
} from "../../api.js";
99
import type { EnvConfig } from "../../env.js";
@@ -31,6 +31,14 @@ export function registerCasesCreate(
3131
projectId: z.number().int().positive().describe("Project to create the case in."),
3232
folderId: z.number().int().positive().describe("Folder to place the case in."),
3333
name: z.string().min(1).max(2000).describe("Test case name."),
34+
templateId: z
35+
.number()
36+
.int()
37+
.positive()
38+
.optional()
39+
.describe(
40+
"Template to use. Defaults to the project's first enabled template. Use testplanit_templates_list to see available templates and their fields.",
41+
),
3442
stateName: z
3543
.string()
3644
.min(1)
@@ -60,16 +68,25 @@ export function registerCasesCreate(
6068
try {
6169
// Pre-resolution: fail fast if project is misconfigured.
6270
const repositoryId = await resolveActiveRepository(input.projectId, deps.env);
63-
const templateId = await resolveDefaultTemplate(input.projectId, deps.env);
71+
// Honor an explicit templateId (validated against the project) or fall
72+
// back to the project's default template when omitted.
73+
const templateId = await resolveTemplateForProject(
74+
input.projectId,
75+
deps.env,
76+
input.templateId,
77+
);
6478
const state = await resolveCaseWorkflowState(
6579
input.projectId,
6680
deps.env,
6781
input.stateName,
6882
);
6983

70-
// Resolve tags and custom fields before the create write.
84+
// Resolve custom fields against the chosen template — a field that
85+
// isn't on the template is rejected (not silently dropped), and the
86+
// template scope removes any global display-name ambiguity.
7187
const resolvedCustomFields = await resolveCustomFields(
7288
input.customFields,
89+
templateId,
7390
deps.env,
7491
);
7592
const tagIds = await resolveTagIds(input.tags, deps.env);

0 commit comments

Comments
 (0)