@testomatio/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.
Files changed (3) hide show
  1. package/README.md +198 -0
  2. package/index.js +768 -0
  3. package/package.json +36 -0
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # Testomatio MCP Server
2
+
3
+ A Model Context Protocol (MCP) server for Testomatio API integration with AI assistants like Cursor.
4
+
5
+ ## Installation
6
+
7
+ ### Prerequisites
8
+
9
+ - Node.js 18 or higher (with built-in fetch support)
10
+ - npm or yarn package manager
11
+ - Testomatio account with API access
12
+
13
+ ### Install via npm
14
+
15
+ ```bash
16
+ npm install -g @testomatio/mcp
17
+ ```
18
+
19
+ ### Or run directly with npx
20
+
21
+ ```bash
22
+ npx @testomatio/mcp --token <your-token> --project <project-id>
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Command Line Options
28
+
29
+ The MCP server can be started using command line arguments or environment variables:
30
+
31
+ #### Using Command Line Arguments
32
+
33
+ ```bash
34
+ # Using short flags
35
+ npx @testomatio/mcp -t testomat_YOUR_TOKEN_HERE -p your-project-id
36
+
37
+ # Using long flags
38
+ npx @testomatio/mcp --token testomat_YOUR_TOKEN_HERE --project your-project-id
39
+
40
+ # If installed globally
41
+ testomatio-mcp --token testomat_YOUR_TOKEN_HERE --project your-project-id
42
+
43
+ # With custom base URL
44
+ npx @testomatio/mcp --token testomat_YOUR_TOKEN_HERE --project your-project-id --base-url https://your-instance.testomat.io
45
+ ```
46
+
47
+ #### Using Environment Variables
48
+
49
+ ```bash
50
+ # Set environment variables
51
+ export TESTOMATIO_API_TOKEN=testomat_YOUR_TOKEN_HERE
52
+ export TESTOMATIO_BASE_URL=https://app.testomat.io # Optional, defaults to https://app.testomat.io
53
+
54
+ # Run with project ID
55
+ npx @testomatio/mcp --project your-project-id
56
+
57
+ # Or run directly with environment variables
58
+ TESTOMATIO_API_TOKEN=testomat_YOUR_TOKEN_HERE npx @testomatio/mcp --project your-project-id
59
+ ```
60
+
61
+ ### Getting Your API Token
62
+
63
+ 1. Go to [Testomatio](https://app.testomat.io)
64
+ 2. Navigate to user tokens https://app.testomat.io/account/access_tokens
65
+ 3. Create and copy **General API token** (starts with `testomat_`)
66
+
67
+ ### Getting Your Project ID
68
+
69
+ Your project ID can be found in the URL when you're viewing your project:
70
+ ```
71
+ https://app.testomat.io/projects/YOUR_PROJECT_ID
72
+ ```
73
+
74
+ ## Integration with Cursor
75
+
76
+ To use this MCP server with Cursor, add the following configuration to your Cursor settings:
77
+
78
+ ### Option 1: Using npx (Recommended)
79
+
80
+ Add this to your Cursor MCP settings (`cursor-settings.json` or through the Cursor settings UI):
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "testomatio": {
86
+ "command": "npx",
87
+ "args": ["@testomatio/mcp", "--token", "testomat_YOUR_TOKEN_HERE", "--project", "YOUR_PROJECT_ID"]
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ ### Option 2: Using Environment Variables
94
+
95
+ First, set your environment variables in your shell profile (`.bashrc`, `.zshrc`, etc.):
96
+
97
+ ```bash
98
+ export TESTOMATIO_API_TOKEN=testomat_YOUR_TOKEN_HERE
99
+ ```
100
+
101
+ Then add this to your Cursor MCP settings:
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "testomatio": {
107
+ "command": "npx",
108
+ "args": ["@testomatio/mcp", "--project", "YOUR_PROJECT_ID"],
109
+ "env": {
110
+ "TESTOMATIO_API_TOKEN": "testomat_YOUR_TOKEN_HERE"
111
+ }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ ### Option 3: Global Installation
118
+
119
+ If you've installed the package globally:
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "testomatio": {
125
+ "command": "testomatio-mcp",
126
+ "args": ["--token", "testomat_YOUR_TOKEN_HERE", "--project", "YOUR_PROJECT_ID"]
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ ## Example Usage in Cursor
133
+
134
+ Once configured, you can ask your AI assistant questions like:
135
+
136
+ - "Show me all the tests in the project"
137
+ - "Get the test runs for test ID abc123"
138
+ - "What are the root suites in this project?"
139
+ - "Show me details for test run xyz789"
140
+ - "List all automated tests with the @smoke tag"
141
+ - "Get all test plans for this project"
142
+
143
+ ## Troubleshooting
144
+
145
+ ### Common Issues
146
+
147
+ 1. **"API token is required" error**
148
+ - Make sure your token starts with `testomat_`
149
+ - Verify the token is correct in your Testomatio project settings
150
+
151
+ 2. **"Project ID is required" error**
152
+ - Check that you're passing the correct project ID
153
+ - Verify the project ID exists and you have access to it
154
+
155
+ 3. **Connection errors**
156
+ - Ensure you have internet connectivity
157
+ - Check if your firewall allows connections to `app.testomat.io`
158
+ - Verify your API token has the necessary permissions
159
+
160
+ 4. **MCP server not starting in Cursor**
161
+ - Check Cursor's MCP logs for error messages
162
+ - Ensure Node.js 18+ is installed and accessible
163
+ - Try running the command manually first to test
164
+
165
+ ### Debug Mode
166
+
167
+ To see detailed logs when running the server:
168
+
169
+ ```bash
170
+ DEBUG=* npx @testomatio/mcp --token <token> --project <project-id>
171
+ ```
172
+
173
+ ## API Reference
174
+
175
+ For detailed information about the underlying Testomatio API, refer to the [Testomatio API Documentation](https://app.testomat.io/docs/api/).
176
+
177
+ ## Contributing
178
+
179
+ Contributions are welcome! Please feel free to submit a Pull Request.
180
+
181
+ ## License
182
+
183
+ This project is licensed under the MIT License - see the LICENSE file for details.
184
+
185
+ ## Support
186
+
187
+ For support, please:
188
+ 1. Check the [Testomatio Documentation](https://docs.testomat.io)
189
+ 2. Open an issue on GitHub
190
+ 3. Contact Testomatio support
191
+
192
+ ## Changelog
193
+
194
+ ### v1.0.0
195
+ - Initial release
196
+ - Support for all major Testomatio API endpoints
197
+ - MCP-compatible tool interface
198
+ - Semantic XML formatting for LLM processing
package/index.js ADDED
@@ -0,0 +1,768 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import { program } from 'commander';
10
+
11
+ class TestomatioMCPServer {
12
+ constructor(config) {
13
+ this.config = config;
14
+ this.jwtToken = null;
15
+ this.server = new Server(
16
+ {
17
+ name: 'testomatio-mcp-server',
18
+ version: '1.0.0',
19
+ },
20
+ {
21
+ capabilities: {
22
+ tools: {},
23
+ },
24
+ }
25
+ );
26
+
27
+ this.setupToolHandlers();
28
+ }
29
+
30
+ async authenticate() {
31
+ if (this.jwtToken) {
32
+ return this.jwtToken;
33
+ }
34
+
35
+ const response = await fetch(`${this.config.baseUrl}/api/login`, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/x-www-form-urlencoded',
39
+ },
40
+ body: `api_token=${this.config.token}`,
41
+ });
42
+
43
+ if (!response.ok) {
44
+ throw new Error(`Authentication failed: HTTP ${response.status}: ${response.statusText}`);
45
+ }
46
+
47
+ const data = await response.json();
48
+
49
+ if (!data.jwt) {
50
+ throw new Error('Authentication failed: No JWT token received in response');
51
+ }
52
+
53
+ this.jwtToken = data.jwt;
54
+
55
+ return this.jwtToken;
56
+ }
57
+
58
+ setupToolHandlers() {
59
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
60
+ return {
61
+ tools: [
62
+ {
63
+ name: 'get_tests',
64
+ description: 'Get all tests for the project with optional filtering',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ plan: {
69
+ type: 'string',
70
+ description: 'Plan ID to fetch tests from specific plan',
71
+ },
72
+ query: {
73
+ type: 'string',
74
+ description: 'Search by text or query language (start with =). Example: "=tag == \'slow\'"',
75
+ },
76
+ state: {
77
+ type: 'string',
78
+ enum: ['manual', 'automated'],
79
+ description: 'Filter by test state',
80
+ },
81
+ suite_id: {
82
+ type: 'string',
83
+ description: 'Get tests from specific suite',
84
+ },
85
+ tag: {
86
+ type: 'string',
87
+ description: 'Filter by tag (e.g., @slow)',
88
+ },
89
+ labels: {
90
+ type: 'array',
91
+ items: { type: 'string' },
92
+ description: 'Filter by labels array',
93
+ },
94
+ },
95
+ },
96
+ },
97
+ {
98
+ name: 'search_tests',
99
+ description: 'Search tests by keywords, tags, labels, TQL queries, and other filters',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ query: {
104
+ type: 'string',
105
+ description: 'Search by keywords, tags (@smoke), or Jira issues (JIRA-123)',
106
+ },
107
+ tql: {
108
+ type: 'string',
109
+ description: 'Test Query Language for advanced filtering (e.g., "tag == \'smoke\' and state == \'manual\'")',
110
+ },
111
+ labels: {
112
+ type: 'array',
113
+ items: { type: 'string' },
114
+ description: 'Filter by labels (e.g., ["ui", "critical"])',
115
+ },
116
+ state: {
117
+ type: 'string',
118
+ enum: ['manual', 'automated'],
119
+ description: 'Filter by test state',
120
+ },
121
+ priority: {
122
+ type: 'string',
123
+ enum: ['low', 'normal', 'high', 'critical'],
124
+ description: 'Filter by priority level',
125
+ },
126
+ filter: {
127
+ type: 'object',
128
+ description: 'Advanced filter hash (e.g., {state: "manual", priority: "high"})',
129
+ additionalProperties: true,
130
+ },
131
+ page: {
132
+ type: 'number',
133
+ description: 'Page number for pagination',
134
+ },
135
+ },
136
+ },
137
+ },
138
+ {
139
+ name: 'search_suites',
140
+ description: 'Search suites and their tests by keywords, tags, labels, and other filters',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ query: {
145
+ type: 'string',
146
+ description: 'Search by keywords, tags (@smoke), or Jira issues (JIRA-123)',
147
+ },
148
+ labels: {
149
+ type: 'array',
150
+ items: { type: 'string' },
151
+ description: 'Filter by labels (e.g., ["ui", "critical"])',
152
+ },
153
+ state: {
154
+ type: 'string',
155
+ enum: ['manual', 'automated'],
156
+ description: 'Filter by test state',
157
+ },
158
+ priority: {
159
+ type: 'string',
160
+ enum: ['low', 'normal', 'high', 'critical'],
161
+ description: 'Filter by priority level',
162
+ },
163
+ page: {
164
+ type: 'number',
165
+ description: 'Page number for pagination',
166
+ },
167
+ },
168
+ },
169
+ },
170
+ {
171
+ name: 'get_root_suites',
172
+ description: 'Get all root-level suites for the project',
173
+ inputSchema: {
174
+ type: 'object',
175
+ properties: {},
176
+ },
177
+ },
178
+ {
179
+ name: 'get_suite',
180
+ description: 'Get a specific suite with its child suites and tests',
181
+ inputSchema: {
182
+ type: 'object',
183
+ properties: {
184
+ suite_id: {
185
+ type: 'string',
186
+ description: 'Suite identifier',
187
+ },
188
+ },
189
+ required: ['suite_id'],
190
+ },
191
+ },
192
+ {
193
+ name: 'get_runs',
194
+ description: 'Get all test runs for the project',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {},
198
+ },
199
+ },
200
+ {
201
+ name: 'get_run',
202
+ description: 'Get a specific test run with detailed information',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ run_id: {
207
+ type: 'string',
208
+ description: 'Run identifier',
209
+ },
210
+ tree: {
211
+ type: 'boolean',
212
+ description: 'Include list of tests',
213
+ },
214
+ },
215
+ required: ['run_id'],
216
+ },
217
+ },
218
+ {
219
+ name: 'get_testruns',
220
+ description: 'Get test runs for a specific test with optional date filtering',
221
+ inputSchema: {
222
+ type: 'object',
223
+ properties: {
224
+ test_id: {
225
+ type: 'string',
226
+ description: 'Test identifier',
227
+ },
228
+ finished_at_date_range: {
229
+ type: 'string',
230
+ description: 'Date range filter (format: YYYY-MM-DD,YYYY-MM-DD)',
231
+ },
232
+ },
233
+ required: ['test_id'],
234
+ },
235
+ },
236
+ {
237
+ name: 'get_plans',
238
+ description: 'Get all test plans for the project',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ detail: {
243
+ type: 'boolean',
244
+ description: 'Include detailed information',
245
+ },
246
+ labels: {
247
+ type: 'array',
248
+ items: { type: 'string' },
249
+ description: 'Filter by labels array',
250
+ },
251
+ page: {
252
+ type: 'number',
253
+ description: 'Page number for pagination',
254
+ },
255
+ },
256
+ },
257
+ },
258
+ {
259
+ name: 'get_plan',
260
+ description: 'Get a specific test plan with attached items',
261
+ inputSchema: {
262
+ type: 'object',
263
+ properties: {
264
+ plan_id: {
265
+ type: 'string',
266
+ description: 'Plan identifier',
267
+ },
268
+ },
269
+ required: ['plan_id'],
270
+ },
271
+ },
272
+ ],
273
+ };
274
+ });
275
+
276
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
277
+ const { name, arguments: args } = request.params;
278
+
279
+ try {
280
+ switch (name) {
281
+ case 'get_tests':
282
+ return await this.getTests(args);
283
+ case 'search_tests':
284
+ return await this.searchTests(args);
285
+ case 'search_suites':
286
+ return await this.searchSuites(args);
287
+ case 'get_root_suites':
288
+ return await this.getRootSuites();
289
+ case 'get_suite':
290
+ return await this.getSuite(args.suite_id);
291
+ case 'get_runs':
292
+ return await this.getRuns();
293
+ case 'get_run':
294
+ return await this.getRun(args.run_id, args.tree);
295
+ case 'get_testruns':
296
+ return await this.getTestruns(args.test_id, args.finished_at_date_range);
297
+ case 'get_plans':
298
+ return await this.getPlans(args);
299
+ case 'get_plan':
300
+ return await this.getPlan(args.plan_id);
301
+ default:
302
+ throw new Error(`Unknown tool: ${name}`);
303
+ }
304
+ } catch (error) {
305
+ return {
306
+ content: [
307
+ {
308
+ type: 'text',
309
+ text: `Error: ${error.message}`,
310
+ },
311
+ ],
312
+ };
313
+ }
314
+ });
315
+ }
316
+
317
+ async makeRequest(path, params = {}) {
318
+ // Ensure we have a valid JWT token
319
+ const jwt = await this.authenticate();
320
+
321
+ const url = new URL(`${this.config.baseUrl}/api/${this.config.projectId}${path}`);
322
+
323
+ // Add query parameters with proper array handling
324
+ Object.entries(params).forEach(([key, value]) => {
325
+ if (value !== undefined && value !== null) {
326
+ if (Array.isArray(value)) {
327
+ // Handle arrays (e.g., labels[])
328
+ value.forEach(v => url.searchParams.append(key, v));
329
+ } else {
330
+ url.searchParams.append(key, String(value));
331
+ }
332
+ }
333
+ });
334
+
335
+ const response = await fetch(url.toString(), {
336
+ method: 'GET',
337
+ headers: {
338
+ 'Authorization': jwt,
339
+ 'Content-Type': 'application/json',
340
+ },
341
+ });
342
+
343
+ if (!response.ok) {
344
+ // If unauthorized, clear the JWT token and retry once
345
+ if (response.status === 401 && this.jwtToken) {
346
+ this.jwtToken = null;
347
+ return this.makeRequest(path, params);
348
+ }
349
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
350
+ }
351
+
352
+ return await response.json();
353
+ }
354
+
355
+ escapeXml(text) {
356
+ if (typeof text !== 'string') return text;
357
+ return text
358
+ .replace(/&/g, '&amp;')
359
+ .replace(/</g, '&lt;')
360
+ .replace(/>/g, '&gt;')
361
+ .replace(/"/g, '&quot;')
362
+ .replace(/'/g, '&#39;');
363
+ }
364
+
365
+ formatValue(value, fieldName) {
366
+ if (value === null || value === undefined) {
367
+ return '';
368
+ }
369
+
370
+ // Handle arrays of simple values (tags, labels, etc.)
371
+ if (Array.isArray(value)) {
372
+ if (fieldName === 'tags') {
373
+ return value.map(tag => `<tag>${this.escapeXml(tag)}</tag>`).join('');
374
+ }
375
+ if (fieldName === 'labels') {
376
+ return value.map(label => `<label>${this.escapeXml(label)}</label>`).join('');
377
+ }
378
+ if (fieldName === 'tests-ids') {
379
+ return value.map(id => `<test_id>${id}</test_id>`).join('');
380
+ }
381
+ // Default array handling
382
+ return value.map(item => `<item>${this.escapeXml(item)}</item>`).join('');
383
+ }
384
+
385
+ // Handle nested objects
386
+ if (typeof value === 'object') {
387
+ return this.formatNestedObject(value, fieldName);
388
+ }
389
+
390
+ // Handle strings that need escaping
391
+ if (typeof value === 'string') {
392
+ return this.escapeXml(value);
393
+ }
394
+
395
+ // Handle other primitives
396
+ return String(value);
397
+ }
398
+
399
+ formatNestedObject(obj, fieldName) {
400
+ if (fieldName === 'test' && obj.id) {
401
+ // Special handling for test objects in testruns
402
+ return `
403
+ <id>${obj.id || ''}</id>
404
+ <title>${this.escapeXml(obj.title || '')}</title>
405
+ <priority>${obj.priority || 'normal'}</priority>
406
+ <tags>${(obj.tags || []).map(tag => `<tag>${this.escapeXml(tag)}</tag>`).join('')}</tags>`;
407
+ }
408
+
409
+ // Generic object formatting
410
+ return Object.entries(obj)
411
+ .map(([key, value]) => `<${key}>${this.formatValue(value, key)}</${key}>`)
412
+ .join('\n ');
413
+ }
414
+
415
+ formatModel(model, tagName, fields) {
416
+ const attributes = model.attributes || {};
417
+ const lines = [`<${tagName}>`];
418
+
419
+ // Always include ID from root level
420
+ lines.push(` <id>${model.id || ''}</id>`);
421
+
422
+ // Process specified fields
423
+ fields.forEach(field => {
424
+ let value;
425
+ let xmlFieldName = field;
426
+
427
+ // Handle field mapping for hyphenated API fields
428
+ if (field.includes('-')) {
429
+ value = attributes[field];
430
+ } else {
431
+ // Try both versions for flexibility
432
+ value = attributes[field] || attributes[field.replace('_', '-')];
433
+ xmlFieldName = field.replace('-', '_');
434
+ }
435
+
436
+ const formattedValue = this.formatValue(value, field);
437
+
438
+ if (field === 'test' && typeof value === 'object') {
439
+ // Special case for nested test objects
440
+ lines.push(` <test>${formattedValue}\n </test>`);
441
+ } else {
442
+ lines.push(` <${xmlFieldName}>${formattedValue}</${xmlFieldName}>`);
443
+ }
444
+ });
445
+
446
+ lines.push(`</${tagName}>`);
447
+ return lines.join('\n');
448
+ }
449
+
450
+ async getTests(filters = {}) {
451
+ const params = this.buildSearchParams(filters);
452
+ const data = await this.makeRequest('/tests', params);
453
+ const formattedTests = data.data.map(test =>
454
+ this.formatModel(test, 'test', [
455
+ 'title', 'description', 'code', 'priority',
456
+ 'state', 'suite-id', 'tags', 'file'
457
+ ])
458
+ ).join('\n\n');
459
+
460
+ return {
461
+ content: [
462
+ {
463
+ type: 'text',
464
+ text: `Tests for project ${this.config.projectId}:\n\n${formattedTests}`,
465
+ },
466
+ ],
467
+ };
468
+ }
469
+
470
+ async searchTests(filters = {}) {
471
+ const params = this.buildSearchParams(filters);
472
+ const data = await this.makeRequest('/tests', params);
473
+
474
+ const formattedTests = data.data.map(test =>
475
+ this.formatModel(test, 'test', [
476
+ 'title', 'description', 'code', 'priority',
477
+ 'state', 'suite-id', 'tags', 'file'
478
+ ])
479
+ ).join('\n\n');
480
+
481
+ const searchDescription = this.buildSearchDescription(filters);
482
+
483
+ return {
484
+ content: [
485
+ {
486
+ type: 'text',
487
+ text: `Search results for tests${searchDescription}:\n\n${formattedTests || 'No tests found matching the criteria.'}`,
488
+ },
489
+ ],
490
+ };
491
+ }
492
+
493
+ async searchSuites(filters = {}) {
494
+ // Add filter=true for suites search to include tests
495
+ const params = this.buildSearchParams({ ...filters, filter: true });
496
+ const data = await this.makeRequest('/suites', params);
497
+
498
+ const formattedSuites = data.data.map(suite =>
499
+ this.formatModel(suite, 'suite', [
500
+ 'title', 'description', 'test-count', 'is-root', 'file-type'
501
+ ])
502
+ ).join('\n\n');
503
+
504
+ const searchDescription = this.buildSearchDescription(filters);
505
+
506
+ return {
507
+ content: [
508
+ {
509
+ type: 'text',
510
+ text: `Search results for suites${searchDescription}:\n\n${formattedSuites || 'No suites found matching the criteria.'}`,
511
+ },
512
+ ],
513
+ };
514
+ }
515
+
516
+ buildSearchDescription(filters) {
517
+ const descriptions = [];
518
+
519
+ if (filters.query) {
520
+ if (filters.query.startsWith('@')) {
521
+ descriptions.push(`tagged with "${filters.query}"`);
522
+ } else if (filters.query.match(/^[A-Z]+-\d+$/)) {
523
+ descriptions.push(`linked to Jira issue "${filters.query}"`);
524
+ } else {
525
+ descriptions.push(`containing "${filters.query}"`);
526
+ }
527
+ }
528
+
529
+ if (filters.tql) {
530
+ descriptions.push(`matching TQL: "${filters.tql}"`);
531
+ }
532
+
533
+ if (filters.labels && filters.labels.length > 0) {
534
+ descriptions.push(`with labels: ${filters.labels.join(', ')}`);
535
+ }
536
+
537
+ if (filters.state) {
538
+ descriptions.push(`state: ${filters.state}`);
539
+ }
540
+
541
+ if (filters.priority) {
542
+ descriptions.push(`priority: ${filters.priority}`);
543
+ }
544
+
545
+ if (filters.filter && typeof filters.filter === 'object') {
546
+ const filterDesc = Object.entries(filters.filter)
547
+ .map(([key, value]) => `${key}: ${value}`)
548
+ .join(', ');
549
+ descriptions.push(`filtered by: ${filterDesc}`);
550
+ }
551
+
552
+ return descriptions.length > 0 ? ` (${descriptions.join(', ')})` : '';
553
+ }
554
+
555
+ async getRootSuites() {
556
+ const data = await this.makeRequest('/suites');
557
+ const formattedSuites = data.data.map(suite =>
558
+ this.formatModel(suite, 'suite', [
559
+ 'title', 'description', 'test-count', 'is-root', 'file-type'
560
+ ])
561
+ ).join('\n\n');
562
+
563
+ return {
564
+ content: [
565
+ {
566
+ type: 'text',
567
+ text: `Root suites for project ${this.config.projectId}:\n\n${formattedSuites}`,
568
+ },
569
+ ],
570
+ };
571
+ }
572
+
573
+ async getSuite(suiteId) {
574
+ const data = await this.makeRequest(`/suites/${suiteId}`);
575
+ const formattedSuite = this.formatModel(data.data, 'suite', [
576
+ 'title', 'description', 'test-count', 'is-root', 'file-type'
577
+ ]);
578
+
579
+ // Format child suites and tests if they exist
580
+ let childContent = '';
581
+ if (data.data.relationships?.children?.data) {
582
+ const childSuites = data.data.relationships.children.data
583
+ .map(child => this.formatModel(child, 'suite', [
584
+ 'title', 'description', 'test-count', 'is-root', 'file-type'
585
+ ])).join('\n\n');
586
+ if (childSuites) {
587
+ childContent += `\n\nChild Suites:\n${childSuites}`;
588
+ }
589
+ }
590
+
591
+ if (data.data.relationships?.tests?.data) {
592
+ const tests = data.data.relationships.tests.data
593
+ .map(test => this.formatModel(test, 'test', [
594
+ 'title', 'description', 'code', 'priority',
595
+ 'state', 'suite-id', 'tags', 'file'
596
+ ])).join('\n\n');
597
+ if (tests) {
598
+ childContent += `\n\nTests:\n${tests}`;
599
+ }
600
+ }
601
+
602
+ return {
603
+ content: [
604
+ {
605
+ type: 'text',
606
+ text: `Suite ${suiteId}:\n\n${formattedSuite}${childContent}`,
607
+ },
608
+ ],
609
+ };
610
+ }
611
+
612
+ async getRuns() {
613
+ const data = await this.makeRequest('/runs');
614
+ const formattedRuns = data.data.map(run =>
615
+ this.formatModel(run, 'run', [
616
+ 'status', 'title', 'tests-count', 'automated', 'duration',
617
+ 'passed', 'failed', 'skipped', 'created-at', 'finished-at'
618
+ ])
619
+ ).join('\n\n');
620
+
621
+ return {
622
+ content: [
623
+ {
624
+ type: 'text',
625
+ text: `Test runs for project ${this.config.projectId}:\n\n${formattedRuns}`,
626
+ },
627
+ ],
628
+ };
629
+ }
630
+
631
+ async getRun(runId, tree = false) {
632
+ const params = tree ? { tree: 'true' } : {};
633
+ const data = await this.makeRequest(`/runs/${runId}`, params);
634
+ const formattedRun = this.formatModel(data.data, 'run', [
635
+ 'status', 'title', 'tests-count', 'automated', 'duration',
636
+ 'passed', 'failed', 'skipped', 'created-at', 'finished-at'
637
+ ]);
638
+
639
+ return {
640
+ content: [
641
+ {
642
+ type: 'text',
643
+ text: `Test run ${runId}:\n\n${formattedRun}`,
644
+ },
645
+ ],
646
+ };
647
+ }
648
+
649
+ async getTestruns(testId, dateRange) {
650
+ const params = { test_id: testId };
651
+ if (dateRange) {
652
+ params.finished_at_date_range = dateRange;
653
+ }
654
+
655
+ const data = await this.makeRequest('/testruns', params);
656
+ const formattedTestruns = data.data.map(testrun =>
657
+ this.formatModel(testrun, 'testrun', [
658
+ 'status', 'run-time', 'message', 'run-id', 'test'
659
+ ])
660
+ ).join('\n\n');
661
+
662
+ return {
663
+ content: [
664
+ {
665
+ type: 'text',
666
+ text: `Test runs for test ${testId}:\n\n${formattedTestruns}`,
667
+ },
668
+ ],
669
+ };
670
+ }
671
+
672
+ async getPlans(filters = {}) {
673
+ const data = await this.makeRequest('/plans', filters);
674
+ const formattedPlans = data.data.map(plan =>
675
+ this.formatModel(plan, 'plan', [
676
+ 'title', 'test-count', 'kind', 'created-at', 'tests-ids', 'labels'
677
+ ])
678
+ ).join('\n\n');
679
+
680
+ return {
681
+ content: [
682
+ {
683
+ type: 'text',
684
+ text: `Test plans for project ${this.config.projectId}:\n\n${formattedPlans}`,
685
+ },
686
+ ],
687
+ };
688
+ }
689
+
690
+ async getPlan(planId) {
691
+ const data = await this.makeRequest(`/plans/${planId}`);
692
+ const formattedPlan = this.formatModel(data.data, 'plan', [
693
+ 'title', 'test-count', 'kind', 'created-at', 'tests-ids', 'labels'
694
+ ]);
695
+
696
+ return {
697
+ content: [
698
+ {
699
+ type: 'text',
700
+ text: `Test plan ${planId}:\n\n${formattedPlan}`,
701
+ },
702
+ ],
703
+ };
704
+ }
705
+
706
+ async run() {
707
+ // Test authentication on startup
708
+ try {
709
+ await this.authenticate();
710
+ console.error('✓ Successfully authenticated with Testomatio API');
711
+ } catch (error) {
712
+ console.error('✗ Authentication failed:', error.message);
713
+ process.exit(1);
714
+ }
715
+
716
+ const transport = new StdioServerTransport();
717
+ await this.server.connect(transport);
718
+ console.error('Testomatio MCP server running on stdio');
719
+ }
720
+ }
721
+
722
+ // Parse command line arguments using commander
723
+ function parseArgs() {
724
+ program
725
+ .name('testomatio-mcp')
726
+ .description('Model Context Protocol server for Testomatio API')
727
+ .version('1.0.0')
728
+ .option('-t, --token <token>', 'Testomatio API token')
729
+ .option('-p, --project <project>', 'Project ID')
730
+ .option('--base-url <url>', 'Base URL for Testomatio API', 'https://app.testomat.io')
731
+ .parse();
732
+
733
+ const options = program.opts();
734
+
735
+ const token = options.token || process.env.TESTOMATIO_API_TOKEN;
736
+ const projectId = options.project;
737
+ const baseUrl = options.baseUrl || process.env.TESTOMATIO_BASE_URL || 'https://app.testomat.io';
738
+
739
+ if (!token) {
740
+ console.error('Error: API token is required. Use --token <token> or set TESTOMATIO_API_TOKEN environment variable');
741
+ process.exit(1);
742
+ }
743
+
744
+ if (!projectId) {
745
+ console.error('Error: Project ID is required. Use --project <project_id>');
746
+ process.exit(1);
747
+ }
748
+
749
+ return { token, projectId, baseUrl };
750
+ }
751
+
752
+ // Main execution
753
+ async function main() {
754
+ try {
755
+ const config = parseArgs();
756
+ const server = new TestomatioMCPServer(config);
757
+ await server.run();
758
+ } catch (error) {
759
+ console.error('Failed to start server:', error);
760
+ process.exit(1);
761
+ }
762
+ }
763
+
764
+ if (import.meta.url === `file://${process.argv[1]}`) {
765
+ main().catch(console.error);
766
+ }
767
+
768
+ export { TestomatioMCPServer };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@testomatio/mcp",
3
+ "version": "1.0.0",
4
+ "description": "Model Context Protocol server for Testomatio API",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "testomatio-mcp": "index.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "start": "node index.js",
12
+ "dev": "node index.js",
13
+ "test": "echo \"Error: no test specified\" && exit 1"
14
+ },
15
+ "keywords": [
16
+ "testomatio",
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "testing",
20
+ "api",
21
+ "cursor"
22
+ ],
23
+ "author": "Testomatio Team",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^0.4.0",
27
+ "commander": "^12.0.0"
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "README.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }