@uarlouski/testrail-mcp-server 3.1.0 → 3.3.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/README.md +6 -6
- package/dist/cli.js +11 -1
- package/dist/client/testrail.js +25 -8
- package/dist/index.js +11 -3
- package/dist/tools/cases/export_cases_for_rag.js +41 -19
- package/dist/tools/cases/fields.js +10 -0
- package/dist/tools/cases/get_case.js +3 -3
- package/dist/tools/cases/types.js +2 -2
- package/dist/tools/commons/get_labels.js +1 -1
- package/dist/tools/commons/index.js +0 -2
- package/dist/tools/commons/types.js +7 -8
- package/dist/tools/registry.js +4 -0
- package/dist/tools/{commons → tests}/get_tests.js +9 -3
- package/dist/tools/tests/index.js +7 -0
- package/dist/tools/tests/query_test.js +56 -0
- package/dist/tools/tests/types.js +20 -0
- package/dist/utils/content_hash.js +47 -0
- package/dist/utils/mapper.js +23 -4
- package/dist/utils/sanitizer.js +45 -5
- package/dist/utils/section_path.js +60 -0
- package/dist/version.js +1 -1
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<a href="https://github.com/uarlouski/testrail-mcp-server"><img src="https://img.shields.io/github/stars/uarlouski/testrail-mcp-server.svg?style=social&label=Star" alt="GitHub stars"></a>
|
|
18
18
|
<a href="https://glama.ai/mcp/servers/uarlouski/testrail-mcp-server"><img src="https://glama.ai/mcp/servers/uarlouski/testrail-mcp-server/badges/score.svg" alt="Score Badge"></a>
|
|
19
19
|
<a href="https://m8ven.ai/mcp/uarlouski/testrail-mcp-server"><img src="https://m8ven.ai/badge/mcp/uarlouski/testrail-mcp-server" alt="M8ven Score"></a>
|
|
20
|
+
<a href="https://smithery.ai/servers/uarlouski/testrail-mcp-server"><img src="https://smithery.ai/badge/uarlouski/testrail-mcp-server" alt="Smithery badge"></a>
|
|
20
21
|
</p>
|
|
21
22
|
|
|
22
23
|
<p align="center">
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
|
|
43
44
|
The **TestRail MCP Server** is a free, open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI assistants direct, structured access to a [TestRail](https://www.testrail.com/) instance through the TestRail API v2. Once configured, an assistant such as **Claude Desktop, Cursor, Windsurf, or GitHub Copilot in VS Code** can search test cases, draft new ones, start test runs, record results, and upload attachments on your behalf — without you leaving the chat window.
|
|
44
45
|
|
|
45
|
-
It exposes **
|
|
46
|
+
It exposes **35 tools**, runs locally on **Node.js 18+** over the MCP stdio transport, and is licensed under **Apache 2.0**. There is nothing to host or deploy: your MCP client launches it on demand with `npx`.
|
|
46
47
|
|
|
47
48
|
**No context switching. No tedious copy-pasting. Just ask your AI.**
|
|
48
49
|
|
|
@@ -279,19 +280,19 @@ See the [CLI & CI/CD guide](https://uarlouski.github.io/testrail-mcp-server/guid
|
|
|
279
280
|
| `TESTRAIL_ENABLE_DEPRECATED_TOOLS` | Preserved for backward compatibility with existing host configurations. | | `true` |
|
|
280
281
|
| `TESTRAIL_DISABLED_TOOLS` | Comma-separated list of specific tool names to disable (e.g., `mutate_suite,delete_entity`). Fails if invalid tool names are specified. | | - |
|
|
281
282
|
|
|
282
|
-
With only the three required credentials, **
|
|
283
|
+
With only the three required credentials, **27 of the 35 tools** are registered. Ready-made read-only and least-privilege configurations are in the [Configuration guide](https://uarlouski.github.io/testrail-mcp-server/guide/configuration).
|
|
283
284
|
|
|
284
285
|
---
|
|
285
286
|
|
|
286
287
|
## 🧰 Available Tools
|
|
287
288
|
|
|
288
|
-
All
|
|
289
|
+
All 35 tools, grouped by area. Each declares a `read`, `write`, or `delete` mode, which the server surfaces to clients as MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).
|
|
289
290
|
|
|
290
291
|
| Area | Tools | Reference |
|
|
291
292
|
|------|-------|-----------|
|
|
292
293
|
| **Discovery & Navigation** | `query_project`, `query_suite`, `mutate_suite`, `query_section`, `mutate_section`, `get_users` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/discovery) |
|
|
293
294
|
| **Test Case Management** | `get_case`, `get_cases`, `add_case`, `update_case`, `update_cases`, `get_case_fields`, `resolve_case_field`, `get_case_history`, `export_cases_for_rag` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/cases) |
|
|
294
|
-
| **Execution & Tracking** | `query_run`, `mutate_run`, `get_tests`, `get_results`, `add_results`, `add_results_for_cases` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/execution) |
|
|
295
|
+
| **Execution & Tracking** | `query_run`, `mutate_run`, `query_test`, `get_tests`, `get_results`, `add_results`, `add_results_for_cases` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/execution) |
|
|
295
296
|
| **Attachments & Media** | `add_attachment`, `query_attachment` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/attachments) |
|
|
296
297
|
| **Shared Steps** | `get_shared_step`, `get_shared_steps`, `get_shared_step_history`, `add_shared_step`, `update_shared_step` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/shared-steps) |
|
|
297
298
|
| **System Metadata** | `get_statuses`, `get_priorities`, `get_case_fields`, `get_templates`, `get_labels`, `get_configurations` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/metadata) |
|
|
@@ -355,14 +356,13 @@ For a comprehensive guide, detailed configuration options, and a complete breakd
|
|
|
355
356
|
|
|
356
357
|
- 🚀 **[Getting Started](https://uarlouski.github.io/testrail-mcp-server/guide/getting-started)**: Per-client setup for Claude, Cursor, Windsurf, and VS Code.
|
|
357
358
|
- ⚙️ **[Configuration](https://uarlouski.github.io/testrail-mcp-server/guide/configuration)**: Every environment variable, permission, and feature flag.
|
|
358
|
-
- 🧰 **[All Tools](https://uarlouski.github.io/testrail-mcp-server/reference/)**: All
|
|
359
|
+
- 🧰 **[All Tools](https://uarlouski.github.io/testrail-mcp-server/reference/)**: All 35 tools with modes and feature flags in one table.
|
|
359
360
|
- 🔭 **[Discovery & Navigation](https://uarlouski.github.io/testrail-mcp-server/reference/discovery)**: Exploring projects, suites, and sections.
|
|
360
361
|
- 📋 **[Test Case Management](https://uarlouski.github.io/testrail-mcp-server/reference/cases)**: Fetching, creating, and bulk-updating test cases.
|
|
361
362
|
- ▶️ **[Execution & Tracking](https://uarlouski.github.io/testrail-mcp-server/reference/execution)**: Managing test runs and submitting test results.
|
|
362
363
|
- 📎 **[Attachments](https://uarlouski.github.io/testrail-mcp-server/reference/attachments)**: Automatically zipping and uploading files or directories.
|
|
363
364
|
- 🔗 **[Shared Steps](https://uarlouski.github.io/testrail-mcp-server/reference/shared-steps)**: Managing reusable step definitions.
|
|
364
365
|
- 🖥️ **[CLI & CI/CD](https://uarlouski.github.io/testrail-mcp-server/guide/cli)**: Pipeline automation without an LLM.
|
|
365
|
-
|
|
366
366
|
---
|
|
367
367
|
|
|
368
368
|
## 🤝 Contributing
|
package/dist/cli.js
CHANGED
|
@@ -29,7 +29,17 @@ export function resolveClient(options, env) {
|
|
|
29
29
|
console.error("or supply them via --url, --username, and --api-key options.");
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const timeoutEnv = env.TESTRAIL_REQUEST_TIMEOUT_MS;
|
|
33
|
+
let timeoutMs;
|
|
34
|
+
if (timeoutEnv) {
|
|
35
|
+
const parsed = parseInt(timeoutEnv, 10);
|
|
36
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
37
|
+
timeoutMs = parsed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return new TestRailClient(credResult.data.url, credResult.data.username, credResult.data.apiKey, {
|
|
41
|
+
timeoutMs,
|
|
42
|
+
});
|
|
33
43
|
}
|
|
34
44
|
export function validateToolArgs(tool, flags) {
|
|
35
45
|
const coercedArgs = coerceFlagsForTool(flags, tool.parameters);
|
package/dist/client/testrail.js
CHANGED
|
@@ -7,6 +7,7 @@ export class TestRailClient {
|
|
|
7
7
|
baseUrl;
|
|
8
8
|
headers;
|
|
9
9
|
auth;
|
|
10
|
+
timeoutMs;
|
|
10
11
|
prioritiesPromise = null;
|
|
11
12
|
caseTypesPromise = null;
|
|
12
13
|
caseFieldsPromise = null;
|
|
@@ -14,7 +15,7 @@ export class TestRailClient {
|
|
|
14
15
|
projectsPromise = null;
|
|
15
16
|
templatesPromiseMap = new Map();
|
|
16
17
|
sectionsPromiseMap = new Map();
|
|
17
|
-
constructor(baseUrl, email, apiKey) {
|
|
18
|
+
constructor(baseUrl, email, apiKey, options) {
|
|
18
19
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
19
20
|
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
|
|
20
21
|
this.auth = `Basic ${auth}`;
|
|
@@ -22,6 +23,7 @@ export class TestRailClient {
|
|
|
22
23
|
"Content-Type": "application/json",
|
|
23
24
|
"Authorization": this.auth,
|
|
24
25
|
};
|
|
26
|
+
this.timeoutMs = options?.timeoutMs ?? 30_000;
|
|
25
27
|
}
|
|
26
28
|
async getCase(caseId) {
|
|
27
29
|
return this.get(`${API_BASE_V2}/get_case/${caseId}`);
|
|
@@ -173,6 +175,9 @@ export class TestRailClient {
|
|
|
173
175
|
const url = `${API_BASE_V2}/get_labels/${projectId}`;
|
|
174
176
|
return this.paginateAll(url, 'labels');
|
|
175
177
|
}
|
|
178
|
+
async getTest(testId) {
|
|
179
|
+
return this.get(`${API_BASE_V2}/get_test/${testId}`);
|
|
180
|
+
}
|
|
176
181
|
async getTests(runId, statusId) {
|
|
177
182
|
let url = `${API_BASE_V2}/get_tests/${runId}`;
|
|
178
183
|
if (statusId && statusId.length > 0) {
|
|
@@ -283,17 +288,19 @@ export class TestRailClient {
|
|
|
283
288
|
}
|
|
284
289
|
async _executeRequest(method, endpoint, headers, body, responseType = 'json') {
|
|
285
290
|
const url = `${this.baseUrl}${endpoint}`;
|
|
286
|
-
const params = {
|
|
287
|
-
method,
|
|
288
|
-
headers,
|
|
289
|
-
body,
|
|
290
|
-
};
|
|
291
291
|
const maxRetries = 3;
|
|
292
292
|
const baseDelayMs = 1000;
|
|
293
293
|
let attempt = 0;
|
|
294
294
|
while (attempt <= maxRetries) {
|
|
295
295
|
console.error(`[TestRailClient] Executing ${method} request to ${endpoint}${attempt > 0 ? ` (Attempt ${attempt + 1})` : ''}`);
|
|
296
296
|
const startTime = Date.now();
|
|
297
|
+
const signal = AbortSignal.timeout(this.timeoutMs);
|
|
298
|
+
const params = {
|
|
299
|
+
method,
|
|
300
|
+
headers,
|
|
301
|
+
body,
|
|
302
|
+
signal,
|
|
303
|
+
};
|
|
297
304
|
try {
|
|
298
305
|
const response = await fetch(url, params);
|
|
299
306
|
const duration = Date.now() - startTime;
|
|
@@ -329,10 +336,20 @@ export class TestRailClient {
|
|
|
329
336
|
return await response.text();
|
|
330
337
|
}
|
|
331
338
|
catch (error) {
|
|
332
|
-
const
|
|
339
|
+
const errName = error?.name;
|
|
340
|
+
const errMsg = typeof error?.message === 'string' ? error.message : '';
|
|
341
|
+
const isTimeout = signal.aborted ||
|
|
342
|
+
errName === 'TimeoutError' ||
|
|
343
|
+
signal.reason?.name === 'TimeoutError';
|
|
344
|
+
if (isTimeout) {
|
|
345
|
+
const timeoutMessage = `TestRail request timed out after ${this.timeoutMs}ms: ${method} ${endpoint}`;
|
|
346
|
+
console.error(`[TestRailClient] Request Failed: ${method} ${endpoint} - ${timeoutMessage}`);
|
|
347
|
+
throw new Error(timeoutMessage);
|
|
348
|
+
}
|
|
349
|
+
const networkError = !errMsg.startsWith('TestRail API Error');
|
|
333
350
|
if (networkError && attempt < maxRetries) {
|
|
334
351
|
const delayMs = baseDelayMs * Math.pow(2, attempt);
|
|
335
|
-
console.error(`[TestRailClient] Network error (${
|
|
352
|
+
console.error(`[TestRailClient] Network error (${errMsg}). Retrying in ${delayMs}ms...`);
|
|
336
353
|
await this.delay(delayMs);
|
|
337
354
|
attempt++;
|
|
338
355
|
continue;
|
package/dist/index.js
CHANGED
|
@@ -27,19 +27,27 @@ const EnvSchema = z.object({
|
|
|
27
27
|
TESTRAIL_ALLOW_READ_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
|
|
28
28
|
TESTRAIL_ALLOW_DELETE_OPERATIONS: z.string().optional().transform(val => val === 'true'),
|
|
29
29
|
TESTRAIL_ENABLE_DEPRECATED_TOOLS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
|
|
30
|
-
TESTRAIL_DISABLED_TOOLS: z.string().optional().transform(val => val ? val.split(',').map(s => s.trim()).filter(Boolean) : [])
|
|
30
|
+
TESTRAIL_DISABLED_TOOLS: z.string().optional().transform(val => val ? val.split(',').map(s => s.trim()).filter(Boolean) : []),
|
|
31
|
+
TESTRAIL_REQUEST_TIMEOUT_MS: z.string().optional().refine(val => {
|
|
32
|
+
if (!val)
|
|
33
|
+
return true;
|
|
34
|
+
const num = Number(val);
|
|
35
|
+
return Number.isInteger(num) && num > 0;
|
|
36
|
+
}, { message: 'Must be a positive integer' }).transform(val => val ? parseInt(val, 10) : undefined),
|
|
31
37
|
});
|
|
32
38
|
const parseResult = EnvSchema.safeParse(process.env);
|
|
33
39
|
if (!parseResult.success) {
|
|
34
40
|
console.error("Invalid TestRail environment configuration:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
|
|
35
41
|
process.exit(1);
|
|
36
42
|
}
|
|
37
|
-
const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ENABLE_CASE_HISTORY, TESTRAIL_ENABLE_RAG_TOOLS, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS, TESTRAIL_ENABLE_DEPRECATED_TOOLS, TESTRAIL_DISABLED_TOOLS } = parseResult.data;
|
|
43
|
+
const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ENABLE_CASE_HISTORY, TESTRAIL_ENABLE_RAG_TOOLS, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS, TESTRAIL_ENABLE_DEPRECATED_TOOLS, TESTRAIL_DISABLED_TOOLS, TESTRAIL_REQUEST_TIMEOUT_MS, } = parseResult.data;
|
|
38
44
|
const server = new McpServer({
|
|
39
45
|
name: "TestRail MCP Server",
|
|
40
46
|
version: VERSION,
|
|
41
47
|
});
|
|
42
|
-
const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY
|
|
48
|
+
const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, {
|
|
49
|
+
timeoutMs: TESTRAIL_REQUEST_TIMEOUT_MS,
|
|
50
|
+
});
|
|
43
51
|
let tools;
|
|
44
52
|
try {
|
|
45
53
|
tools = getToolsToRegister({
|
|
@@ -6,6 +6,8 @@ import { resolveCustomFieldValue } from "../../utils/mapper.js";
|
|
|
6
6
|
import { getBaseDirectory } from "../../utils/fs.js";
|
|
7
7
|
import { CaseFieldTypeId, getFieldType } from "./fields.js";
|
|
8
8
|
import { fetchFilteredCases } from "./get_cases.js";
|
|
9
|
+
import { createSectionLookup, formatSectionPath, resolveSectionPath } from "../../utils/section_path.js";
|
|
10
|
+
import { computeContentHash } from "../../utils/content_hash.js";
|
|
9
11
|
const parameters = {
|
|
10
12
|
case_ids: z.array(z.union([z.string(), z.number()])).optional().describe("List of test case IDs (e.g. ['C123', 456]). Provide either case_ids OR project_id."),
|
|
11
13
|
project_id: z.number().optional().describe("The ID of the project to export cases from. Required if case_ids is not provided."),
|
|
@@ -13,7 +15,7 @@ const parameters = {
|
|
|
13
15
|
filter: z.record(z.string(), z.string()).optional().describe("Optional API-side filters (e.g. priority_id, type_id, milestone_id, refs, created_after, etc.)."),
|
|
14
16
|
where: z.record(z.string(), z.any()).optional().describe("Optional client-side filter for any field including custom fields (filters after fetching cases). Supports exact value matching. Example: {\"custom_automation_status\": 1}"),
|
|
15
17
|
output_dir: z.string().optional().describe("Directory to save exported files. Defaults to an auto-generated directory in the current working directory."),
|
|
16
|
-
ignored_fields: z.array(z.string()).optional().describe("Optional list of custom field names or system names to ignore/exclude from export (e.g. ['custom_review_status', 'review_status']). Supports both full system_name and stripped field names. Core metadata attributes (case_id, updated_on, title, section, priority, references, labels) cannot be ignored."),
|
|
18
|
+
ignored_fields: z.array(z.string()).optional().describe("Optional list of custom field names or system names to ignore/exclude from export (e.g. ['custom_review_status', 'review_status']). Supports both full system_name and stripped field names. Core metadata attributes (case_id, updated_on, title, section, section_path, priority, references, labels, content_hash) cannot be ignored."),
|
|
17
19
|
};
|
|
18
20
|
function formatStepsSeparated(steps) {
|
|
19
21
|
return steps.map((step, idx) => {
|
|
@@ -81,9 +83,6 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
|
|
|
81
83
|
if (!systemName.startsWith("custom_")) {
|
|
82
84
|
continue;
|
|
83
85
|
}
|
|
84
|
-
if (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0)) {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
86
|
const strippedName = systemName.replace(/^custom_/, "");
|
|
88
87
|
if (ignoredFieldsSet.has(systemName) || ignoredFieldsSet.has(strippedName)) {
|
|
89
88
|
continue;
|
|
@@ -92,6 +91,10 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
|
|
|
92
91
|
if (!fieldDef) {
|
|
93
92
|
continue;
|
|
94
93
|
}
|
|
94
|
+
const isCheckbox = fieldDef.type_id === CaseFieldTypeId.Checkbox;
|
|
95
|
+
if (!isCheckbox && (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0))) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
95
98
|
const val = resolveCustomFieldValue(fieldDef, rawValue);
|
|
96
99
|
if (isMarkdownField(fieldDef, val)) {
|
|
97
100
|
const header = fieldDef.label || systemName.replace(/^custom_/, "");
|
|
@@ -104,11 +107,11 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
|
|
|
104
107
|
}
|
|
105
108
|
return { markdownFields, metadataFields };
|
|
106
109
|
}
|
|
107
|
-
function buildMarkdownBody(testCase,
|
|
110
|
+
function buildMarkdownBody(testCase, sectionPath, markdownFields) {
|
|
108
111
|
const mdLines = [];
|
|
109
112
|
mdLines.push(`# [C${testCase.id}] ${sanitizeValue(testCase.title)}`);
|
|
110
113
|
mdLines.push("");
|
|
111
|
-
mdLines.push(`**Section**: ${
|
|
114
|
+
mdLines.push(`**Section**: ${sectionPath.length > 0 ? formatSectionPath(sectionPath) : "Unknown"}`);
|
|
112
115
|
mdLines.push("");
|
|
113
116
|
for (const { header, value, fieldDef } of markdownFields) {
|
|
114
117
|
mdLines.push(`## ${header}`);
|
|
@@ -122,27 +125,44 @@ function buildMarkdownBody(testCase, sectionName, markdownFields) {
|
|
|
122
125
|
}
|
|
123
126
|
return mdLines.join("\n").trim() + "\n";
|
|
124
127
|
}
|
|
125
|
-
function buildMetadataAttributes(testCase,
|
|
128
|
+
function buildMetadataAttributes(testCase, sectionPath, priority, metadataFields) {
|
|
126
129
|
const metadataAttributes = {
|
|
127
130
|
case_id: testCase.id,
|
|
128
131
|
updated_on: testCase.updated_on,
|
|
129
132
|
title: testCase.title,
|
|
130
|
-
section:
|
|
133
|
+
section: sectionPath.length > 0 ? sectionPath[sectionPath.length - 1] : "Unknown",
|
|
131
134
|
priority: priority,
|
|
132
135
|
};
|
|
136
|
+
if (sectionPath.length > 0) {
|
|
137
|
+
metadataAttributes.section_path = sectionPath;
|
|
138
|
+
}
|
|
133
139
|
if (testCase.refs) {
|
|
134
140
|
metadataAttributes.references = testCase.refs;
|
|
135
141
|
}
|
|
136
142
|
if (testCase.labels && testCase.labels.length > 0) {
|
|
137
143
|
metadataAttributes.labels = testCase.labels.map(l => l.title);
|
|
138
144
|
}
|
|
145
|
+
const CORE_METADATA_KEYS = new Set([
|
|
146
|
+
"case_id",
|
|
147
|
+
"updated_on",
|
|
148
|
+
"title",
|
|
149
|
+
"section",
|
|
150
|
+
"section_path",
|
|
151
|
+
"priority",
|
|
152
|
+
"references",
|
|
153
|
+
"labels",
|
|
154
|
+
"content_hash",
|
|
155
|
+
]);
|
|
139
156
|
for (const { key, value, fieldDef } of metadataFields) {
|
|
157
|
+
const targetKey = CORE_METADATA_KEYS.has(key)
|
|
158
|
+
? (fieldDef?.system_name || `custom_${key}`)
|
|
159
|
+
: key;
|
|
140
160
|
if (fieldDef?.type_id === CaseFieldTypeId.MultiSelect) {
|
|
141
161
|
const listVal = Array.isArray(value) ? value : [value];
|
|
142
|
-
metadataAttributes[
|
|
162
|
+
metadataAttributes[targetKey] = sanitizeMetadataValue(listVal.map(item => String(item)));
|
|
143
163
|
}
|
|
144
164
|
else {
|
|
145
|
-
metadataAttributes[
|
|
165
|
+
metadataAttributes[targetKey] = sanitizeMetadataValue(value);
|
|
146
166
|
}
|
|
147
167
|
}
|
|
148
168
|
return metadataAttributes;
|
|
@@ -194,21 +214,23 @@ export const exportCasesForRagTool = {
|
|
|
194
214
|
}
|
|
195
215
|
}
|
|
196
216
|
}
|
|
217
|
+
let preloadedSections = [];
|
|
218
|
+
if (testCases.length > 0) {
|
|
219
|
+
preloadedSections = await client.getSections(project_id, suite_id).catch(() => []);
|
|
220
|
+
}
|
|
221
|
+
const sectionLookup = createSectionLookup(client, preloadedSections);
|
|
197
222
|
const writtenFiles = [];
|
|
198
223
|
for (const testCase of testCases) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (section?.name) {
|
|
203
|
-
sectionName = section.name;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
224
|
+
const sectionPath = testCase.section_id
|
|
225
|
+
? await resolveSectionPath(Number(testCase.section_id), sectionLookup)
|
|
226
|
+
: [];
|
|
206
227
|
const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
|
|
207
228
|
const templateId = testCase.template_id;
|
|
208
229
|
const applicableFields = caseFields.filter(field => field.include_all || (templateId !== null && templateId !== undefined && field.template_ids.includes(templateId)));
|
|
209
230
|
const { markdownFields, metadataFields } = categorizeCustomFields(testCase, applicableFields, ignoredFieldsSet);
|
|
210
|
-
const markdownContent = buildMarkdownBody(testCase,
|
|
211
|
-
const metadataAttributes = buildMetadataAttributes(testCase,
|
|
231
|
+
const markdownContent = buildMarkdownBody(testCase, sectionPath, markdownFields);
|
|
232
|
+
const metadataAttributes = buildMetadataAttributes(testCase, sectionPath, priority, metadataFields);
|
|
233
|
+
metadataAttributes.content_hash = computeContentHash(markdownContent, metadataAttributes);
|
|
212
234
|
const docFilename = `C${testCase.id}.md`;
|
|
213
235
|
const metaFilename = `C${testCase.id}.md.metadata.json`;
|
|
214
236
|
await fs.promises.writeFile(path.join(targetDir, docFilename), markdownContent, "utf-8");
|
|
@@ -55,3 +55,13 @@ export const READONLY_CASE_FIELDS = [
|
|
|
55
55
|
"estimate_forecast",
|
|
56
56
|
"is_deleted",
|
|
57
57
|
];
|
|
58
|
+
/**
|
|
59
|
+
* Standard case property names that must not be overwritten by custom fields.
|
|
60
|
+
* If a custom field's converted label collides with one of these names (e.g. "labels", "title"),
|
|
61
|
+
* the custom field retains its `system_name` (e.g. `custom_labels`, `custom_title`) to prevent collisions.
|
|
62
|
+
*/
|
|
63
|
+
export const RESERVED_CASE_FIELDS = new Set([
|
|
64
|
+
...SYSTEM_FIELDS.map(f => f.label.toLowerCase()),
|
|
65
|
+
...SYSTEM_FIELDS.map(f => f.system_name),
|
|
66
|
+
...READONLY_CASE_FIELDS,
|
|
67
|
+
]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { processCustomFields } from "../../utils/mapper.js";
|
|
3
3
|
import { normalizeEntityId } from "../../utils/sanitizer.js";
|
|
4
|
-
import {
|
|
4
|
+
import { CaseLabelSchema } from "../commons/types.js";
|
|
5
5
|
const parameters = {
|
|
6
6
|
case_id: z.string().describe("The ID of the test case (e.g. '123' or 'C123')"),
|
|
7
7
|
};
|
|
@@ -23,15 +23,15 @@ export const getCaseTool = {
|
|
|
23
23
|
const priority = priorities.find(p => p.id === testCase.priority_id);
|
|
24
24
|
const customFields = processCustomFields(testCase, caseFields);
|
|
25
25
|
const response = {
|
|
26
|
+
...customFields,
|
|
26
27
|
id: testCase.id,
|
|
27
28
|
title: testCase.title,
|
|
28
29
|
section: section.name,
|
|
29
30
|
type: caseType ? caseType.name : "Unknown",
|
|
30
31
|
priority: priority ? priority.name : "Unknown",
|
|
31
|
-
labels: (testCase.labels || []).map((label) =>
|
|
32
|
+
labels: (testCase.labels || []).map((label) => CaseLabelSchema.parse(label)),
|
|
32
33
|
references: testCase.refs,
|
|
33
34
|
updated_on: testCase.updated_on,
|
|
34
|
-
...customFields
|
|
35
35
|
};
|
|
36
36
|
return response;
|
|
37
37
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { CaseLabelSchema } from "../commons/types.js";
|
|
3
3
|
import { CaseFieldTypeId } from "./fields.js";
|
|
4
4
|
export { CaseFieldTypeId };
|
|
5
5
|
export const CaseFieldConfigSchema = z.object({
|
|
@@ -42,7 +42,7 @@ export const CaseSchema = z.object({
|
|
|
42
42
|
estimate_forecast: z.string().nullable().optional(),
|
|
43
43
|
suite_id: z.number(),
|
|
44
44
|
is_deleted: z.number().optional(),
|
|
45
|
-
labels: z.array(
|
|
45
|
+
labels: z.array(CaseLabelSchema),
|
|
46
46
|
}).loose();
|
|
47
47
|
export const CaseHistoryChangeSchema = z.object({
|
|
48
48
|
field: z.string(),
|
|
@@ -6,7 +6,7 @@ const parameters = {
|
|
|
6
6
|
export const getLabelsTool = {
|
|
7
7
|
name: "get_labels",
|
|
8
8
|
mode: "read",
|
|
9
|
-
description: "Get all available test case labels (sometimes called tags) for a project. Returns label IDs and
|
|
9
|
+
description: "Get all available test case labels (sometimes called tags) for a project. Returns label IDs and names that can be used when creating or updating test cases.",
|
|
10
10
|
parameters,
|
|
11
11
|
handler: async ({ project_id }, client) => {
|
|
12
12
|
const labels = await client.getLabels(project_id);
|
|
@@ -4,7 +4,6 @@ import { getLabelsTool } from "./get_labels.js";
|
|
|
4
4
|
import { getPrioritiesTool } from "./get_priorities.js";
|
|
5
5
|
import { getStatusesTool } from "./get_statuses.js";
|
|
6
6
|
import { getTemplatesTool } from "./get_templates.js";
|
|
7
|
-
import { getTestsTool } from "./get_tests.js";
|
|
8
7
|
import { getUsersTool } from "./get_users.js";
|
|
9
8
|
export const commonsTools = [
|
|
10
9
|
deleteEntityTool,
|
|
@@ -13,6 +12,5 @@ export const commonsTools = [
|
|
|
13
12
|
getPrioritiesTool,
|
|
14
13
|
getStatusesTool,
|
|
15
14
|
getTemplatesTool,
|
|
16
|
-
getTestsTool,
|
|
17
15
|
getUsersTool,
|
|
18
16
|
];
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Label as returned by the `get_labels` endpoint (uses `name`). */
|
|
2
3
|
export const LabelSchema = z.object({
|
|
4
|
+
id: z.number(),
|
|
5
|
+
name: z.string(),
|
|
6
|
+
});
|
|
7
|
+
/** Label as embedded in a case payload (`get_case`, `get_cases`); uses `title`. */
|
|
8
|
+
export const CaseLabelSchema = z.object({
|
|
3
9
|
id: z.number(),
|
|
4
10
|
title: z.string(),
|
|
5
11
|
});
|
|
@@ -23,14 +29,7 @@ export const StatusSchema = z.object({
|
|
|
23
29
|
name: z.string(),
|
|
24
30
|
label: z.string(),
|
|
25
31
|
});
|
|
26
|
-
export
|
|
27
|
-
id: z.number(),
|
|
28
|
-
case_id: z.number(),
|
|
29
|
-
status_id: z.number(),
|
|
30
|
-
title: z.string(),
|
|
31
|
-
run_id: z.number(),
|
|
32
|
-
});
|
|
33
|
-
export const TestsSchema = z.array(TestSchema);
|
|
32
|
+
export { TestSchema, TestsSchema } from "../tests/types.js";
|
|
34
33
|
export const UserSchema = z.object({
|
|
35
34
|
id: z.number(),
|
|
36
35
|
name: z.string(),
|
package/dist/tools/registry.js
CHANGED
|
@@ -7,6 +7,7 @@ import { sharedStepsTools } from "./shared_steps/index.js";
|
|
|
7
7
|
import { resultsTools } from "./results/index.js";
|
|
8
8
|
import { commonsTools } from "./commons/index.js";
|
|
9
9
|
import { attachmentsTools } from "./attachments/index.js";
|
|
10
|
+
import { testsTools } from "./tests/index.js";
|
|
10
11
|
export const ALL_TOOLS = [
|
|
11
12
|
...projectsTools,
|
|
12
13
|
...suitesTools,
|
|
@@ -15,6 +16,7 @@ export const ALL_TOOLS = [
|
|
|
15
16
|
exportCasesForRagTool,
|
|
16
17
|
...sectionsTools,
|
|
17
18
|
...runsTools,
|
|
19
|
+
...testsTools,
|
|
18
20
|
...resultsTools,
|
|
19
21
|
...attachmentsTools,
|
|
20
22
|
...commonsTools,
|
|
@@ -34,6 +36,7 @@ export function getToolsToRegister(config) {
|
|
|
34
36
|
...casesTools,
|
|
35
37
|
...sectionsTools,
|
|
36
38
|
...runsTools,
|
|
39
|
+
...testsTools,
|
|
37
40
|
...resultsTools,
|
|
38
41
|
...attachmentsTools,
|
|
39
42
|
...commonsTools,
|
|
@@ -68,6 +71,7 @@ export function getToolsToRegister(config) {
|
|
|
68
71
|
readOnlyHint: tool.mode === 'read',
|
|
69
72
|
destructiveHint: tool.mode === 'delete',
|
|
70
73
|
idempotentHint: tool.mode === 'read',
|
|
74
|
+
openWorldHint: false,
|
|
71
75
|
};
|
|
72
76
|
return {
|
|
73
77
|
...tool,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { TestSchema } from "./types.js";
|
|
3
2
|
const parameters = {
|
|
4
3
|
run_id: z.number().describe("The ID of the test run"),
|
|
5
4
|
status_id: z.array(z.number()).optional().describe("Optional array of status IDs to filter by. Use get_statuses to retrieve available status IDs"),
|
|
@@ -7,12 +6,19 @@ const parameters = {
|
|
|
7
6
|
export const getTestsTool = {
|
|
8
7
|
name: "get_tests",
|
|
9
8
|
mode: "read",
|
|
10
|
-
|
|
9
|
+
deprecated: true,
|
|
10
|
+
description: "Get tests for a test run, optionally filtered by status (Deprecated: use query_test with action 'many' instead)",
|
|
11
11
|
parameters,
|
|
12
12
|
handler: async ({ run_id, status_id }, client) => {
|
|
13
13
|
const tests = await client.getTests(run_id, status_id);
|
|
14
14
|
return {
|
|
15
|
-
tests: tests.map(test =>
|
|
15
|
+
tests: tests.map(test => ({
|
|
16
|
+
id: test.id,
|
|
17
|
+
case_id: test.case_id,
|
|
18
|
+
status_id: test.status_id,
|
|
19
|
+
title: test.title,
|
|
20
|
+
run_id: test.run_id,
|
|
21
|
+
})),
|
|
16
22
|
};
|
|
17
23
|
},
|
|
18
24
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { GetOneTestSchema, GetManyTestsSchema, TestSchema } from "./types.js";
|
|
4
|
+
import { handleQuery } from "../../utils/query_handler.js";
|
|
5
|
+
import { normalizeEntityId } from "../../utils/sanitizer.js";
|
|
6
|
+
const parameters = {
|
|
7
|
+
payload: z.discriminatedUnion("action", [
|
|
8
|
+
GetOneTestSchema,
|
|
9
|
+
GetManyTestsSchema,
|
|
10
|
+
]).describe("The payload containing the action ('one' or 'many') and corresponding parameters"),
|
|
11
|
+
};
|
|
12
|
+
export const queryTestTool = {
|
|
13
|
+
name: "query_test",
|
|
14
|
+
mode: "read",
|
|
15
|
+
description: "Retrieve a single test by ID or all tests for a test run in TestRail. Set payload.action to 'one' or 'many' to specify the operation.",
|
|
16
|
+
parameters,
|
|
17
|
+
handler: async (args, client) => {
|
|
18
|
+
return handleQuery(args.payload, async (p) => {
|
|
19
|
+
const testId = normalizeEntityId(p.test_id);
|
|
20
|
+
const test = await client.getTest(testId);
|
|
21
|
+
return {
|
|
22
|
+
test: TestSchema.parse(test),
|
|
23
|
+
};
|
|
24
|
+
}, async (p) => {
|
|
25
|
+
const tests = await client.getTests(p.run_id, p.status_id);
|
|
26
|
+
const response = {
|
|
27
|
+
tests: tests.map(t => {
|
|
28
|
+
const baseTest = {
|
|
29
|
+
id: t.id,
|
|
30
|
+
case_id: t.case_id,
|
|
31
|
+
status_id: t.status_id,
|
|
32
|
+
title: t.title,
|
|
33
|
+
run_id: t.run_id,
|
|
34
|
+
};
|
|
35
|
+
if (p.fields && p.fields.length > 0) {
|
|
36
|
+
for (const field of p.fields) {
|
|
37
|
+
if (field in t) {
|
|
38
|
+
baseTest[field] = t[field];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return baseTest;
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
if (p.output_file) {
|
|
46
|
+
await fs.promises.writeFile(p.output_file, JSON.stringify(response), "utf-8");
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
message: `Successfully exported ${response.tests.length} tests to ${p.output_file}`,
|
|
50
|
+
file: p.output_file,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return response;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const TestSchema = z.looseObject({
|
|
3
|
+
id: z.number(),
|
|
4
|
+
case_id: z.number(),
|
|
5
|
+
status_id: z.number(),
|
|
6
|
+
title: z.string(),
|
|
7
|
+
run_id: z.number(),
|
|
8
|
+
});
|
|
9
|
+
export const TestsSchema = z.array(TestSchema);
|
|
10
|
+
export const GetOneTestSchema = z.object({
|
|
11
|
+
action: z.literal("one").describe("Retrieve a single test by ID"),
|
|
12
|
+
test_id: z.union([z.number(), z.string()]).describe("The ID of the test (e.g. 123 or 'T123')"),
|
|
13
|
+
});
|
|
14
|
+
export const GetManyTestsSchema = z.object({
|
|
15
|
+
action: z.literal("many").describe("Retrieve tests for a test run"),
|
|
16
|
+
run_id: z.number().int().describe("The ID of the test run"),
|
|
17
|
+
status_id: z.array(z.number()).optional().describe("Optional array of status IDs to filter by. Use get_statuses to retrieve available status IDs"),
|
|
18
|
+
fields: z.array(z.string()).optional().describe("Additional fields to include in response beyond default fields (id, case_id, status_id, title, run_id). Example: ['assignedto_id', 'priority_id', 'refs']"),
|
|
19
|
+
output_file: z.string().optional().describe("Absolute file path to save the JSON response to. Use this for large datasets to avoid blowing up context limits."),
|
|
20
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
export const DEFAULT_VOLATILE_KEYS = new Set([
|
|
3
|
+
"content_hash",
|
|
4
|
+
"export_time",
|
|
5
|
+
"exported_at",
|
|
6
|
+
"output_path",
|
|
7
|
+
"output_dir",
|
|
8
|
+
]);
|
|
9
|
+
/**
|
|
10
|
+
* Serializes a value to canonical JSON with sorted object keys,
|
|
11
|
+
* normalized \n line endings, and volatile keys omitted.
|
|
12
|
+
*/
|
|
13
|
+
export function canonicalizeJson(val, volatileKeys = DEFAULT_VOLATILE_KEYS) {
|
|
14
|
+
if (val === undefined) {
|
|
15
|
+
return "null";
|
|
16
|
+
}
|
|
17
|
+
if (val === null || typeof val !== "object") {
|
|
18
|
+
if (typeof val === "string") {
|
|
19
|
+
return JSON.stringify(val.replace(/\r\n/g, "\n"));
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(val);
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(val)) {
|
|
24
|
+
return "[" + val.map(item => canonicalizeJson(item, volatileKeys)).join(",") + "]";
|
|
25
|
+
}
|
|
26
|
+
const sortedKeys = Object.keys(val)
|
|
27
|
+
.filter(key => !volatileKeys.has(key) && val[key] !== undefined)
|
|
28
|
+
.sort();
|
|
29
|
+
const entries = sortedKeys.map(key => {
|
|
30
|
+
return JSON.stringify(key) + ":" + canonicalizeJson(val[key], volatileKeys);
|
|
31
|
+
});
|
|
32
|
+
return "{" + entries.join(",") + "}";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Computes a deterministic SHA-256 content hash of the exported Markdown body
|
|
36
|
+
* and its companion metadataAttributes (excluding content_hash and volatile keys).
|
|
37
|
+
*
|
|
38
|
+
* Formula: sha256( markdown body + "\n" + canonical JSON of metadataAttributes, excluding content_hash )
|
|
39
|
+
* Format: "sha256:<hex>"
|
|
40
|
+
*/
|
|
41
|
+
export function computeContentHash(markdownBody, metadataAttributes, volatileKeys = DEFAULT_VOLATILE_KEYS) {
|
|
42
|
+
const normalizedMarkdown = markdownBody.replace(/\r\n/g, "\n");
|
|
43
|
+
const canonicalJson = canonicalizeJson(metadataAttributes, volatileKeys);
|
|
44
|
+
const hashInput = `${normalizedMarkdown}\n${canonicalJson}`;
|
|
45
|
+
const digest = crypto.createHash("sha256").update(hashInput, "utf-8").digest("hex");
|
|
46
|
+
return `sha256:${digest}`;
|
|
47
|
+
}
|
package/dist/utils/mapper.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CaseFieldTypeId } from "../tools/cases/types.js";
|
|
2
|
+
import { RESERVED_CASE_FIELDS } from "../tools/cases/fields.js";
|
|
2
3
|
import { sanitizeValue } from "./sanitizer.js";
|
|
4
|
+
export { RESERVED_CASE_FIELDS };
|
|
3
5
|
/**
|
|
4
6
|
* Processes custom fields from a test case, converting system names to human-readable labels
|
|
5
7
|
* and mapping dropdown values to their display text.
|
|
@@ -14,7 +16,9 @@ export function processCustomFields(testCase, caseFields) {
|
|
|
14
16
|
const fieldNameMap = new Map();
|
|
15
17
|
const dropdownOptionsMap = new Map();
|
|
16
18
|
for (const field of applicableFields) {
|
|
17
|
-
|
|
19
|
+
const snakeLabel = toSnakeCase(field.label);
|
|
20
|
+
const outputKey = RESERVED_CASE_FIELDS.has(snakeLabel) ? field.system_name : snakeLabel;
|
|
21
|
+
fieldNameMap.set(field.system_name, outputKey);
|
|
18
22
|
const optionsMap = parseDropdownOptions(field);
|
|
19
23
|
if (optionsMap.size > 0) {
|
|
20
24
|
dropdownOptionsMap.set(field.system_name, optionsMap);
|
|
@@ -23,15 +27,23 @@ export function processCustomFields(testCase, caseFields) {
|
|
|
23
27
|
for (const [key, value] of Object.entries(testCase)) {
|
|
24
28
|
if (!key.startsWith("custom_"))
|
|
25
29
|
continue;
|
|
26
|
-
if (value === null || value === undefined)
|
|
27
|
-
continue;
|
|
28
30
|
if (!fieldNameMap.has(key)) {
|
|
31
|
+
if (value === null || value === undefined)
|
|
32
|
+
continue;
|
|
29
33
|
console.error(`No field mapping found for: ${key}`);
|
|
30
34
|
result[key] = value;
|
|
31
35
|
continue;
|
|
32
36
|
}
|
|
33
|
-
const outputKey = fieldNameMap.get(key);
|
|
34
37
|
const field = applicableFields.find(f => f.system_name === key);
|
|
38
|
+
const isCheckbox = field.type_id === CaseFieldTypeId.Checkbox;
|
|
39
|
+
if (value === null || value === undefined) {
|
|
40
|
+
if (isCheckbox) {
|
|
41
|
+
const outputKey = fieldNameMap.get(key);
|
|
42
|
+
result[outputKey] = resolveCustomFieldValue(field, value);
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const outputKey = fieldNameMap.get(key);
|
|
35
47
|
const options = dropdownOptionsMap.get(key);
|
|
36
48
|
result[outputKey] = resolveCustomFieldValue(field, value, options);
|
|
37
49
|
}
|
|
@@ -41,9 +53,16 @@ export function processCustomFields(testCase, caseFields) {
|
|
|
41
53
|
* Resolves a custom field value against its field definition:
|
|
42
54
|
* - Maps dropdown option IDs to their display labels
|
|
43
55
|
* - Normalizes multi-select values into arrays
|
|
56
|
+
* - Normalizes checkbox / boolean values (defaults null/undefined to false)
|
|
44
57
|
* - Sanitizes HTML formatting in strings
|
|
45
58
|
*/
|
|
46
59
|
export function resolveCustomFieldValue(field, value, optionsMap) {
|
|
60
|
+
if (field.type_id === CaseFieldTypeId.Checkbox) {
|
|
61
|
+
if (value === null || value === undefined) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return value === true || value === 1 || value === "1" || value === "true";
|
|
65
|
+
}
|
|
47
66
|
if (value === null || value === undefined) {
|
|
48
67
|
return value;
|
|
49
68
|
}
|
package/dist/utils/sanitizer.js
CHANGED
|
@@ -12,6 +12,31 @@ function decodeHtmlEntities(str) {
|
|
|
12
12
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
13
13
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
|
|
14
14
|
}
|
|
15
|
+
const KNOWN_HTML_TAGS = new Set([
|
|
16
|
+
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
|
|
17
|
+
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
|
|
18
|
+
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
|
|
19
|
+
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
|
|
20
|
+
'em', 'embed',
|
|
21
|
+
'fieldset', 'figcaption', 'figure', 'footer', 'form',
|
|
22
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
|
|
23
|
+
'i', 'iframe', 'img', 'input', 'ins',
|
|
24
|
+
'kbd',
|
|
25
|
+
'label', 'legend', 'li', 'link',
|
|
26
|
+
'main', 'map', 'mark', 'menu', 'meta', 'meter',
|
|
27
|
+
'nav', 'noscript',
|
|
28
|
+
'object', 'ol', 'optgroup', 'option', 'output',
|
|
29
|
+
'p', 'picture', 'pre', 'progress',
|
|
30
|
+
'q',
|
|
31
|
+
'rp', 'rt', 'ruby',
|
|
32
|
+
's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub',
|
|
33
|
+
'summary', 'sup', 'svg',
|
|
34
|
+
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
|
|
35
|
+
'u', 'ul',
|
|
36
|
+
'var', 'video',
|
|
37
|
+
'wbr',
|
|
38
|
+
'font', 'center', 'strike', 'tt', 'big', 'marquee', 'nobr', 'o:p'
|
|
39
|
+
]);
|
|
15
40
|
/**
|
|
16
41
|
* Converts TestRail HTML markup to clean, structured Markdown / plain text.
|
|
17
42
|
*/
|
|
@@ -78,8 +103,20 @@ export function htmlToMarkdown(html) {
|
|
|
78
103
|
});
|
|
79
104
|
return `\n${items}\n`;
|
|
80
105
|
});
|
|
81
|
-
// Strip
|
|
82
|
-
text = text.replace(/<[^>]
|
|
106
|
+
// Strip scripts and styles along with their content
|
|
107
|
+
text = text.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
108
|
+
text = text.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
109
|
+
// Strip HTML comments, CDATA, and DOCTYPE declarations
|
|
110
|
+
text = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
111
|
+
text = text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gi, '$1');
|
|
112
|
+
text = text.replace(/<!DOCTYPE[^>]*>/gi, '');
|
|
113
|
+
// Strip remaining known HTML tags while preserving comparison signs and non-HTML angle brackets
|
|
114
|
+
text = text.replace(/<\/?([a-zA-Z][a-zA-Z0-9:-]*)(?:\s+[a-zA-Z_:@][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*\s*\/?>/gi, (match, tagName) => {
|
|
115
|
+
if (KNOWN_HTML_TAGS.has(tagName.toLowerCase())) {
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
return match;
|
|
119
|
+
});
|
|
83
120
|
// Decode HTML entities
|
|
84
121
|
text = decodeHtmlEntities(text);
|
|
85
122
|
// Clean up whitespace and newlines
|
|
@@ -145,12 +182,15 @@ export function isActive(value) {
|
|
|
145
182
|
return activeFlag === 1 || activeFlag === true || activeFlag === '1' || activeFlag === 'true';
|
|
146
183
|
}
|
|
147
184
|
/**
|
|
148
|
-
* Normalizes an entity ID (e.g. case ID, run ID, attachment ID).
|
|
149
|
-
* Handles string IDs with 'C' prefixes (e.g. 'C123' -> 123), numeric IDs, and trims whitespace.
|
|
185
|
+
* Normalizes an entity ID (e.g. case ID, test ID, run ID, attachment ID).
|
|
186
|
+
* Handles string IDs with 'C', 'T', or 'R' prefixes (e.g. 'C123' -> 123, 'T456' -> 456), numeric IDs, and trims whitespace.
|
|
150
187
|
*/
|
|
151
188
|
export function normalizeEntityId(id) {
|
|
152
189
|
const idString = typeof id === "string" ? id.trim() : String(id);
|
|
153
|
-
const
|
|
190
|
+
const upper = idString.toUpperCase();
|
|
191
|
+
const cleaned = (upper.startsWith("C") || upper.startsWith("T") || upper.startsWith("R"))
|
|
192
|
+
? idString.substring(1)
|
|
193
|
+
: idString;
|
|
154
194
|
const parsed = Number(cleaned);
|
|
155
195
|
if (isNaN(parsed)) {
|
|
156
196
|
throw new Error(`Invalid entity ID: ${id}`);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const SECTION_PATH_SEPARATOR = " > ";
|
|
2
|
+
export const MAX_SECTION_DEPTH = 50;
|
|
3
|
+
/**
|
|
4
|
+
* Creates a memoized section lookup. Sections passed in `preloaded` (e.g. from a single
|
|
5
|
+
* `getSections` call) are served from memory; anything missing is fetched lazily via
|
|
6
|
+
* `client.getSection`. Failed lookups resolve to `null`.
|
|
7
|
+
*/
|
|
8
|
+
export function createSectionLookup(client, preloaded = []) {
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
for (const section of preloaded) {
|
|
11
|
+
cache.set(section.id, section);
|
|
12
|
+
}
|
|
13
|
+
return async (sectionId) => {
|
|
14
|
+
const cached = cache.get(sectionId);
|
|
15
|
+
if (cached) {
|
|
16
|
+
return cached;
|
|
17
|
+
}
|
|
18
|
+
const section = await client.getSection(sectionId).catch(() => null);
|
|
19
|
+
if (section) {
|
|
20
|
+
cache.set(sectionId, section);
|
|
21
|
+
}
|
|
22
|
+
return section ?? null;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the section hierarchy from root to leaf by walking `parent_id`.
|
|
27
|
+
*
|
|
28
|
+
* - Returns `[]` if the leaf section itself cannot be resolved (or has no name).
|
|
29
|
+
* - Falls back to `[leafName]` if any ancestor cannot be resolved, a cycle is detected,
|
|
30
|
+
* or the hierarchy exceeds `MAX_SECTION_DEPTH`.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveSectionPath(sectionId, lookup) {
|
|
33
|
+
const leaf = await lookup(sectionId);
|
|
34
|
+
if (!leaf?.name) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const names = [leaf.name];
|
|
38
|
+
const visited = new Set([sectionId]);
|
|
39
|
+
let parentId = leaf.parent_id;
|
|
40
|
+
while (parentId !== null && parentId !== undefined) {
|
|
41
|
+
if (visited.has(parentId) || names.length >= MAX_SECTION_DEPTH) {
|
|
42
|
+
return [leaf.name];
|
|
43
|
+
}
|
|
44
|
+
visited.add(parentId);
|
|
45
|
+
const parent = await lookup(parentId);
|
|
46
|
+
if (!parent?.name) {
|
|
47
|
+
return [leaf.name];
|
|
48
|
+
}
|
|
49
|
+
names.unshift(parent.name);
|
|
50
|
+
parentId = parent.parent_id;
|
|
51
|
+
}
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Formats a section path for Markdown output. Literal `>` characters inside section names
|
|
56
|
+
* are escaped as `\>` so they cannot be confused with the path separator.
|
|
57
|
+
*/
|
|
58
|
+
export function formatSectionPath(names) {
|
|
59
|
+
return names.map(name => name.replace(/>/g, "\\>")).join(SECTION_PATH_SEPARATOR);
|
|
60
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "3.
|
|
1
|
+
export const VERSION = "3.3.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uarlouski/testrail-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"mcpName": "io.github.uarlouski/testrail-mcp-server",
|
|
5
5
|
"description": "Model Context Protocol (MCP) server for TestRail: let Claude, Cursor, and Windsurf manage test cases, runs, results, and attachments.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"testrail",
|
|
20
20
|
"testrail-api",
|
|
21
21
|
"testrail-mcp",
|
|
22
|
+
"testrail-mcp-server",
|
|
23
|
+
"mcp-testrail",
|
|
22
24
|
"testrail-integration",
|
|
23
25
|
"test-management",
|
|
24
26
|
"test-case-management",
|
|
@@ -37,10 +39,14 @@
|
|
|
37
39
|
"ai-agents",
|
|
38
40
|
"llm",
|
|
39
41
|
"claude",
|
|
42
|
+
"claude-desktop",
|
|
43
|
+
"claude-code",
|
|
40
44
|
"cursor",
|
|
41
45
|
"windsurf",
|
|
42
46
|
"vscode",
|
|
43
47
|
"copilot",
|
|
48
|
+
"github-copilot",
|
|
49
|
+
"test-case-generation",
|
|
44
50
|
"rag",
|
|
45
51
|
"typescript",
|
|
46
52
|
"cli"
|
|
@@ -53,10 +59,10 @@
|
|
|
53
59
|
"bugs": {
|
|
54
60
|
"url": "https://github.com/uarlouski/testrail-mcp-server/issues"
|
|
55
61
|
},
|
|
56
|
-
"homepage": "https://github.
|
|
62
|
+
"homepage": "https://uarlouski.github.io/testrail-mcp-server/",
|
|
57
63
|
"license": "Apache-2.0",
|
|
58
64
|
"dependencies": {
|
|
59
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
65
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
60
66
|
"archiver": "^7.0.1",
|
|
61
67
|
"dotenv": "^17.4.2",
|
|
62
68
|
"zod": "^4.6.5"
|