@testomatio/mcp 2.0.1 → 2.1.0-beta

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 CHANGED
@@ -6,14 +6,16 @@ Model Context Protocol (MCP) server that enables AI assistants (Claude, Cursor,
6
6
 
7
7
  - **Full CRUD** for core entities:
8
8
  - Tests, Suites, Plans, Runs, TestRuns, RunGroups, Steps, Snippets, Labels
9
- - Tags (read-only access)
9
+ - Tags and Milestones (read-only access)
10
10
  - Issues (global + scoped helpers for tests/suites/runs/testruns/plans)
11
+ - Requirements (including file uploads from local file paths)
11
12
  - **Smart Search** - delegates to list endpoints with OpenAPI-aligned query/filter forwarding
12
13
  - **Issue Linking** - link/unlink issues to any resource
13
14
  - **API Compatibility** - automatic handling of payload format differences (flat vs wrapped)
15
+ - **Automatic API Sessions** - groups MCP changes in Testomat.io history using API sessions
14
16
  - **Run Management** - status transitions via `status_event` parameter
15
17
  - **TQL-Only Search** - `tests_list/tests_search` and `runs_list/runs_search` use `tql` as the single search/filter input
16
- - **Safe TQL Guidance** - prefer documented expressions like `priority == high`, `state == automated`, `size == 5`, `size > 1`; do not guess undocumented TQL syntax; fall back only when the API rejects the TQL expression or the needed field is not supported
18
+ - **Built-In TQL Reference** - MCP tool descriptions include exact TQL fields, syntax, and examples for agents
17
19
 
18
20
  ## Quick Start
19
21
 
@@ -23,6 +25,8 @@ Model Context Protocol (MCP) server that enables AI assistants (Claude, Cursor,
23
25
  npm install -g @testomatio/mcp@latest
24
26
  ```
25
27
 
28
+ Need enterprise analytics tools? Install `@testomatio/mcp-enterprise@latest` instead. Details are in the `Enterprise Analytics` section below.
29
+
26
30
  ### Configuration
27
31
 
28
32
  **Required credentials:**
@@ -135,7 +139,7 @@ Add this config to `opencode.json` in your project root, or to `~/.config/openco
135
139
  ```json
136
140
  {
137
141
  "name": "tests_list",
138
- "arguments": { "page": 1, "per_page": 50, "tql": "priority == high" }
142
+ "arguments": { "page": 1, "per_page": 50, "tql": "priority == 'high'" }
139
143
  }
140
144
  ```
141
145
 
@@ -240,9 +244,11 @@ NODE_EXTRA_CA_CERTS=/path/to/company-root-ca.pem testomatio-mcp --token <TOKEN>
240
244
  - **Run Status** - Use `runs_update` with `status_event` for transitions (finish, launch, rerun, etc.)
241
245
  - **Search** - No dedicated `/search` endpoints. MCP search tools delegate to list tools; for `tests` and `runs` the MCP interface is intentionally simplified to `tql`, while other entities stay closer to Public API v2 filters
242
246
  - **TQL** - Use `tql` as the single search/filter input for `tests_list/tests_search` and `runs_list/runs_search`
243
- - **TQL Safety** - Prefer documented expressions like `priority == high`, `state == automated`, `size == 5`, `size > 1`; do not invent tag-style or free-text syntax unless it was verified
244
- - **Fallback Rule** - For `tests` and `runs`, try `tql` first. Use other tools or extra analysis only after the API rejects the TQL expression or the needed field is not supported by TQL
247
+ - **TQL Syntax** - For user-facing syntax details and more examples, see the official TQL docs: https://docs.testomat.io/advanced/tql/
248
+ - **TQL Scope** - The full agent-oriented whitelist of documented fields lives inside MCP tool descriptions for `tests` and `runs`
245
249
  - **Issue Linking** - Scoped helpers available: `{entity}_issues_link/unlink`
250
+ - **Enterprise Package** - Analytics tools are intentionally exposed only by `@testomatio/mcp-enterprise`, not by the standard `@testomatio/mcp` package
251
+ - **API Sessions** - The server automatically starts a Testomat.io session before the first `POST`, `PUT`, or `DELETE` request, sends the returned session hash as `X-Session-Hash` on later mutating requests, and stops the session when the MCP server shuts down. `GET` requests do not start or use sessions.
246
252
 
247
253
  ## Development
248
254
 
@@ -263,3 +269,55 @@ For local MCP development, point Claude Desktop to the checked-out entrypoint:
263
269
  }
264
270
  }
