@uarlouski/testrail-mcp-server 3.0.0 → 3.2.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 +5 -6
- package/dist/cli.js +11 -1
- package/dist/client/testrail.js +25 -8
- package/dist/index.js +11 -3
- package/dist/tools/cases/add_case.js +1 -1
- package/dist/tools/cases/export_cases_for_rag.js +4 -3
- package/dist/tools/cases/fields.js +10 -0
- package/dist/tools/cases/get_case_fields.js +2 -2
- package/dist/tools/cases/get_cases.js +1 -1
- package/dist/tools/cases/types.js +4 -0
- package/dist/tools/cases/update_case.js +1 -1
- package/dist/tools/cases/update_cases.js +1 -1
- package/dist/tools/commons/index.js +0 -2
- package/dist/tools/commons/types.js +1 -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/mapper.js +18 -3
- package/dist/utils/sanitizer.js +6 -3
- package/dist/utils/validator.js +8 -4
- package/dist/version.js +1 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
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
44
|
|
|
45
|
-
It exposes **
|
|
45
|
+
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
46
|
|
|
47
47
|
**No context switching. No tedious copy-pasting. Just ask your AI.**
|
|
48
48
|
|
|
@@ -279,19 +279,19 @@ See the [CLI & CI/CD guide](https://uarlouski.github.io/testrail-mcp-server/guid
|
|
|
279
279
|
| `TESTRAIL_ENABLE_DEPRECATED_TOOLS` | Preserved for backward compatibility with existing host configurations. | | `true` |
|
|
280
280
|
| `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
281
|
|
|
282
|
-
With only the three required credentials, **
|
|
282
|
+
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
283
|
|
|
284
284
|
---
|
|
285
285
|
|
|
286
286
|
## 🧰 Available Tools
|
|
287
287
|
|
|
288
|
-
All
|
|
288
|
+
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
289
|
|
|
290
290
|
| Area | Tools | Reference |
|
|
291
291
|
|------|-------|-----------|
|
|
292
292
|
| **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
293
|
| **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) |
|
|
294
|
+
| **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
295
|
| **Attachments & Media** | `add_attachment`, `query_attachment` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/attachments) |
|
|
296
296
|
| **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
297
|
| **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 +355,13 @@ For a comprehensive guide, detailed configuration options, and a complete breakd
|
|
|
355
355
|
|
|
356
356
|
- 🚀 **[Getting Started](https://uarlouski.github.io/testrail-mcp-server/guide/getting-started)**: Per-client setup for Claude, Cursor, Windsurf, and VS Code.
|
|
357
357
|
- ⚙️ **[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
|
|
358
|
+
- 🧰 **[All Tools](https://uarlouski.github.io/testrail-mcp-server/reference/)**: All 35 tools with modes and feature flags in one table.
|
|
359
359
|
- 🔭 **[Discovery & Navigation](https://uarlouski.github.io/testrail-mcp-server/reference/discovery)**: Exploring projects, suites, and sections.
|
|
360
360
|
- 📋 **[Test Case Management](https://uarlouski.github.io/testrail-mcp-server/reference/cases)**: Fetching, creating, and bulk-updating test cases.
|
|
361
361
|
- ▶️ **[Execution & Tracking](https://uarlouski.github.io/testrail-mcp-server/reference/execution)**: Managing test runs and submitting test results.
|
|
362
362
|
- 📎 **[Attachments](https://uarlouski.github.io/testrail-mcp-server/reference/attachments)**: Automatically zipping and uploading files or directories.
|
|
363
363
|
- 🔗 **[Shared Steps](https://uarlouski.github.io/testrail-mcp-server/reference/shared-steps)**: Managing reusable step definitions.
|
|
364
364
|
- 🖥️ **[CLI & CI/CD](https://uarlouski.github.io/testrail-mcp-server/guide/cli)**: Pipeline automation without an LLM.
|
|
365
|
-
|
|
366
365
|
---
|
|
367
366
|
|
|
368
367
|
## 🤝 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({
|
|
@@ -17,7 +17,7 @@ export const addCaseTool = {
|
|
|
17
17
|
parameters,
|
|
18
18
|
handler: async ({ section_id, title, fields }, client) => {
|
|
19
19
|
if (fields) {
|
|
20
|
-
validateCaseFields(fields, await client.getCaseFields());
|
|
20
|
+
validateCaseFields(fields, await client.getCaseFields(), { allowReadonly: false });
|
|
21
21
|
}
|
|
22
22
|
const caseData = {
|
|
23
23
|
title,
|
|
@@ -81,9 +81,6 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
|
|
|
81
81
|
if (!systemName.startsWith("custom_")) {
|
|
82
82
|
continue;
|
|
83
83
|
}
|
|
84
|
-
if (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0)) {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
84
|
const strippedName = systemName.replace(/^custom_/, "");
|
|
88
85
|
if (ignoredFieldsSet.has(systemName) || ignoredFieldsSet.has(strippedName)) {
|
|
89
86
|
continue;
|
|
@@ -92,6 +89,10 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
|
|
|
92
89
|
if (!fieldDef) {
|
|
93
90
|
continue;
|
|
94
91
|
}
|
|
92
|
+
const isCheckbox = fieldDef.type_id === CaseFieldTypeId.Checkbox;
|
|
93
|
+
if (!isCheckbox && (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0))) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
95
96
|
const val = resolveCustomFieldValue(fieldDef, rawValue);
|
|
96
97
|
if (isMarkdownField(fieldDef, val)) {
|
|
97
98
|
const header = fieldDef.label || systemName.replace(/^custom_/, "");
|
|
@@ -45,3 +45,13 @@ export const SYSTEM_FIELDS = [
|
|
|
45
45
|
{ system_name: "refs", label: "References", type: getFieldType(CaseFieldTypeId.String).name, is_required: false, project_scope: { scope: "global" } },
|
|
46
46
|
{ system_name: "labels", label: "Labels", type: getFieldType(CaseFieldTypeId.List).name, is_required: false, project_scope: { scope: "global" }, description: "Use get_labels tool to get available labels/tags." },
|
|
47
47
|
];
|
|
48
|
+
export const READONLY_CASE_FIELDS = [
|
|
49
|
+
"id",
|
|
50
|
+
"suite_id",
|
|
51
|
+
"created_on",
|
|
52
|
+
"updated_on",
|
|
53
|
+
"created_by",
|
|
54
|
+
"updated_by",
|
|
55
|
+
"estimate_forecast",
|
|
56
|
+
"is_deleted",
|
|
57
|
+
];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { CaseFieldTypeId, FIELD_TYPES, getFieldType, SYSTEM_FIELDS } from "./fields.js";
|
|
2
|
+
import { CaseFieldTypeId, FIELD_TYPES, getFieldType, SYSTEM_FIELDS, READONLY_CASE_FIELDS } from "./fields.js";
|
|
3
3
|
import { parseDropdownOptions } from "../../utils/mapper.js";
|
|
4
4
|
import { isActive } from "../../utils/sanitizer.js";
|
|
5
5
|
// Field remains optional for backward compatibility with previous implementation.
|
|
@@ -14,7 +14,7 @@ Example: {"priority_id": 2, "template_id": 1, "labels": [1, 2]}
|
|
|
14
14
|
Call get_case_fields with project_id first if field names are not already known.
|
|
15
15
|
Using an unknown field name (e.g. 'label_ids') will result in an error.
|
|
16
16
|
`;
|
|
17
|
-
export { CaseFieldTypeId, FIELD_TYPES, getFieldType, SYSTEM_FIELDS };
|
|
17
|
+
export { CaseFieldTypeId, FIELD_TYPES, getFieldType, SYSTEM_FIELDS, READONLY_CASE_FIELDS };
|
|
18
18
|
function isFieldRequired(field) {
|
|
19
19
|
return field.configs.some(config => config.options?.is_required === true);
|
|
20
20
|
}
|
|
@@ -11,7 +11,7 @@ const parameters = {
|
|
|
11
11
|
}).optional().describe("Section filter configuration. Use query_section to find available sections"),
|
|
12
12
|
filter: z.record(z.string(), z.string()).optional().describe("Optional API-side filters (more efficient for large datasets). Supported: priority_id, type_id, created_by, updated_by, milestone_id, refs, created_after, created_before, updated_after, updated_before. Use comma-separated values for IDs. Example: {\"priority_id\": \"1,2\", \"type_id\": \"3\"}"),
|
|
13
13
|
where: z.record(z.string(), z.any()).optional().describe("Optional client-side filter for any field including custom fields (filters after fetching all cases). Supports exact value matching. Example: {\"custom_automation_status\": 1, \"priority_id\": 2}"),
|
|
14
|
-
fields: z.array(z.string()).optional().describe("Additional fields to include in response beyond id, title, and suite_id
|
|
14
|
+
fields: z.array(z.string()).optional().describe("Additional fields to include in response beyond id, title, and suite_id (supports system fields such as updated_on, created_on, created_by, updated_by, as well as custom fields). Example: [\"updated_on\", \"priority_id\", \"custom_automation_status\"]"),
|
|
15
15
|
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."),
|
|
16
16
|
};
|
|
17
17
|
export async function fetchFilteredCases(client, options) {
|
|
@@ -34,10 +34,14 @@ export const CaseSchema = z.object({
|
|
|
34
34
|
priority_id: z.number(),
|
|
35
35
|
milestone_id: z.number().nullable(),
|
|
36
36
|
refs: z.string().nullable(),
|
|
37
|
+
created_by: z.number().optional(),
|
|
37
38
|
created_on: z.number(),
|
|
39
|
+
updated_by: z.number().optional(),
|
|
38
40
|
updated_on: z.number(),
|
|
39
41
|
estimate: z.string().nullable(),
|
|
42
|
+
estimate_forecast: z.string().nullable().optional(),
|
|
40
43
|
suite_id: z.number(),
|
|
44
|
+
is_deleted: z.number().optional(),
|
|
41
45
|
labels: z.array(LabelSchema),
|
|
42
46
|
}).loose();
|
|
43
47
|
export const CaseHistoryChangeSchema = z.object({
|
|
@@ -17,7 +17,7 @@ export const updateCaseTool = {
|
|
|
17
17
|
description: description.trim(),
|
|
18
18
|
parameters,
|
|
19
19
|
handler: async ({ case_id, fields }, client) => {
|
|
20
|
-
validateCaseFields(fields, await client.getCaseFields());
|
|
20
|
+
validateCaseFields(fields, await client.getCaseFields(), { allowReadonly: false });
|
|
21
21
|
const id = normalizeEntityId(case_id);
|
|
22
22
|
const updatedCase = await client.updateCase(id, fields);
|
|
23
23
|
return {
|
|
@@ -16,7 +16,7 @@ export const updateCasesTool = {
|
|
|
16
16
|
description: description.trim(),
|
|
17
17
|
parameters,
|
|
18
18
|
handler: async ({ case_ids, fields }, client) => {
|
|
19
|
-
validateCaseFields(fields, await client.getCaseFields());
|
|
19
|
+
validateCaseFields(fields, await client.getCaseFields(), { allowReadonly: false });
|
|
20
20
|
const caseData = await client.getCase(case_ids[0]);
|
|
21
21
|
const updatedCases = await client.updateCases(caseData.suite_id, case_ids, fields);
|
|
22
22
|
return {
|
|
@@ -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
|
];
|
|
@@ -23,14 +23,7 @@ export const StatusSchema = z.object({
|
|
|
23
23
|
name: z.string(),
|
|
24
24
|
label: z.string(),
|
|
25
25
|
});
|
|
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);
|
|
26
|
+
export { TestSchema, TestsSchema } from "../tests/types.js";
|
|
34
27
|
export const UserSchema = z.object({
|
|
35
28
|
id: z.number(),
|
|
36
29
|
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
|
+
});
|
package/dist/utils/mapper.js
CHANGED
|
@@ -23,15 +23,23 @@ export function processCustomFields(testCase, caseFields) {
|
|
|
23
23
|
for (const [key, value] of Object.entries(testCase)) {
|
|
24
24
|
if (!key.startsWith("custom_"))
|
|
25
25
|
continue;
|
|
26
|
-
if (value === null || value === undefined)
|
|
27
|
-
continue;
|
|
28
26
|
if (!fieldNameMap.has(key)) {
|
|
27
|
+
if (value === null || value === undefined)
|
|
28
|
+
continue;
|
|
29
29
|
console.error(`No field mapping found for: ${key}`);
|
|
30
30
|
result[key] = value;
|
|
31
31
|
continue;
|
|
32
32
|
}
|
|
33
|
-
const outputKey = fieldNameMap.get(key);
|
|
34
33
|
const field = applicableFields.find(f => f.system_name === key);
|
|
34
|
+
const isCheckbox = field.type_id === CaseFieldTypeId.Checkbox;
|
|
35
|
+
if (value === null || value === undefined) {
|
|
36
|
+
if (isCheckbox) {
|
|
37
|
+
const outputKey = fieldNameMap.get(key);
|
|
38
|
+
result[outputKey] = resolveCustomFieldValue(field, value);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const outputKey = fieldNameMap.get(key);
|
|
35
43
|
const options = dropdownOptionsMap.get(key);
|
|
36
44
|
result[outputKey] = resolveCustomFieldValue(field, value, options);
|
|
37
45
|
}
|
|
@@ -41,9 +49,16 @@ export function processCustomFields(testCase, caseFields) {
|
|
|
41
49
|
* Resolves a custom field value against its field definition:
|
|
42
50
|
* - Maps dropdown option IDs to their display labels
|
|
43
51
|
* - Normalizes multi-select values into arrays
|
|
52
|
+
* - Normalizes checkbox / boolean values (defaults null/undefined to false)
|
|
44
53
|
* - Sanitizes HTML formatting in strings
|
|
45
54
|
*/
|
|
46
55
|
export function resolveCustomFieldValue(field, value, optionsMap) {
|
|
56
|
+
if (field.type_id === CaseFieldTypeId.Checkbox) {
|
|
57
|
+
if (value === null || value === undefined) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return value === true || value === 1 || value === "1" || value === "true";
|
|
61
|
+
}
|
|
47
62
|
if (value === null || value === undefined) {
|
|
48
63
|
return value;
|
|
49
64
|
}
|
package/dist/utils/sanitizer.js
CHANGED
|
@@ -145,12 +145,15 @@ export function isActive(value) {
|
|
|
145
145
|
return activeFlag === 1 || activeFlag === true || activeFlag === '1' || activeFlag === 'true';
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
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.
|
|
148
|
+
* Normalizes an entity ID (e.g. case ID, test ID, run ID, attachment ID).
|
|
149
|
+
* Handles string IDs with 'C', 'T', or 'R' prefixes (e.g. 'C123' -> 123, 'T456' -> 456), numeric IDs, and trims whitespace.
|
|
150
150
|
*/
|
|
151
151
|
export function normalizeEntityId(id) {
|
|
152
152
|
const idString = typeof id === "string" ? id.trim() : String(id);
|
|
153
|
-
const
|
|
153
|
+
const upper = idString.toUpperCase();
|
|
154
|
+
const cleaned = (upper.startsWith("C") || upper.startsWith("T") || upper.startsWith("R"))
|
|
155
|
+
? idString.substring(1)
|
|
156
|
+
: idString;
|
|
154
157
|
const parsed = Number(cleaned);
|
|
155
158
|
if (isNaN(parsed)) {
|
|
156
159
|
throw new Error(`Invalid entity ID: ${id}`);
|
package/dist/utils/validator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SYSTEM_FIELDS, mapToFieldSchema } from "../tools/cases/get_case_fields.js";
|
|
1
|
+
import { SYSTEM_FIELDS, READONLY_CASE_FIELDS, mapToFieldSchema } from "../tools/cases/get_case_fields.js";
|
|
2
2
|
import { isActive } from "./sanitizer.js";
|
|
3
3
|
const SUITE_MODE_SINGLE_WITH_BASELINES = 2;
|
|
4
4
|
const SUITE_MODE_MULTI = 3;
|
|
@@ -21,16 +21,20 @@ export async function validateSuiteId(client, projectId, suiteId) {
|
|
|
21
21
|
*
|
|
22
22
|
* @param fields A record of fields or array of field names to validate
|
|
23
23
|
* @param caseFields Available custom case fields from TestRail
|
|
24
|
+
* @param options Validation options (e.g. allowReadonly)
|
|
24
25
|
*/
|
|
25
|
-
export function validateCaseFields(fields, caseFields) {
|
|
26
|
+
export function validateCaseFields(fields, caseFields, options = { allowReadonly: true }) {
|
|
26
27
|
const fieldKeys = Array.isArray(fields) ? fields : Object.keys(fields);
|
|
27
28
|
if (fieldKeys.length === 0) {
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
31
|
const customFieldSchemas = caseFields.filter(isActive).map(mapToFieldSchema);
|
|
31
32
|
const validFieldNames = new Set([...SYSTEM_FIELDS, ...customFieldSchemas].map(f => f.system_name));
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
if (options.allowReadonly !== false) {
|
|
34
|
+
for (const field of READONLY_CASE_FIELDS) {
|
|
35
|
+
validFieldNames.add(field);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
34
38
|
const invalidFields = [];
|
|
35
39
|
for (const key of fieldKeys) {
|
|
36
40
|
if (!validFieldNames.has(key)) {
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "3.
|
|
1
|
+
export const VERSION = "3.2.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uarlouski/testrail-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.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,7 +59,7 @@
|
|
|
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
65
|
"@modelcontextprotocol/sdk": "^1.30.0",
|