@testomatio/mcp 2.2.1 → 3.0.0-beta.1
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 +88 -2
- package/package.json +11 -4
- package/src/api/testomatio-client.js +6 -2
- package/src/cli/main.js +1 -0
- package/src/config/load-config.js +35 -5
- package/src/core/path-segment.js +21 -0
- package/src/mcp/create-server.js +16 -0
- package/src/mcp/server.js +35 -15
package/README.md
CHANGED
|
@@ -47,7 +47,15 @@ export TESTOMATIO_PROJECT_ID=<PROJECT_ID>
|
|
|
47
47
|
testomatio-mcp
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
**Optional: custom
|
|
50
|
+
**Optional: custom host**
|
|
51
|
+
```bash
|
|
52
|
+
export TESTOMATIO_HOST=beta.testomat.io
|
|
53
|
+
testomatio-mcp --host beta.testomat.io
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A bare hostname is expanded to `https://<host>`. For full control use
|
|
57
|
+
`--base-url` / `TESTOMATIO_BASE_URL`, which takes precedence over the host option:
|
|
58
|
+
|
|
51
59
|
```bash
|
|
52
60
|
export TESTOMATIO_BASE_URL=https://beta.testomat.io
|
|
53
61
|
```
|
|
@@ -151,6 +159,48 @@ Add this config to `opencode.json` in your project root, or to `~/.config/openco
|
|
|
151
159
|
}
|
|
152
160
|
```
|
|
153
161
|
|
|
162
|
+
## HTTP Transport
|
|
163
|
+
|
|
164
|
+
Besides stdio, the server runs over Streamable HTTP on a Cloudflare Worker hosted by
|
|
165
|
+
Testomat.io. The project is part of the URL, so every tool signature stays the same:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
https://mcp.testomat.io/mcp/<project_id>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Point an MCP client at that URL with a project token:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"mcpServers": {
|
|
176
|
+
"testomatio": {
|
|
177
|
+
"url": "https://mcp.testomat.io/mcp/<PROJECT_ID>",
|
|
178
|
+
"headers": {
|
|
179
|
+
"Authorization": "Bearer <PROJECT_TOKEN>"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Web connectors such as claude.ai have nowhere to put a static token and instead run
|
|
187
|
+
OAuth 2.1 with PKCE and Dynamic Client Registration against the same URL. Tokens
|
|
188
|
+
starting with `testomat_` or `tstmt_` always bypass OAuth and are passed straight
|
|
189
|
+
through, so IDE clients and CI keep working.
|
|
190
|
+
|
|
191
|
+
The endpoint is POST only; `GET` returns `405`, because the server never initiates
|
|
192
|
+
traffic. Testing with `curl` requires both media types in `Accept`:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
curl -sS https://mcp.testomat.io/mcp/<PROJECT_ID> \
|
|
196
|
+
-H "Authorization: Bearer <PROJECT_TOKEN>" \
|
|
197
|
+
-H "Content-Type: application/json" \
|
|
198
|
+
-H "Accept: application/json, text/event-stream" \
|
|
199
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Self-hosted installations keep using stdio.
|
|
203
|
+
|
|
154
204
|
## Quick Examples
|
|
155
205
|
|
|
156
206
|
**List tests:**
|
|
@@ -232,7 +282,8 @@ src/
|
|
|
232
282
|
| `TESTOMATIO_PROJECT_TOKEN` | Yes* | - | Project token (preferred) |
|
|
233
283
|
| `TESTOMATIO_API_TOKEN` | Yes* | - | Alternative token |
|
|
234
284
|
| `TESTOMATIO_PROJECT_ID` | Yes | - | Project ID |
|
|
235
|
-
| `
|
|
285
|
+
| `TESTOMATIO_HOST` | No | - | API host, e.g. `beta.testomat.io` |
|
|
286
|
+
| `TESTOMATIO_BASE_URL` | No | `https://app.testomat.io` | API base URL, wins over `TESTOMATIO_HOST` |
|
|
236
287
|
| `TESTOMATIO_TOOLS` | No | `full` | Tool profile: `full`, `core`, or `read` |
|
|
237
288
|
|
|
238
289
|
*Either `TESTOMATIO_PROJECT_TOKEN` or `TESTOMATIO_API_TOKEN`
|
|
@@ -287,6 +338,7 @@ NODE_EXTRA_CA_CERTS=/path/to/company-root-ca.pem testomatio-mcp --token <TOKEN>
|
|
|
287
338
|
```bash
|
|
288
339
|
npm install
|
|
289
340
|
npm run start -- --token <TOKEN> --project <PROJECT_ID>
|
|
341
|
+
npm test
|
|
290
342
|
```
|
|
291
343
|
|
|
292
344
|
For local MCP development, point Claude Desktop to the checked-out entrypoint:
|
|
@@ -372,3 +424,37 @@ Example `analytics_charts_results` call:
|
|
|
372
424
|
}
|
|
373
425
|
}
|
|
374
426
|
```
|
|
427
|
+
|
|
428
|
+
### Worker deployment
|
|
429
|
+
|
|
430
|
+
The `worker/` directory holds the Cloudflare Worker and is excluded from the npm
|
|
431
|
+
package. Deploy it from that directory:
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
cd worker
|
|
435
|
+
npx wrangler kv namespace create OAUTH_KV
|
|
436
|
+
npx wrangler secret put TESTOMATIO_MCP_WORKER_SECRET
|
|
437
|
+
npx wrangler deploy
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Put the namespace id returned by the first command into `kv_namespaces` in
|
|
441
|
+
`worker/wrangler.jsonc`. `TESTOMATIO_MCP_WORKER_SECRET` is the shared secret used to
|
|
442
|
+
redeem authorization codes against Testomat.io server-to-server and is never
|
|
443
|
+
committed.
|
|
444
|
+
|
|
445
|
+
Before exposing the Worker publicly, configure Cloudflare rate-limiting rules for
|
|
446
|
+
`/register`, `/authorize`, `/token`, and `/mcp/*`. These endpoints intentionally
|
|
447
|
+
support unauthenticated OAuth discovery and client registration, so rate limiting
|
|
448
|
+
belongs at the edge rather than in per-isolate memory. Keep separate rules and KV
|
|
449
|
+
namespaces for beta and production.
|
|
450
|
+
|
|
451
|
+
A beta worker is the same code deployed to the `beta` environment, which targets
|
|
452
|
+
`https://beta.testomat.io` and keeps its own KV namespace so beta grants never
|
|
453
|
+
reach the production one:
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
npx wrangler kv namespace create OAUTH_KV --env beta
|
|
457
|
+
npx wrangler secret put TESTOMATIO_MCP_WORKER_SECRET --env beta
|
|
458
|
+
npx wrangler deploy --env beta
|
|
459
|
+
```
|
|
460
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testomatio/mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-beta.1",
|
|
4
4
|
"description": "Model Context Protocol server for Testomatio API",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node index.js",
|
|
12
12
|
"dev": "node index.js",
|
|
13
|
-
"sync:enterprise-package": "node scripts/sync-enterprise-package.js"
|
|
13
|
+
"sync:enterprise-package": "node scripts/sync-enterprise-package.js",
|
|
14
|
+
"test": "vitest run"
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|
|
16
17
|
"testomatio",
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"url": "https://github.com/testomatio/mcp"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@modelcontextprotocol/sdk": "^
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
31
32
|
"commander": "^12.0.0",
|
|
32
33
|
"dotenv": "^17.2.3"
|
|
33
34
|
},
|
|
@@ -39,5 +40,11 @@
|
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=18.0.0"
|
|
41
42
|
},
|
|
42
|
-
"devDependencies": {
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
45
|
+
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
46
|
+
"@cloudflare/workers-oauth-provider": "^0.10.3",
|
|
47
|
+
"vitest": "^4.1.11",
|
|
48
|
+
"wrangler": "^4.131.2"
|
|
49
|
+
}
|
|
43
50
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpClient } from './http-client.js';
|
|
2
|
+
import { encodePathParameter } from '../core/path-segment.js';
|
|
2
3
|
|
|
3
4
|
export class TestomatioApiClient {
|
|
4
5
|
constructor({ baseUrl, projectId, token, logger }) {
|
|
@@ -14,9 +15,12 @@ export class TestomatioApiClient {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
buildPath(resource, id = '') {
|
|
18
|
+
const safeProjectId = encodePathParameter(this.projectId, 'Project ID');
|
|
17
19
|
const safeResource = String(resource).replace(/^\/+|\/+$/g, '');
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
+
const resourceId = String(id ?? '');
|
|
21
|
+
const safeId = resourceId ? `/${encodePathParameter(resourceId, 'Resource ID')}` : '';
|
|
22
|
+
|
|
23
|
+
return `/api/v2/${safeProjectId}/${safeResource}${safeId}`;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
list(resource, query = {}) {
|
package/src/cli/main.js
CHANGED
|
@@ -15,6 +15,7 @@ export function parseArgs(argv = process.argv) {
|
|
|
15
15
|
.option('-t, --token <token>', 'Testomatio Project token')
|
|
16
16
|
.option('-p, --project <project>', 'Project ID')
|
|
17
17
|
.option('--base-url <url>', 'Base URL for Testomatio API')
|
|
18
|
+
.option('--host <host>', 'Testomatio host, e.g. beta.testomat.io')
|
|
18
19
|
.option(
|
|
19
20
|
'--tools <profile>',
|
|
20
21
|
'Tool surface: full (default, all tools), core (common entities only), read (read-only)'
|
|
@@ -10,13 +10,43 @@ function normalizeBaseUrl(value) {
|
|
|
10
10
|
return normalized.replace(/\/+$/, '');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
function hostToBaseUrl(value) {
|
|
14
|
+
const host = normalizeBaseUrl(value);
|
|
15
|
+
|
|
16
|
+
if (!host) {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return /^https?:\/\//i.test(host) ? host : `https://${host}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveBaseUrl(argvOptions = {}, env = process.env) {
|
|
24
|
+
const explicitBaseUrl = normalizeBaseUrl(argvOptions.baseUrl || env.TESTOMATIO_BASE_URL);
|
|
25
|
+
if (explicitBaseUrl) {
|
|
26
|
+
return explicitBaseUrl;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const hostBaseUrl = hostToBaseUrl(argvOptions.host || env.TESTOMATIO_HOST);
|
|
30
|
+
if (hostBaseUrl) {
|
|
31
|
+
return hostBaseUrl;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return DEFAULT_BASE_URL;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadServerConfig(argvOptions = {}, env = process.env) {
|
|
38
|
+
return {
|
|
39
|
+
baseUrl: resolveBaseUrl(argvOptions, env),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadConfig(argvOptions = {}, env = process.env) {
|
|
14
44
|
const token = normalizeString(
|
|
15
|
-
argvOptions.token ||
|
|
45
|
+
argvOptions.token || env.TESTOMATIO_PROJECT_TOKEN || env.TESTOMATIO_API_TOKEN
|
|
16
46
|
);
|
|
17
|
-
const projectId = normalizeString(argvOptions.project ||
|
|
18
|
-
const baseUrl =
|
|
19
|
-
const rawToolsProfile = normalizeString(argvOptions.tools ||
|
|
47
|
+
const projectId = normalizeString(argvOptions.project || env.TESTOMATIO_PROJECT_ID);
|
|
48
|
+
const baseUrl = resolveBaseUrl(argvOptions, env);
|
|
49
|
+
const rawToolsProfile = normalizeString(argvOptions.tools || env.TESTOMATIO_TOOLS).toLowerCase();
|
|
20
50
|
const toolsProfile = rawToolsProfile || DEFAULT_PROFILE;
|
|
21
51
|
|
|
22
52
|
if (!token) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function pathParameter(value, label = 'Path parameter') {
|
|
2
|
+
const segment = String(value ?? '');
|
|
3
|
+
|
|
4
|
+
if (!segment || segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\')) {
|
|
5
|
+
throw new TypeError(`${label} must be a single URL path segment`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return segment;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function encodePathParameter(value, label) {
|
|
12
|
+
return encodeURIComponent(pathParameter(value, label));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function decodePathParameter(value, label) {
|
|
16
|
+
try {
|
|
17
|
+
return pathParameter(decodeURIComponent(value), label);
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TestomatioApiClient } from '../api/testomatio-client.js';
|
|
2
|
+
import { createLogger } from '../core/logger.js';
|
|
3
|
+
import { TestomatioMCPServer } from './server.js';
|
|
4
|
+
|
|
5
|
+
export function createMcpServer({ token, projectId, baseUrl, logger, version, jsonSchemaValidator }) {
|
|
6
|
+
const config = { token, projectId, baseUrl };
|
|
7
|
+
const serverLogger = logger || createLogger();
|
|
8
|
+
|
|
9
|
+
return new TestomatioMCPServer({
|
|
10
|
+
config,
|
|
11
|
+
apiClient: new TestomatioApiClient({ ...config, logger: serverLogger }),
|
|
12
|
+
logger: serverLogger,
|
|
13
|
+
version,
|
|
14
|
+
jsonSchemaValidator,
|
|
15
|
+
});
|
|
16
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -14,6 +14,8 @@ export class TestomatioMCPServer {
|
|
|
14
14
|
tools = TOOL_DEFINITIONS,
|
|
15
15
|
name = 'testomatio-mcp-server',
|
|
16
16
|
registryOptions = {},
|
|
17
|
+
version,
|
|
18
|
+
jsonSchemaValidator,
|
|
17
19
|
}) {
|
|
18
20
|
this.config = config;
|
|
19
21
|
this.apiClient = apiClient;
|
|
@@ -26,17 +28,19 @@ export class TestomatioMCPServer {
|
|
|
26
28
|
tools,
|
|
27
29
|
...registryOptions,
|
|
28
30
|
});
|
|
29
|
-
this.
|
|
31
|
+
this.closePromise = null;
|
|
32
|
+
this.sessionCleanupPromise = null;
|
|
30
33
|
|
|
31
34
|
this.server = new Server(
|
|
32
35
|
{
|
|
33
36
|
name,
|
|
34
|
-
version: getPackageVersion(),
|
|
37
|
+
version: version || getPackageVersion(),
|
|
35
38
|
},
|
|
36
39
|
{
|
|
37
40
|
capabilities: {
|
|
38
41
|
tools: {},
|
|
39
42
|
},
|
|
43
|
+
jsonSchemaValidator,
|
|
40
44
|
}
|
|
41
45
|
);
|
|
42
46
|
|
|
@@ -57,36 +61,52 @@ export class TestomatioMCPServer {
|
|
|
57
61
|
});
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
async
|
|
61
|
-
const transport = new StdioServerTransport();
|
|
64
|
+
async connect(transport) {
|
|
62
65
|
await this.server.connect(transport);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async run() {
|
|
69
|
+
await this.connect(new StdioServerTransport());
|
|
63
70
|
this.installSessionCleanup();
|
|
64
71
|
this.logger.info('Testomatio MCP server started');
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
close() {
|
|
75
|
+
if (!this.closePromise) {
|
|
76
|
+
this.closePromise = (async () => {
|
|
77
|
+
try {
|
|
78
|
+
await this.server.close();
|
|
79
|
+
} finally {
|
|
80
|
+
await this.#stopSession();
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
72
84
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
};
|
|
85
|
+
return this.closePromise;
|
|
86
|
+
}
|
|
76
87
|
|
|
88
|
+
installSessionCleanup() {
|
|
77
89
|
this.server.onclose = () => {
|
|
78
|
-
void
|
|
90
|
+
void this.#stopSession();
|
|
79
91
|
};
|
|
80
92
|
|
|
81
93
|
process.once('beforeExit', () => {
|
|
82
|
-
void
|
|
94
|
+
void this.#stopSession();
|
|
83
95
|
});
|
|
84
96
|
|
|
85
97
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
86
98
|
process.once(signal, async () => {
|
|
87
|
-
await
|
|
99
|
+
await this.#stopSession();
|
|
88
100
|
process.exit(0);
|
|
89
101
|
});
|
|
90
102
|
}
|
|
91
103
|
}
|
|
104
|
+
|
|
105
|
+
#stopSession() {
|
|
106
|
+
if (!this.sessionCleanupPromise) {
|
|
107
|
+
this.sessionCleanupPromise = Promise.resolve().then(() => this.apiClient?.stopSession?.());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return this.sessionCleanupPromise;
|
|
111
|
+
}
|
|
92
112
|
}
|