@robotyxx/robotyx-mcp 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Robotyx MCP server — a thin, stdio MCP wrapper around Robotyx's REST API.
4
+ *
5
+ * Lets Claude Code / Claude Desktop browse a recorded workflow, refine the
6
+ * functional TestSteps (group "1-7" → "Log in as admin"), and push the result
7
+ * back — without leaving the chat. No new business logic lives here; every
8
+ * tool is a wrapper around an existing endpoint the web UI also uses.
9
+ *
10
+ * Env:
11
+ * ROBOTYX_URL — gateway base URL (default http://localhost:3001)
12
+ * ROBOTYX_API_KEY — long-lived `rx_…` Bearer token (required)
13
+ *
14
+ * Wire-up (Claude Code):
15
+ * In the project's `.mcp.json`:
16
+ * {
17
+ * "mcpServers": {
18
+ * "robotyx": {
19
+ * "command": "npx",
20
+ * "args": ["-y", "tsx", "<path-to-this-file>"],
21
+ * "env": { "ROBOTYX_URL": "http://localhost:3001", "ROBOTYX_API_KEY": "rx_…" }
22
+ * }
23
+ * }
24
+ * }
25
+ */
26
+ import { readFileSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+ import { dirname, resolve } from "node:path";
29
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
30
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
31
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
32
+ // Load robotyx-mcp/.env (next to package.json) without adding a dependency.
33
+ // Existing process env wins, so `.mcp.json` / real env vars still override.
34
+ try {
35
+ const envPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env");
36
+ for (const line of readFileSync(envPath, "utf8").split("\n")) {
37
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
38
+ if (!m)
39
+ continue;
40
+ if (process.env[m[1]] === undefined) {
41
+ process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
42
+ }
43
+ }
44
+ }
45
+ catch {
46
+ /* no .env file — fall back to process env */
47
+ }
48
+ // ── Config ──────────────────────────────────────────────────────────────── //
49
+ const ROBOTYX_URL = (process.env.ROBOTYX_URL || "http://localhost:3001").replace(/\/+$/, "");
50
+ const ROBOTYX_API_KEY = process.env.ROBOTYX_API_KEY || "";
51
+ // Tenant scope — tenant-scoped routes (testsuites, projects, …) resolve the
52
+ // customer from X-Customer-Id, falling back to the catch-all when absent. Set
53
+ // this so the MCP sees a specific customer's data (e.g. live recordings) rather
54
+ // than only catch-all rows.
55
+ const ROBOTYX_CUSTOMER_ID = process.env.ROBOTYX_CUSTOMER_ID || "";
56
+ if (!ROBOTYX_API_KEY) {
57
+ // Fail loudly on stderr — stdout is reserved for MCP protocol frames.
58
+ process.stderr.write("[robotyx-mcp] ROBOTYX_API_KEY is not set. Mint a key in the Robotyx UI " +
59
+ "(Settings → API Keys) and add it to the MCP env block.\n");
60
+ }
61
+ // ── HTTP helper ─────────────────────────────────────────────────────────── //
62
+ async function robotyx(method, path, body) {
63
+ const url = `${ROBOTYX_URL}${path}`;
64
+ const res = await fetch(url, {
65
+ method,
66
+ headers: {
67
+ Authorization: `Bearer ${ROBOTYX_API_KEY}`,
68
+ "Content-Type": "application/json",
69
+ ...(ROBOTYX_CUSTOMER_ID ? { "X-Customer-Id": ROBOTYX_CUSTOMER_ID } : {}),
70
+ },
71
+ body: body !== undefined ? JSON.stringify(body) : undefined,
72
+ });
73
+ if (!res.ok) {
74
+ const text = await res.text().catch(() => "");
75
+ throw new Error(`Robotyx ${method} ${path} → ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
76
+ }
77
+ // 204 No Content paths still need a value the caller can JSON-serialize.
78
+ if (res.status === 204)
79
+ return {};
80
+ return (await res.json());
81
+ }
82
+ /** Fetch a blob URL ref (relative like "/api/blobs/sha.png") or absolute URL,
83
+ * returning base64-encoded bytes + mime type. Used to surface screenshots as
84
+ * MCP image content blocks — vision tokens (~1.5k each) instead of base64
85
+ * text inside JSON (which would be ~120k tokens for a 400KB PNG). */
86
+ async function fetchBlobBase64(refOrUrl) {
87
+ try {
88
+ const url = refOrUrl.startsWith("http") ? refOrUrl : `${ROBOTYX_URL}${refOrUrl}`;
89
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${ROBOTYX_API_KEY}` } });
90
+ if (!res.ok)
91
+ return null;
92
+ const mimeType = res.headers.get("content-type") || "image/png";
93
+ const ab = await res.arrayBuffer();
94
+ return { base64: Buffer.from(ab).toString("base64"), mimeType };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /** Upload a local image file to /api/blobs and return the screenshot ref to
101
+ * store on a TestStep (e.g. "/api/blobs/<id>.png"). Shared by add_test_step
102
+ * and update_test_step. */
103
+ async function uploadScreenshotRef(imagePath, mimeType) {
104
+ const mime = mimeType || "image/png";
105
+ const buf = readFileSync(imagePath);
106
+ const res = await fetch(`${ROBOTYX_URL}/api/blobs`, {
107
+ method: "POST",
108
+ headers: { Authorization: `Bearer ${ROBOTYX_API_KEY}`, "Content-Type": mime },
109
+ body: new Uint8Array(buf),
110
+ });
111
+ if (!res.ok) {
112
+ const t = await res.text().catch(() => "");
113
+ throw new Error(`Robotyx POST /api/blobs → ${res.status} ${res.statusText}${t ? `: ${t}` : ""}`);
114
+ }
115
+ const blob = (await res.json());
116
+ const ext = mime === "image/jpeg" ? "jpg" : mime.split("/")[1] || "png";
117
+ const ref = blob.ref ?? blob.url ?? (blob.id ? `/api/blobs/${blob.id}.${ext}` : undefined);
118
+ if (!ref)
119
+ throw new Error(`Robotyx /api/blobs returned no id/url/ref: ${JSON.stringify(blob)}`);
120
+ return ref;
121
+ }
122
+ /** Legacy fallback: some old workflow_steps.parameters may still hold a
123
+ * data:image/png;base64,… URL. Decode in-place rather than refusing. */
124
+ function dataUrlToBase64(dataUrl) {
125
+ const m = /^data:([^;,]+)(;base64)?,(.+)$/i.exec(dataUrl);
126
+ if (!m)
127
+ return null;
128
+ const mimeType = m[1] || "image/png";
129
+ return m[2] ? { base64: m[3], mimeType } : null;
130
+ }
131
+ function trimStep(step, includeScreenshot) {
132
+ const params = { ...(step.parameters || {}) };
133
+ if (!includeScreenshot)
134
+ delete params.screenshot;
135
+ // Always drop the per-step footprint bookkeeping fields — they're useless
136
+ // for refinement (raw a11y dumps, console logs) and bulk up token count.
137
+ for (const k of Object.keys(params)) {
138
+ if (k.startsWith("footprint_"))
139
+ delete params[k];
140
+ }
141
+ return { ...step, parameters: params };
142
+ }
143
+ // ── ISTQB classification helpers ────────────────────────────────────────── //
144
+ // Robotyx has no dedicated ISTQB columns: the react-ui Requirement editor
145
+ // stores classification on the requirement's free-form `attributes` JSON bag
146
+ // (see Projects.tsx → RequirementEditor). The MCP writes the *same* keys so a
147
+ // requirement classified here is editable in the app — and lands on the
148
+ // Product-Risk (PRA) matrix — and vice-versa. Enum values are mirrored in the
149
+ // create/update_requirement input schemas below.
150
+ /** MCP arg name → attributes key the UI reads/writes. */
151
+ const ISTQB_ARG_TO_ATTR = {
152
+ istqb_test_level: "istqbTestLevel",
153
+ istqb_quality_characteristic: "istqbQualityCharacteristic",
154
+ istqb_risk_class: "istqbRiskClass",
155
+ istqb_probability: "istqbProbability",
156
+ };
157
+ function hasIstqbArgs(args) {
158
+ return Object.keys(ISTQB_ARG_TO_ATTR).some((k) => args[k] !== undefined);
159
+ }
160
+ /** Merge ISTQB picks into an existing attributes bag. A provided null or empty
161
+ * string deletes the key (matches the UI's setOrDrop) so unclassified
162
+ * requirements stay clean; all other keys in the bag are preserved untouched. */
163
+ function mergeIstqbAttributes(base, args) {
164
+ const out = { ...(base ?? {}) };
165
+ for (const [arg, attr] of Object.entries(ISTQB_ARG_TO_ATTR)) {
166
+ if (args[arg] === undefined)
167
+ continue;
168
+ const v = args[arg];
169
+ if (v === null || v === "")
170
+ delete out[attr];
171
+ else
172
+ out[attr] = v;
173
+ }
174
+ return out;
175
+ }
176
+ // ── Tool definitions ────────────────────────────────────────────────────── //
177
+ const TOOLS = [
178
+ {
179
+ name: "list_workflows",
180
+ description: "List all Workflows on the Robotyx gateway. Returns id + name + tags + step count for each — pick one to inspect with get_workflow.",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {},
184
+ additionalProperties: false,
185
+ },
186
+ },
187
+ {
188
+ name: "get_workflow",
189
+ description: "Fetch a Workflow with its full ordered step list. Screenshot URLs are always stripped from the JSON payload; when include_screenshots is 'all' or 'indices', the matching screenshots are attached as real image content blocks (vision-tokenized, ~1.5k tokens each) interleaved with text captions identifying the step. Use 'indices' + screenshot_step_indices:[…] (1-based) to fetch only specific steps' screenshots when grouping ambiguous interactions.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ workflow_id: { type: "string", description: "Workflow UUID" },
194
+ include_screenshots: {
195
+ type: "string",
196
+ enum: ["none", "all", "indices"],
197
+ default: "none",
198
+ description: "none = drop all screenshots; all = include every step's; indices = include only the steps named in screenshot_step_indices.",
199
+ },
200
+ screenshot_step_indices: {
201
+ type: "array",
202
+ items: { type: "integer", minimum: 1 },
203
+ description: "1-based step indices to include screenshots for (only used when include_screenshots is 'indices').",
204
+ },
205
+ },
206
+ required: ["workflow_id"],
207
+ additionalProperties: false,
208
+ },
209
+ },
210
+ {
211
+ name: "list_testsuites",
212
+ description: "List all TestSuites on the gateway.",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {},
216
+ additionalProperties: false,
217
+ },
218
+ },
219
+ {
220
+ name: "get_testsuite",
221
+ description: "Fetch a TestSuite with its cases and each case's TestSteps. Use this to see the current grouping before refining.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ testsuite_id: { type: "string" },
226
+ },
227
+ required: ["testsuite_id"],
228
+ additionalProperties: false,
229
+ },
230
+ },
231
+ {
232
+ name: "create_testsuite",
233
+ description: "Create a new TestSuite (cases-collection for a release/sprint/run). project_id is required for tenancy — the suite and every case added to it inherit it.",
234
+ inputSchema: {
235
+ type: "object",
236
+ properties: {
237
+ project_id: { type: "string", description: "Owning Project id — required (tenancy). Cases added to this suite inherit it." },
238
+ name: { type: "string" },
239
+ category: { type: "string", description: "Optional grouping label, e.g. '2026-06 regression', 'Smoke'." },
240
+ description: { type: "string" },
241
+ owner: { type: "string" },
242
+ status: { type: "string", enum: ["draft", "ready", "in_review", "archived"], default: "draft" },
243
+ tags: { type: "array", items: { type: "string" } },
244
+ },
245
+ required: ["project_id", "name"],
246
+ additionalProperties: false,
247
+ },
248
+ },
249
+ {
250
+ name: "add_test_case",
251
+ description: "Add a TestCase to a TestSuite, optionally linking it to a Workflow id (so the case's TestSteps can reference WorkflowSteps).",
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ testsuite_id: { type: "string" },
256
+ name: { type: "string" },
257
+ workflow_id: { type: "string", description: "Optional Workflow this case wires up." },
258
+ priority: { type: "string", enum: ["P1", "P2", "P3", "P4"], default: "P3" },
259
+ description: { type: "string" },
260
+ expected_result: { type: "string" },
261
+ coverage: { type: "string", enum: ["covered", "built_no_story", "transcript_no_story", "not_built", "unknown"], description: "Build/story kenmerk (default unknown)." },
262
+ },
263
+ required: ["testsuite_id", "name"],
264
+ additionalProperties: false,
265
+ },
266
+ },
267
+ {
268
+ name: "add_test_step",
269
+ description: "Append a single functional TestStep to a TestCase — manual authoring, NOT workflow-derived (unlike set_test_steps which needs a Workflow). order auto-increments when omitted. Call once per step to build up a step-by-step case.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ testsuite_id: { type: "string" },
274
+ testcase_id: { type: "string" },
275
+ description: { type: "string", description: "The action/step, e.g. 'Open /klant and pick a destination'." },
276
+ expected_result: { type: ["string", "null"], description: "Per-step expected result (optional)." },
277
+ order: { type: "integer", minimum: 0, description: "Optional 0-based position; appended to the end when omitted." },
278
+ screenshot: { type: "string", description: "Optional blob ref for a screenshot of this step, e.g. '/api/blobs/<id>.png' (as returned by upload_screenshot). Takes precedence over image_path if both given." },
279
+ image_path: { type: "string", description: "Optional local image file to upload as this step's screenshot; uploaded to /api/blobs and the resulting ref is attached automatically." },
280
+ mime_type: { type: "string", description: "MIME type for image_path upload (default image/png)." },
281
+ },
282
+ required: ["testsuite_id", "testcase_id", "description"],
283
+ additionalProperties: false,
284
+ },
285
+ },
286
+ {
287
+ name: "update_test_step",
288
+ description: "Update an existing TestStep in place (PATCH). Use to fix a step's description/expected_result/order or to attach a screenshot to a step that was created without one. Only provided fields change. Pass image_path to upload a local screenshot to /api/blobs and attach it; or screenshot for an existing blob ref.",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ testsuite_id: { type: "string" },
293
+ testcase_id: { type: "string" },
294
+ step_id: { type: "string", description: "The TestStep id to update (from get_testsuite)." },
295
+ description: { type: "string", description: "New step text (optional)." },
296
+ expected_result: { type: ["string", "null"], description: "New per-step expected result (optional)." },
297
+ order: { type: "integer", minimum: 0, description: "New 0-based position (optional)." },
298
+ screenshot: { type: ["string", "null"], description: "Blob ref to attach, e.g. '/api/blobs/<id>.png'; null clears it. Takes precedence over image_path." },
299
+ image_path: { type: "string", description: "Local image file to upload to /api/blobs and attach as this step's screenshot." },
300
+ mime_type: { type: "string", description: "MIME type for image_path upload (default image/png)." },
301
+ },
302
+ required: ["testsuite_id", "testcase_id", "step_id"],
303
+ additionalProperties: false,
304
+ },
305
+ },
306
+ {
307
+ name: "delete_test_step",
308
+ description: "Delete a TestStep from a TestCase. Use to remove a generic/incorrect step so it can be re-authored screen-grounded.",
309
+ inputSchema: {
310
+ type: "object",
311
+ properties: {
312
+ testsuite_id: { type: "string" },
313
+ testcase_id: { type: "string" },
314
+ step_id: { type: "string" },
315
+ },
316
+ required: ["testsuite_id", "testcase_id", "step_id"],
317
+ additionalProperties: false,
318
+ },
319
+ },
320
+ {
321
+ name: "create_test_run",
322
+ description: "Create a TestRun — the test-perspective execution record (expected ⇄ actual), the test analogue of a workflow/flow run. Most runs come from CI/CD; this also covers manual triggers. Pass seed_from_test_case_id (or seed_from_test_suite_id) to snapshot the case's functional steps (action + expected result + reference screenshot) + test data as the EXPECTED context (actuals start 'not_run'), so you can later fill actual results and compare. Or pass steps[] explicitly for external ingest. id is optional for idempotent re-POSTs.",
323
+ inputSchema: {
324
+ type: "object",
325
+ properties: {
326
+ project_id: { type: "string", description: "Tenancy anchor (required)." },
327
+ id: { type: "string", description: "Optional caller-supplied id for idempotent CI re-POSTs." },
328
+ test_suite_id: { type: "string", description: "Suite this run exercised (optional)." },
329
+ test_case_id: { type: "string", description: "Single case this run exercised (optional)." },
330
+ seed_from_test_case_id: { type: "string", description: "Snapshot expected steps from this TestCase at create time." },
331
+ seed_from_test_suite_id: { type: "string", description: "Snapshot expected steps from every case in this TestSuite." },
332
+ name: { type: "string", description: "Label, e.g. 'CI #123' or 'Manual 2026-06-16'." },
333
+ status: { type: "string", enum: ["running", "passed", "failed", "blocked", "skipped", "aborted"] },
334
+ trigger: { type: "string", description: "'cicd' | 'manual' | 'external' | …" },
335
+ environment: { type: "string", description: "Where it ran, e.g. 'sme-test.koppel.ai'." },
336
+ external_ref: { type: "string", description: "Pointer to the external system (CI build url/id)." },
337
+ started_at: { type: "string", description: "ISO timestamp; defaults to now." },
338
+ completed_at: { type: "string" },
339
+ duration_ms: { type: "integer", minimum: 0 },
340
+ steps: {
341
+ type: "array",
342
+ description: "Explicit steps for external ingest (snake_case keys: description, expected_result, expected_screenshot, test_data, status, actual_screenshot, error, duration_ms, test_case_id, test_step_id, order).",
343
+ items: { type: "object", additionalProperties: true },
344
+ },
345
+ },
346
+ required: ["project_id"],
347
+ additionalProperties: false,
348
+ },
349
+ },
350
+ {
351
+ name: "list_test_runs",
352
+ description: "List TestRuns for reporting (steps omitted). Filter by project_id (recommended) and optionally test_case_id / test_suite_id. Newest first.",
353
+ inputSchema: {
354
+ type: "object",
355
+ properties: {
356
+ project_id: { type: "string" },
357
+ test_case_id: { type: "string" },
358
+ test_suite_id: { type: "string" },
359
+ },
360
+ additionalProperties: false,
361
+ },
362
+ },
363
+ {
364
+ name: "get_test_run",
365
+ description: "Fetch a TestRun with its steps (expected ⇄ actual) — the expected-vs-actual report for one execution.",
366
+ inputSchema: {
367
+ type: "object",
368
+ properties: { testrun_id: { type: "string" } },
369
+ required: ["testrun_id"],
370
+ additionalProperties: false,
371
+ },
372
+ },
373
+ {
374
+ name: "update_test_run",
375
+ description: "Update run-level fields of a TestRun (status, name, trigger, environment, external_ref, timings). Only provided fields change. Does not touch steps.",
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ testrun_id: { type: "string" },
380
+ status: { type: "string", enum: ["running", "passed", "failed", "blocked", "skipped", "aborted"] },
381
+ name: { type: "string" },
382
+ trigger: { type: "string" },
383
+ environment: { type: "string" },
384
+ external_ref: { type: "string" },
385
+ started_at: { type: "string" },
386
+ completed_at: { type: "string" },
387
+ duration_ms: { type: "integer", minimum: 0 },
388
+ },
389
+ required: ["testrun_id"],
390
+ additionalProperties: false,
391
+ },
392
+ },
393
+ {
394
+ name: "add_case_to_test_run",
395
+ description: "Add a TestCase to an existing TestRun — snapshots that case's expected steps (appended). Use to assemble a multi-case run/cycle.",
396
+ inputSchema: {
397
+ type: "object",
398
+ properties: {
399
+ testrun_id: { type: "string" },
400
+ test_case_id: { type: "string" },
401
+ },
402
+ required: ["testrun_id", "test_case_id"],
403
+ additionalProperties: false,
404
+ },
405
+ },
406
+ {
407
+ name: "set_test_run_step_result",
408
+ description: "Record the ACTUAL outcome of one TestRun step (validate expected vs actual). Pass image_path to upload the actual screenshot to /api/blobs and attach it; or actual_screenshot for an existing blob ref.",
409
+ inputSchema: {
410
+ type: "object",
411
+ properties: {
412
+ testrun_id: { type: "string" },
413
+ step_id: { type: "string", description: "TestRunStep id (from get_test_run)." },
414
+ status: { type: "string", enum: ["not_run", "passed", "failed", "blocked", "skipped"] },
415
+ error: { type: ["string", "null"], description: "Failure / deviation detail." },
416
+ duration_ms: { type: "integer", minimum: 0 },
417
+ actual_screenshot: { type: ["string", "null"], description: "Blob ref for the actual screenshot." },
418
+ image_path: { type: "string", description: "Local image to upload as the actual screenshot." },
419
+ mime_type: { type: "string", description: "MIME for image_path (default image/png)." },
420
+ },
421
+ required: ["testrun_id", "step_id"],
422
+ additionalProperties: false,
423
+ },
424
+ },
425
+ {
426
+ name: "delete_test_run",
427
+ description: "Delete a TestRun (and its steps).",
428
+ inputSchema: {
429
+ type: "object",
430
+ properties: { testrun_id: { type: "string" } },
431
+ required: ["testrun_id"],
432
+ additionalProperties: false,
433
+ },
434
+ },
435
+ {
436
+ name: "set_test_steps",
437
+ description: "Replace (or append) a TestCase's functional TestSteps from a mapping. Each mapping row groups 1-N WorkflowSteps by 1-based indices. Same shape as the Robotyx UI's Excel import path. Use this after get_workflow to apply your AI-proposed grouping. The workflow_step_ids array MUST be the workflow's full ordered step-id list (in order); the server resolves indices → ids.",
438
+ inputSchema: {
439
+ type: "object",
440
+ properties: {
441
+ testsuite_id: { type: "string" },
442
+ testcase_id: { type: "string" },
443
+ workflow_step_ids: {
444
+ type: "array",
445
+ items: { type: "string" },
446
+ description: "Full ordered list of the linked workflow's step ids — same order as get_workflow returns them.",
447
+ },
448
+ mappings: {
449
+ type: "array",
450
+ items: {
451
+ type: "object",
452
+ properties: {
453
+ description: { type: "string", description: "Functional step description, e.g. 'Log in as admin'." },
454
+ expected_result: { type: "string" },
455
+ workflow_step_indices: {
456
+ type: "array",
457
+ items: { type: "integer", minimum: 1 },
458
+ description: "1-based indices into workflow_step_ids that this functional step covers.",
459
+ },
460
+ },
461
+ required: ["description", "workflow_step_indices"],
462
+ additionalProperties: false,
463
+ },
464
+ },
465
+ mode: { type: "string", enum: ["replace", "append"], default: "replace" },
466
+ },
467
+ required: ["testsuite_id", "testcase_id", "workflow_step_ids", "mappings"],
468
+ additionalProperties: false,
469
+ },
470
+ },
471
+ {
472
+ name: "merge_step",
473
+ description: "Collapse a TestStep into its predecessor (workflowStepIds are concatenated, descriptions joined). The trailing step's screenshot wins.",
474
+ inputSchema: {
475
+ type: "object",
476
+ properties: {
477
+ testsuite_id: { type: "string" },
478
+ testcase_id: { type: "string" },
479
+ teststep_id: { type: "string" },
480
+ },
481
+ required: ["testsuite_id", "testcase_id", "teststep_id"],
482
+ additionalProperties: false,
483
+ },
484
+ },
485
+ {
486
+ name: "split_step",
487
+ description: "Re-explode a multi-step TestStep into N consecutive single-step rows. Reverse of merge_step / set_test_steps.",
488
+ inputSchema: {
489
+ type: "object",
490
+ properties: {
491
+ testsuite_id: { type: "string" },
492
+ testcase_id: { type: "string" },
493
+ teststep_id: { type: "string" },
494
+ },
495
+ required: ["testsuite_id", "testcase_id", "teststep_id"],
496
+ additionalProperties: false,
497
+ },
498
+ },
499
+ {
500
+ name: "list_projects",
501
+ description: "List Projects — long-lived containers above TestSuite. Each owns its own Requirements and is the bucket TestSuites get organised under.",
502
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
503
+ },
504
+ {
505
+ name: "create_project",
506
+ description: "Create a new Project. Optional 'key' is a short code (e.g. 'DELA') for human-friendly prefixes.",
507
+ inputSchema: {
508
+ type: "object",
509
+ properties: {
510
+ name: { type: "string" },
511
+ key: { type: "string" },
512
+ description: { type: "string" },
513
+ owner: { type: "string" },
514
+ status: { type: "string", enum: ["active", "archived"], default: "active" },
515
+ tags: { type: "array", items: { type: "string" } },
516
+ },
517
+ required: ["name"],
518
+ additionalProperties: false,
519
+ },
520
+ },
521
+ {
522
+ name: "list_processes",
523
+ description: "List business Processes under a Project. A Process (e.g. \"Intake\", \"Manifest\") sits between Project and Requirement; each Requirement is assigned to at most one. Returns id + prefixId (PRC-N) + name — use the id as process_id on create_requirement/update_requirement to plot requirements onto a process.",
524
+ inputSchema: {
525
+ type: "object",
526
+ properties: {
527
+ project_id: { type: "string" },
528
+ },
529
+ required: ["project_id"],
530
+ additionalProperties: false,
531
+ },
532
+ },
533
+ {
534
+ name: "create_process",
535
+ description: "Create a business Process under a Project. prefix_id (PRC-N) is auto-allocated. Use the returned id as process_id on requirements to map them to this process.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ project_id: { type: "string" },
540
+ name: { type: "string" },
541
+ description: { type: "string" },
542
+ status: { type: "string", enum: ["active", "archived"], default: "active" },
543
+ tags: { type: "array", items: { type: "string" } },
544
+ },
545
+ required: ["project_id", "name"],
546
+ additionalProperties: false,
547
+ },
548
+ },
549
+ {
550
+ name: "update_process",
551
+ description: "Update fields on a Process — name, description, status, tags. All fields optional; only the ones you pass are changed.",
552
+ inputSchema: {
553
+ type: "object",
554
+ properties: {
555
+ project_id: { type: "string" },
556
+ process_id: { type: "string" },
557
+ name: { type: "string" },
558
+ description: { type: ["string", "null"] },
559
+ status: { type: "string", enum: ["active", "archived"] },
560
+ tags: { type: "array", items: { type: "string" } },
561
+ },
562
+ required: ["project_id", "process_id"],
563
+ additionalProperties: false,
564
+ },
565
+ },
566
+ {
567
+ name: "delete_process",
568
+ description: "Delete a Process. Requirements assigned to it are NOT deleted — their process_id is set back to null (unassigned).",
569
+ inputSchema: {
570
+ type: "object",
571
+ properties: {
572
+ project_id: { type: "string" },
573
+ process_id: { type: "string" },
574
+ },
575
+ required: ["project_id", "process_id"],
576
+ additionalProperties: false,
577
+ },
578
+ },
579
+ {
580
+ name: "list_products",
581
+ description: "List Products under a Project. A Product is the documentation container; it links M:N to Processes and can hold product documentation (markdown, incl. embedded screenshots) on attributes.documentation.",
582
+ inputSchema: {
583
+ type: "object",
584
+ properties: { project_id: { type: "string" } },
585
+ required: ["project_id"],
586
+ additionalProperties: false,
587
+ },
588
+ },
589
+ {
590
+ name: "create_product",
591
+ description: "Create a Product under a Project. Use `documentation` (markdown) for the product/werkproces documentation — embed screenshots with ![caption](/api/blobs/<id>) (upload via upload_screenshot). Works for not-yet-built functionality (text-first).",
592
+ inputSchema: {
593
+ type: "object",
594
+ properties: {
595
+ project_id: { type: "string" },
596
+ name: { type: "string" },
597
+ description: { type: "string", description: "Short one-line summary." },
598
+ documentation: { type: "string", description: "Full product documentation in markdown (stored on attributes.documentation). May embed /api/blobs screenshots." },
599
+ status: { type: "string", enum: ["active", "archived"], default: "active" },
600
+ tags: { type: "array", items: { type: "string" } },
601
+ },
602
+ required: ["project_id", "name"],
603
+ additionalProperties: false,
604
+ },
605
+ },
606
+ {
607
+ name: "update_product",
608
+ description: "Update a Product — name, short description, documentation (markdown), status, tags. All optional. Passing `documentation` replaces attributes.documentation.",
609
+ inputSchema: {
610
+ type: "object",
611
+ properties: {
612
+ project_id: { type: "string" },
613
+ product_id: { type: "string" },
614
+ name: { type: "string" },
615
+ description: { type: ["string", "null"] },
616
+ documentation: { type: "string", description: "Full product documentation markdown (stored on attributes.documentation)." },
617
+ status: { type: "string", enum: ["active", "archived"] },
618
+ tags: { type: "array", items: { type: "string" } },
619
+ },
620
+ required: ["project_id", "product_id"],
621
+ additionalProperties: false,
622
+ },
623
+ },
624
+ {
625
+ name: "link_product_to_process",
626
+ description: "Link a Product to a Process (M:N). Idempotent. Use this to attach the product documentation to the werkprocessen (PRC-N) it describes.",
627
+ inputSchema: {
628
+ type: "object",
629
+ properties: {
630
+ project_id: { type: "string" },
631
+ product_id: { type: "string" },
632
+ process_id: { type: "string" },
633
+ },
634
+ required: ["project_id", "product_id", "process_id"],
635
+ additionalProperties: false,
636
+ },
637
+ },
638
+ {
639
+ name: "upload_screenshot",
640
+ description: "Upload an image to the Robotyx blob store and get back a ref. Pass image_path (a local file, e.g. a screenshot saved by selenium-mcp take_screenshot — read by the MCP host) OR image_base64. Use the ref to embed in product documentation or on test steps: ![caption](/api/blobs/<id>).",
641
+ inputSchema: {
642
+ type: "object",
643
+ properties: {
644
+ image_path: { type: "string", description: "Local file path to an image (read by the MCP host). Use this OR image_base64." },
645
+ image_base64: { type: "string", description: "Base64-encoded image bytes. Use this OR image_path." },
646
+ mime_type: { type: "string", default: "image/png", description: "MIME type of the image." },
647
+ },
648
+ required: [],
649
+ additionalProperties: false,
650
+ },
651
+ },
652
+ {
653
+ name: "list_requirements",
654
+ description: "List Requirements under a Project. Server orders parents first so the tree is easy to reconstruct via parentId. Each row carries prefixId (REQ-N) for human reference.",
655
+ inputSchema: {
656
+ type: "object",
657
+ properties: {
658
+ project_id: { type: "string" },
659
+ },
660
+ required: ["project_id"],
661
+ additionalProperties: false,
662
+ },
663
+ },
664
+ {
665
+ name: "get_requirement",
666
+ description: "Fetch a Requirement with its immediate child requirements and linked test case ids.",
667
+ inputSchema: {
668
+ type: "object",
669
+ properties: {
670
+ project_id: { type: "string" },
671
+ requirement_id: { type: "string" },
672
+ },
673
+ required: ["project_id", "requirement_id"],
674
+ additionalProperties: false,
675
+ },
676
+ },
677
+ {
678
+ name: "create_requirement",
679
+ description: "Create a Requirement under a Project. prefix_id (REQ-N) is auto-allocated. Pass parent_id for hierarchical children, process_id to plot it onto a business Process, and the istqb_* fields to classify it (they round-trip through the app's Requirement editor).",
680
+ inputSchema: {
681
+ type: "object",
682
+ properties: {
683
+ project_id: { type: "string" },
684
+ short_description: { type: "string" },
685
+ long_description: { type: "string" },
686
+ parent_id: { type: "string", description: "Optional parent requirement id for hierarchy." },
687
+ process_id: { type: ["string", "null"], description: "Optional business Process id (PRC-N's uuid) to assign this requirement to. null = unassigned. See list_processes / create_process." },
688
+ status: { type: "string", enum: ["draft", "reviewed", "approved", "implemented", "deprecated"], default: "draft" },
689
+ importance: { type: "string", enum: ["critical", "high", "medium", "low"], default: "medium" },
690
+ confluence_link: { type: "string" },
691
+ jira_story_link: { type: "string" },
692
+ spec_reference: { type: "string", description: "Free-form reference to a spec / doc / .md file — URL, repo path, or any identifier." },
693
+ istqb_test_level: { type: ["string", "null"], enum: ["component", "integration", "system", "acceptance", null], description: "ISTQB test level. Stored on attributes.istqbTestLevel — the same field the app's Requirement editor uses." },
694
+ istqb_quality_characteristic: { type: ["string", "null"], enum: ["functional suitability", "performance efficiency", "compatibility", "usability", "reliability", "security", "maintainability", "portability", null], description: "ISO/IEC 25010 quality characteristic (for non-functional reqs). Stored on attributes.istqbQualityCharacteristic." },
695
+ istqb_risk_class: { type: ["string", "null"], enum: ["low", "medium", "high", "critical", null], description: "Risk class = impact axis of the Product-Risk (PRA) matrix. Stored on attributes.istqbRiskClass." },
696
+ istqb_probability: { type: ["string", "null"], enum: ["low", "medium", "high", "critical", null], description: "Likelihood axis of the PRA matrix. Stored on attributes.istqbProbability." },
697
+ tags: { type: "array", items: { type: "string" } },
698
+ },
699
+ required: ["project_id", "short_description"],
700
+ additionalProperties: false,
701
+ },
702
+ },
703
+ {
704
+ name: "update_requirement",
705
+ description: "Update fields on an existing Requirement — short/long description, status, importance, parentId, process assignment, ISTQB classification, links. All fields optional; only the ones you pass are changed. ISTQB fields merge into the existing attributes bag (pass null/empty to clear one). Use this to apply AI-proposed refinements without recreating.",
706
+ inputSchema: {
707
+ type: "object",
708
+ properties: {
709
+ project_id: { type: "string" },
710
+ requirement_id: { type: "string" },
711
+ short_description: { type: "string" },
712
+ long_description: { type: "string" },
713
+ parent_id: { type: ["string", "null"] },
714
+ process_id: { type: ["string", "null"], description: "Business Process id to assign (PRC-N's uuid), or null to unassign. See list_processes." },
715
+ status: { type: "string", enum: ["draft", "reviewed", "approved", "implemented", "deprecated"] },
716
+ importance: { type: "string", enum: ["critical", "high", "medium", "low"] },
717
+ confluence_link: { type: ["string", "null"] },
718
+ jira_story_link: { type: ["string", "null"] },
719
+ spec_reference: { type: ["string", "null"] },
720
+ istqb_test_level: { type: ["string", "null"], enum: ["component", "integration", "system", "acceptance", null], description: "ISTQB test level (attributes.istqbTestLevel). null/'' clears it." },
721
+ istqb_quality_characteristic: { type: ["string", "null"], enum: ["functional suitability", "performance efficiency", "compatibility", "usability", "reliability", "security", "maintainability", "portability", null], description: "ISO/IEC 25010 quality characteristic (attributes.istqbQualityCharacteristic)." },
722
+ istqb_risk_class: { type: ["string", "null"], enum: ["low", "medium", "high", "critical", null], description: "Risk class / impact axis of the PRA matrix (attributes.istqbRiskClass)." },
723
+ istqb_probability: { type: ["string", "null"], enum: ["low", "medium", "high", "critical", null], description: "Likelihood axis of the PRA matrix (attributes.istqbProbability)." },
724
+ tags: { type: "array", items: { type: "string" } },
725
+ },
726
+ required: ["project_id", "requirement_id"],
727
+ additionalProperties: false,
728
+ },
729
+ },
730
+ {
731
+ name: "update_test_case",
732
+ description: "Update fields on a TestCase — name, priority, status, workflow link, description, test data, expected result, screenshot URL, coverage. All fields optional. Pass workflow_id: null to unlink the workflow. Refinement loop: get_testsuite → propose changes → update_test_case.",
733
+ inputSchema: {
734
+ type: "object",
735
+ properties: {
736
+ testsuite_id: { type: "string" },
737
+ testcase_id: { type: "string" },
738
+ name: { type: "string" },
739
+ workflow_id: { type: ["string", "null"] },
740
+ priority: { type: "string", enum: ["P1", "P2", "P3", "P4"] },
741
+ status: { type: "string", enum: ["draft", "ready", "deprecated"] },
742
+ description: { type: ["string", "null"] },
743
+ test_data: { type: ["string", "null"] },
744
+ expected_result: { type: ["string", "null"] },
745
+ screenshot: { type: ["string", "null"], description: "Blob ref URL like /api/blobs/<sha>.png — upload bytes via /api/blobs first." },
746
+ tags: { type: "array", items: { type: "string" } },
747
+ coverage: { type: "string", enum: ["covered", "built_no_story", "transcript_no_story", "not_built", "unknown"], description: "Build/story kenmerk: covered=built+story; built_no_story=built, no story; transcript_no_story=in transcript, not built; not_built=gap/defect; unknown." },
748
+ automation: { type: ["string", "null"], enum: ["record", "refined", "automated", null], description: "Automation lifecycle kenmerk: record=footprint captured; refined=functional steps (expected results) authored; automated=runnable/replayable. Promote to 'refined' after filling expected results." },
749
+ recording_source: { type: ["string", "null"], enum: ["selenium", "windows", "manual", null], description: "Origin of the footprint (selenium | windows | manual)." },
750
+ },
751
+ required: ["testsuite_id", "testcase_id"],
752
+ additionalProperties: false,
753
+ },
754
+ },
755
+ {
756
+ name: "link_requirement_to_testcase",
757
+ description: "Many-to-many link between a TestCase and a Requirement (traceability). Idempotent.",
758
+ inputSchema: {
759
+ type: "object",
760
+ properties: {
761
+ project_id: { type: "string" },
762
+ requirement_id: { type: "string" },
763
+ testcase_id: { type: "string" },
764
+ },
765
+ required: ["project_id", "requirement_id", "testcase_id"],
766
+ additionalProperties: false,
767
+ },
768
+ },
769
+ {
770
+ name: "create_defect",
771
+ description: "File a defect against a TestSuite/TestCase (or standalone).",
772
+ inputSchema: {
773
+ type: "object",
774
+ properties: {
775
+ title: { type: "string" },
776
+ description: { type: "string" },
777
+ severity: { type: "string", enum: ["low", "medium", "high", "critical"], default: "medium" },
778
+ priority: { type: "string", enum: ["P0", "P1", "P2", "P3", "P4"], default: "P3" },
779
+ testsuite_id: { type: "string" },
780
+ testcase_id: { type: "string" },
781
+ source_label: { type: "string", default: "mcp-client" },
782
+ },
783
+ required: ["title"],
784
+ additionalProperties: false,
785
+ },
786
+ },
787
+ ];
788
+ function isRawContent(v) {
789
+ return typeof v === "object" && v !== null && Array.isArray(v.__raw_content);
790
+ }
791
+ async function callTool(name, args) {
792
+ switch (name) {
793
+ case "list_workflows": {
794
+ const data = await robotyx("GET", "/api/workflows");
795
+ // The list endpoint returns summary rows only — no .steps. Call
796
+ // get_workflow on a specific id to inspect the step list.
797
+ const workflows = (data.workflows ?? []).map((w) => ({
798
+ id: w.id,
799
+ name: w.name,
800
+ tags: w.tags ?? [],
801
+ updatedAt: w.updatedAt,
802
+ }));
803
+ return { workflows };
804
+ }
805
+ case "get_workflow": {
806
+ const wf = await robotyx("GET", `/api/workflows/${args.workflow_id}`);
807
+ const mode = args.include_screenshots ?? "none";
808
+ const onlySet = new Set(Array.isArray(args.screenshot_step_indices) ? args.screenshot_step_indices : []);
809
+ // Always strip screenshots from the JSON payload — the URLs are short
810
+ // refs anyway and the image bytes are returned as image blocks.
811
+ const trimmed = [];
812
+ const screenshotsToFetch = [];
813
+ for (let i = 0; i < (wf.steps ?? []).length; i++) {
814
+ const s = wf.steps[i];
815
+ trimmed.push(trimStep(s, false)); // never inline; we attach image blocks instead.
816
+ const include = mode === "all" || (mode === "indices" && onlySet.has(i + 1));
817
+ // Live recordings store the ref on screenshotPath; footprint imports on
818
+ // parameters.screenshot. Accept either.
819
+ const ref = s.screenshotPath
820
+ || s.parameters?.screenshot;
821
+ if (include && typeof ref === "string" && ref.length > 0) {
822
+ screenshotsToFetch.push({
823
+ stepIndex: i + 1,
824
+ ref,
825
+ action: String(s.action ?? ""),
826
+ domain: String(s.domain ?? ""),
827
+ });
828
+ }
829
+ }
830
+ const jsonPayload = { ...wf, steps: trimmed };
831
+ const content = [
832
+ { type: "text", text: JSON.stringify(jsonPayload, null, 2) },
833
+ ];
834
+ // Walk screenshots sequentially so steps stay in order. Failed fetches
835
+ // become text breadcrumbs instead of crashing the whole call.
836
+ for (const shot of screenshotsToFetch) {
837
+ let img;
838
+ if (shot.ref.startsWith("data:")) {
839
+ img = dataUrlToBase64(shot.ref);
840
+ }
841
+ else {
842
+ img = await fetchBlobBase64(shot.ref);
843
+ }
844
+ if (!img) {
845
+ content.push({
846
+ type: "text",
847
+ text: `Screenshot for step ${shot.stepIndex} (${shot.domain}/${shot.action}): could not load ${shot.ref}`,
848
+ });
849
+ continue;
850
+ }
851
+ content.push({
852
+ type: "text",
853
+ text: `Screenshot for step ${shot.stepIndex} (${shot.domain}/${shot.action}):`,
854
+ });
855
+ content.push({
856
+ type: "image",
857
+ data: img.base64,
858
+ mimeType: img.mimeType,
859
+ });
860
+ }
861
+ return { __raw_content: content };
862
+ }
863
+ case "list_testsuites": {
864
+ const data = await robotyx("GET", "/api/testsuites");
865
+ return { suites: data.suites ?? [] };
866
+ }
867
+ case "get_testsuite": {
868
+ return await robotyx("GET", `/api/testsuites/${args.testsuite_id}`);
869
+ }
870
+ case "create_testsuite": {
871
+ const body = { name: args.name, projectId: args.project_id };
872
+ if (args.category)
873
+ body.category = args.category;
874
+ if (args.description)
875
+ body.description = args.description;
876
+ if (args.owner)
877
+ body.owner = args.owner;
878
+ if (args.status)
879
+ body.status = args.status;
880
+ if (args.tags)
881
+ body.tags = args.tags;
882
+ return await robotyx("POST", "/api/testsuites", body);
883
+ }
884
+ case "add_test_case": {
885
+ const body = {
886
+ name: args.name,
887
+ workflowId: args.workflow_id ?? null,
888
+ priority: args.priority ?? "P3",
889
+ description: args.description ?? null,
890
+ expectedResult: args.expected_result ?? null,
891
+ };
892
+ if (args.coverage !== undefined)
893
+ body.coverage = args.coverage;
894
+ return await robotyx("POST", `/api/testsuites/${args.testsuite_id}/cases`, body);
895
+ }
896
+ case "add_test_step": {
897
+ const body = { description: args.description };
898
+ if (args.expected_result !== undefined)
899
+ body.expectedResult = args.expected_result;
900
+ if (args.order !== undefined)
901
+ body.order = args.order;
902
+ // Per-step screenshot: explicit ref wins; otherwise upload image_path to /api/blobs.
903
+ if (args.screenshot) {
904
+ body.screenshot = args.screenshot;
905
+ }
906
+ else if (args.image_path) {
907
+ body.screenshot = await uploadScreenshotRef(args.image_path, args.mime_type);
908
+ }
909
+ return await robotyx("POST", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}/steps`, body);
910
+ }
911
+ case "update_test_step": {
912
+ const body = {};
913
+ if (args.description !== undefined)
914
+ body.description = args.description;
915
+ if (args.expected_result !== undefined)
916
+ body.expectedResult = args.expected_result;
917
+ if (args.order !== undefined)
918
+ body.order = args.order;
919
+ if (args.screenshot !== undefined)
920
+ body.screenshot = args.screenshot;
921
+ else if (args.image_path)
922
+ body.screenshot = await uploadScreenshotRef(args.image_path, args.mime_type);
923
+ return await robotyx("PATCH", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}/steps/${args.step_id}`, body);
924
+ }
925
+ case "delete_test_step": {
926
+ await robotyx("DELETE", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}/steps/${args.step_id}`);
927
+ return { deleted: true, step_id: args.step_id };
928
+ }
929
+ case "create_test_run": {
930
+ const body = { projectId: args.project_id };
931
+ if (args.id !== undefined)
932
+ body.id = args.id;
933
+ if (args.test_suite_id !== undefined)
934
+ body.testSuiteId = args.test_suite_id;
935
+ if (args.test_case_id !== undefined)
936
+ body.testCaseId = args.test_case_id;
937
+ if (args.seed_from_test_case_id !== undefined)
938
+ body.seedFromTestCaseId = args.seed_from_test_case_id;
939
+ if (args.seed_from_test_suite_id !== undefined)
940
+ body.seedFromTestSuiteId = args.seed_from_test_suite_id;
941
+ if (args.name !== undefined)
942
+ body.name = args.name;
943
+ if (args.status !== undefined)
944
+ body.status = args.status;
945
+ if (args.trigger !== undefined)
946
+ body.trigger = args.trigger;
947
+ if (args.environment !== undefined)
948
+ body.environment = args.environment;
949
+ if (args.external_ref !== undefined)
950
+ body.externalRef = args.external_ref;
951
+ if (args.started_at !== undefined)
952
+ body.startedAt = args.started_at;
953
+ if (args.completed_at !== undefined)
954
+ body.completedAt = args.completed_at;
955
+ if (args.duration_ms !== undefined)
956
+ body.durationMs = args.duration_ms;
957
+ if (Array.isArray(args.steps)) {
958
+ body.steps = args.steps.map((s) => ({
959
+ testCaseId: s.test_case_id, testStepId: s.test_step_id, order: s.order,
960
+ description: s.description, expectedResult: s.expected_result,
961
+ expectedScreenshot: s.expected_screenshot, testData: s.test_data,
962
+ status: s.status, actualScreenshot: s.actual_screenshot,
963
+ error: s.error, durationMs: s.duration_ms,
964
+ }));
965
+ }
966
+ return await robotyx("POST", `/api/testruns`, body);
967
+ }
968
+ case "list_test_runs": {
969
+ const qs = new URLSearchParams();
970
+ if (args.project_id)
971
+ qs.set("projectId", args.project_id);
972
+ if (args.test_case_id)
973
+ qs.set("testCaseId", args.test_case_id);
974
+ if (args.test_suite_id)
975
+ qs.set("testSuiteId", args.test_suite_id);
976
+ const q = qs.toString();
977
+ const data = await robotyx("GET", `/api/testruns${q ? `?${q}` : ""}`);
978
+ return { runs: data.runs ?? [] };
979
+ }
980
+ case "get_test_run": {
981
+ return await robotyx("GET", `/api/testruns/${args.testrun_id}`);
982
+ }
983
+ case "update_test_run": {
984
+ const body = {};
985
+ if (args.status !== undefined)
986
+ body.status = args.status;
987
+ if (args.name !== undefined)
988
+ body.name = args.name;
989
+ if (args.trigger !== undefined)
990
+ body.trigger = args.trigger;
991
+ if (args.environment !== undefined)
992
+ body.environment = args.environment;
993
+ if (args.external_ref !== undefined)
994
+ body.externalRef = args.external_ref;
995
+ if (args.started_at !== undefined)
996
+ body.startedAt = args.started_at;
997
+ if (args.completed_at !== undefined)
998
+ body.completedAt = args.completed_at;
999
+ if (args.duration_ms !== undefined)
1000
+ body.durationMs = args.duration_ms;
1001
+ return await robotyx("PATCH", `/api/testruns/${args.testrun_id}`, body);
1002
+ }
1003
+ case "add_case_to_test_run": {
1004
+ return await robotyx("POST", `/api/testruns/${args.testrun_id}/cases`, {
1005
+ testCaseId: args.test_case_id,
1006
+ });
1007
+ }
1008
+ case "set_test_run_step_result": {
1009
+ const body = {};
1010
+ if (args.status !== undefined)
1011
+ body.status = args.status;
1012
+ if (args.error !== undefined)
1013
+ body.error = args.error;
1014
+ if (args.duration_ms !== undefined)
1015
+ body.durationMs = args.duration_ms;
1016
+ if (args.actual_screenshot !== undefined)
1017
+ body.actualScreenshot = args.actual_screenshot;
1018
+ else if (args.image_path)
1019
+ body.actualScreenshot = await uploadScreenshotRef(args.image_path, args.mime_type);
1020
+ return await robotyx("PATCH", `/api/testruns/${args.testrun_id}/steps/${args.step_id}`, body);
1021
+ }
1022
+ case "delete_test_run": {
1023
+ await robotyx("DELETE", `/api/testruns/${args.testrun_id}`);
1024
+ return { deleted: true, testrun_id: args.testrun_id };
1025
+ }
1026
+ case "set_test_steps": {
1027
+ const body = {
1028
+ workflowStepIds: args.workflow_step_ids,
1029
+ mappings: args.mappings.map((m) => ({
1030
+ description: m.description,
1031
+ expectedResult: m.expected_result ?? null,
1032
+ workflowStepIndices: m.workflow_step_indices,
1033
+ })),
1034
+ mode: args.mode ?? "replace",
1035
+ };
1036
+ return await robotyx("POST", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}/import-mapping`, body);
1037
+ }
1038
+ case "merge_step": {
1039
+ return await robotyx("POST", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}/steps/${args.teststep_id}/merge`, {});
1040
+ }
1041
+ case "split_step": {
1042
+ return await robotyx("POST", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}/steps/${args.teststep_id}/split`, {});
1043
+ }
1044
+ case "list_projects": {
1045
+ const data = await robotyx("GET", "/api/projects");
1046
+ return { projects: data.projects ?? [] };
1047
+ }
1048
+ case "create_project": {
1049
+ const body = { name: args.name };
1050
+ if (args.key)
1051
+ body.key = args.key;
1052
+ if (args.description)
1053
+ body.description = args.description;
1054
+ if (args.owner)
1055
+ body.owner = args.owner;
1056
+ if (args.status)
1057
+ body.status = args.status;
1058
+ if (args.tags)
1059
+ body.tags = args.tags;
1060
+ return await robotyx("POST", "/api/projects", body);
1061
+ }
1062
+ case "list_processes": {
1063
+ const data = await robotyx("GET", `/api/projects/${args.project_id}/processes`);
1064
+ return { processes: data.processes ?? [] };
1065
+ }
1066
+ case "create_process": {
1067
+ const body = { name: args.name };
1068
+ if (args.description !== undefined)
1069
+ body.description = args.description;
1070
+ if (args.status !== undefined)
1071
+ body.status = args.status;
1072
+ if (args.tags !== undefined)
1073
+ body.tags = args.tags;
1074
+ return await robotyx("POST", `/api/projects/${args.project_id}/processes`, body);
1075
+ }
1076
+ case "update_process": {
1077
+ const body = {};
1078
+ if (args.name !== undefined)
1079
+ body.name = args.name;
1080
+ if (args.description !== undefined)
1081
+ body.description = args.description;
1082
+ if (args.status !== undefined)
1083
+ body.status = args.status;
1084
+ if (args.tags !== undefined)
1085
+ body.tags = args.tags;
1086
+ return await robotyx("PATCH", `/api/projects/${args.project_id}/processes/${args.process_id}`, body);
1087
+ }
1088
+ case "delete_process": {
1089
+ await robotyx("DELETE", `/api/projects/${args.project_id}/processes/${args.process_id}`);
1090
+ return { deleted: true, process_id: args.process_id };
1091
+ }
1092
+ case "list_products": {
1093
+ const data = await robotyx("GET", `/api/projects/${args.project_id}/products`);
1094
+ return { products: data.products ?? [] };
1095
+ }
1096
+ case "create_product": {
1097
+ const body = { name: args.name };
1098
+ if (args.description !== undefined)
1099
+ body.description = args.description;
1100
+ if (args.status !== undefined)
1101
+ body.status = args.status;
1102
+ if (args.tags !== undefined)
1103
+ body.tags = args.tags;
1104
+ if (args.documentation !== undefined)
1105
+ body.attributes = { documentation: args.documentation };
1106
+ return await robotyx("POST", `/api/projects/${args.project_id}/products`, body);
1107
+ }
1108
+ case "update_product": {
1109
+ const body = {};
1110
+ if (args.name !== undefined)
1111
+ body.name = args.name;
1112
+ if (args.description !== undefined)
1113
+ body.description = args.description;
1114
+ if (args.status !== undefined)
1115
+ body.status = args.status;
1116
+ if (args.tags !== undefined)
1117
+ body.tags = args.tags;
1118
+ if (args.documentation !== undefined)
1119
+ body.attributes = { documentation: args.documentation };
1120
+ return await robotyx("PATCH", `/api/projects/${args.project_id}/products/${args.product_id}`, body);
1121
+ }
1122
+ case "link_product_to_process": {
1123
+ await robotyx("POST", `/api/projects/${args.project_id}/products/${args.product_id}/processes/${args.process_id}`, {});
1124
+ return { linked: true, product_id: args.product_id, process_id: args.process_id };
1125
+ }
1126
+ case "upload_screenshot": {
1127
+ const mime = args.mime_type || "image/png";
1128
+ let buf;
1129
+ if (args.image_path)
1130
+ buf = readFileSync(args.image_path);
1131
+ else if (args.image_base64)
1132
+ buf = Buffer.from(args.image_base64, "base64");
1133
+ else
1134
+ throw new Error("upload_screenshot requires image_path or image_base64");
1135
+ const res = await fetch(`${ROBOTYX_URL}/api/blobs`, {
1136
+ method: "POST",
1137
+ headers: { Authorization: `Bearer ${ROBOTYX_API_KEY}`, "Content-Type": mime },
1138
+ body: new Uint8Array(buf),
1139
+ });
1140
+ if (!res.ok) {
1141
+ const t = await res.text().catch(() => "");
1142
+ throw new Error(`Robotyx POST /api/blobs → ${res.status} ${res.statusText}${t ? `: ${t}` : ""}`);
1143
+ }
1144
+ return await res.json();
1145
+ }
1146
+ case "list_requirements": {
1147
+ const data = await robotyx("GET", `/api/projects/${args.project_id}/requirements`);
1148
+ return { requirements: data.requirements ?? [] };
1149
+ }
1150
+ case "get_requirement": {
1151
+ return await robotyx("GET", `/api/projects/${args.project_id}/requirements/${args.requirement_id}`);
1152
+ }
1153
+ case "create_requirement": {
1154
+ const body = {
1155
+ shortDescription: args.short_description,
1156
+ longDescription: args.long_description ?? null,
1157
+ processId: args.process_id ?? null,
1158
+ parentId: args.parent_id ?? null,
1159
+ status: args.status ?? "draft",
1160
+ importance: args.importance ?? "medium",
1161
+ confluenceLink: args.confluence_link ?? null,
1162
+ jiraStoryLink: args.jira_story_link ?? null,
1163
+ specReference: args.spec_reference ?? null,
1164
+ tags: args.tags ?? [],
1165
+ };
1166
+ // ISTQB classification rides on the `attributes` bag using the exact keys
1167
+ // the react-ui Requirement editor reads — only attach it when supplied.
1168
+ if (hasIstqbArgs(args)) {
1169
+ body.attributes = mergeIstqbAttributes({}, args);
1170
+ }
1171
+ return await robotyx("POST", `/api/projects/${args.project_id}/requirements`, body);
1172
+ }
1173
+ case "update_requirement": {
1174
+ const body = {};
1175
+ if (args.short_description !== undefined)
1176
+ body.shortDescription = args.short_description;
1177
+ if (args.long_description !== undefined)
1178
+ body.longDescription = args.long_description;
1179
+ if (args.process_id !== undefined)
1180
+ body.processId = args.process_id;
1181
+ if (args.parent_id !== undefined)
1182
+ body.parentId = args.parent_id;
1183
+ if (args.status !== undefined)
1184
+ body.status = args.status;
1185
+ if (args.importance !== undefined)
1186
+ body.importance = args.importance;
1187
+ if (args.confluence_link !== undefined)
1188
+ body.confluenceLink = args.confluence_link;
1189
+ if (args.jira_story_link !== undefined)
1190
+ body.jiraStoryLink = args.jira_story_link;
1191
+ if (args.spec_reference !== undefined)
1192
+ body.specReference = args.spec_reference;
1193
+ if (args.tags !== undefined)
1194
+ body.tags = args.tags;
1195
+ // ISTQB picks merge into the *existing* attributes so we never clobber
1196
+ // other keys the bag holds — needs a read-before-write because the PATCH
1197
+ // replaces attributes wholesale server-side.
1198
+ if (hasIstqbArgs(args)) {
1199
+ const current = await robotyx("GET", `/api/projects/${args.project_id}/requirements/${args.requirement_id}`);
1200
+ body.attributes = mergeIstqbAttributes(current?.attributes ?? {}, args);
1201
+ }
1202
+ return await robotyx("PATCH", `/api/projects/${args.project_id}/requirements/${args.requirement_id}`, body);
1203
+ }
1204
+ case "update_test_case": {
1205
+ const body = {};
1206
+ if (args.name !== undefined)
1207
+ body.name = args.name;
1208
+ if (args.workflow_id !== undefined)
1209
+ body.workflowId = args.workflow_id;
1210
+ if (args.priority !== undefined)
1211
+ body.priority = args.priority;
1212
+ if (args.status !== undefined)
1213
+ body.status = args.status;
1214
+ if (args.description !== undefined)
1215
+ body.description = args.description;
1216
+ if (args.test_data !== undefined)
1217
+ body.testData = args.test_data;
1218
+ if (args.expected_result !== undefined)
1219
+ body.expectedResult = args.expected_result;
1220
+ if (args.screenshot !== undefined)
1221
+ body.screenshot = args.screenshot;
1222
+ if (args.tags !== undefined)
1223
+ body.tags = args.tags;
1224
+ if (args.coverage !== undefined)
1225
+ body.coverage = args.coverage;
1226
+ if (args.automation !== undefined)
1227
+ body.automation = args.automation;
1228
+ if (args.recording_source !== undefined)
1229
+ body.recordingSource = args.recording_source;
1230
+ return await robotyx("PATCH", `/api/testsuites/${args.testsuite_id}/cases/${args.testcase_id}`, body);
1231
+ }
1232
+ case "link_requirement_to_testcase": {
1233
+ return await robotyx("POST", `/api/projects/${args.project_id}/requirements/${args.requirement_id}/testcases/${args.testcase_id}`, {});
1234
+ }
1235
+ case "create_defect": {
1236
+ const body = {
1237
+ title: args.title,
1238
+ description: args.description ?? null,
1239
+ severity: args.severity ?? "medium",
1240
+ priority: args.priority ?? "P3",
1241
+ testSuiteId: args.testsuite_id ?? null,
1242
+ testCaseId: args.testcase_id ?? null,
1243
+ sourceLabel: args.source_label ?? "mcp-client",
1244
+ };
1245
+ return await robotyx("POST", "/api/defects", body);
1246
+ }
1247
+ default:
1248
+ throw new Error(`Unknown tool: ${name}`);
1249
+ }
1250
+ }
1251
+ // ── Server boot ─────────────────────────────────────────────────────────── //
1252
+ const server = new Server({ name: "robotyx-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
1253
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1254
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1255
+ const { name, arguments: args } = req.params;
1256
+ try {
1257
+ const result = await callTool(name, (args ?? {}));
1258
+ if (isRawContent(result)) {
1259
+ return { content: result.__raw_content };
1260
+ }
1261
+ return {
1262
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1263
+ };
1264
+ }
1265
+ catch (err) {
1266
+ return {
1267
+ isError: true,
1268
+ content: [{ type: "text", text: err.message ?? String(err) }],
1269
+ };
1270
+ }
1271
+ });
1272
+ const transport = new StdioServerTransport();
1273
+ await server.connect(transport);
1274
+ process.stderr.write(`[robotyx-mcp] connected, target ${ROBOTYX_URL}\n`);