265
271
  ```
272
+
273
+ ## Enterprise Analytics
274
+
275
+ Enterprise analytics is available only in the separate `@testomatio/mcp-enterprise` package.
276
+
277
+ Installation:
278
+
279
+ ```bash
280
+ npm install -g @testomatio/mcp-enterprise@latest
281
+ ```
282
+
283
+ Run:
284
+
285
+ ```bash
286
+ testomatio-mcp-enterprise --token <PROJECT_TOKEN> --project <PROJECT_ID>
287
+ ```
288
+
289
+ Included tools:
290
+
291
+ - `analytics_tests` - `GET /api/v2/{project_id}/analytics/tests/{kind}`
292
+ - `analytics_stats` - `GET /api/v2/{project_id}/analytics/stats/{kind}`
293
+
294
+ Analytics endpoints require the `api_analytics` subscription feature. Use `q` as the TQL filter parameter for analytics tools.
295
+
296
+ Example `analytics_tests` call:
297
+
298
+ ```json
299
+ {
300
+ "name": "analytics_tests",
301
+ "arguments": {
302
+ "kind": "flaky",
303
+ "q": "priority == 'high'",
304
+ "days": 30,
305
+ "page": 1,
306
+ "per_page": 20
307
+ }
308
+ }
309
+ ```
310
+
311
+ Example `analytics_stats` call:
312
+
313
+ ```json
314
+ {
315
+ "name": "analytics_stats",
316
+ "arguments": {
317
+ "kind": "success-rate-by-date",
318
+ "q": "tag IN ['@smoke']",
319
+ "from": "2026-04-01",
320
+ "to": "2026-04-30"
321
+ }
322
+ }
323
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/mcp",
3
- "version": "2.0.1",
3
+ "version": "2.1.0-beta",
4
4
  "description": "Model Context Protocol server for Testomatio API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "start": "node index.js",
12
- "dev": "node index.js"
12
+ "dev": "node index.js",
13
+ "sync:enterprise-package": "node scripts/sync-enterprise-package.js"
13
14
  },
