Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<!-- suite\nid: @S380c64db\n-->\n# Login Functionality\n<!-- test\nid: @T12345678\ntype: manual\npriority: high\n-->\n# Successful Login\n## Steps\n* Navigate to the login page\n *Expected*: Login form is displayed\n<!-- test -->\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)
Expand Down
30 changes: 30 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<!-- suite\nid: @S380c64db\n-->\n# Login Functionality\n<!-- test\nid: @T12345678\npriority: high\n-->\n# Successful Login\n## Steps\n* Navigate to the login page\n *Expected*: Login form is displayed\n<!-- test -->\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
Expand Down
28 changes: 28 additions & 0 deletions src/mcp/definitions/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<!-- suite ... -->) containing test blocks (<!-- test ... -->), 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
}
}
];
196 changes: 196 additions & 0 deletions src/mcp/markdown/parse-tests-markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
export const TESTS_BULK_LIMITS = {
maxTests: 100,
maxSuites: 25,
maxMarkdownBytes: 512 * 1024,
};

const SUITE_MARKER = '<!-- suite';
const TEST_MARKER = '<!-- test';
const HEADING_PREFIX = '#';
const COMMA_SEPARATED_LIST_KEYS = new Set(['tags', 'labels']);

function parseCommaSeparatedList(value) {
if (!value) return [];
return value
.split(',')
.map(item => item.trim())
.filter(Boolean);
}

function parseMetadataBlock(lines, startIndex) {
if (lines[startIndex].trim().endsWith('-->')) {
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 "<!-- suite" block' });
break;
}
i = block.endIndex;

while (i < lines.length && !lines[i].trim()) i += 1;
if (i >= lines.length || !lines[i].trim().startsWith(HEADING_PREFIX)) {
closeTest();
closeSuite();
errors.push({
line: markerLine,
message: 'Suite block is not followed by a title heading; suite skipped',
});
continue;
}

closeTest();
closeSuite();
currentSuite = {
uid: block.data.id ?? null,
title: lines[i].trim().replace(/^#+\s*/, ''),
description: '',
tags: block.data.tags ?? [],
labels: block.data.labels ?? [],
assignee: block.data.assignee,
tests: [],
};
i += 1;
continue;
}

if (line.startsWith(TEST_MARKER)) {
const markerLine = i + 1;
const block = parseMetadataBlock(lines, i);
if (block.unterminated) {
closeTest();
errors.push({ line: markerLine, message: 'Unterminated "<!-- test" block' });
break;
}
i = block.endIndex;

while (i < lines.length && !lines[i].trim()) i += 1;
if (i >= lines.length || !lines[i].trim().startsWith(HEADING_PREFIX)) {
closeTest();
errors.push({
line: markerLine,
message: 'Test block is not followed by a title heading; test skipped',
});
continue;
}

closeTest();
if (!currentSuite) {
errors.push({ line: markerLine, message: 'Test block appears before any suite block; test skipped' });
i += 1;
continue;
}

currentTest = {
uid: block.data.id ?? null,
title: lines[i].trim().replace(/^#+\s*/, ''),
state: block.data.type,
priority: block.data.priority,
assignee: block.data.assignee,
tags: block.data.tags ?? [],
labels: block.data.labels ?? [],
descriptionLines: [],
};
i += 1;

while (i < lines.length) {
const next = lines[i].trim();
if (next.startsWith(SUITE_MARKER) || next.startsWith(TEST_MARKER)) break;
currentTest.descriptionLines.push(lines[i]);
i += 1;
}
continue;
}

if (currentSuite && !currentTest) {
suiteDescriptionLines.push(lines[i]);
}
i += 1;
}

closeTest();
closeSuite();

return { suites, errors };
}
Loading