Skip to content

Commit b19d07b

Browse files
authored
enhancement(mcp): accept Result Field values on test_run_results_create (#398)
* enhancement(mcp): accept Result Field values on test_run_results_create `testplanit_test_run_results_create` previously took only status / notes / elapsed, so a result submission against a template that marks any Result Field required would be rejected server-side with REQUIRED_FIELDS_MISSING — the MCP path was unusable for required-field templates. Adds an optional `fieldValues: [{ name, value }]` input. `name` matches the field's displayName (case-insensitive) or its systemName scoped to the case's template. Names are resolved server-side to numeric fieldIds before the submit-result call, so the agent never sees the IDs. Unknown names are rejected with the available field list in the error message. The server-side guard (`hasMissingRequiredResultField` in `lib/services/resultGuards.ts`) is unchanged — this is purely a tool-surface capability fix, not an enforcement change. Tests: 7 new co-located cases in create.test.ts (happy path with no fieldValues, displayName resolution, systemName resolution, unknown-name error, REQUIRED_FIELDS_MISSING surfaced, no-template rejection, not-found), plus the existing index.test.ts now asserts the create tool is registered alongside list + get. * chore(packages): add changeset for mcp-server fieldValues Patch bump for @testplanit/mcp-server so the result-field-values addition publishes as 0.1.4 on merge of the Version Packages PR.
1 parent f0ef18f commit b19d07b

4 files changed

Lines changed: 425 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@testplanit/mcp-server": patch
3+
---
4+
5+
`testplanit_test_run_results_create` now accepts an optional `fieldValues: [{ name, value }]` input to record custom Result Field entries alongside the result. Resolution is by display name (case-insensitive) or system name, scoped to the case's template; unknown names are rejected with the available field list in the error message. This unblocks result submission against templates that mark any Result Field required — previously the server rejected those submissions with `REQUIRED_FIELDS_MISSING` because the tool surface couldn't construct a valid payload.
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5+
6+
// Mock global fetch — submit-result is reached via a raw fetch.
7+
const mockFetch = vi.fn();
8+
global.fetch = mockFetch as unknown as typeof fetch;
9+
10+
vi.mock("../../../api.js", () => ({
11+
zenstack: vi.fn(),
12+
}));
13+
14+
import { zenstack } from "../../../api.js";
15+
import { registerRunResultsCreate } from "./create.js";
16+
17+
const mockZenstack = vi.mocked(zenstack);
18+
19+
const mockEnv = {
20+
apiUrl: "https://testplanit.example.com",
21+
apiToken: "tpi_testtoken",
22+
};
23+
24+
const deps = { env: mockEnv };
25+
26+
const okSubmitResponse = (id: number) => ({
27+
ok: true,
28+
status: 200,
29+
text: async () => JSON.stringify({ result: { id } }),
30+
});
31+
32+
const errorSubmitResponse = (status: number, body: unknown) => ({
33+
ok: false,
34+
status,
35+
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
36+
});
37+
38+
function makeRawDetail(overrides: Record<string, unknown> = {}) {
39+
return {
40+
id: 999,
41+
statusId: 1,
42+
status: { id: 1, name: "Passed" },
43+
executedBy: { id: "u1", name: "Alice", email: "a@b" },
44+
editedBy: null,
45+
editedAt: null,
46+
executedAt: "2026-02-01T00:00:00.000Z",
47+
attempt: 1,
48+
elapsed: null,
49+
notes: null,
50+
evidence: null,
51+
testRunCase: {
52+
id: 50,
53+
repositoryCaseId: 100,
54+
repositoryCase: { id: 100, name: "Case 1", source: "MANUAL" },
55+
testRun: { id: 7, name: "Run A" },
56+
},
57+
attachments: [],
58+
issues: [],
59+
resultFieldValues: [],
60+
stepResults: [],
61+
...overrides,
62+
};
63+
}
64+
65+
const RUN_CASE = {
66+
id: 50,
67+
testRunId: 7,
68+
testRun: { projectId: 1 },
69+
repositoryCase: { templateId: 12 },
70+
};
71+
72+
async function setupClient() {
73+
const server = new McpServer({ name: "test", version: "0.0.0" });
74+
registerRunResultsCreate(server, deps);
75+
const [clientTransport, serverTransport] =
76+
InMemoryTransport.createLinkedPair();
77+
const client = new Client({ name: "test-client", version: "0.0.0" });
78+
await server.connect(serverTransport);
79+
await client.connect(clientTransport);
80+
return { client };
81+
}
82+
83+
describe("registerRunResultsCreate", () => {
84+
beforeEach(() => {
85+
mockZenstack.mockReset();
86+
mockFetch.mockReset();
87+
});
88+
89+
it("creates a minimal result (no fieldValues): looks up runCase, status, count, re-fetch", async () => {
90+
// 1. testRunCases.findUnique
91+
mockZenstack.mockResolvedValueOnce(RUN_CASE);
92+
// 2. status.findMany
93+
mockZenstack.mockResolvedValueOnce([{ id: 5 }]);
94+
// 3. testRunResults.count
95+
mockZenstack.mockResolvedValueOnce(2);
96+
// submit-result via fetch
97+
mockFetch.mockResolvedValueOnce(okSubmitResponse(999));
98+
// 4. testRunResults.findUnique (re-fetch)
99+
mockZenstack.mockResolvedValueOnce(makeRawDetail());
100+
101+
const { client } = await setupClient();
102+
const result = await client.callTool({
103+
name: "testplanit_test_run_results_create",
104+
arguments: { testRunCaseId: 50, statusName: "Passed" },
105+
});
106+
107+
expect(result.isError).toBeFalsy();
108+
expect(mockFetch).toHaveBeenCalledTimes(1);
109+
const body = JSON.parse(mockFetch.mock.calls[0]?.[1].body as string);
110+
expect(body).toMatchObject({
111+
testRunId: 7,
112+
testRunCaseId: 50,
113+
statusId: 5,
114+
attempt: 3,
115+
});
116+
expect(body.fieldValues).toBeUndefined();
117+
});
118+
119+
it("resolves fieldValues by displayName (case-insensitive) → fieldId; sends them in the submit payload", async () => {
120+
mockZenstack.mockResolvedValueOnce(RUN_CASE);
121+
mockZenstack.mockResolvedValueOnce([{ id: 5 }]);
122+
// templateResultAssignment.findMany
123+
mockZenstack.mockResolvedValueOnce([
124+
{
125+
resultField: {
126+
id: 11,
127+
displayName: "Defect Severity",
128+
systemName: "defect_severity",
129+
},
130+
},
131+
{
132+
resultField: {
133+
id: 12,
134+
displayName: "Reproducibility",
135+
systemName: "reproducibility",
136+
},
137+
},
138+
]);
139+
mockZenstack.mockResolvedValueOnce(0); // count
140+
mockFetch.mockResolvedValueOnce(okSubmitResponse(999));
141+
mockZenstack.mockResolvedValueOnce(makeRawDetail());
142+
143+
const { client } = await setupClient();
144+
const result = await client.callTool({
145+
name: "testplanit_test_run_results_create",
146+
arguments: {
147+
testRunCaseId: 50,
148+
statusName: "Failed",
149+
fieldValues: [
150+
{ name: "defect severity", value: "High" }, // case-insensitive displayName
151+
{ name: "reproducibility", value: 3 }, // number coerced to string
152+
],
153+
},
154+
});
155+
156+
expect(result.isError).toBeFalsy();
157+
const body = JSON.parse(mockFetch.mock.calls[0]?.[1].body as string);
158+
expect(body.fieldValues).toEqual([
159+
{ fieldId: 11, value: "High" },
160+
{ fieldId: 12, value: "3" },
161+
]);
162+
});
163+
164+
it("resolves fieldValues by systemName when displayName doesn't match", async () => {
165+
mockZenstack.mockResolvedValueOnce(RUN_CASE);
166+
mockZenstack.mockResolvedValueOnce([{ id: 5 }]);
167+
mockZenstack.mockResolvedValueOnce([
168+
{
169+
resultField: {
170+
id: 11,
171+
displayName: "Defect Severity",
172+
systemName: "severity",
173+
},
174+
},
175+
]);
176+
mockZenstack.mockResolvedValueOnce(0);
177+
mockFetch.mockResolvedValueOnce(okSubmitResponse(999));
178+
mockZenstack.mockResolvedValueOnce(makeRawDetail());
179+
180+
const { client } = await setupClient();
181+
const result = await client.callTool({
182+
name: "testplanit_test_run_results_create",
183+
arguments: {
184+
testRunCaseId: 50,
185+
statusName: "Passed",
186+
fieldValues: [{ name: "severity", value: "Low" }],
187+
},
188+
});
189+
190+
expect(result.isError).toBeFalsy();
191+
const body = JSON.parse(mockFetch.mock.calls[0]?.[1].body as string);
192+
expect(body.fieldValues).toEqual([{ fieldId: 11, value: "Low" }]);
193+
});
194+
195+
it("returns isError with the available field list when a fieldValues name doesn't match", async () => {
196+
mockZenstack.mockResolvedValueOnce(RUN_CASE);
197+
mockZenstack.mockResolvedValueOnce([{ id: 5 }]);
198+
mockZenstack.mockResolvedValueOnce([
199+
{
200+
resultField: {
201+
id: 11,
202+
displayName: "Defect Severity",
203+
systemName: "defect_severity",
204+
},
205+
},
206+
]);
207+
208+
const { client } = await setupClient();
209+
const result = await client.callTool({
210+
name: "testplanit_test_run_results_create",
211+
arguments: {
212+
testRunCaseId: 50,
213+
statusName: "Passed",
214+
fieldValues: [{ name: "Unknown Field", value: "x" }],
215+
},
216+
});
217+
218+
expect(result.isError).toBe(true);
219+
const text = (result.content as Array<{ text: string }>)[0].text;
220+
expect(text).toMatch(/Unknown result field name\(s\): Unknown Field/);
221+
expect(text).toMatch(/Defect Severity/);
222+
expect(mockFetch).not.toHaveBeenCalled();
223+
});
224+
225+
it("surfaces REQUIRED_FIELDS_MISSING from the server unchanged (gate intact)", async () => {
226+
mockZenstack.mockResolvedValueOnce(RUN_CASE);
227+
mockZenstack.mockResolvedValueOnce([{ id: 5 }]);
228+
mockZenstack.mockResolvedValueOnce(0); // count (no fieldValues path, no template lookup)
229+
mockFetch.mockResolvedValueOnce(
230+
errorSubmitResponse(400, {
231+
error: "A required result field is missing a value",
232+
code: "REQUIRED_FIELDS_MISSING",
233+
})
234+
);
235+
236+
const { client } = await setupClient();
237+
const result = await client.callTool({
238+
name: "testplanit_test_run_results_create",
239+
arguments: { testRunCaseId: 50, statusName: "Passed" },
240+
});
241+
242+
expect(result.isError).toBe(true);
243+
const text = (result.content as Array<{ text: string }>)[0].text;
244+
expect(text).toMatch(/required result field is missing/);
245+
});
246+
247+
it("rejects fieldValues when the case has no template (templateId null)", async () => {
248+
mockZenstack.mockResolvedValueOnce({
249+
...RUN_CASE,
250+
repositoryCase: { templateId: null },
251+
});
252+
mockZenstack.mockResolvedValueOnce([{ id: 5 }]);
253+
254+
const { client } = await setupClient();
255+
const result = await client.callTool({
256+
name: "testplanit_test_run_results_create",
257+
arguments: {
258+
testRunCaseId: 50,
259+
statusName: "Passed",
260+
fieldValues: [{ name: "Severity", value: "High" }],
261+
},
262+
});
263+
264+
expect(result.isError).toBe(true);
265+
const text = (result.content as Array<{ text: string }>)[0].text;
266+
expect(text).toMatch(/no template/i);
267+
expect(mockFetch).not.toHaveBeenCalled();
268+
});
269+
270+
it("returns the not-found error when the testRunCase doesn't exist", async () => {
271+
mockZenstack.mockResolvedValueOnce(null);
272+
273+
const { client } = await setupClient();
274+
const result = await client.callTool({
275+
name: "testplanit_test_run_results_create",
276+
arguments: { testRunCaseId: 999, statusName: "Passed" },
277+
});
278+
279+
expect(result.isError).toBe(true);
280+
const text = (result.content as Array<{ text: string }>)[0].text;
281+
expect(text).toMatch(/TestRunCase 999 not found/);
282+
expect(mockFetch).not.toHaveBeenCalled();
283+
});
284+
});

0 commit comments

Comments
 (0)