@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.
- package/README.md +198 -0
- package/index.js +768 -0
- 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, '&')
|
|
359
|
+
.replace(/</g, '<')
|
|
360
|
+
.replace(/>/g, '>')
|
|
361
|
+
.replace(/"/g, '"')
|
|
362
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|