From 7173cbdea9b2003d53e8cff8dcaf6a4bd38c2c28 Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Mon, 14 Sep 2026 16:04:05 +0200 Subject: [PATCH] Add tests_bulk_upsert MCP tool --- README.md | 14 ++ docs/tools.md | 30 +++ src/mcp/definitions/tests.js | 28 +++ src/mcp/markdown/parse-tests-markdown.js | 196 +++++++++++++++ src/mcp/registry/bulk-upsert.js | 292 +++++++++++++++++++++++ src/mcp/registry/handlers.js | 56 +++++ src/mcp/tool-registry.js | 4 +- 7 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 src/mcp/markdown/parse-tests-markdown.js create mode 100644 src/mcp/registry/bulk-upsert.js diff --git a/README.md b/README.md index a99c23b..359e97f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Model Context Protocol (MCP) server that enables AI assistants (Claude, Cursor, - Attachments (scoped helpers for tests/suites/testruns) - Requirements (including file uploads from local file paths) - **Project Information** - fetch project configuration, metadata, features, and CI profiles +- **Bulk Test Upsert** - `tests_bulk_upsert` creates/updates many tests from one classical tests markdown document (with `dry_run` preview) - **Issue Linking** - link/unlink issues to any resource - **API Compatibility** - automatic handling of payload format differences (flat vs wrapped) - **Automatic API Sessions** - groups MCP changes in Testomat.io history using API sessions @@ -207,6 +208,19 @@ Add this config to `opencode.json` in your project root, or to `~/.config/openco } ``` +**Bulk create/update tests from markdown:** +```json +{ + "name": "tests_bulk_upsert", + "arguments": { + "markdown": "\n# Login Functionality\n\n# Successful Login\n## Steps\n* Navigate to the login page\n *Expected*: Login form is displayed\n\n# Failed Login\n## Steps\n* Enter invalid credentials\n *Expected*: Error message is displayed", + "dry_run": true + } +} +``` + +`tests_bulk_upsert` accepts a document in the [classical tests markdown format](https://docs.testomat.io/project/import-export/export-tests/classical-tests-markdown-format/) (the same format used by markdown export/sync). Tests with an `id: @T...` in their metadata are updated; tests without an id are created. Suites are resolved by `id: @S...` or title, and created when missing (`create_missing_suites: false` disables this). Use `dry_run: true` to parse the document and preview the planned actions without writing anything. Limits: up to 100 tests and 25 suites per call. All writes are grouped in a single API session; per-test errors are reported without blocking the rest of the document. + ## Documentation Complete tool reference: [docs/tools.md](./docs/tools.md) diff --git a/docs/tools.md b/docs/tools.md index 471a514..b8baa1a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -354,6 +354,36 @@ Unlink an issue from a test. --- +### tests_bulk_upsert + +Bulk create/update tests from a [classical tests markdown](https://docs.testomat.io/project/import-export/export-tests/classical-tests-markdown-format/) document. Tests with an `id: @T...` in their metadata are updated; tests without an id are created. Suites are resolved by `id: @S...` or title, and created when missing. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| markdown | string | Yes | Markdown document in the testomat.io classical tests format | +| dry_run | boolean | No | Parse the document and report planned actions without writing (default: false) | +| create_missing_suites | boolean | No | Create suites that cannot be resolved by id or title (default: true) | +| branch | string | No | Branch name | + +**Example:** +```json +{ + "name": "tests_bulk_upsert", + "arguments": { + "markdown": "\n# Login Functionality\n\n# Successful Login\n## Steps\n* Navigate to the login page\n *Expected*: Login form is displayed\n\n# Failed Login\n## Steps\n* Enter invalid credentials\n *Expected*: Error message is displayed" + } +} +``` + +**Response:** summary with `stats` (suites created/reused, tests created/updated, errors), per-test `created`/`updated` lists, and per-test `errors` (failures do not block the rest of the document). + +**Notes:** limits are 100 tests and 25 suites per call; all writes share one API session (single entry in the change history). + +**API Endpoint:** `POST /api/v2/{project_id}/tests/bulk` (falls back to sequential `POST /tests` + `PUT /tests/{id}` when the bulk endpoint is unavailable) + +--- + ## Suite Management ### suites_list diff --git a/src/mcp/definitions/tests.js b/src/mcp/definitions/tests.js index 3a6fa9a..ea6197f 100644 --- a/src/mcp/definitions/tests.js +++ b/src/mcp/definitions/tests.js @@ -310,5 +310,33 @@ export const TESTS_TOOLS = [ ], "additionalProperties": false } + }, + { + "name": "tests_bulk_upsert", + "description": "Bulk create/update tests from a testomat.io classical tests markdown document (/api/v2/{project_id}/tests). Tests with an id (@T...) in their metadata are updated, tests without id are created. Suites are resolved by id (@S...) or title, and created when missing. Recommended batch size: up to 100 tests per call.", + "inputSchema": { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "description": "Markdown document in the testomat.io classical tests format: suite blocks () containing test blocks (), each followed by a title heading and a description. See https://docs.testomat.io/project/import-export/export-tests/classical-tests-markdown-format/" + }, + "dry_run": { + "type": "boolean", + "default": false, + "description": "Parse the document and report the planned actions without writing anything" + }, + "create_missing_suites": { + "type": "boolean", + "default": true, + "description": "Create suites that cannot be resolved by id or title. When false, tests of unresolved suites are reported as errors" + }, + "branch": BRANCH_PARAM + }, + "required": [ + "markdown" + ], + "additionalProperties": false + } } ]; diff --git a/src/mcp/markdown/parse-tests-markdown.js b/src/mcp/markdown/parse-tests-markdown.js new file mode 100644 index 0000000..f3124a7 --- /dev/null +++ b/src/mcp/markdown/parse-tests-markdown.js @@ -0,0 +1,196 @@ +export const TESTS_BULK_LIMITS = { + maxTests: 100, + maxSuites: 25, + maxMarkdownBytes: 512 * 1024, +}; + +const SUITE_MARKER = '')) { + return { data: {}, endIndex: startIndex + 1, unterminated: false }; + } + + const metadata = {}; + let i = startIndex + 1; + while (i < lines.length && lines[i].trim() !== '-->') { + const match = lines[i].trim().match(/^([^:]+):\s*(.*)$/); + if (match) { + const key = match[1].trim(); + const value = match[2].trim(); + metadata[key] = COMMA_SEPARATED_LIST_KEYS.has(key) ? parseCommaSeparatedList(value) : value; + } + i += 1; + } + + return { + data: metadata, + endIndex: Math.min(i + 1, lines.length), + unterminated: i >= lines.length, + }; +} + +function trimBlankLines(listOfLines) { + let start = 0; + let end = listOfLines.length; + while (start < end && !listOfLines[start].trim()) start += 1; + while (end > start && !listOfLines[end - 1].trim()) end -= 1; + return listOfLines.slice(start, end); +} + +export function parseTestsMarkdown(markdown) { + const suites = []; + const errors = []; + + if (typeof markdown !== 'string' || !markdown.trim()) { + errors.push({ line: 1, message: 'Markdown document is empty' }); + return { suites, errors }; + } + + if (Buffer.byteLength(markdown, 'utf8') > TESTS_BULK_LIMITS.maxMarkdownBytes) { + errors.push({ + line: 1, + message: `Document exceeds the ${TESTS_BULK_LIMITS.maxMarkdownBytes} bytes limit; split it into smaller documents`, + }); + return { suites, errors }; + } + + const lines = markdown.replace(/\r\n?/g, '\n').split('\n'); + let currentSuite = null; + let currentTest = null; + let suiteDescriptionLines = []; + + const closeTest = () => { + if (!currentTest) return; + currentSuite.tests.push({ + uid: currentTest.uid, + title: currentTest.title, + description: trimBlankLines(currentTest.descriptionLines).join('\n'), + state: currentTest.state, + priority: currentTest.priority, + assignee: currentTest.assignee, + tags: currentTest.tags, + labels: currentTest.labels, + }); + currentTest = null; + }; + + const closeSuite = () => { + if (!currentSuite) return; + currentSuite.description = trimBlankLines(suiteDescriptionLines).join('\n'); + suites.push(currentSuite); + currentSuite = null; + suiteDescriptionLines = []; + }; + + let i = 0; + while (i < lines.length) { + const line = lines[i].trim(); + + if (line.startsWith(SUITE_MARKER)) { + const markerLine = i + 1; + const block = parseMetadataBlock(lines, i); + if (block.unterminated) { + closeTest(); + closeSuite(); + errors.push({ line: markerLine, message: 'Unterminated "