@testomatio/mcp 2.2.0 → 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 CHANGED
@@ -47,7 +47,15 @@ export TESTOMATIO_PROJECT_ID=<PROJECT_ID>
47
47
  testomatio-mcp
48
48
  ```
49
49
 
50
- **Optional: custom base URL**
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
- | `TESTOMATIO_BASE_URL` | No | `https://app.testomat.io` | API base URL |
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": "2.2.0",
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": "^0.4.0",
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 safeId = id ? `/${String(id).replace(/^\/+|\/+$/g, '')}` : '';
19
- return `/api/v2/${this.projectId}/${safeResource}${safeId}`;
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
- export function loadConfig(argvOptions = {}) {
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 || process.env.TESTOMATIO_PROJECT_TOKEN || process.env.TESTOMATIO_API_TOKEN
45
+ argvOptions.token || env.TESTOMATIO_PROJECT_TOKEN || env.TESTOMATIO_API_TOKEN
16
46
  );
17
- const projectId = normalizeString(argvOptions.project || process.env.TESTOMATIO_PROJECT_ID);
18
- const baseUrl = normalizeBaseUrl(argvOptions.baseUrl || process.env.TESTOMATIO_BASE_URL || DEFAULT_BASE_URL);
19
- const rawToolsProfile = normalizeString(argvOptions.tools || process.env.TESTOMATIO_TOOLS).toLowerCase();
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.cleanupStarted = false;
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 run() {
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
- installSessionCleanup() {
68
- const cleanup = async () => {
69
- if (this.cleanupStarted) {
70
- return;
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
- this.cleanupStarted = true;
74
- await this.apiClient?.stopSession?.();
75
- };
85
+ return this.closePromise;
86
+ }
76
87
 
88
+ installSessionCleanup() {
77
89
  this.server.onclose = () => {
78
- void cleanup();
90
+ void this.#stopSession();
79
91
  };
80
92
 
81
93
  process.once('beforeExit', () => {
82
- void cleanup();
94
+ void this.#stopSession();
83
95
  });
84
96
 
85
97
  for (const signal of ['SIGINT', 'SIGTERM']) {
86
98
  process.once(signal, async () => {
87
- await cleanup();
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
  }
@@ -13,7 +13,11 @@ function isAttachment(name) {
13
13
  }
14
14
 
15
15
  function isReadOp(name) {
16
- return name === 'system_ping' || /_(list|get)$/.test(name) || name.endsWith('_issues_list');
16
+ return (
17
+ name === 'system_ping' ||
18
+ /_(list|get|results)$/.test(name) ||
19
+ name.endsWith('_issues_list')
20
+ );
17
21
  }
18
22
 
19
23
  /**