@smartruns/mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -0
- package/dist/api-client.js +142 -0
- package/dist/config.js +33 -0
- package/dist/index.js +19 -0
- package/dist/server.js +42 -0
- package/dist/tools/ai.js +134 -0
- package/dist/tools/comments.js +72 -0
- package/dist/tools/defects.js +150 -0
- package/dist/tools/labels.js +54 -0
- package/dist/tools/notifications.js +55 -0
- package/dist/tools/projects.js +35 -0
- package/dist/tools/reference-data.js +82 -0
- package/dist/tools/specs.js +146 -0
- package/dist/tools/statuses.js +18 -0
- package/dist/tools/test-plans.js +133 -0
- package/dist/tools/test-runs.js +113 -0
- package/dist/tools/test-suites.js +150 -0
- package/dist/tools/tests.js +167 -0
- package/dist/tools/users.js +27 -0
- package/dist/tools/watchers.js +49 -0
- package/dist/types.js +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function handleError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
const msg = e.status === 409
|
|
5
|
+
? `Conflict (lock_version stale). Re-fetch the record and retry with the updated lock_version. API response: ${JSON.stringify(e.body)}`
|
|
6
|
+
: `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
7
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
8
|
+
}
|
|
9
|
+
// SR-382 (slice 3) introduces this file with ONLY delete_spec. SR-384 (slice 6) adds
|
|
10
|
+
// the rest of the specs toolset (list / get / create / update / history) to this same
|
|
11
|
+
// file — keep additions additive and do not duplicate the file.
|
|
12
|
+
//
|
|
13
|
+
// Routes verified against config/routes.rb (`resources :specs` + member `get :history`)
|
|
14
|
+
// and app/controllers/specs_controller.rb — the namespace integration tokens / PATs hit
|
|
15
|
+
// (the web_app/ controller is the SPA's and is not reachable by token auth):
|
|
16
|
+
// GET /specs (index → PLAIN ARRAY of list_payload, supports page/per_page)
|
|
17
|
+
// GET /specs/:id (show)
|
|
18
|
+
// POST /specs (create → spec_payload; title required, status enum)
|
|
19
|
+
// PUT /specs/:id (update → spec_payload; PARTIAL via strong-params permit)
|
|
20
|
+
// GET /specs/:id/history (member → PLAIN ARRAY of AuditLog entries, newest-first)
|
|
21
|
+
// DELETE /specs/:id (destroy → { deleted: true })
|
|
22
|
+
// Strong params (spec_params): title, description, status, acceptance_criteria:
|
|
23
|
+
// [id, description, position, _destroy]. The controller also reads params[:tickets].
|
|
24
|
+
// IMPORTANT: Spec is NOT optimistically locked — there is no lock_version column, so
|
|
25
|
+
// update_spec must NOT require or send one (unlike test-suites/test-runs). Because the
|
|
26
|
+
// controller uses params.permit + assign_attributes, omitted fields are left untouched,
|
|
27
|
+
// so update_spec is a true partial update.
|
|
28
|
+
const SPEC_STATUSES = ['draft', 'active', 'archived'];
|
|
29
|
+
// Acceptance criteria are nested records. On create supply { description, position };
|
|
30
|
+
// on update include { id } to edit an existing criterion or { id, _destroy: true } to
|
|
31
|
+
// remove one. Shape mirrors the controller's permitted acceptance_criteria keys.
|
|
32
|
+
const acceptanceCriterionShape = z.object({
|
|
33
|
+
id: z.number().int().optional().describe('Existing acceptance criterion ID (omit to create a new one)'),
|
|
34
|
+
description: z.string().optional().describe('Acceptance criterion text'),
|
|
35
|
+
position: z.number().int().optional().describe('Ordering position (0-based)'),
|
|
36
|
+
_destroy: z.boolean().optional().describe('Set true to delete this existing criterion (requires id)'),
|
|
37
|
+
});
|
|
38
|
+
export function registerSpecTools(server, client) {
|
|
39
|
+
server.tool('list_specs', 'List specs (spec-driven-development entities) in the current project, newest first. Returns a plain array of specs, each with an acceptance_criteria_count. NOTE: Specs are feature-gated (SR-371); if the flag is off for the account the API may return 403/404, surfaced verbatim.', {
|
|
40
|
+
page: z.number().int().optional().describe('Page number for pagination (default 1)'),
|
|
41
|
+
per_page: z.number().int().optional().describe('Results per page (default 10)'),
|
|
42
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
43
|
+
}, async ({ page, per_page, project_id }) => {
|
|
44
|
+
try {
|
|
45
|
+
const params = {};
|
|
46
|
+
if (page !== undefined)
|
|
47
|
+
params.page = page;
|
|
48
|
+
if (per_page !== undefined)
|
|
49
|
+
params.per_page = per_page;
|
|
50
|
+
const result = await client.get('/specs', params, project_id);
|
|
51
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return handleError(err);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
server.tool('get_spec', 'Get a single spec by ID, including its acceptance_criteria, linked Jira tickets, attachments, linked_tests and coverage roll-up.', {
|
|
58
|
+
id: z.number().int().describe('Spec ID'),
|
|
59
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
60
|
+
}, async ({ id, project_id }) => {
|
|
61
|
+
try {
|
|
62
|
+
const result = await client.get(`/specs/${id}`, undefined, project_id);
|
|
63
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return handleError(err);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
server.tool('create_spec', 'Create a new spec in the current project. Only title is required; status defaults to "draft". Optionally seed acceptance_criteria (use { description, position }).', {
|
|
70
|
+
title: z.string().describe('Spec title (required — the model validates its presence)'),
|
|
71
|
+
description: z.string().optional().describe('Spec description / body'),
|
|
72
|
+
status: z.enum(SPEC_STATUSES).optional().describe('Spec status: draft (default), active or archived'),
|
|
73
|
+
acceptance_criteria: z.array(acceptanceCriterionShape).optional().describe('Acceptance criteria to create with the spec, each { description, position }'),
|
|
74
|
+
tickets: z.array(z.record(z.string(), z.unknown())).optional().describe('Linked Jira tickets to associate (each e.g. { id, key, name, kind, source, status })'),
|
|
75
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
76
|
+
}, async ({ title, description, status, acceptance_criteria, tickets, project_id }) => {
|
|
77
|
+
try {
|
|
78
|
+
const body = { title };
|
|
79
|
+
if (description !== undefined)
|
|
80
|
+
body.description = description;
|
|
81
|
+
if (status !== undefined)
|
|
82
|
+
body.status = status;
|
|
83
|
+
if (acceptance_criteria !== undefined)
|
|
84
|
+
body.acceptance_criteria = acceptance_criteria;
|
|
85
|
+
if (tickets !== undefined)
|
|
86
|
+
body.tickets = tickets;
|
|
87
|
+
const result = await client.post('/specs', body, project_id);
|
|
88
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return handleError(err);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
server.tool('update_spec', 'Update an existing spec. This is a PARTIAL update — only the fields you pass are changed; omitted fields are left untouched. Specs are NOT optimistically locked, so no lock_version is needed. To edit acceptance criteria include their { id }; to remove one pass { id, _destroy: true }.', {
|
|
95
|
+
id: z.number().int().describe('Spec ID'),
|
|
96
|
+
title: z.string().optional().describe('New spec title (omit to leave unchanged)'),
|
|
97
|
+
description: z.string().optional().describe('New spec description / body'),
|
|
98
|
+
status: z.enum(SPEC_STATUSES).optional().describe('New spec status: draft, active or archived'),
|
|
99
|
+
acceptance_criteria: z.array(acceptanceCriterionShape).optional().describe('Acceptance criteria changes: new ones, edits (with id), or removals (id + _destroy)'),
|
|
100
|
+
tickets: z.array(z.record(z.string(), z.unknown())).optional().describe('Replacement set of linked Jira tickets'),
|
|
101
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
102
|
+
}, async ({ id, title, description, status, acceptance_criteria, tickets, project_id }) => {
|
|
103
|
+
try {
|
|
104
|
+
const body = {};
|
|
105
|
+
if (title !== undefined)
|
|
106
|
+
body.title = title;
|
|
107
|
+
if (description !== undefined)
|
|
108
|
+
body.description = description;
|
|
109
|
+
if (status !== undefined)
|
|
110
|
+
body.status = status;
|
|
111
|
+
if (acceptance_criteria !== undefined)
|
|
112
|
+
body.acceptance_criteria = acceptance_criteria;
|
|
113
|
+
if (tickets !== undefined)
|
|
114
|
+
body.tickets = tickets;
|
|
115
|
+
const result = await client.put(`/specs/${id}`, body, project_id);
|
|
116
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return handleError(err);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
server.tool('get_spec_history', 'Get the activity/history feed for a spec (SR-370): the AuditLog entries (spec_create / spec_update / spec_delete events) for this spec, newest-first. Returns the raw history array.', {
|
|
123
|
+
id: z.number().int().describe('Spec ID'),
|
|
124
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
125
|
+
}, async ({ id, project_id }) => {
|
|
126
|
+
try {
|
|
127
|
+
const result = await client.get(`/specs/${id}/history`, undefined, project_id);
|
|
128
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return handleError(err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
server.tool('delete_spec', 'Permanently delete a single spec by ID. This action is IRREVERSIBLE. Deletion is permitted wherever the authenticated user can access the spec (account/project tenant-scoped) — there is no dedicated RBAC permission gate on this route — but the server still surfaces a 403/404 if access is denied or the spec is not found.', {
|
|
135
|
+
id: z.number().int().describe('Spec ID to delete'),
|
|
136
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
137
|
+
}, async ({ id, project_id }) => {
|
|
138
|
+
try {
|
|
139
|
+
await client.delete(`/specs/${id}`, project_id);
|
|
140
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }, null, 2) }] };
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return handleError(err);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function handleError(err) {
|
|
2
|
+
const e = err;
|
|
3
|
+
const msg = e.status === 409
|
|
4
|
+
? `Conflict (lock_version stale). Re-fetch the record and retry with the updated lock_version. API response: ${JSON.stringify(e.body)}`
|
|
5
|
+
: `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
6
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
7
|
+
}
|
|
8
|
+
export function registerStatusTools(server, client) {
|
|
9
|
+
server.tool('list_statuses', 'List all available statuses grouped by scope (test plan, test run, defect, etc.). Returns an object keyed by scope name.', {}, async () => {
|
|
10
|
+
try {
|
|
11
|
+
const result = await client.get('/status');
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
return handleError(err);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function handleError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
const msg = e.status === 409
|
|
5
|
+
? `Conflict (lock_version stale). Re-fetch the record and retry with the updated lock_version. API response: ${JSON.stringify(e.body)}`
|
|
6
|
+
: `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
7
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
8
|
+
}
|
|
9
|
+
export function registerTestPlanTools(server, client) {
|
|
10
|
+
server.tool('list_test_plans', 'List test plans in the current project with optional filters.', {
|
|
11
|
+
page: z.number().int().optional().describe('Page number for pagination'),
|
|
12
|
+
order: z.string().optional().describe('Sort order, e.g. "created_at desc"'),
|
|
13
|
+
status_id: z.number().int().optional().describe('Filter by status ID'),
|
|
14
|
+
assignee_id: z.number().int().optional().describe('Filter by assignee user ID'),
|
|
15
|
+
search: z.string().optional().describe('Search term to filter by name'),
|
|
16
|
+
}, async ({ page, order, status_id, assignee_id, search }) => {
|
|
17
|
+
try {
|
|
18
|
+
const params = {};
|
|
19
|
+
if (page !== undefined)
|
|
20
|
+
params.page = page;
|
|
21
|
+
if (order !== undefined)
|
|
22
|
+
params.order = order;
|
|
23
|
+
if (status_id !== undefined)
|
|
24
|
+
params.status_id = status_id;
|
|
25
|
+
if (assignee_id !== undefined)
|
|
26
|
+
params.assignee_id = assignee_id;
|
|
27
|
+
if (search !== undefined)
|
|
28
|
+
params.search = search;
|
|
29
|
+
const result = await client.get('/test-plans', params);
|
|
30
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return handleError(err);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
server.tool('get_test_plan', 'Get a single test plan by ID.', {
|
|
37
|
+
id: z.number().int().describe('Test plan ID'),
|
|
38
|
+
}, async ({ id }) => {
|
|
39
|
+
try {
|
|
40
|
+
const result = await client.get(`/test-plans/${id}`);
|
|
41
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return handleError(err);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
server.tool('create_test_plan', 'Create a new test plan in the current project.', {
|
|
48
|
+
name: z.string().describe('Test plan name'),
|
|
49
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).describe('Status object with ID'),
|
|
50
|
+
reference: z.string().optional().describe('External reference or ticket key'),
|
|
51
|
+
preconditions: z.string().optional().describe('Preconditions for the test plan'),
|
|
52
|
+
assignee: z.object({ id: z.number().int().describe('Assignee user ID') }).optional().describe('Assignee object with user ID'),
|
|
53
|
+
labels: z.array(z.object({ id: z.number().int() })).optional().describe('Array of label objects with IDs'),
|
|
54
|
+
tests: z.array(z.object({ id: z.number().int() })).optional().describe('Array of test objects with IDs to include'),
|
|
55
|
+
stages: z.array(z.unknown()).optional().describe('Array of stage definitions'),
|
|
56
|
+
is_template: z.boolean().optional().describe('Whether this test plan is a template'),
|
|
57
|
+
}, async ({ name, status, reference, preconditions, assignee, labels, tests, stages, is_template }) => {
|
|
58
|
+
try {
|
|
59
|
+
const body = { name, status };
|
|
60
|
+
if (reference !== undefined)
|
|
61
|
+
body.reference = reference;
|
|
62
|
+
if (preconditions !== undefined)
|
|
63
|
+
body.preconditions = preconditions;
|
|
64
|
+
if (assignee !== undefined)
|
|
65
|
+
body.assignee = assignee;
|
|
66
|
+
if (labels !== undefined)
|
|
67
|
+
body.labels = labels;
|
|
68
|
+
if (tests !== undefined)
|
|
69
|
+
body.tests = tests;
|
|
70
|
+
if (stages !== undefined)
|
|
71
|
+
body.stages = stages;
|
|
72
|
+
if (is_template !== undefined)
|
|
73
|
+
body.is_template = is_template;
|
|
74
|
+
const result = await client.post('/test-plans', body);
|
|
75
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return handleError(err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
server.tool('update_test_plan', 'Update an existing test plan. Requires the current lock_version to prevent overwriting concurrent edits.', {
|
|
82
|
+
id: z.number().int().describe('Test plan ID'),
|
|
83
|
+
lock_version: z.number().int().describe('Lock version from the last get_test_plan response — required to prevent overwriting concurrent edits'),
|
|
84
|
+
name: z.string().optional().describe('Test plan name'),
|
|
85
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).optional().describe('Status object with ID'),
|
|
86
|
+
reference: z.string().optional().describe('External reference or ticket key'),
|
|
87
|
+
preconditions: z.string().optional().describe('Preconditions for the test plan'),
|
|
88
|
+
assignee: z.object({ id: z.number().int().describe('Assignee user ID') }).optional().describe('Assignee object with user ID'),
|
|
89
|
+
labels: z.array(z.object({ id: z.number().int() })).optional().describe('Array of label objects with IDs'),
|
|
90
|
+
tests: z.array(z.object({ id: z.number().int() })).optional().describe('Array of test objects with IDs to include'),
|
|
91
|
+
stages: z.array(z.unknown()).optional().describe('Array of stage definitions'),
|
|
92
|
+
is_template: z.boolean().optional().describe('Whether this test plan is a template'),
|
|
93
|
+
}, async ({ id, lock_version, name, status, reference, preconditions, assignee, labels, tests, stages, is_template }) => {
|
|
94
|
+
try {
|
|
95
|
+
const body = { lock_version };
|
|
96
|
+
if (name !== undefined)
|
|
97
|
+
body.name = name;
|
|
98
|
+
if (status !== undefined)
|
|
99
|
+
body.status = status;
|
|
100
|
+
if (reference !== undefined)
|
|
101
|
+
body.reference = reference;
|
|
102
|
+
if (preconditions !== undefined)
|
|
103
|
+
body.preconditions = preconditions;
|
|
104
|
+
if (assignee !== undefined)
|
|
105
|
+
body.assignee = assignee;
|
|
106
|
+
if (labels !== undefined)
|
|
107
|
+
body.labels = labels;
|
|
108
|
+
if (tests !== undefined)
|
|
109
|
+
body.tests = tests;
|
|
110
|
+
if (stages !== undefined)
|
|
111
|
+
body.stages = stages;
|
|
112
|
+
if (is_template !== undefined)
|
|
113
|
+
body.is_template = is_template;
|
|
114
|
+
const result = await client.put(`/test-plans/${id}`, body);
|
|
115
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return handleError(err);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
server.tool('delete_test_plan', 'Permanently delete a single test plan by ID (along with its plan-scoped tests and uploads). This action is IRREVERSIBLE. Deletion is RBAC-gated server-side (test_plans_delete_mine / test_plans_delete_all): if the authenticated user lacks permission, the API returns 403 and no record is removed.', {
|
|
122
|
+
id: z.number().int().describe('Test plan ID to delete'),
|
|
123
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
124
|
+
}, async ({ id, project_id }) => {
|
|
125
|
+
try {
|
|
126
|
+
await client.delete(`/test-plans/${id}`, project_id);
|
|
127
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }, null, 2) }] };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return handleError(err);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function handleError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
const msg = e.status === 409
|
|
5
|
+
? `Conflict (lock_version stale). Re-fetch the record and retry with the updated lock_version. API response: ${JSON.stringify(e.body)}`
|
|
6
|
+
: `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
7
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
8
|
+
}
|
|
9
|
+
export function registerTestRunTools(server, client) {
|
|
10
|
+
server.tool('list_test_runs', 'List test runs in the current project with optional filters.', {
|
|
11
|
+
page: z.number().int().optional().describe('Page number for pagination'),
|
|
12
|
+
jira_ticket_key: z.string().optional().describe('Filter by associated Jira ticket key, e.g. "PROJ-123"'),
|
|
13
|
+
}, async ({ page, jira_ticket_key }) => {
|
|
14
|
+
try {
|
|
15
|
+
const params = {};
|
|
16
|
+
if (page !== undefined)
|
|
17
|
+
params.page = page;
|
|
18
|
+
if (jira_ticket_key !== undefined)
|
|
19
|
+
params.jira_ticket_key = jira_ticket_key;
|
|
20
|
+
const result = await client.get('/test-runs', params);
|
|
21
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return handleError(err);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
server.tool('get_test_run', 'Get a single test run by ID.', {
|
|
28
|
+
id: z.number().int().describe('Test run ID'),
|
|
29
|
+
}, async ({ id }) => {
|
|
30
|
+
try {
|
|
31
|
+
const result = await client.get(`/test-runs/${id}`);
|
|
32
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return handleError(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
server.tool('create_test_run', 'Create a new test run in the current project.', {
|
|
39
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).describe('Status object with ID'),
|
|
40
|
+
stage: z.object({ id: z.number().int().describe('Stage ID') }).describe('Stage object with ID'),
|
|
41
|
+
details: z.string().optional().describe('Free-text details or description of the test run'),
|
|
42
|
+
assignee: z.object({ id: z.number().int().describe('Assignee user ID') }).optional().describe('Assignee object with user ID'),
|
|
43
|
+
test_plan: z.object({ id: z.number().int().describe('Test plan ID') }).optional().describe('Associated test plan'),
|
|
44
|
+
// The API binds this to testing_environment_id via params.dig(:testing_environment, :id),
|
|
45
|
+
// so it must be an object { id } — a bare string is silently ignored (SR-380).
|
|
46
|
+
testing_environment: z.object({ id: z.number().int().describe('Testing environment ID') }).optional().describe('Testing environment object with ID — use list_testing_environments to find a valid ID.'),
|
|
47
|
+
test_results: z.array(z.unknown()).optional().describe('Array of test result objects to include in the run'),
|
|
48
|
+
custom_fields: z.record(z.string(), z.unknown()).optional().describe('Custom field key-value pairs'),
|
|
49
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
50
|
+
}, async ({ status, stage, details, assignee, test_plan, testing_environment, test_results, custom_fields, project_id }) => {
|
|
51
|
+
try {
|
|
52
|
+
const body = { status, stage };
|
|
53
|
+
if (details !== undefined)
|
|
54
|
+
body.details = details;
|
|
55
|
+
if (assignee !== undefined)
|
|
56
|
+
body.assignee = assignee;
|
|
57
|
+
if (test_plan !== undefined)
|
|
58
|
+
body.test_plan = test_plan;
|
|
59
|
+
if (testing_environment !== undefined)
|
|
60
|
+
body.testing_environment = testing_environment;
|
|
61
|
+
if (test_results !== undefined)
|
|
62
|
+
body.test_results = test_results;
|
|
63
|
+
if (custom_fields !== undefined)
|
|
64
|
+
body.custom_fields = custom_fields;
|
|
65
|
+
const result = await client.post('/test-runs', body, project_id);
|
|
66
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return handleError(err);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
server.tool('update_test_run', 'Update an existing test run. Requires the current lock_version to prevent overwriting concurrent edits.', {
|
|
73
|
+
id: z.number().int().describe('Test run ID'),
|
|
74
|
+
lock_version: z.number().int().describe('Lock version from the last get_test_run response — required to prevent overwriting concurrent edits'),
|
|
75
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).optional().describe('Status object with ID'),
|
|
76
|
+
stage: z.object({ id: z.number().int().describe('Stage ID') }).optional().describe('Stage object with ID'),
|
|
77
|
+
details: z.string().optional().describe('Free-text details or description of the test run'),
|
|
78
|
+
assignee: z.object({ id: z.number().int().describe('Assignee user ID') }).optional().describe('Assignee object with user ID'),
|
|
79
|
+
test_plan: z.object({ id: z.number().int().describe('Test plan ID') }).optional().describe('Associated test plan'),
|
|
80
|
+
// Same object shape as create_test_run — bound to testing_environment_id by the API (SR-380).
|
|
81
|
+
testing_environment: z.object({ id: z.number().int().describe('Testing environment ID') }).optional().describe('Testing environment object with ID — use list_testing_environments to find a valid ID.'),
|
|
82
|
+
test_results: z.array(z.object({
|
|
83
|
+
id: z.number().int().describe('Test result ID'),
|
|
84
|
+
lock_version: z.number().int().describe('Test result lock version'),
|
|
85
|
+
}).passthrough()).optional().describe('Array of test result objects with id and lock_version'),
|
|
86
|
+
custom_fields: z.record(z.string(), z.unknown()).optional().describe('Custom field key-value pairs'),
|
|
87
|
+
}, async ({ id, lock_version, status, stage, details, assignee, test_plan, testing_environment, test_results, custom_fields }) => {
|
|
88
|
+
try {
|
|
89
|
+
const body = { lock_version };
|
|
90
|
+
if (status !== undefined)
|
|
91
|
+
body.status = status;
|
|
92
|
+
if (stage !== undefined)
|
|
93
|
+
body.stage = stage;
|
|
94
|
+
if (details !== undefined)
|
|
95
|
+
body.details = details;
|
|
96
|
+
if (assignee !== undefined)
|
|
97
|
+
body.assignee = assignee;
|
|
98
|
+
if (test_plan !== undefined)
|
|
99
|
+
body.test_plan = test_plan;
|
|
100
|
+
if (testing_environment !== undefined)
|
|
101
|
+
body.testing_environment = testing_environment;
|
|
102
|
+
if (test_results !== undefined)
|
|
103
|
+
body.test_results = test_results;
|
|
104
|
+
if (custom_fields !== undefined)
|
|
105
|
+
body.custom_fields = custom_fields;
|
|
106
|
+
const result = await client.put(`/test-runs/${id}`, body);
|
|
107
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return handleError(err);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function handleError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
let msg;
|
|
5
|
+
if (e.status === 409) {
|
|
6
|
+
// Not every 409 here is a stale optimistic-lock conflict. update_test_suite can hit a
|
|
7
|
+
// lock conflict (body carries a lock_version), but create_test_suite can also return 409
|
|
8
|
+
// for a plain validation conflict (e.g. "Assignee must exist"). Only call it a lock
|
|
9
|
+
// conflict when the body actually carries lock_version, so we don't mislabel the rest.
|
|
10
|
+
const isLockConflict = typeof e.body === 'object' && e.body !== null && 'lock_version' in e.body;
|
|
11
|
+
msg = isLockConflict
|
|
12
|
+
? `Conflict (409, stale lock_version) on ${e.method} ${e.path}. Re-fetch the record and retry with the updated lock_version. API response: ${JSON.stringify(e.body)}`
|
|
13
|
+
: `Conflict (409) on ${e.method} ${e.path}: the request conflicts with the current state (e.g. a validation conflict such as a missing/invalid assignee), not necessarily a stale lock. API response: ${JSON.stringify(e.body)}`;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
msg = `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
17
|
+
}
|
|
18
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
19
|
+
}
|
|
20
|
+
// SR-383 (slice 5) — full test-suites tool surface.
|
|
21
|
+
// Routes verified against config/routes.rb + app/controllers/test_suites_controller.rb:
|
|
22
|
+
// GET /test-suites (index → { entries, meta.pagination })
|
|
23
|
+
// GET /test-suites/:id (show)
|
|
24
|
+
// POST /test-suites (create)
|
|
25
|
+
// PUT /test-suites/:id (update — optimistic-locked via lock_version)
|
|
26
|
+
// POST /test-suites/:id/clone (clone member route)
|
|
27
|
+
// DELETE /test-suites/:id (destroy — RBAC-gated, returns 200 { deleted: true })
|
|
28
|
+
// The controller reads params directly (no strong-params block): name + status.id are
|
|
29
|
+
// required (status_id/name presence-validated); assignee.id, jira_version_key,
|
|
30
|
+
// custom_fields and test_plans are optional. There is NO web_app/ counterpart.
|
|
31
|
+
export function registerTestSuiteTools(server, client) {
|
|
32
|
+
server.tool('list_test_suites', 'List test suites in the current project. Returns a paginated { entries, meta.pagination } payload.', {
|
|
33
|
+
page: z.number().int().optional().describe('Page number for pagination'),
|
|
34
|
+
order: z.string().optional().describe('Sort order as "<field>_<dir>", e.g. "created_desc", "updated_asc", "name_asc". Defaults to created_desc.'),
|
|
35
|
+
name: z.string().optional().describe('Filter by suite name (case-insensitive LIKE match)'),
|
|
36
|
+
status_id: z.number().int().optional().describe('Filter by status ID'),
|
|
37
|
+
assignee_id: z.number().int().optional().describe('Filter by assignee user ID'),
|
|
38
|
+
author_id: z.number().int().optional().describe('Filter by author user ID'),
|
|
39
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
40
|
+
}, async ({ page, order, name, status_id, assignee_id, author_id, project_id }) => {
|
|
41
|
+
try {
|
|
42
|
+
const params = {};
|
|
43
|
+
if (page !== undefined)
|
|
44
|
+
params.page = page;
|
|
45
|
+
if (order !== undefined)
|
|
46
|
+
params.order = order;
|
|
47
|
+
if (name !== undefined)
|
|
48
|
+
params.name = name;
|
|
49
|
+
if (status_id !== undefined)
|
|
50
|
+
params.status_id = status_id;
|
|
51
|
+
if (assignee_id !== undefined)
|
|
52
|
+
params.assignee_id = assignee_id;
|
|
53
|
+
if (author_id !== undefined)
|
|
54
|
+
params.author_id = author_id;
|
|
55
|
+
const result = await client.get('/test-suites', params, project_id);
|
|
56
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return handleError(err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
server.tool('get_test_suite', 'Get a single test suite by ID, including its test plans, watchers and lock_version.', {
|
|
63
|
+
id: z.number().int().describe('Test suite ID'),
|
|
64
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
65
|
+
}, async ({ id, project_id }) => {
|
|
66
|
+
try {
|
|
67
|
+
const result = await client.get(`/test-suites/${id}`, undefined, project_id);
|
|
68
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return handleError(err);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
server.tool('create_test_suite', 'Create a new test suite in the current project. name, status AND assignee are all required: although only name/status are model-validated, the backend rejects a missing assignee with a 409 "Assignee must exist", so assignee is de-facto required and is enforced client-side here to fail fast. Use list_statuses to find a valid status ID and list_users to find a valid assignee ID.', {
|
|
75
|
+
name: z.string().describe('Test suite name (required)'),
|
|
76
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).describe('Status object with ID (required)'),
|
|
77
|
+
assignee: z.object({ id: z.number().int().describe('Assignee user ID') }).describe('Assignee object with user ID (required — the backend returns 409 "Assignee must exist" if omitted)'),
|
|
78
|
+
jira_version_key: z.string().optional().describe('Associated Jira version key'),
|
|
79
|
+
test_plans: z.array(z.object({ id: z.number().int() })).optional().describe('Array of test plan objects with IDs to attach to the suite'),
|
|
80
|
+
custom_fields: z.record(z.string(), z.unknown()).optional().describe('Custom field key-value pairs'),
|
|
81
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
82
|
+
}, async ({ name, status, assignee, jira_version_key, test_plans, custom_fields, project_id }) => {
|
|
83
|
+
try {
|
|
84
|
+
const body = { name, status, assignee };
|
|
85
|
+
if (jira_version_key !== undefined)
|
|
86
|
+
body.jira_version_key = jira_version_key;
|
|
87
|
+
if (test_plans !== undefined)
|
|
88
|
+
body.test_plans = test_plans;
|
|
89
|
+
if (custom_fields !== undefined)
|
|
90
|
+
body.custom_fields = custom_fields;
|
|
91
|
+
const result = await client.post('/test-suites', body, project_id);
|
|
92
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return handleError(err);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.tool('update_test_suite', 'Update an existing test suite. Requires the current lock_version to prevent overwriting concurrent edits (409 on stale). NOTE: the API overwrites name, status, assignee and jira_version_key from the payload — omitting name or status clears them and fails validation, so always include both. Use get_test_suite first to read current values.', {
|
|
99
|
+
id: z.number().int().describe('Test suite ID'),
|
|
100
|
+
lock_version: z.number().int().describe('Lock version from the last get_test_suite response — required to prevent overwriting concurrent edits'),
|
|
101
|
+
name: z.string().describe('Test suite name — must be included (the API clears it if omitted)'),
|
|
102
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).describe('Status object with ID — must be included (the API clears it if omitted)'),
|
|
103
|
+
assignee: z.object({ id: z.number().int().describe('Assignee user ID') }).optional().describe('Assignee object with user ID'),
|
|
104
|
+
jira_version_key: z.string().optional().describe('Associated Jira version key'),
|
|
105
|
+
test_plans: z.array(z.object({ id: z.number().int() })).optional().describe('Array of test plan objects with IDs — replaces the current set of attached plans'),
|
|
106
|
+
custom_fields: z.record(z.string(), z.unknown()).optional().describe('Custom field key-value pairs'),
|
|
107
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
108
|
+
}, async ({ id, lock_version, name, status, assignee, jira_version_key, test_plans, custom_fields, project_id }) => {
|
|
109
|
+
try {
|
|
110
|
+
const body = { lock_version, name, status };
|
|
111
|
+
if (assignee !== undefined)
|
|
112
|
+
body.assignee = assignee;
|
|
113
|
+
if (jira_version_key !== undefined)
|
|
114
|
+
body.jira_version_key = jira_version_key;
|
|
115
|
+
if (test_plans !== undefined)
|
|
116
|
+
body.test_plans = test_plans;
|
|
117
|
+
if (custom_fields !== undefined)
|
|
118
|
+
body.custom_fields = custom_fields;
|
|
119
|
+
const result = await client.put(`/test-suites/${id}`, body, project_id);
|
|
120
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return handleError(err);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
server.tool('clone_test_suite', 'Clone an existing test suite, creating a duplicate named "Clone of \'<name>\'" with the same attached test plans and custom fields. Cloning is RBAC-gated (test_suites_create).', {
|
|
127
|
+
id: z.number().int().describe('Test suite ID to clone'),
|
|
128
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
129
|
+
}, async ({ id, project_id }) => {
|
|
130
|
+
try {
|
|
131
|
+
const result = await client.post(`/test-suites/${id}/clone`, {}, project_id);
|
|
132
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return handleError(err);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
server.tool('delete_test_suite', 'Permanently delete a single test suite by ID. This action is IRREVERSIBLE. Deletion is RBAC-gated server-side (test_suites_delete_mine / test_suites_delete_all): if the authenticated user lacks permission, the API returns 403 and no record is removed.', {
|
|
139
|
+
id: z.number().int().describe('Test suite ID to delete'),
|
|
140
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
141
|
+
}, async ({ id, project_id }) => {
|
|
142
|
+
try {
|
|
143
|
+
await client.delete(`/test-suites/${id}`, project_id);
|
|
144
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }, null, 2) }] };
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return handleError(err);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|