@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
package/dist/tools/ai.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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-387 (slice 9, BILLING-flagged). Every tool description in this file MUST begin
|
|
10
|
+
// with this warning so an external LLM treats the tool cautiously and a user knows the
|
|
11
|
+
// cost: invoking any of these tools generates content via the SmartRuns AI backend and
|
|
12
|
+
// consumes the account's AI credits (and may incur billing). This is a hard AC (AC3).
|
|
13
|
+
const CREDIT_WARNING = '⚠ Consumes AI credits and may incur billing — invoking this generates content via the SmartRuns AI backend.';
|
|
14
|
+
export function registerAiTools(server, client) {
|
|
15
|
+
// SYNCHRONOUS. POST /tests/generate-tests-with-ai (collection action `generate` in
|
|
16
|
+
// TestsController). Body: { jira_ticket_key }. Returns the generated proposal JSON in
|
|
17
|
+
// the same response (TestCaseGenerator.call). Consumes AI credits server-side.
|
|
18
|
+
server.tool('generate_tests_with_ai', `${CREDIT_WARNING} Synchronously generates proposed test cases for a Jira ticket and returns them in the response. This is a SYNCHRONOUS call (no polling needed). For the richer async agent-task flow with progress logs and confirmation, use create_agent_task instead.`, {
|
|
19
|
+
jira_ticket_key: z.string().describe('Jira ticket key to generate tests from, e.g. "SR-123" (required)'),
|
|
20
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
21
|
+
}, async ({ jira_ticket_key, project_id }) => {
|
|
22
|
+
try {
|
|
23
|
+
const result = await client.post('/tests/generate-tests-with-ai', { jira_ticket_key }, project_id);
|
|
24
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return handleError(err);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
// ASYNC fire-and-forget. POST /tests/:id/request-ai-suggestions (member action) enqueues
|
|
31
|
+
// TestAiWorker and returns { status: 'job scheduled' } immediately. There is NO dedicated
|
|
32
|
+
// poll endpoint for this worker — re-read the test (get_test) afterwards to see suggestions.
|
|
33
|
+
server.tool('request_ai_suggestions', `${CREDIT_WARNING} Asynchronously requests AI suggestions/improvements for an EXISTING test case. Enqueues a background job and returns immediately with { status: "job scheduled" } — it does NOT return the suggestions inline and has no dedicated poll endpoint; re-read the test with get_test after a short delay to see the applied suggestions.`, {
|
|
34
|
+
id: z.number().int().describe('ID of the existing test to request AI suggestions for (required)'),
|
|
35
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
36
|
+
}, async ({ id, project_id }) => {
|
|
37
|
+
try {
|
|
38
|
+
const result = await client.post(`/tests/${id}/request-ai-suggestions`, {}, project_id);
|
|
39
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return handleError(err);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// ASYNC create→poll→confirm. POST /agent_tasks creates an AgentTask, returns 202 with
|
|
46
|
+
// { task: { id, status: { value } } } where status starts at "pending"/"running". The
|
|
47
|
+
// create is CREDIT-GATED server-side (enforce_ai_credits!): when the account has
|
|
48
|
+
// exhausted its monthly AI credits the API returns 403 { error: { code: 'plan_limit' } }.
|
|
49
|
+
// Do NOT block inside this tool — poll get_agent_task until the status is terminal.
|
|
50
|
+
server.tool('create_agent_task', `${CREDIT_WARNING} Creates an ASYNCHRONOUS AI agent task (test-case generation or requirements review) and returns its task id immediately (HTTP 202) — it does NOT wait for completion. Poll get_agent_task with the returned id until status is terminal (succeeded / failed / awaiting_confirmation). If AI credits are exhausted the API returns 403 plan_limit and no task is created. For a TestCaseGenerationTask that reaches "awaiting_confirmation", call confirm_agent_task to persist the generated tests.`, {
|
|
51
|
+
type: z.string().optional().describe('Task type: "test_case_generation" (default, or "TestCaseGenerationTask") or "requirements_review" (or "RequirementsReviewTask"). Omit for test-case generation.'),
|
|
52
|
+
jira_ticket_key: z.string().optional().describe('Jira ticket key to drive the task, e.g. "SR-123". Provide this or jira_ticket_keys.'),
|
|
53
|
+
jira_ticket_keys: z.array(z.string()).optional().describe('Multiple Jira ticket keys (alternative to jira_ticket_key).'),
|
|
54
|
+
custom_instructions: z.string().optional().describe('Extra guidance appended to the AI prompt.'),
|
|
55
|
+
selected_bot_id: z.number().int().optional().describe('Associate the run with a specific bot profile.'),
|
|
56
|
+
create_test_plan: z.boolean().optional().describe('TestCaseGenerationTask only: also create a test plan from the generated tests.'),
|
|
57
|
+
spec_id: z.number().int().optional().describe('TestCaseGenerationTask only: generate from a Spec instead of a Jira ticket.'),
|
|
58
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
59
|
+
}, async ({ type, jira_ticket_key, jira_ticket_keys, custom_instructions, selected_bot_id, create_test_plan, spec_id, project_id }) => {
|
|
60
|
+
try {
|
|
61
|
+
const body = {};
|
|
62
|
+
if (type !== undefined)
|
|
63
|
+
body.type = type;
|
|
64
|
+
if (jira_ticket_key !== undefined)
|
|
65
|
+
body.jira_ticket_key = jira_ticket_key;
|
|
66
|
+
if (jira_ticket_keys !== undefined)
|
|
67
|
+
body.jira_ticket_keys = jira_ticket_keys;
|
|
68
|
+
if (custom_instructions !== undefined)
|
|
69
|
+
body.custom_instructions = custom_instructions;
|
|
70
|
+
if (selected_bot_id !== undefined)
|
|
71
|
+
body.selected_bot_id = selected_bot_id;
|
|
72
|
+
if (create_test_plan !== undefined)
|
|
73
|
+
body.create_test_plan = create_test_plan;
|
|
74
|
+
if (spec_id !== undefined)
|
|
75
|
+
body.spec_id = spec_id;
|
|
76
|
+
const result = await client.post('/agent_tasks', body, project_id);
|
|
77
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return handleError(err);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// GET /agent_tasks/:id — the STATUS/RESULT route. Returns { task, logs, result, result_objects }.
|
|
84
|
+
// This is the poll endpoint for create_agent_task. Reading does not consume credits, but
|
|
85
|
+
// the warning is retained so the tool stays grouped and clearly part of the AI flow.
|
|
86
|
+
server.tool('get_agent_task', `${CREDIT_WARNING} (This read-only poll itself does not consume credits, but it belongs to the credit-consuming agent-task flow.) Polls a single AI agent task by id and returns its status, progress logs, and result payload. Call repeatedly after create_agent_task until status.value is terminal (succeeded / failed / awaiting_confirmation). Returns 404 if the task is not in the current account/project scope.`, {
|
|
87
|
+
id: z.number().int().describe('Agent task id returned by create_agent_task (required)'),
|
|
88
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
89
|
+
}, async ({ id, project_id }) => {
|
|
90
|
+
try {
|
|
91
|
+
const result = await client.get(`/agent_tasks/${id}`, undefined, project_id);
|
|
92
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return handleError(err);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// GET /agent_tasks — list/filter tasks for the current account/project. Optional status
|
|
99
|
+
// and type filters (Rails Array.wrap accepts the scalar form). Read-only.
|
|
100
|
+
server.tool('list_agent_tasks', `${CREDIT_WARNING} (This read-only listing itself does not consume credits, but it belongs to the credit-consuming agent-task flow.) Lists AI agent tasks for the current project, optionally filtered by status and type. Use it to discover in-progress or completed tasks to poll with get_agent_task.`, {
|
|
101
|
+
status: z.string().optional().describe('Filter by status, e.g. "pending", "running", "awaiting_confirmation", "succeeded", "failed".'),
|
|
102
|
+
type: z.string().optional().describe('Filter by type, e.g. "test_case_generation" or "requirements_review".'),
|
|
103
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
104
|
+
}, async ({ status, type, project_id }) => {
|
|
105
|
+
try {
|
|
106
|
+
const params = {};
|
|
107
|
+
if (status !== undefined)
|
|
108
|
+
params.status = status;
|
|
109
|
+
if (type !== undefined)
|
|
110
|
+
params.type = type;
|
|
111
|
+
const result = await client.get('/agent_tasks', params, project_id);
|
|
112
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return handleError(err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
// POST /agent_tasks/:id/confirm — confirms a TestCaseGenerationTask that is in the
|
|
119
|
+
// "awaiting_confirmation" state, persisting the generated tests. Returns { task,
|
|
120
|
+
// created_tests, test_plan, spec_id, spec_tests }. 404 if missing; 422 if the task is
|
|
121
|
+
// not awaiting confirmation or is a different task type.
|
|
122
|
+
server.tool('confirm_agent_task', `${CREDIT_WARNING} (This confirmation persists already-generated content; the credits were spent during create_agent_task.) Confirms a test-case-generation agent task that is in the "awaiting_confirmation" state, persisting the generated tests into the project. Call this only after get_agent_task reports status.value = "awaiting_confirmation". Returns 422 if the task is not awaiting confirmation.`, {
|
|
123
|
+
id: z.number().int().describe('Agent task id to confirm (must be awaiting_confirmation) (required)'),
|
|
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.post(`/agent_tasks/${id}/confirm`, {}, project_id);
|
|
128
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return handleError(err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function handleError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
const msg = e.status === 403
|
|
5
|
+
? `Forbidden (403) on ${e.method} ${e.path}: editing or deleting a comment may be restricted to its author, or your token lacks the required permission. 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
|
+
// Comments in SmartRuns are polymorphic but NOT via Rails-standard commentable_type /
|
|
10
|
+
// commentable_id, and NOT nested under a parent route. The CommentsController
|
|
11
|
+
// (app/controllers/comments_controller.rb) drives everything off two flat params,
|
|
12
|
+
// `source_type` + `source_id`, validated against Comment::SUPPORTED_SCOPES
|
|
13
|
+
// (app/models/comment.rb). The comment body lives on the `text` attribute. Keep this
|
|
14
|
+
// enum in sync with Comment::SUPPORTED_SCOPES.
|
|
15
|
+
const SOURCE_TYPES = ['test', 'test_plan', 'test_run', 'test_suite', 'spec'];
|
|
16
|
+
const sourceTypeSchema = z
|
|
17
|
+
.enum(SOURCE_TYPES)
|
|
18
|
+
.describe('Parent entity type the comment belongs to (Comment::SUPPORTED_SCOPES).');
|
|
19
|
+
export function registerCommentTools(server, client) {
|
|
20
|
+
server.tool('list_comments', 'List comments on a parent entity (test, test_plan, test_run, test_suite, or spec). Comments are polymorphic: identify the parent with source_type + source_id. Scoped to the current account and project server-side.', {
|
|
21
|
+
source_type: sourceTypeSchema,
|
|
22
|
+
source_id: z.number().int().describe('ID of the parent entity the comments belong to'),
|
|
23
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
24
|
+
}, async ({ source_type, source_id, project_id }) => {
|
|
25
|
+
try {
|
|
26
|
+
const result = await client.get('/comments', { source_type, source_id }, project_id);
|
|
27
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return handleError(err);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
server.tool('create_comment', 'Create a comment on a parent entity (test, test_plan, test_run, test_suite, or spec). The comment body goes in the "text" field. The parent is identified by source_type + source_id. The author is set server-side from the authenticated token.', {
|
|
34
|
+
source_type: sourceTypeSchema,
|
|
35
|
+
source_id: z.number().int().describe('ID of the parent entity to comment on'),
|
|
36
|
+
text: z.string().describe('The comment body (required)'),
|
|
37
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
38
|
+
}, async ({ source_type, source_id, text, project_id }) => {
|
|
39
|
+
try {
|
|
40
|
+
const result = await client.post('/comments', { source_type, source_id, text }, project_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('update_comment', 'Update an existing comment by ID. The new body goes in the "text" field. NOTE: the server may restrict editing to the comment\'s own author (RBAC: comments_edit_mine / comments_edit_all); if not permitted the API returns 403.', {
|
|
48
|
+
id: z.number().int().describe('Comment ID to update'),
|
|
49
|
+
text: z.string().describe('The new comment body (required)'),
|
|
50
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
51
|
+
}, async ({ id, text, project_id }) => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await client.put(`/comments/${id}`, { text }, project_id);
|
|
54
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return handleError(err);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
server.tool('delete_comment', 'Permanently delete a comment by ID. This action is IRREVERSIBLE. NOTE: the server may restrict deletion to the comment\'s own author (RBAC: comments_delete_mine / comments_delete_all); if not permitted the API returns 403 and no record is removed.', {
|
|
61
|
+
id: z.number().int().describe('Comment ID to delete'),
|
|
62
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
63
|
+
}, async ({ id, project_id }) => {
|
|
64
|
+
try {
|
|
65
|
+
await client.delete(`/comments/${id}`, project_id);
|
|
66
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }, null, 2) }] };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return handleError(err);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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 registerDefectTools(server, client) {
|
|
10
|
+
server.tool('list_defects', 'List defects in the current project. At most ONE filter (ticket_id, pull_request_id, or test_run_id) may be specified at a time.', {
|
|
11
|
+
page: z.number().int().optional().describe('Page number for pagination'),
|
|
12
|
+
per_page: z.number().int().optional().describe('Number of results per page'),
|
|
13
|
+
ticket_id: z.number().int().optional().describe('Filter by Jira ticket ID (mutually exclusive with pull_request_id and test_run_id)'),
|
|
14
|
+
pull_request_id: z.number().int().optional().describe('Filter by pull request ID (mutually exclusive with ticket_id and test_run_id)'),
|
|
15
|
+
test_run_id: z.number().int().optional().describe('Filter by test run ID (mutually exclusive with ticket_id and pull_request_id)'),
|
|
16
|
+
}, async ({ page, per_page, ticket_id, pull_request_id, test_run_id }) => {
|
|
17
|
+
const filterCount = [ticket_id, pull_request_id, test_run_id].filter(v => v !== undefined).length;
|
|
18
|
+
if (filterCount > 1) {
|
|
19
|
+
return {
|
|
20
|
+
isError: true,
|
|
21
|
+
content: [{ type: 'text', text: 'At most one filter (ticket_id, pull_request_id, or test_run_id) may be specified at a time.' }],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const params = {};
|
|
26
|
+
if (page !== undefined)
|
|
27
|
+
params.page = page;
|
|
28
|
+
if (per_page !== undefined)
|
|
29
|
+
params.per_page = per_page;
|
|
30
|
+
if (ticket_id !== undefined)
|
|
31
|
+
params.ticket_id = ticket_id;
|
|
32
|
+
if (pull_request_id !== undefined)
|
|
33
|
+
params.pull_request_id = pull_request_id;
|
|
34
|
+
if (test_run_id !== undefined)
|
|
35
|
+
params.test_run_id = test_run_id;
|
|
36
|
+
const result = await client.get('/defects', params);
|
|
37
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return handleError(err);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
server.tool('get_defect', 'Get a single defect by ID.', {
|
|
44
|
+
id: z.number().int().describe('Defect ID'),
|
|
45
|
+
}, async ({ id }) => {
|
|
46
|
+
try {
|
|
47
|
+
const result = await client.get(`/defects/${id}`);
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return handleError(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.tool('create_defect', 'Create a new defect in the current project. summary, steps, current, expected and status are all required by the Defect model — use list_statuses (scope: defect) to find a valid status ID.', {
|
|
55
|
+
// Required set mirrors Defect model validations (app/models/defect.rb:14-15):
|
|
56
|
+
// summary, steps, current, expected and status_id all validate presence: true.
|
|
57
|
+
// `creator` is server-set; `state` is auto-derived from status (before_validation
|
|
58
|
+
// :sync_state_from_status), so both stay off / optional here (SR-380).
|
|
59
|
+
summary: z.string().describe('Short summary describing the defect (required)'),
|
|
60
|
+
steps: z.string().describe('Steps to reproduce the defect (required)'),
|
|
61
|
+
current: z.string().describe('Current (actual) behavior (required)'),
|
|
62
|
+
expected: z.string().describe('Expected behavior (required)'),
|
|
63
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).describe('Status object with ID (required) — drives the defect state'),
|
|
64
|
+
extra: z.string().optional().describe('Additional context or notes'),
|
|
65
|
+
state: z.string().optional().describe('Defect state — usually omitted; the API derives it from the status'),
|
|
66
|
+
ticket_id: z.number().int().optional().describe('Associated Jira ticket ID'),
|
|
67
|
+
pull_request_id: z.number().int().optional().describe('Associated pull request ID'),
|
|
68
|
+
test_run_id: z.number().int().optional().describe('Associated test run ID'),
|
|
69
|
+
test_plan_id: z.number().int().optional().describe('Associated test plan ID'),
|
|
70
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
71
|
+
}, async ({ summary, steps, current, expected, extra, state, status, ticket_id, pull_request_id, test_run_id, test_plan_id, project_id }) => {
|
|
72
|
+
try {
|
|
73
|
+
const body = { summary, steps, current, expected, status };
|
|
74
|
+
if (extra !== undefined)
|
|
75
|
+
body.extra = extra;
|
|
76
|
+
if (state !== undefined)
|
|
77
|
+
body.state = state;
|
|
78
|
+
if (ticket_id !== undefined)
|
|
79
|
+
body.ticket_id = ticket_id;
|
|
80
|
+
if (pull_request_id !== undefined)
|
|
81
|
+
body.pull_request_id = pull_request_id;
|
|
82
|
+
if (test_run_id !== undefined)
|
|
83
|
+
body.test_run_id = test_run_id;
|
|
84
|
+
if (test_plan_id !== undefined)
|
|
85
|
+
body.test_plan_id = test_plan_id;
|
|
86
|
+
const result = await client.post('/defects', body, project_id);
|
|
87
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return handleError(err);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
server.tool('update_defect', 'Update an existing defect.', {
|
|
94
|
+
id: z.number().int().describe('Defect ID'),
|
|
95
|
+
summary: z.string().optional().describe('Short summary describing the defect'),
|
|
96
|
+
steps: z.string().optional().describe('Steps to reproduce the defect'),
|
|
97
|
+
current: z.string().optional().describe('Current (actual) behavior'),
|
|
98
|
+
expected: z.string().optional().describe('Expected behavior'),
|
|
99
|
+
extra: z.string().optional().describe('Additional context or notes'),
|
|
100
|
+
state: z.string().optional().describe('Defect state, e.g. "open" or "closed"'),
|
|
101
|
+
status: z.object({ id: z.number().int().describe('Status ID') }).optional().describe('Status object with ID'),
|
|
102
|
+
ticket_id: z.number().int().optional().describe('Associated Jira ticket ID'),
|
|
103
|
+
pull_request_id: z.number().int().optional().describe('Associated pull request ID'),
|
|
104
|
+
test_run_id: z.number().int().optional().describe('Associated test run ID'),
|
|
105
|
+
test_plan_id: z.number().int().optional().describe('Associated test plan ID'),
|
|
106
|
+
}, async ({ id, summary, steps, current, expected, extra, state, status, ticket_id, pull_request_id, test_run_id, test_plan_id }) => {
|
|
107
|
+
try {
|
|
108
|
+
const body = {};
|
|
109
|
+
if (summary !== undefined)
|
|
110
|
+
body.summary = summary;
|
|
111
|
+
if (steps !== undefined)
|
|
112
|
+
body.steps = steps;
|
|
113
|
+
if (current !== undefined)
|
|
114
|
+
body.current = current;
|
|
115
|
+
if (expected !== undefined)
|
|
116
|
+
body.expected = expected;
|
|
117
|
+
if (extra !== undefined)
|
|
118
|
+
body.extra = extra;
|
|
119
|
+
if (state !== undefined)
|
|
120
|
+
body.state = state;
|
|
121
|
+
if (status !== undefined)
|
|
122
|
+
body.status = status;
|
|
123
|
+
if (ticket_id !== undefined)
|
|
124
|
+
body.ticket_id = ticket_id;
|
|
125
|
+
if (pull_request_id !== undefined)
|
|
126
|
+
body.pull_request_id = pull_request_id;
|
|
127
|
+
if (test_run_id !== undefined)
|
|
128
|
+
body.test_run_id = test_run_id;
|
|
129
|
+
if (test_plan_id !== undefined)
|
|
130
|
+
body.test_plan_id = test_plan_id;
|
|
131
|
+
const result = await client.put(`/defects/${id}`, body);
|
|
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_defect', 'Permanently delete a single defect by ID. This action is IRREVERSIBLE. Deletion is permitted wherever the authenticated user can access the defect (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 defect is not found.', {
|
|
139
|
+
id: z.number().int().describe('Defect 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(`/defects/${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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// NOTE: the Label model has NO uniqueness validation, so a duplicate name is NOT rejected
|
|
3
|
+
// (create/rename to an existing name succeeds with 200). Do not special-case 409 as a
|
|
4
|
+
// "name already taken" conflict — surface any error uniformly.
|
|
5
|
+
function handleError(err) {
|
|
6
|
+
const e = err;
|
|
7
|
+
const msg = `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
8
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
9
|
+
}
|
|
10
|
+
export function registerLabelTools(server, client) {
|
|
11
|
+
server.tool('list_labels', 'List labels in the current project, optionally filtered by search terms.', {
|
|
12
|
+
terms: z.string().optional().describe('Search terms to filter labels by name'),
|
|
13
|
+
}, async ({ terms }) => {
|
|
14
|
+
try {
|
|
15
|
+
const params = {};
|
|
16
|
+
if (terms !== undefined)
|
|
17
|
+
params.terms = terms;
|
|
18
|
+
const result = await client.get('/labels', params);
|
|
19
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return handleError(err);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
// Labels are scoped to the current account AND project server-side
|
|
26
|
+
// (LabelsController uses for_current_account.for_current_project), so both
|
|
27
|
+
// create and update honour the per-call project_id (WTProject header).
|
|
28
|
+
// NOTE: merge and delete are intentionally NOT exposed (out of scope for SR-386).
|
|
29
|
+
server.tool('create_label', 'Create a label in the current project. The only writable field is "name"; the account and project are set server-side from the authenticated token / WTProject header. Returns the created label. NOTE: label names are NOT required to be unique — creating a label whose name already exists succeeds (200) and yields a second label; duplicates are not rejected.', {
|
|
30
|
+
name: z.string().describe('The label name (required). Duplicate names are allowed — the Label model does not enforce uniqueness.'),
|
|
31
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
32
|
+
}, async ({ name, project_id }) => {
|
|
33
|
+
try {
|
|
34
|
+
const result = await client.post('/labels', { name }, project_id);
|
|
35
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return handleError(err);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
server.tool('update_label', 'Rename an existing label by ID. The only writable field is "name". Returns the updated label, or 404 if the label does not exist in the current project. NOTE: label names are NOT required to be unique, so renaming to a name that already exists is allowed and succeeds (it does NOT return a conflict). This is a rename only — it is NOT a merge.', {
|
|
42
|
+
id: z.number().int().describe('Label ID to update'),
|
|
43
|
+
name: z.string().describe('The new label name (required)'),
|
|
44
|
+
project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
45
|
+
}, async ({ id, name, project_id }) => {
|
|
46
|
+
try {
|
|
47
|
+
const result = await client.put(`/labels/${id}`, { name }, project_id);
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return handleError(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function handleError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
const msg = `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
5
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
6
|
+
}
|
|
7
|
+
// Notifications are USER-scoped, not project-scoped: NotificationsController#base_scope
|
|
8
|
+
// is Notification.for_current_account.where(user: Current.user) with no project filter.
|
|
9
|
+
// Therefore these tools intentionally do NOT expose a per-call project_id arg.
|
|
10
|
+
// GET /notifications -> index (list, paginated, ?unread filter)
|
|
11
|
+
// PUT /notifications/:id -> update (mark a single id read/unread)
|
|
12
|
+
// POST /notifications/mark_all_read -> mark_all_read
|
|
13
|
+
export function registerNotificationTools(server, client) {
|
|
14
|
+
server.tool('list_notifications', 'List the authenticated user\'s notifications (paginated, newest first). Notifications are user-scoped, not project-scoped. Returns { entries, meta: { pagination, counts: { unread } } }.', {
|
|
15
|
+
page: z.number().int().positive().optional().describe('Page number (1-based, default 1)'),
|
|
16
|
+
per_page: z.number().int().positive().optional().describe('Items per page (default 25)'),
|
|
17
|
+
unread: z.boolean().optional().describe('When true, return only unread notifications'),
|
|
18
|
+
}, async ({ page, per_page, unread }) => {
|
|
19
|
+
try {
|
|
20
|
+
const params = {};
|
|
21
|
+
if (page !== undefined)
|
|
22
|
+
params.page = page;
|
|
23
|
+
if (per_page !== undefined)
|
|
24
|
+
params.per_page = per_page;
|
|
25
|
+
if (unread !== undefined)
|
|
26
|
+
params.unread = unread;
|
|
27
|
+
const result = await client.get('/notifications', params);
|
|
28
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return handleError(err);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
server.tool('mark_notification_read', 'Mark a single notification as read (or unread) by ID. Pass read=false to mark it unread; defaults to read=true. This is a single-id operation only — use mark_all_notifications_read to clear everything. Returns the updated notification and the remaining unread count.', {
|
|
35
|
+
id: z.number().int().describe('Notification ID to update'),
|
|
36
|
+
read: z.boolean().optional().describe('Read state to set (default true). Pass false to mark unread.'),
|
|
37
|
+
}, async ({ id, read }) => {
|
|
38
|
+
try {
|
|
39
|
+
const result = await client.put(`/notifications/${id}`, { read: read ?? true });
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return handleError(err);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
server.tool('mark_all_notifications_read', 'Mark ALL of the authenticated user\'s unread notifications as read in one call. Returns the remaining unread count (0 on success).', {}, async () => {
|
|
47
|
+
try {
|
|
48
|
+
const result = await client.post('/notifications/mark_all_read', {});
|
|
49
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return handleError(err);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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 registerProjectTools(server, client) {
|
|
10
|
+
server.tool('list_projects', 'List all projects in the account. Optionally filter by archived status.', {
|
|
11
|
+
archived: z.boolean().optional().describe('Filter by archived status. Omit to return all projects.'),
|
|
12
|
+
}, async ({ archived }) => {
|
|
13
|
+
try {
|
|
14
|
+
const params = {};
|
|
15
|
+
if (archived !== undefined)
|
|
16
|
+
params.archived = archived;
|
|
17
|
+
const result = await client.get('/projects', params);
|
|
18
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
return handleError(err);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
server.tool('get_project', 'Get a single project by ID.', {
|
|
25
|
+
id: z.number().int().describe('Project ID'),
|
|
26
|
+
}, async ({ id }) => {
|
|
27
|
+
try {
|
|
28
|
+
const result = await client.get(`/projects/${id}`);
|
|
29
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return handleError(err);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// These are all GET-only reference lookups, so there is no optimistic-lock conflict to
|
|
3
|
+
// handle — a 409 lock_version branch would be dead code. Surface every error uniformly.
|
|
4
|
+
function handleError(err) {
|
|
5
|
+
const e = err;
|
|
6
|
+
const msg = `API error ${e.status} on ${e.method} ${e.path}: ${JSON.stringify(e.body)}`;
|
|
7
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
8
|
+
}
|
|
9
|
+
// Shared per-call project override. Every reference list below is project-scoped on the
|
|
10
|
+
// backend (`for_current_account.for_current_project`), so each accepts an optional project_id
|
|
11
|
+
// that overrides the WTProject header for the call (SR-378 per-call override decision).
|
|
12
|
+
const projectIdArg = {
|
|
13
|
+
project_id: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
|
|
17
|
+
};
|
|
18
|
+
// These are READ-ONLY reference/lookup tools. An LLM calls them to discover valid IDs before
|
|
19
|
+
// constructing a write payload (create_test_run, create_test, create_defect, etc.). There are
|
|
20
|
+
// deliberately NO create/update/delete tools here — reference data is admin/project configuration
|
|
21
|
+
// and is explicitly out of scope for the MCP epic (SR-378 / SR-385).
|
|
22
|
+
export function registerReferenceDataTools(server, client) {
|
|
23
|
+
server.tool('list_product_areas', 'List the product areas configured in the current project (read-only). Call this to get a valid product_area { id } before setting create_test/update_test\'s product_area. Returns a tree of areas; supports an optional name search.', {
|
|
24
|
+
search: z.string().optional().describe('Filter product areas by name (substring match)'),
|
|
25
|
+
...projectIdArg,
|
|
26
|
+
}, async ({ search, project_id }) => {
|
|
27
|
+
try {
|
|
28
|
+
const params = {};
|
|
29
|
+
if (search !== undefined)
|
|
30
|
+
params.search = search;
|
|
31
|
+
const result = await client.get('/product_areas', params, project_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('list_test_kinds', 'List the test kinds configured in the current project (read-only). Call this to get a valid test_kind { id } before create_test/update_test, which require one (the API clears the kind if it is omitted on update).', {
|
|
39
|
+
...projectIdArg,
|
|
40
|
+
}, async ({ project_id }) => {
|
|
41
|
+
try {
|
|
42
|
+
const result = await client.get('/test-kinds', {}, project_id);
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return handleError(err);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
server.tool('list_stages', 'List the stages configured in the current project (read-only). Call this to get a valid stage { id } before create_test_run/update_test_run, which require a stage.', {
|
|
50
|
+
...projectIdArg,
|
|
51
|
+
}, async ({ project_id }) => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await client.get('/stages', {}, project_id);
|
|
54
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return handleError(err);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
server.tool('list_testing_environments', 'List the testing environments configured in the current project (read-only). Call this to get a valid testing_environment { id } for create_test_run/update_test_run, which bind testing_environment to its id.', {
|
|
61
|
+
...projectIdArg,
|
|
62
|
+
}, async ({ project_id }) => {
|
|
63
|
+
try {
|
|
64
|
+
const result = await client.get('/testing-environments', {}, project_id);
|
|
65
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return handleError(err);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
server.tool('list_custom_fields', 'List the custom field definitions configured in the current project (read-only). Call this to learn which custom_fields keys/types a write accepts before populating the custom_fields map on create_test/create_test_run/create_defect. This is read-only — it does NOT create or edit field definitions (that is admin configuration, out of scope).', {
|
|
72
|
+
...projectIdArg,
|
|
73
|
+
}, async ({ project_id }) => {
|
|
74
|
+
try {
|
|
75
|
+
const result = await client.get('/custom-fields', {}, project_id);
|
|
76
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return handleError(err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|