@testomatio/mcp 1.0.13 → 2.0.0-beta.8

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,106 @@
1
+ import { ISSUE_RESOURCE_KEYS } from '../configs/issues-config.js';
2
+
3
+ export const issueMethods = {
4
+ listIssues({ page, per_page: perPage, source, ...resourceQuery } = {}) {
5
+ this.validateIssueResourceQuery(resourceQuery, { allowEmpty: true, allowMany: false });
6
+ return this.listIssuesForResource({
7
+ ...resourceQuery,
8
+ page,
9
+ per_page: perPage,
10
+ source,
11
+ });
12
+ },
13
+
14
+ searchIssues(args = {}) {
15
+ return this.listIssues(args);
16
+ },
17
+
18
+ createIssue({ url, jira_id: jiraId, ...resourceQuery } = {}) {
19
+ this.validateIssueResourceQuery(resourceQuery, { allowEmpty: false, allowMany: false });
20
+ return this.linkIssueToResource({
21
+ ...resourceQuery,
22
+ url,
23
+ jira_id: jiraId,
24
+ });
25
+ },
26
+
27
+ listIssuesForResource({ page, per_page: perPage, source, ...resourceQuery } = {}) {
28
+ return this.apiClient.list('issues', {
29
+ ...resourceQuery,
30
+ page,
31
+ per_page: perPage,
32
+ source,
33
+ });
34
+ },
35
+
36
+ listIssuesForKey({ resourceKey, resourceId, page, per_page: perPage, source }) {
37
+ this.assertSupportedIssueResourceKey(resourceKey);
38
+ return this.listIssuesForResource({
39
+ [resourceKey]: resourceId,
40
+ page,
41
+ per_page: perPage,
42
+ source,
43
+ });
44
+ },
45
+
46
+ linkIssueForKey({ resourceKey, resourceId, url, jira_id: jiraId }) {
47
+ this.assertSupportedIssueResourceKey(resourceKey);
48
+ return this.linkIssueToResource({
49
+ [resourceKey]: resourceId,
50
+ url,
51
+ jira_id: jiraId,
52
+ });
53
+ },
54
+
55
+ linkIssueToResource({ url, jira_id: jiraId, ...resourceQuery } = {}) {
56
+ if (Boolean(url) === Boolean(jiraId)) {
57
+ throw new Error('Provide exactly one field: "url" or "jira_id".');
58
+ }
59
+ this.validateIssueResourceQuery(resourceQuery, { allowEmpty: false, allowMany: false });
60
+
61
+ const payload = { url, jira_id: jiraId };
62
+ return this.createIssueWithFallback(resourceQuery, payload);
63
+ },
64
+
65
+ async createIssueWithFallback(resourceQuery, payload) {
66
+ try {
67
+ return await this.apiClient.createWithQuery('issues', {
68
+ query: resourceQuery,
69
+ body: payload,
70
+ });
71
+ } catch (error) {
72
+ if (!this.shouldRetryWrappedBody(error, 'issue')) {
73
+ throw error;
74
+ }
75
+ return this.apiClient.createWithQuery('issues', {
76
+ query: resourceQuery,
77
+ body: { issue: payload },
78
+ });
79
+ }
80
+ },
81
+
82
+ assertSupportedIssueResourceKey(resourceKey) {
83
+ if (!ISSUE_RESOURCE_KEYS.includes(resourceKey)) {
84
+ throw new Error(`Unsupported issue resource key: ${resourceKey}`);
85
+ }
86
+ },
87
+
88
+ validateIssueResourceQuery(resourceQuery = {}, { allowEmpty = false, allowMany = false } = {}) {
89
+ const provided = ISSUE_RESOURCE_KEYS.filter((key) => {
90
+ const value = resourceQuery[key];
91
+ return value !== undefined && value !== null && value !== '';
92
+ });
93
+
94
+ if (!allowEmpty && provided.length === 0) {
95
+ throw new Error(`Provide one resource id: ${ISSUE_RESOURCE_KEYS.join(', ')}.`);
96
+ }
97
+
98
+ if (!allowMany && provided.length > 1) {
99
+ throw new Error(
100
+ `Provide only one resource id at a time: ${ISSUE_RESOURCE_KEYS.join(', ')}.`
101
+ );
102
+ }
103
+
104
+ return provided;
105
+ },
106
+ };
@@ -0,0 +1,108 @@
1
+ export const listingMethods = {
2
+ listTests({ page, per_page: perPage, suite_id: suiteId, search_text: searchText, query, ...filters } = {}) {
3
+ return this.apiClient.list('tests', {
4
+ page,
5
+ per_page: perPage,
6
+ suite_id: suiteId,
7
+ search_text: searchText || query,
8
+ ...filters,
9
+ });
10
+ },
11
+
12
+ searchTests({ query, search_text: searchText, ...rest } = {}) {
13
+ return this.listTests({
14
+ ...rest,
15
+ search_text: searchText || query,
16
+ });
17
+ },
18
+
19
+ listSuites({ page, per_page: perPage, file_type: fileType, tag, labels, search_text: searchText, query } = {}) {
20
+ return this.apiClient.list('suites', {
21
+ page,
22
+ per_page: perPage,
23
+ file_type: fileType,
24
+ tag,
25
+ labels,
26
+ search_text: searchText || query,
27
+ });
28
+ },
29
+
30
+ searchSuites({ query, search_text: searchText, ...rest } = {}) {
31
+ return this.listSuites({
32
+ ...rest,
33
+ search_text: searchText || query,
34
+ });
35
+ },
36
+
37
+ listRuns({ page, per_page: perPage, query } = {}) {
38
+ return this.apiClient.list('runs', { page, per_page: perPage, query });
39
+ },
40
+
41
+ searchRuns({ page, per_page: perPage, query } = {}) {
42
+ return this.listRuns({ page, per_page: perPage, query });
43
+ },
44
+
45
+ listTestruns({ page, per_page: perPage, run_id: runId, query } = {}) {
46
+ return this.apiClient.list('testruns', { page, per_page: perPage, run_id: runId, query });
47
+ },
48
+
49
+ searchTestruns({ page, per_page: perPage, run_id: runId, query } = {}) {
50
+ return this.listTestruns({ page, per_page: perPage, run_id: runId, query });
51
+ },
52
+
53
+ listRungroups({ page, per_page: perPage, query } = {}) {
54
+ return this.apiClient.list('rungroups', { page, per_page: perPage, query });
55
+ },
56
+
57
+ searchRungroups({ page, per_page: perPage, query } = {}) {
58
+ return this.listRungroups({ page, per_page: perPage, query });
59
+ },
60
+
61
+ listSteps({ page, per_page: perPage, query } = {}) {
62
+ return this.apiClient.list('steps', { page, per_page: perPage, query });
63
+ },
64
+
65
+ searchSteps({ page, per_page: perPage, query } = {}) {
66
+ return this.listSteps({ page, per_page: perPage, query });
67
+ },
68
+
69
+ listSnippets({ page, per_page: perPage, query } = {}) {
70
+ return this.apiClient.list('snippets', { page, per_page: perPage, query });
71
+ },
72
+
73
+ searchSnippets({ page, per_page: perPage, query } = {}) {
74
+ return this.listSnippets({ page, per_page: perPage, query });
75
+ },
76
+
77
+ listLabels({ page, per_page: perPage, query } = {}) {
78
+ return this.apiClient.list('labels', { page, per_page: perPage, query });
79
+ },
80
+
81
+ searchLabels({ page, per_page: perPage, query } = {}) {
82
+ return this.listLabels({ page, per_page: perPage, query });
83
+ },
84
+
85
+ listPlans({ page, per_page: perPage, query } = {}) {
86
+ return this.apiClient.list('plans', { page, per_page: perPage, query });
87
+ },
88
+
89
+ searchPlans({ page, per_page: perPage, query } = {}) {
90
+ return this.listPlans({ page, per_page: perPage, query });
91
+ },
92
+
93
+ listTags() {
94
+ return this.apiClient.list('tags');
95
+ },
96
+
97
+ getTagByTitle(tagId) {
98
+ return this.apiClient.get('tags', tagId);
99
+ },
100
+
101
+ searchTags({ tag_id: tagId, query } = {}) {
102
+ const resolvedTag = tagId || query;
103
+ if (!resolvedTag) {
104
+ throw new Error('Provide "tag_id" or "query" for tags_search.');
105
+ }
106
+ return this.getTagByTitle(resolvedTag);
107
+ },
108
+ };
@@ -0,0 +1,269 @@
1
+ export const payloadMethods = {
2
+ async createWrapped(resource, wrapperKey, payload) {
3
+ const wrappedBody = { [wrapperKey]: payload };
4
+ try {
5
+ return await this.apiClient.create(resource, payload);
6
+ } catch (error) {
7
+ if (!this.shouldRetryWrappedBody(error, wrapperKey)) {
8
+ throw error;
9
+ }
10
+ return this.apiClient.create(resource, wrappedBody);
11
+ }
12
+ },
13
+
14
+ async updateWrapped(resource, id, wrapperKey, payload) {
15
+ const wrappedBody = { [wrapperKey]: payload };
16
+ try {
17
+ return await this.apiClient.update(resource, id, payload);
18
+ } catch (error) {
19
+ if (!this.shouldRetryWrappedBody(error, wrapperKey)) {
20
+ throw error;
21
+ }
22
+ return this.apiClient.update(resource, id, wrappedBody);
23
+ }
24
+ },
25
+
26
+ buildTestPayload({
27
+ title,
28
+ suite_id: suiteId,
29
+ description,
30
+ emoji,
31
+ priority,
32
+ assigned_to: assignedTo,
33
+ code,
34
+ state,
35
+ sync,
36
+ link,
37
+ } = {}) {
38
+ return {
39
+ title,
40
+ suite_id: suiteId,
41
+ description,
42
+ emoji,
43
+ priority,
44
+ assigned_to: assignedTo,
45
+ code,
46
+ state,
47
+ sync,
48
+ link,
49
+ };
50
+ },
51
+
52
+ buildSuitePayload({
53
+ title,
54
+ description,
55
+ emoji,
56
+ parent_id: parentId,
57
+ file_type: fileType,
58
+ assigned_to: assignedTo,
59
+ file,
60
+ children,
61
+ link,
62
+ } = {}) {
63
+ return {
64
+ title,
65
+ description,
66
+ emoji,
67
+ parent_id: parentId,
68
+ file_type: fileType,
69
+ assigned_to: assignedTo,
70
+ file,
71
+ children,
72
+ link,
73
+ };
74
+ },
75
+
76
+ buildRunCreatePayload({
77
+ title,
78
+ description,
79
+ plan_id: planId,
80
+ kind,
81
+ rungroup_id: rungroupId,
82
+ env,
83
+ assigned_to: assignedTo,
84
+ assign_strategy: assignStrategy,
85
+ test_ids: testIds,
86
+ suite_ids: suiteIds,
87
+ envs,
88
+ configuration,
89
+ link,
90
+ } = {}) {
91
+ return {
92
+ title,
93
+ description,
94
+ plan_id: planId,
95
+ kind,
96
+ rungroup_id: rungroupId,
97
+ env,
98
+ assigned_to: assignedTo,
99
+ assign_strategy: assignStrategy,
100
+ test_ids: testIds,
101
+ suite_ids: suiteIds,
102
+ envs,
103
+ configuration,
104
+ link,
105
+ };
106
+ },
107
+
108
+ buildRunUpdatePayload({
109
+ title,
110
+ description,
111
+ plan_id: planId,
112
+ kind,
113
+ rungroup_id: rungroupId,
114
+ env,
115
+ status_event: statusEvent,
116
+ assigned_to: assignedTo,
117
+ assign_strategy: assignStrategy,
118
+ test_ids: testIds,
119
+ suite_ids: suiteIds,
120
+ configuration,
121
+ link,
122
+ } = {}) {
123
+ return {
124
+ title,
125
+ description,
126
+ plan_id: planId,
127
+ kind,
128
+ rungroup_id: rungroupId,
129
+ env,
130
+ status_event: statusEvent,
131
+ assigned_to: assignedTo,
132
+ assign_strategy: assignStrategy,
133
+ test_ids: testIds,
134
+ suite_ids: suiteIds,
135
+ configuration,
136
+ link,
137
+ };
138
+ },
139
+
140
+ async createRunWithFallback(args = {}) {
141
+ const payload = this.buildRunCreatePayload(args);
142
+ try {
143
+ return await this.apiClient.create('runs', payload);
144
+ } catch (error) {
145
+ if (!this.shouldRetryWrappedBody(error, 'run')) {
146
+ throw error;
147
+ }
148
+ return this.apiClient.create('runs', { run: payload });
149
+ }
150
+ },
151
+
152
+ async updateRunWithFallback(runId, args = {}) {
153
+ const payload = this.buildRunUpdatePayload(args);
154
+ try {
155
+ return await this.apiClient.update('runs', runId, payload);
156
+ } catch (error) {
157
+ if (!this.shouldRetryWrappedBody(error, 'run')) {
158
+ throw error;
159
+ }
160
+ return this.apiClient.update('runs', runId, { run: payload });
161
+ }
162
+ },
163
+
164
+ shouldRetryWrappedBody(error, wrapperKey) {
165
+ if (!error || ![400, 422].includes(error.status)) {
166
+ return false;
167
+ }
168
+ const payload = error.payload || {};
169
+ const blob = JSON.stringify(payload).toLowerCase();
170
+ const key = wrapperKey.toLowerCase();
171
+
172
+ const mentionsMissingParam =
173
+ blob.includes('param is missing') ||
174
+ blob.includes('missing required') ||
175
+ blob.includes('is required');
176
+
177
+ return blob.includes(key) && mentionsMissingParam;
178
+ },
179
+
180
+ buildTestrunPayload({
181
+ run_id: runId,
182
+ test_id: testId,
183
+ status,
184
+ message,
185
+ run_time: runTime,
186
+ assigned_to: assignedTo,
187
+ test_title: testTitle,
188
+ automated,
189
+ } = {}) {
190
+ return {
191
+ run_id: runId,
192
+ test_id: testId,
193
+ status,
194
+ message,
195
+ run_time: runTime,
196
+ assigned_to: assignedTo,
197
+ test_title: testTitle,
198
+ automated,
199
+ };
200
+ },
201
+
202
+ buildRungroupPayload({
203
+ title,
204
+ description,
205
+ emoji,
206
+ kind,
207
+ pin,
208
+ status,
209
+ parent_id: parentId,
210
+ children,
211
+ } = {}) {
212
+ return {
213
+ title,
214
+ description,
215
+ emoji,
216
+ kind,
217
+ pin,
218
+ status,
219
+ parent_id: parentId,
220
+ children,
221
+ };
222
+ },
223
+
224
+ buildStepPayload({ title, description, link } = {}) {
225
+ return {
226
+ title,
227
+ description,
228
+ link,
229
+ };
230
+ },
231
+
232
+ buildSnippetPayload({ title, description, link } = {}) {
233
+ return {
234
+ title,
235
+ description,
236
+ link,
237
+ };
238
+ },
239
+
240
+ buildLabelPayload({ title, color, visibility, scope, field } = {}) {
241
+ return {
242
+ title,
243
+ color,
244
+ visibility,
245
+ scope,
246
+ field,
247
+ };
248
+ },
249
+
250
+ buildPlanPayload({
251
+ title,
252
+ description,
253
+ kind,
254
+ hidden,
255
+ as_manual: asManual,
256
+ test_plan: testPlan,
257
+ link,
258
+ } = {}) {
259
+ return {
260
+ title,
261
+ description,
262
+ kind,
263
+ hidden,
264
+ as_manual: asManual,
265
+ test_plan: testPlan,
266
+ link,
267
+ };
268
+ },
269
+ };
@@ -0,0 +1,49 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { TOOL_DEFINITIONS } from './tool-definitions.js';
5
+ import { ToolRegistry } from './tool-registry.js';
6
+ import { createLogger } from '../core/logger.js';
7
+ import { getPackageVersion } from '../config/package-version.js';
8
+
9
+ export class TestomatioMCPServer {
10
+ constructor({ config, apiClient, logger }) {
11
+ this.config = config;
12
+ this.logger = logger || createLogger();
13
+ this.toolRegistry = new ToolRegistry({ config, apiClient, logger: this.logger });
14
+
15
+ this.server = new Server(
16
+ {
17
+ name: 'testomatio-mcp-server',
18
+ version: getPackageVersion(),
19
+ },
20
+ {
21
+ capabilities: {
22
+ tools: {},
23
+ },
24
+ }
25
+ );
26
+
27
+ this.setupHandlers();
28
+ }
29
+
30
+ setupHandlers() {
31
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
+ tools: TOOL_DEFINITIONS,
33
+ }));
34
+
35
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
36
+ const toolName = request.params.name;
37
+ const args = request.params.arguments || {};
38
+ this.logger.info('Tool call', { toolName });
39
+
40
+ return this.toolRegistry.execute(toolName, args);
41
+ });
42
+ }
43
+
44
+ async run() {
45
+ const transport = new StdioServerTransport();
46
+ await this.server.connect(transport);
47
+ this.logger.info('Testomatio MCP server started');
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ import { SYSTEM_TOOLS } from './definitions/system.js';
2
+ import { TESTS_TOOLS } from './definitions/tests.js';
3
+ import { SUITES_TOOLS } from './definitions/suites.js';
4
+ import { RUNS_TOOLS } from './definitions/runs.js';
5
+ import { TESTRUNS_TOOLS } from './definitions/testruns.js';
6
+ import { RUNGROUPS_TOOLS } from './definitions/rungroups.js';
7
+ import { STEPS_TOOLS } from './definitions/steps.js';
8
+ import { SNIPPETS_TOOLS } from './definitions/snippets.js';
9
+ import { LABELS_TOOLS } from './definitions/labels.js';
10
+ import { TAGS_TOOLS } from './definitions/tags.js';
11
+ import { ISSUES_TOOLS } from './definitions/issues.js';
12
+ import { PLANS_TOOLS } from './definitions/plans.js';
13
+
14
+ export const TOOL_DEFINITIONS = [
15
+ ...SYSTEM_TOOLS,
16
+ ...TESTS_TOOLS,
17
+ ...SUITES_TOOLS,
18
+ ...RUNS_TOOLS,
19
+ ...TESTRUNS_TOOLS,
20
+ ...RUNGROUPS_TOOLS,
21
+ ...STEPS_TOOLS,
22
+ ...SNIPPETS_TOOLS,
23
+ ...LABELS_TOOLS,
24
+ ...TAGS_TOOLS,
25
+ ...ISSUES_TOOLS,
26
+ ...PLANS_TOOLS,
27
+ ];
@@ -0,0 +1,81 @@
1
+ import { DEFAULT_TOOL_RESPONSE } from '../config/constants.js';
2
+ import { ApiError, NotImplementedToolError } from '../core/errors.js';
3
+ import { textResponse } from '../helpers/mcp-response.js';
4
+ import { TOOL_DEFINITIONS } from './tool-definitions.js';
5
+ import { handlerMethods } from './registry/handlers.js';
6
+ import { issueMethods } from './registry/issues.js';
7
+ import { listingMethods } from './registry/listings.js';
8
+ import { payloadMethods } from './registry/payloads.js';
9
+
10
+ function formatJson(payload) {
11
+ return JSON.stringify(payload, null, 2);
12
+ }
13
+
14
+ export class ToolRegistry {
15
+ constructor({ config, apiClient, logger }) {
16
+ this.config = config;
17
+ this.apiClient = apiClient;
18
+ this.logger = logger;
19
+ this.handlers = this.buildHandlers();
20
+ }
21
+
22
+ asText(payload) {
23
+ return textResponse(formatJson(payload));
24
+ }
25
+
26
+ buildHandlers() {
27
+ const handlers = {
28
+ system_ping: async () =>
29
+ this.asText({
30
+ status: 'ok',
31
+ projectId: this.config.projectId,
32
+ baseUrl: this.config.baseUrl,
33
+ apiVersion: 'v2',
34
+ }),
35
+ };
36
+
37
+ this.registerEntityCrudHandlers(handlers);
38
+ this.registerScopedIssueHandlers(handlers);
39
+ this.registerGlobalHandlers(handlers);
40
+
41
+ for (const tool of TOOL_DEFINITIONS) {
42
+ if (tool.name === 'system_ping') continue;
43
+ if (!handlers[tool.name]) {
44
+ handlers[tool.name] = async () => textResponse(`${DEFAULT_TOOL_RESPONSE} (${tool.name})`);
45
+ }
46
+ }
47
+
48
+ return handlers;
49
+ }
50
+
51
+ async execute(name, args = {}) {
52
+ const handler = this.handlers[name];
53
+ if (!handler) {
54
+ throw new NotImplementedToolError(name);
55
+ }
56
+
57
+ try {
58
+ return await handler(args);
59
+ } catch (error) {
60
+ if (error instanceof ApiError) {
61
+ return this.asText({
62
+ error: error.message,
63
+ status: error.status,
64
+ url: error.url,
65
+ details: error.payload || null,
66
+ });
67
+ }
68
+
69
+ this.logger?.error('Unhandled tool execution error', { name, error: error?.message || error });
70
+ return this.asText({ error: error?.message || 'Unknown execution error' });
71
+ }
72
+ }
73
+ }
74
+
75
+ Object.assign(
76
+ ToolRegistry.prototype,
77
+ handlerMethods,
78
+ listingMethods,
79
+ issueMethods,
80
+ payloadMethods
81
+ );