@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.
@@ -0,0 +1,167 @@
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 registerTestTools(server, client) {
10
+ server.tool('list_tests', 'Search/list tests in the current project. Uses the /tests/search endpoint.', {
11
+ search: z.string().optional().describe('Search term to filter tests by name or content'),
12
+ page: z.number().int().optional().describe('Page number for pagination'),
13
+ per_page: z.number().int().max(50).optional().describe('Number of results per page (max 50)'),
14
+ exclude_ids: z.string().optional().describe('Comma-separated list of test IDs to exclude from results'),
15
+ project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
16
+ },
17
+ // project_id is destructured separately and passed as the trailing verb argument —
18
+ // never spread into params/body. This is the reference pattern for SR-378 slices 2–10.
19
+ async ({ search, page, per_page, exclude_ids, project_id }) => {
20
+ try {
21
+ const params = {};
22
+ if (search !== undefined)
23
+ params.search = search;
24
+ if (page !== undefined)
25
+ params.page = page;
26
+ if (per_page !== undefined)
27
+ params.per_page = per_page;
28
+ if (exclude_ids !== undefined)
29
+ params.exclude_ids = exclude_ids;
30
+ const result = await client.get('/tests/search', params, project_id);
31
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
32
+ }
33
+ catch (err) {
34
+ return handleError(err);
35
+ }
36
+ });
37
+ server.tool('get_test', 'Get a single test case by ID.', {
38
+ id: z.number().int().describe('Test ID'),
39
+ }, async ({ id }) => {
40
+ try {
41
+ const result = await client.get(`/tests/${id}`);
42
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
43
+ }
44
+ catch (err) {
45
+ return handleError(err);
46
+ }
47
+ });
48
+ server.tool('get_test_history', 'Get the audit/activity history for a single test (read-only). Returns the AuditLog entries for the test, newest first — who changed what and when. Maps to GET /tests/:id/history.', {
49
+ id: z.number().int().describe('Test ID'),
50
+ project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
51
+ }, async ({ id, project_id }) => {
52
+ try {
53
+ const result = await client.get(`/tests/${id}/history`, {}, 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('create_test', 'Create a new test case in the current project.', {
61
+ name: z.string().describe('Test case name'),
62
+ test_kind: z.object({ id: z.number().int().describe('Test kind ID') }).describe('Test kind — required by the API. Use get_test on an existing test to find a valid ID.'),
63
+ preconditions: z.string().optional().describe('Preconditions for the test'),
64
+ steps: z.string().optional().describe('Test steps as free text'),
65
+ expectation: z.string().optional().describe('Expected outcome'),
66
+ gherkin: z.string().optional().describe('Gherkin-format scenario (Given/When/Then)'),
67
+ automation_status: z.string().optional().describe('Automation status, e.g. "automated" or "manual"'),
68
+ product_area: z.object({ id: z.number().int().describe('Product area ID') }).optional().describe('Product area object with ID'),
69
+ test_preconditions: z.array(z.unknown()).optional().describe('Structured precondition objects'),
70
+ test_steps: z.array(z.unknown()).optional().describe('Structured test step objects'),
71
+ custom_fields: z.record(z.string(), z.unknown()).optional().describe('Custom field key-value pairs'),
72
+ }, async ({ name, test_kind, preconditions, steps, expectation, gherkin, automation_status, product_area, test_preconditions, test_steps, custom_fields }) => {
73
+ try {
74
+ const body = { name, test_kind };
75
+ if (preconditions !== undefined)
76
+ body.preconditions = preconditions;
77
+ if (steps !== undefined)
78
+ body.steps = steps;
79
+ if (expectation !== undefined)
80
+ body.expectation = expectation;
81
+ if (gherkin !== undefined)
82
+ body.gherkin = gherkin;
83
+ if (automation_status !== undefined)
84
+ body.automation_status = automation_status;
85
+ if (product_area !== undefined)
86
+ body.product_area = product_area;
87
+ if (test_preconditions !== undefined)
88
+ body.test_preconditions = test_preconditions;
89
+ if (test_steps !== undefined)
90
+ body.test_steps = test_steps;
91
+ if (custom_fields !== undefined)
92
+ body.custom_fields = custom_fields;
93
+ const result = await client.post('/tests', body);
94
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
95
+ }
96
+ catch (err) {
97
+ return handleError(err);
98
+ }
99
+ });
100
+ server.tool('update_test', 'Update an existing test case. Requires the current lock_version to prevent overwriting concurrent edits. NOTE: test_kind must always be included — the API clears it if omitted. Use get_test first to read the current value.', {
101
+ id: z.number().int().describe('Test ID'),
102
+ lock_version: z.number().int().describe('Lock version from the last get_test response — required to prevent overwriting concurrent edits'),
103
+ test_kind: z.object({ id: z.number().int().describe('Test kind ID') }).describe('Test kind — must be included to avoid the API clearing it. Use get_test to read the current value.'),
104
+ name: z.string().optional().describe('Test case name'),
105
+ preconditions: z.string().optional().describe('Preconditions for the test'),
106
+ steps: z.string().optional().describe('Test steps as free text'),
107
+ expectation: z.string().optional().describe('Expected outcome'),
108
+ gherkin: z.string().optional().describe('Gherkin-format scenario (Given/When/Then)'),
109
+ automation_status: z.string().optional().describe('Automation status, e.g. "automated" or "manual"'),
110
+ product_area: z.object({ id: z.number().int().describe('Product area ID') }).optional().describe('Product area object with ID'),
111
+ test_preconditions: z.array(z.unknown()).optional().describe('Structured precondition objects'),
112
+ test_steps: z.array(z.unknown()).optional().describe('Structured test step objects'),
113
+ custom_fields: z.record(z.string(), z.unknown()).optional().describe('Custom field key-value pairs'),
114
+ }, async ({ id, lock_version, test_kind, name, preconditions, steps, expectation, gherkin, automation_status, product_area, test_preconditions, test_steps, custom_fields }) => {
115
+ try {
116
+ const body = { lock_version, test_kind };
117
+ if (name !== undefined)
118
+ body.name = name;
119
+ if (preconditions !== undefined)
120
+ body.preconditions = preconditions;
121
+ if (steps !== undefined)
122
+ body.steps = steps;
123
+ if (expectation !== undefined)
124
+ body.expectation = expectation;
125
+ if (gherkin !== undefined)
126
+ body.gherkin = gherkin;
127
+ if (automation_status !== undefined)
128
+ body.automation_status = automation_status;
129
+ if (product_area !== undefined)
130
+ body.product_area = product_area;
131
+ if (test_preconditions !== undefined)
132
+ body.test_preconditions = test_preconditions;
133
+ if (test_steps !== undefined)
134
+ body.test_steps = test_steps;
135
+ if (custom_fields !== undefined)
136
+ body.custom_fields = custom_fields;
137
+ const result = await client.put(`/tests/${id}`, body);
138
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
139
+ }
140
+ catch (err) {
141
+ return handleError(err);
142
+ }
143
+ });
144
+ server.tool('clone_test', 'Clone an existing test case, creating a duplicate.', {
145
+ id: z.number().int().describe('Test ID to clone'),
146
+ }, async ({ id }) => {
147
+ try {
148
+ const result = await client.post(`/tests/${id}/clone`, {});
149
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
150
+ }
151
+ catch (err) {
152
+ return handleError(err);
153
+ }
154
+ });
155
+ server.tool('delete_test', 'Permanently delete a single test case by ID. This action is IRREVERSIBLE. Deletion is permitted wherever the authenticated user can access the test (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 test is not found. The destroy route responds 204 No Content, so success is reported as { deleted: true, id }.', {
156
+ id: z.number().int().describe('Test ID to delete'),
157
+ project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
158
+ }, async ({ id, project_id }) => {
159
+ try {
160
+ await client.delete(`/tests/${id}`, project_id);
161
+ return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }, null, 2) }] };
162
+ }
163
+ catch (err) {
164
+ return handleError(err);
165
+ }
166
+ });
167
+ }
@@ -0,0 +1,27 @@
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 registerUserTools(server, client) {
9
+ server.tool('list_users', 'List all users in the current account.', {}, async () => {
10
+ try {
11
+ const result = await client.get('/users');
12
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
13
+ }
14
+ catch (err) {
15
+ return handleError(err);
16
+ }
17
+ });
18
+ server.tool('get_current_user', 'Get the currently authenticated user profile.', {}, async () => {
19
+ try {
20
+ const result = await client.get('/users/me');
21
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
22
+ }
23
+ catch (err) {
24
+ return handleError(err);
25
+ }
26
+ });
27
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod';
2
+ function handleError(err) {
3
+ const e = err;
4
+ const msg = e.status === 422
5
+ ? `Unsupported source_type (422) on ${e.method} ${e.path}: the entity type cannot be watched. 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
+ // Watching in SmartRuns is polymorphic and driven by two flat params,
10
+ // `source_type` + `source_id`, on a pair of singular routes:
11
+ // POST /watch -> WatchersController#create (add current user as watcher)
12
+ // DELETE /watch -> WatchersController#destroy (remove current user as watcher)
13
+ // The supported types are WatchersController::SUPPORTED_WATCHABLES. The watcher is
14
+ // always the authenticated user (set server-side); the entity itself is resolved
15
+ // with for_current_account.for_current_project, so these calls are project-scoped.
16
+ // Keep this enum in sync with WatchersController::SUPPORTED_WATCHABLES.
17
+ const SOURCE_TYPES = ['test', 'test_plan', 'test_run', 'test_suite', 'defect'];
18
+ const sourceTypeSchema = z
19
+ .enum(SOURCE_TYPES)
20
+ .describe('Type of entity to watch (WatchersController::SUPPORTED_WATCHABLES).');
21
+ export function registerWatcherTools(server, client) {
22
+ server.tool('watch_entity', 'Subscribe the authenticated user to notifications for an entity (test, test_plan, test_run, test_suite, or defect). Identify the entity with source_type + source_id. The watcher is always the current user (set server-side). Returns the entity\'s updated watcher list as { watchers: [...] }.', {
23
+ source_type: sourceTypeSchema,
24
+ source_id: z.number().int().describe('ID of the entity to watch'),
25
+ project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
26
+ }, async ({ source_type, source_id, project_id }) => {
27
+ try {
28
+ const result = await client.post('/watch', { source_type, source_id }, project_id);
29
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
30
+ }
31
+ catch (err) {
32
+ return handleError(err);
33
+ }
34
+ });
35
+ server.tool('unwatch_entity', 'Unsubscribe the authenticated user from notifications for an entity (test, test_plan, test_run, test_suite, or defect). Identify the entity with source_type + source_id. Returns the entity\'s updated watcher list as { watchers: [...] }.', {
36
+ source_type: sourceTypeSchema,
37
+ source_id: z.number().int().describe('ID of the entity to stop watching'),
38
+ project_id: z.string().optional().describe('Override the project for this call (WTProject header). Defaults to SMARTRUNS_PROJECT_ID.'),
39
+ }, async ({ source_type, source_id, project_id }) => {
40
+ try {
41
+ // DELETE /watch identifies the target via a JSON body, not the path.
42
+ const result = await client.delete('/watch', project_id, { source_type, source_id });
43
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
44
+ }
45
+ catch (err) {
46
+ return handleError(err);
47
+ }
48
+ });
49
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@smartruns/mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for the SmartRuns REST API",
5
+ "type": "module",
6
+ "bin": {
7
+ "smartruns-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc && node scripts/add-shebang.mjs",
21
+ "start": "node dist/index.js",
22
+ "dev": "tsx src/index.ts",
23
+ "test": "vitest run",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "1.29.0",
28
+ "zod": "^4.4.3"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^25.9.3",
32
+ "tsx": "^4.22.4",
33
+ "typescript": "^6.0.3",
34
+ "vitest": "^3.2.4"
35
+ }
36
+ }