14
15
  "keywords": [
15
16
  "testomatio",
@@ -1,5 +1,9 @@
1
1
  import { ApiError } from '../core/errors.js';
2
2
 
3
+ function isFormData(body) {
4
+ return typeof FormData !== 'undefined' && body instanceof FormData;
5
+ }
6
+
3
7
  function buildUrl(baseUrl, path, query = {}) {
4
8
  const url = new URL(path, `${baseUrl}/`);
5
9
 
@@ -26,12 +30,13 @@ export class HttpClient {
26
30
  this.logger = logger;
27
31
  }
28
32
 
29
- async request(method, path, { query, body } = {}) {
33
+ async request(method, path, { query, body, headers: requestHeaders = {} } = {}) {
30
34
  const url = buildUrl(this.baseUrl, path, query);
31
35
 
32
36
  const headers = {
33
37
  Accept: 'application/json',
34
38
  Authorization: `Bearer ${this.token}`,
39
+ ...requestHeaders,
35
40
  };
36
41
 
37
42
  const options = {
@@ -40,8 +45,12 @@ export class HttpClient {
40
45
  };
41
46
 
42
47
  if (body !== undefined) {
43
- headers['Content-Type'] = 'application/json';
44
- options.body = JSON.stringify(body);
48
+ if (isFormData(body)) {
49
+ options.body = body;
50
+ } else {
51
+ headers['Content-Type'] = 'application/json';
52
+ options.body = JSON.stringify(body);
53
+ }
45
54
  }
46
55
 
47
56
  this.logger.debug('HTTP request', { method, url });
@@ -3,6 +3,9 @@ import { HttpClient } from './http-client.js';
3
3
  export class TestomatioApiClient {
4
4
  constructor({ baseUrl, projectId, token, logger }) {
5
5
  this.projectId = projectId;
6
+ this.logger = logger;
7
+ this.sessionHash = null;
8
+ this.sessionPromise = null;
6
9
  this.http = new HttpClient({
7
10
  baseUrl,
8
11
  token,
@@ -24,23 +27,101 @@ export class TestomatioApiClient {
24
27
  return this.http.request('GET', this.buildPath(resource, id), { query });
25
28
  }
26
29
 
27
- create(resource, body = {}) {
28
- return this.http.request('POST', this.buildPath(resource), { body });
30
+ async create(resource, body = {}) {
31
+ return this.mutate('POST', this.buildPath(resource), { body });
29
32
  }
30
33
 
31
- createWithQuery(resource, { query = {}, body = {} } = {}) {
32
- return this.http.request('POST', this.buildPath(resource), { query, body });
34
+ async createMultipart(resource, formData) {
35
+ return this.mutate('POST', this.buildPath(resource), { body: formData });
33
36
  }
34
37
 
35
- update(resource, id, body = {}) {
36
- return this.http.request('PUT', this.buildPath(resource, id), { body });
38
+ async createWithQuery(resource, { query = {}, body = {} } = {}) {
39
+ return this.mutate('POST', this.buildPath(resource), { query, body });
37
40
  }
38
41
 
39
- delete(resource, id, query = {}) {
40
- return this.http.request('DELETE', this.buildPath(resource, id), { query });
42
+ async update(resource, id, body = {}) {
43
+ return this.mutate('PUT', this.buildPath(resource, id), { body });
44
+ }
45
+
46
+ async patch(resource, id, body = {}) {
47
+ return this.mutate('PATCH', this.buildPath(resource, id), { body });
48
+ }
49
+
50
+ async patchMultipart(resource, id, formData) {
51
+ return this.mutate('PATCH', this.buildPath(resource, id), { body: formData });
52
+ }
53
+
54
+ async delete(resource, id, query = {}) {
55
+ return this.mutate('DELETE', this.buildPath(resource, id), { query });
41
56
  }
42
57
 
43
58
  search(resource, query = {}) {
44
59
  return this.list(resource, query);
45
60
  }
61
+
62
+ async mutate(method, path, options = {}) {
63
+ const sessionHash = await this.ensureSession();
64
+ return this.http.request(method, path, {
65
+ ...options,
66
+ headers: {
67
+ ...options.headers,
68
+ 'X-Session-Hash': sessionHash,
69
+ },
70
+ });
71
+ }
72
+
73
+ async ensureSession() {
74
+ if (this.sessionHash) {
75
+ return this.sessionHash;
76
+ }
77
+
78
+ if (!this.sessionPromise) {
79
+ this.sessionPromise = this.startSession().finally(() => {
80
+ this.sessionPromise = null;
81
+ });
82
+ }
83
+
84
+ return this.sessionPromise;
85
+ }
86
+
87
+ async startSession() {
88
+ const response = await this.http.request('POST', this.buildPath('sessions'), {
89
+ body: { description: 'MCP session' },
90
+ });
91
+ const sessionHash = response?.data?.hash || response?.hash;
92
+
93
+ if (!sessionHash) {
94
+ throw new Error('Failed to start Testomat.io API session: missing session hash');
95
+ }
96
+
97
+ this.sessionHash = sessionHash;
98
+ return sessionHash;
99
+ }
100
+
101
+ async stopSession() {
102
+ if (this.sessionPromise) {
103
+ try {
104
+ await this.sessionPromise;
105
+ } catch {
106
+ return;
107
+ }
108
+ }
109
+
110
+ if (!this.sessionHash) {
111
+ return;
112
+ }
113
+
114
+ const sessionHash = this.sessionHash;
115
+ this.sessionHash = null;
116
+
117
+ try {
118
+ await this.http.request('DELETE', this.buildPath('sessions', sessionHash));
119
+ } catch (error) {
120
+ if (error?.status !== 404) {
121
+ this.logger?.error('Failed to stop Testomat.io API session', {
122
+ error: error?.message || error,
123
+ });
124
+ }
125
+ }
126
+ }
46
127
  }
package/src/index.js CHANGED
@@ -1,15 +1,36 @@
1
1
  import { TestomatioApiClient } from './api/testomatio-client.js';
2
2
  import { loadConfig } from './config/load-config.js';
3
+ import { ConfigurationError } from './core/errors.js';
3
4
  import { createLogger } from './core/logger.js';
4
5
  import { TestomatioMCPServer } from './mcp/server.js';
6
+ import { TOOL_DEFINITIONS } from './mcp/tool-definitions.js';
7
+ import {
8
+ ANALYTICS_STATS_TQL_INPUT_DESCRIPTION,
9
+ ANALYTICS_STATS_TQL_REFERENCE,
10
+ ANALYTICS_TESTS_TQL_INPUT_DESCRIPTION,
11
+ ANALYTICS_TESTS_TQL_REFERENCE,
12
+ } from './mcp/definitions/tql-reference.js';
5
13
 
6
14
  export { TestomatioMCPServer };
15
+ export {
16
+ ANALYTICS_STATS_TQL_INPUT_DESCRIPTION,
17
+ ANALYTICS_STATS_TQL_REFERENCE,
18
+ ANALYTICS_TESTS_TQL_INPUT_DESCRIPTION,
19
+ ANALYTICS_TESTS_TQL_REFERENCE,
20
+ ConfigurationError,
21
+ TOOL_DEFINITIONS,
22
+ };
7
23
 
8
- export function createApplication(argvOptions = {}) {
24
+ export function createApplication(argvOptions = {}, serverOptions = {}) {
9
25
  const config = loadConfig(argvOptions);
10
26
  const logger = createLogger();
11
27
  const apiClient = new TestomatioApiClient({ ...config, logger });
12
- const mcpServer = new TestomatioMCPServer({ config, apiClient, logger });
28
+ const mcpServer = new TestomatioMCPServer({
29
+ config,
30
+ apiClient,
31
+ logger,
32
+ ...serverOptions,
33
+ });
13
34
 
14
35
  return {
15
36
  config,
@@ -92,4 +92,16 @@ export const ENTITY_CRUD_CONFIGS = [
92
92
  createMode: 'wrapped',
93
93
  updateMode: 'wrapped',
94
94
  },
95
+ {
96
+ toolPrefix: 'requirements',
97
+ resource: 'requirements',
98
+ idArg: 'requirement_id',
99
+ listMethod: 'listRequirements',
100
+ searchMethod: 'searchRequirements',
101
+ payloadBuilder: 'buildRequirementPayload',
102
+ wrapperKey: 'requirement',
103
+ createMode: 'requirement',
104
+ updateMode: 'requirement',
105
+ updateMethod: 'patch',
106
+ },
95
107
  ];
@@ -0,0 +1,43 @@
1
+ export const MILESTONES_TOOLS = [
2
+ {
3
+ name: 'milestones_list',
4
+ description: 'List milestones (/api/v2/{project_id}/milestones)',
5
+ inputSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ page: {
9
+ type: 'integer',
10
+ minimum: 1,
11
+ },
12
+ per_page: {
13
+ type: 'integer',
14
+ minimum: 1,
15
+ maximum: 100,
16
+ },
17
+ type: {
18
+ type: 'string',
19
+ description: 'Filter by milestone type (title), e.g. Sprint or Release.',
20
+ },
21
+ status: {
22
+ type: 'string',
23
+ enum: ['created', 'active', 'closed'],
24
+ },
25
+ },
26
+ additionalProperties: false,
27
+ },
28
+ },
29
+ {
30
+ name: 'milestones_get',
31
+ description: 'Get milestone by ID',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ milestone_id: {
36
+ type: 'string',
37
+ },
38
+ },
39
+ required: ['milestone_id'],
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ ];
@@ -0,0 +1,163 @@
1
+ export const REQUIREMENTS_TOOLS = [
2
+ {
3
+ name: 'requirements_list',
4
+ description: 'List requirements (/api/v2/{project_id}/requirements)',
5
+ inputSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ page: {
9
+ type: 'integer',
10
+ minimum: 1,
11
+ },
12
+ per_page: {
13
+ type: 'integer',
14
+ minimum: 1,
15
+ maximum: 100,
16
+ },
17
+ source: {
18
+ type: 'string',
19
+ enum: ['jira', 'confluence', 'file', 'text'],
20
+ },
21
+ scope: {
22
+ type: 'string',
23
+ enum: ['global', 'attached', 'detached', 'without_suites'],
24
+ },
25
+ },
26
+ additionalProperties: false,
27
+ },
28
+ },
29
+ {
30
+ name: 'requirements_get',
31
+ description: 'Get requirement by ID',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ requirement_id: {
36
+ type: 'string',
37
+ },
38
+ },
39
+ required: ['requirement_id'],
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ {
44
+ name: 'requirements_create',
45
+ description: 'Create requirement (/api/v2/{project_id}/requirements)',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ title: {
50
+ type: 'string',
51
+ },
52
+ source_type: {
53
+ type: 'string',
54
+ enum: ['jira', 'confluence', 'file', 'text'],
55
+ },
56
+ description: {
57
+ type: 'string',
58
+ description: 'Required for text requirements. Must be at least 500 characters.',
59
+ },
60
+ details: {
61
+ type: 'string',
62
+ },
63
+ active: {
64
+ type: 'boolean',
65
+ },
66
+ global: {
67
+ type: 'boolean',
68
+ },
69
+ confluence_url: {
70
+ type: 'string',
71
+ description: 'Required for confluence requirements.',
72
+ },
73
+ files: {
74
+ type: 'array',
75
+ items: {
76
+ type: 'string',
77
+ },
78
+ description: 'Local file paths to upload for file requirements.',
79
+ },
80
+ },
81
+ required: ['title', 'source_type'],
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ {
86
+ name: 'requirements_update',
87
+ description: 'Update requirement (/api/v2/{project_id}/requirements/{id})',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {
91
+ requirement_id: {
92
+ type: 'string',
93
+ },
94
+ title: {
95
+ type: 'string',
96
+ },
97
+ description: {
98
+ type: 'string',
99
+ description: 'Only applied for text requirements.',
100
+ },
101
+ details: {
102
+ type: 'string',
103
+ },
104
+ active: {
105
+ type: 'boolean',
106
+ },
107
+ global: {
108
+ type: 'boolean',
109
+ },
110
+ files: {
111
+ type: 'array',
112
+ items: {
113
+ type: 'string',
114
+ },
115
+ description: 'Local file paths to upload for file requirements.',
116
+ },
117
+ },
118
+ required: ['requirement_id'],
119
+ additionalProperties: false,
120
+ },
121
+ },
122
+ {
123
+ name: 'requirements_delete',
124
+ description: 'Delete requirement (/api/v2/{project_id}/requirements/{id})',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ requirement_id: {
129
+ type: 'string',
130
+ },
131
+ },
132
+ required: ['requirement_id'],
133
+ additionalProperties: false,
134
+ },
135
+ },
136
+ {
137
+ name: 'requirements_search',
138
+ description: 'Search requirements (delegates to requirements list with filters)',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ page: {
143
+ type: 'integer',
144
+ minimum: 1,
145
+ },
146
+ per_page: {
147
+ type: 'integer',
148
+ minimum: 1,
149
+ maximum: 100,
150
+ },
151
+ source: {
152
+ type: 'string',
153
+ enum: ['jira', 'confluence', 'file', 'text'],
154
+ },
155
+ scope: {
156
+ type: 'string',
157
+ enum: ['global', 'attached', 'detached', 'without_suites'],
158
+ },
159
+ },
160
+ additionalProperties: false,
161
+ },
162
+ },
163
+ ];
@@ -1,7 +1,9 @@
1
+ import { RUNS_TQL_INPUT_DESCRIPTION, RUNS_TQL_REFERENCE } from './tql-reference.js';
2
+
1
3
  export const RUNS_TOOLS = [
2
4
  {
3
5
  "name": "runs_list",
4
- "description": "List runs (/api/v2/{project_id}/runs). Use `tql` first for search/filtering. Prefer known-safe expressions such as `size == 5` or `size > 1`. Do not guess undocumented TQL syntax. Fall back to other tools or analysis only if the API rejects the TQL expression or the needed field is not supported by TQL.",
6
+ "description": `List runs (/api/v2/{project_id}/runs). ${RUNS_TQL_REFERENCE}`,
5
7
  "inputSchema": {
6
8
  "type": "object",
7
9
  "properties": {
@@ -16,7 +18,7 @@ export const RUNS_TOOLS = [
16
18
  },
17
19
  "tql": {
18
20
  "type": "string",
19
- "description": "Universal TQL filter for runs. Prefer known-safe expressions like `size == 5` or `size > 1`. Do not guess undocumented syntax. Fall back only if the API rejects the TQL expression or the needed field is not supported by TQL."
21
+ "description": RUNS_TQL_INPUT_DESCRIPTION
20
22
  }
21
23
  },
22
24
  "additionalProperties": false
@@ -264,7 +266,7 @@ export const RUNS_TOOLS = [
264
266
  },
265
267
  {
266
268
  "name": "runs_search",
267
- "description": "Search runs using TQL (delegates to runs_list). Use `tql` first. Prefer known-safe expressions such as `size == 5` or `size > 1`. Do not guess undocumented TQL syntax. Fall back to other tools or analysis only if the API rejects the TQL expression or the needed field is not supported by TQL.",
269
+ "description": `Search runs using TQL (delegates to runs_list). ${RUNS_TQL_REFERENCE}`,
268
270
  "inputSchema": {
269
271
  "type": "object",
270
272
  "properties": {
@@ -279,7 +281,7 @@ export const RUNS_TOOLS = [
279
281
  },
280
282
  "tql": {
281
283
  "type": "string",
282
- "description": "Universal TQL filter for runs. Prefer known-safe expressions like `size == 5` or `size > 1`. Do not guess undocumented syntax. Fall back only if the API rejects the TQL expression or the needed field is not supported by TQL."
284
+ "description": RUNS_TQL_INPUT_DESCRIPTION
283
285
  }
284
286
  },
285
287
  "additionalProperties": false
@@ -1,7 +1,9 @@
1
+ import { TESTS_TQL_INPUT_DESCRIPTION, TESTS_TQL_REFERENCE } from './tql-reference.js';
2
+
1
3
  export const TESTS_TOOLS = [
2
4
  {
3
5
  "name": "tests_list",
4
- "description": "List tests (/api/v2/{project_id}/tests). Use `tql` first for search/filtering. Prefer known-safe expressions such as `priority == high` or `state == automated`. Do not guess undocumented TQL syntax. Fall back to other tools or analysis only if the API rejects the TQL expression or the needed field is not supported by TQL.",
6
+ "description": `List tests (/api/v2/{project_id}/tests). ${TESTS_TQL_REFERENCE}`,
5
7
  "inputSchema": {
6
8
  "type": "object",
7
9
  "properties": {
@@ -16,7 +18,7 @@ export const TESTS_TOOLS = [
16
18
  },
17
19
  "tql": {
18
20
  "type": "string",
19
- "description": "Universal TQL filter for tests. Prefer known-safe expressions like `priority == high` or `state == automated`. Do not guess undocumented syntax. Fall back only if the API rejects the TQL expression or the needed field is not supported by TQL."
21
+ "description": TESTS_TQL_INPUT_DESCRIPTION
20
22
  }
21
23
  },
22
24
  "additionalProperties": false
@@ -207,7 +209,7 @@ export const TESTS_TOOLS = [
207
209
  },
208
210
  {
209
211
  "name": "tests_search",
210
- "description": "Search tests using TQL (delegates to tests_list). Use `tql` first. Prefer known-safe expressions such as `priority == high` or `state == automated`. Do not guess undocumented TQL syntax. Fall back to other tools or analysis only if the API rejects the TQL expression or the needed field is not supported by TQL.",
212
+ "description": `Search tests using TQL (delegates to tests_list). ${TESTS_TQL_REFERENCE}`,
211
213
  "inputSchema": {
212
214
  "type": "object",
213
215
  "properties": {
@@ -222,7 +224,7 @@ export const TESTS_TOOLS = [
222
224
  },
223
225
  "tql": {
224
226
  "type": "string",
225
- "description": "Universal TQL filter for tests. Prefer known-safe expressions like `priority == high` or `state == automated`. Do not guess undocumented syntax. Fall back only if the API rejects the TQL expression or the needed field is not supported by TQL."
227
+ "description": TESTS_TQL_INPUT_DESCRIPTION
226
228
  }
227
229
  },
228
230
  "additionalProperties": false
@@ -0,0 +1,133 @@
1
+ const TESTS_TQL_VARIABLES = [
2
+ 'tag',
3
+ 'label',
4
+ 'priority',
5
+ 'issue',
6
+ 'jira',
7
+ 'state',
8
+ 'status',
9
+ 'custom_status',
10
+ 'created_at',
11
+ 'updated_at',
12
+ 'last_run_at',
13
+ 'executed_at',
14
+ 'created_by',
15
+ 'assigned_to',
16
+ 'suite',
17
+ 'test',
18
+ ];
19
+
20
+ const RUNS_TQL_VARIABLES = [
21
+ 'title',
22
+ 'plan',
23
+ 'rungroup',
24
+ 'env',
25
+ 'tag',
26
+ 'label',
27
+ 'jira',
28
+ 'duration',
29
+ 'passed_count',
30
+ 'failed_count',
31
+ 'skipped_count',
32
+ 'automated',
33
+ 'manual',
34
+ 'mixed',
35
+ 'finished',
36
+ 'unfinished',
37
+ 'passed',
38
+ 'failed',
39
+ 'terminated',
40
+ 'published',
41
+ 'private',
42
+ 'archived',
43
+ 'unarchived',
44
+ 'with_defect',
45
+ 'has_defect',
46
+ 'has_test',
47
+ 'has_test_tag',
48
+ 'has_test_label',
49
+ 'has_suite',
50
+ 'has_message',
51
+ 'has_custom_status',
52
+ 'has_assigned_to',
53
+ 'has_retries',
54
+ 'has_test_duration',
55
+ 'has_priority',
56
+ 'created_at',
57
+ 'updated_at',
58
+ 'launched_at',
59
+ 'finished_at',
60
+ ];
61
+
62
+ const TESTS_TQL_EXAMPLES = [
63
+ "priority == 'high'",
64
+ "state == 'automated'",
65
+ "tag in ['smoke', 'stage1'] and status == 'failed'",
66
+ "suite % 'Checkout'",
67
+ "test % 'User login'",
68
+ "created_at < 1.month_ago",
69
+ "jira in ['JST-1', 'JST-2']",
70
+ ];
71
+
72
+ const RUNS_TQL_EXAMPLES = [
73
+ "title % 'Manual tests'",
74
+ "plan == '{PLAN_ID}'",
75
+ "env in ['Windows', 'Linux']",
76
+ "failed and has_test_tag == 'regression'",
77
+ 'finished and with_defect',
78
+ 'has_retries > 2',
79
+ "automated and env == 'Production' and has_message % 'Server Error'",
80
+ "finished_at >= '2025-07-01' and finished_at <= '2025-07-31' and failed",
81
+ ];
82
+
83
+ const COMMON_TQL_SYNTAX =
84
+ "Supported syntax includes `==`, `!=`, `>`, `<`, `>=`, `<=`, `in [...]`, `%` for partial text match, `and`, `or`, `not`, and parentheses for grouping. Use quotes for string values, for example `state == 'automated'`.";
85
+
86
+ export const TESTS_TQL_REFERENCE =
87
+ `TQL (Testomat.io Query Language) is a string expression passed in \`tql\` to filter tests. ${COMMON_TQL_SYNTAX} ` +
88
+ `Documented test variables: ${TESTS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
89
+ `Documented examples: ${TESTS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}. ` +
90
+ 'Do not invent undocumented fields or syntax. If a query fails, simplify it to one documented predicate.';
91
+
92
+ export const TESTS_TQL_INPUT_DESCRIPTION =
93
+ `TQL filter for tests. Documented variables: ${TESTS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
94
+ `Examples: ${TESTS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}.`;
95
+
96
+ export const RUNS_TQL_REFERENCE =
97
+ `TQL (Testomat.io Query Language) is a string expression passed in \`tql\` to filter runs. ${COMMON_TQL_SYNTAX} ` +
98
+ 'Runs also support boolean flags without comparison such as `failed`, `finished`, `automated`, or `with_defect`. ' +
99
+ `Documented run variables: ${RUNS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
100
+ `Documented examples: ${RUNS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}. ` +
101
+ 'Do not invent undocumented fields or syntax. If a query fails, simplify it to one documented predicate.';
102
+
103
+ export const RUNS_TQL_INPUT_DESCRIPTION =
104
+ `TQL filter for runs. Documented variables: ${RUNS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
105
+ `Examples: ${RUNS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}.`;
106
+
107
+ export const ANALYTICS_TESTS_TQL_REFERENCE =
108
+ `TQL (Testomat.io Query Language) is a string expression passed in \`q\` to filter enterprise analytics test reports. ${COMMON_TQL_SYNTAX} ` +
109
+ 'For analytics tools, the API parameter name is `q`, not `tql`. ' +
110
+ `Documented analytics test variables: ${TESTS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
111
+ `Documented examples: ${TESTS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}. ` +
112
+ 'Do not invent undocumented fields or syntax. If a query fails, simplify it to one documented predicate.';
113
+
114
+ export const ANALYTICS_TESTS_TQL_INPUT_DESCRIPTION =
115
+ 'TQL filter for analytics test reports. The API parameter name is `q`, not `tql`. ' +
116
+ `Documented variables: ${TESTS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
117
+ `Examples: ${TESTS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}.`;
118
+
119
+ export const ANALYTICS_STATS_TQL_REFERENCE =
120
+ `TQL (Testomat.io Query Language) is a string expression passed in \`q\` to filter enterprise analytics aggregated reports. ${COMMON_TQL_SYNTAX} ` +
121
+ 'For analytics tools, the API parameter name is `q`, not `tql`. ' +
122
+ 'According to the official Analytics docs, analytics queries are configured using supported query variables for two data sources: `Tests Variables` and `Runs Variables`. ' +
123
+ `Documented Tests Variables: ${TESTS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
124
+ `Documented Runs Variables: ${RUNS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
125
+ `Documented Tests examples: ${TESTS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}. ` +
126
+ `Documented Runs examples: ${RUNS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}. ` +
127
+ 'Use Tests Variables for test-centric filters and Runs Variables for run-centric filters such as `plan`, `rungroup`, `env`, `finished_at`, or `has_test_tag`. Do not invent undocumented fields or syntax. If a query fails, simplify it to one documented predicate.';
128
+
129
+ export const ANALYTICS_STATS_TQL_INPUT_DESCRIPTION =
130
+ 'TQL filter for analytics aggregated reports. The API parameter name is `q`, not `tql`. ' +
131
+ `Documented Tests Variables: ${TESTS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
132
+ `Documented Runs Variables: ${RUNS_TQL_VARIABLES.map((item) => `\`${item}\``).join(', ')}. ` +
133
+ `Examples: ${TESTS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}, ${RUNS_TQL_EXAMPLES.map((item) => `\`${item}\``).join(', ')}.`;
@@ -59,6 +59,10 @@ export const handlerMethods = {
59
59
  handlers.tags_get = async ({ tag_id: tagId }) => this.asText(await this.getTagByTitle(tagId));
60
60
  handlers.tags_search = async (args = {}) => this.asText(await this.searchTags(args));
61
61
 
62
+ handlers.milestones_list = async (args = {}) => this.asText(await this.listMilestones(args));
63
+ handlers.milestones_get = async ({ milestone_id: milestoneId }) =>
64
+ this.asText(await this.apiClient.get('milestones', milestoneId));
65
+
62
66
  handlers.issues_list = async (args = {}) => this.asText(await this.listIssues(args));
63
67
  handlers.issues_search = async (args = {}) => this.asText(await this.searchIssues(args));
64
68
  handlers.issues_create = async (args = {}) => this.asText(await this.createIssue(args));
@@ -84,6 +88,9 @@ export const handlerMethods = {
84
88
  if (spec.createMode === 'run') {
85
89
  return this.createRunWithFallback(args);
86
90
  }
91
+ if (spec.createMode === 'requirement') {
92
+ return this.createRequirement(args);
93
+ }
87
94
  const payload = this[spec.payloadBuilder](args);
88
95
  return this.createWrapped(spec.resource, spec.wrapperKey, payload);
89
96
  },
@@ -92,7 +99,13 @@ export const handlerMethods = {
92
99
  if (spec.updateMode === 'run') {
93
100
  return this.updateRunWithFallback(id, args);
94
101
  }
102
+ if (spec.updateMode === 'requirement') {
103
+ return this.updateRequirement(id, args);
104
+ }
95
105
  const payload = this[spec.payloadBuilder](args);
106
+ if (spec.updateMethod === 'patch') {
107
+ return this.patchWrapped(spec.resource, id, spec.wrapperKey, payload);
108
+ }
96
109
  return this.updateWrapped(spec.resource, id, spec.wrapperKey, payload);
97
110
  },
98
111
  };
@@ -136,6 +136,18 @@ export const listingMethods = {
136
136
  return this.listPlans({ page, per_page: perPage, search_text: searchText, ...rest });
137
137
  },
138
138
 
139
+ listRequirements({ page, per_page: perPage, source, scope } = {}) {
140
+ return this.apiClient.list('requirements', { page, per_page: perPage, source, scope });
141
+ },
142
+
143
+ searchRequirements({ page, per_page: perPage, source, scope } = {}) {
144
+ return this.listRequirements({ page, per_page: perPage, source, scope });
145
+ },
146
+
147
+ listMilestones({ page, per_page: perPage, type, status } = {}) {
148
+ return this.apiClient.list('milestones', { page, per_page: perPage, type, status });
149
+ },
150
+
139
151
  listTags() {
140
152
  return this.apiClient.list('tags');
141
153
  },
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
1
4
  export const payloadMethods = {
2
5
  async createWrapped(resource, wrapperKey, payload) {
3
6
  const wrappedBody = { [wrapperKey]: payload };
@@ -23,6 +26,70 @@ export const payloadMethods = {
23
26
  }
24
27
  },
25
28
 
29
+ async patchWrapped(resource, id, wrapperKey, payload) {
30
+ const wrappedBody = { [wrapperKey]: payload };
31
+ try {
32
+ return await this.apiClient.patch(resource, id, payload);
33
+ } catch (error) {
34
+ if (!this.shouldRetryWrappedBody(error, wrapperKey)) {
35
+ throw error;
36
+ }
37
+ return this.apiClient.patch(resource, id, wrappedBody);
38
+ }
39
+ },
40
+
41
+ async createRequirement(args = {}) {
42
+ const { files, ...payloadArgs } = args;
43
+ const payload = this.buildRequirementPayload(payloadArgs);
44
+
45
+ if (this.hasFiles(files)) {
46
+ return this.apiClient.createMultipart(
47
+ 'requirements',
48
+ await this.buildRequirementFormData(payload, files)
49
+ );
50
+ }
51
+
52
+ return this.createWrapped('requirements', 'requirement', payload);
53
+ },
54
+
55
+ async updateRequirement(requirementId, args = {}) {
56
+ const { files, ...payloadArgs } = args;
57
+ const payload = this.buildRequirementPayload(payloadArgs);
58
+
59
+ if (this.hasFiles(files)) {
60
+ return this.apiClient.patchMultipart(
61
+ 'requirements',
62
+ requirementId,
63
+ await this.buildRequirementFormData(payload, files)
64
+ );
65
+ }
66
+
67
+ return this.patchWrapped('requirements', requirementId, 'requirement', payload);
68
+ },
69
+
70
+ hasFiles(files) {
71
+ return Array.isArray(files) && files.length > 0;
72
+ },
73
+
74
+ async buildRequirementFormData(payload, files = []) {
75
+ const formData = new FormData();
76
+
77
+ Object.entries(payload).forEach(([key, value]) => {
78
+ if (value === undefined || value === null) {
79
+ return;
80
+ }
81
+ formData.append(key, typeof value === 'boolean' ? String(value) : value);
82
+ });
83
+
84
+ for (const filePath of files) {
85
+ const resolvedPath = path.resolve(String(filePath));
86
+ const data = await fs.readFile(resolvedPath);
87
+ formData.append('files', new Blob([data]), path.basename(resolvedPath));
88
+ }
89
+
90
+ return formData;
91
+ },
92
+
26
93
  buildTestPayload({
27
94
  title,
28
95
  suite_id: suiteId,
@@ -262,4 +329,24 @@ export const payloadMethods = {
262
329
  link,
263
330
  };
264
331
  },
332
+
333
+ buildRequirementPayload({
334
+ title,
335
+ source_type: sourceType,
336
+ description,
337
+ details,
338
+ active,
339
+ global,
340
+ confluence_url: confluenceUrl,
341
+ } = {}) {
342
+ return {
343
+ title,
344
+ source_type: sourceType,
345
+ description,
346
+ details,
347
+ active,
348
+ global,
349
+ confluence_url: confluenceUrl,
350
+ };
351
+ },
265
352
  };
package/src/mcp/server.js CHANGED
@@ -7,14 +7,30 @@ import { createLogger } from '../core/logger.js';
7
7
  import { getPackageVersion } from '../config/package-version.js';
8
8
 
9
9
  export class TestomatioMCPServer {
10
- constructor({ config, apiClient, logger }) {
10
+ constructor({
11
+ config,
12
+ apiClient,
13
+ logger,
14
+ tools = TOOL_DEFINITIONS,
15
+ name = 'testomatio-mcp-server',
16
+ registryOptions = {},
17
+ }) {
11
18
  this.config = config;
19
+ this.apiClient = apiClient;
12
20
  this.logger = logger || createLogger();
13
- this.toolRegistry = new ToolRegistry({ config, apiClient, logger: this.logger });
21
+ this.tools = tools;
22
+ this.toolRegistry = new ToolRegistry({
23
+ config,
24
+ apiClient,
25
+ logger: this.logger,
26
+ tools,
27
+ ...registryOptions,
28
+ });
29
+ this.cleanupStarted = false;
14
30
 
15
31
  this.server = new Server(
16
32
  {
17
- name: 'testomatio-mcp-server',
33
+ name,
18
34
  version: getPackageVersion(),
19
35
  },
20
36
  {
@@ -29,7 +45,7 @@ export class TestomatioMCPServer {
29
45
 
30
46
  setupHandlers() {
31
47
  this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
- tools: TOOL_DEFINITIONS,
48
+ tools: this.tools,
33
49
  }));
34
50
 
35
51
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -44,6 +60,33 @@ export class TestomatioMCPServer {
44
60
  async run() {
45
61
  const transport = new StdioServerTransport();
46
62
  await this.server.connect(transport);
63
+ this.installSessionCleanup();
47
64
  this.logger.info('Testomatio MCP server started');
48
65
  }
66
+
67
+ installSessionCleanup() {
68
+ const cleanup = async () => {
69
+ if (this.cleanupStarted) {
70
+ return;
71
+ }
72
+
73
+ this.cleanupStarted = true;
74
+ await this.apiClient?.stopSession?.();
75
+ };
76
+
77
+ this.server.onclose = () => {
78
+ void cleanup();
79
+ };
80
+
81
+ process.once('beforeExit', () => {
82
+ void cleanup();
83
+ });
84
+
85
+ for (const signal of ['SIGINT', 'SIGTERM']) {
86
+ process.once(signal, async () => {
87
+ await cleanup();
88
+ process.exit(0);
89
+ });
90
+ }
91
+ }
49
92
  }
@@ -8,8 +8,10 @@ import { STEPS_TOOLS } from './definitions/steps.js';
8
8
  import { SNIPPETS_TOOLS } from './definitions/snippets.js';
9
9
  import { LABELS_TOOLS } from './definitions/labels.js';
10
10
  import { TAGS_TOOLS } from './definitions/tags.js';
11
+ import { MILESTONES_TOOLS } from './definitions/milestones.js';
11
12
  import { ISSUES_TOOLS } from './definitions/issues.js';
12
13
  import { PLANS_TOOLS } from './definitions/plans.js';
14
+ import { REQUIREMENTS_TOOLS } from './definitions/requirements.js';
13
15
 
14
16
  export const TOOL_DEFINITIONS = [
15
17
  ...SYSTEM_TOOLS,
@@ -22,6 +24,8 @@ export const TOOL_DEFINITIONS = [
22
24
  ...SNIPPETS_TOOLS,
23
25
  ...LABELS_TOOLS,
24
26
  ...TAGS_TOOLS,
27
+ ...MILESTONES_TOOLS,
25
28
  ...ISSUES_TOOLS,
26
29
  ...PLANS_TOOLS,
30
+ ...REQUIREMENTS_TOOLS,
27
31
  ];
@@ -12,10 +12,12 @@ function formatJson(payload) {
12
12
  }
13
13
 
14
14
  export class ToolRegistry {
15
- constructor({ config, apiClient, logger }) {
15
+ constructor({ config, apiClient, logger, tools = TOOL_DEFINITIONS, handlerRegistrars = [] }) {
16
16
  this.config = config;
17
17
  this.apiClient = apiClient;
18
18
  this.logger = logger;
19
+ this.tools = tools;
20
+ this.handlerRegistrars = handlerRegistrars;
19
21
  this.handlers = this.buildHandlers();
20
22
  }
21
23
 
@@ -37,8 +39,11 @@ export class ToolRegistry {
37
39
  this.registerEntityCrudHandlers(handlers);
38
40
  this.registerScopedIssueHandlers(handlers);
39
41
  this.registerGlobalHandlers(handlers);
42
+ for (const registerHandlers of this.handlerRegistrars) {
43
+ registerHandlers.call(this, handlers);
44
+ }
40
45
 
41
- for (const tool of TOOL_DEFINITIONS) {
46
+ for (const tool of this.tools) {
42
47
  if (tool.name === 'system_ping') continue;
43
48
  if (!handlers[tool.name]) {
44
49
  handlers[tool.name] = async () => textResponse(`${DEFAULT_TOOL_RESPONSE} (${tool.name})`);