@uarlouski/testrail-mcp-server 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/testrail.js +68 -42
- package/dist/index.js +11 -6
- package/dist/tools/create_case.js +4 -0
- package/dist/tools/get_case_fields.js +2 -2
- package/dist/tools/get_cases.js +8 -0
- package/dist/tools/update_case.js +2 -0
- package/dist/tools/update_cases.js +2 -0
- package/dist/utils/validator.js +29 -0
- package/package.json +1 -1
package/dist/client/testrail.js
CHANGED
|
@@ -50,17 +50,7 @@ export class TestRailClient {
|
|
|
50
50
|
url += `&${key}=${encodeURIComponent(value)}`;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
let nextUrl = url;
|
|
55
|
-
while (nextUrl) {
|
|
56
|
-
const response = await this.get(nextUrl);
|
|
57
|
-
allCases.push(...response.cases);
|
|
58
|
-
nextUrl = response._links?.next;
|
|
59
|
-
if (nextUrl) {
|
|
60
|
-
nextUrl = `${API_INDEX}${nextUrl}`;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return allCases;
|
|
53
|
+
return this.paginateAll(url, 'cases');
|
|
64
54
|
}
|
|
65
55
|
async getSection(sectionId) {
|
|
66
56
|
return this.get(`${API_BASE_V2}/get_section/${sectionId}`);
|
|
@@ -112,8 +102,8 @@ export class TestRailClient {
|
|
|
112
102
|
return this.post(`${API_BASE_V2}/add_run/${projectId}`, fields);
|
|
113
103
|
}
|
|
114
104
|
async getSections(projectId) {
|
|
115
|
-
const
|
|
116
|
-
return
|
|
105
|
+
const url = `${API_BASE_V2}/get_sections/${projectId}`;
|
|
106
|
+
return this.paginateAll(url, 'sections');
|
|
117
107
|
}
|
|
118
108
|
async getProjects() {
|
|
119
109
|
if (!this.projectsPromise) {
|
|
@@ -127,17 +117,7 @@ export class TestRailClient {
|
|
|
127
117
|
if (statusId && statusId.length > 0) {
|
|
128
118
|
url += `&status_id=${statusId.join(',')}`;
|
|
129
119
|
}
|
|
130
|
-
|
|
131
|
-
let nextUrl = url;
|
|
132
|
-
while (nextUrl) {
|
|
133
|
-
const response = await this.get(nextUrl);
|
|
134
|
-
allTests.push(...response.tests);
|
|
135
|
-
nextUrl = response._links?.next;
|
|
136
|
-
if (nextUrl) {
|
|
137
|
-
nextUrl = `${API_INDEX}${nextUrl}`;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return allTests;
|
|
120
|
+
return this.paginateAll(url, 'tests');
|
|
141
121
|
}
|
|
142
122
|
async addResults(runId, results) {
|
|
143
123
|
return this.post(`${API_BASE_V2}/add_results/${runId}`, { results });
|
|
@@ -152,6 +132,21 @@ export class TestRailClient {
|
|
|
152
132
|
};
|
|
153
133
|
return this._executeRequest('POST', `${API_BASE_V2}/add_attachment_to_run/${runId}`, headers, formData);
|
|
154
134
|
}
|
|
135
|
+
async paginateAll(url, dataKey) {
|
|
136
|
+
const allItems = [];
|
|
137
|
+
let nextUrl = url;
|
|
138
|
+
while (nextUrl) {
|
|
139
|
+
const response = await this.get(nextUrl);
|
|
140
|
+
if (response[dataKey] && Array.isArray(response[dataKey])) {
|
|
141
|
+
allItems.push(...response[dataKey]);
|
|
142
|
+
}
|
|
143
|
+
nextUrl = response._links?.next || null;
|
|
144
|
+
if (nextUrl) {
|
|
145
|
+
nextUrl = `${API_INDEX}${nextUrl}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return allItems;
|
|
149
|
+
}
|
|
155
150
|
async get(endpoint) {
|
|
156
151
|
return this.executeRequest('GET', endpoint);
|
|
157
152
|
}
|
|
@@ -172,26 +167,57 @@ export class TestRailClient {
|
|
|
172
167
|
headers,
|
|
173
168
|
body,
|
|
174
169
|
};
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
170
|
+
const maxRetries = 3;
|
|
171
|
+
const baseDelayMs = 1000;
|
|
172
|
+
let attempt = 0;
|
|
173
|
+
while (attempt <= maxRetries) {
|
|
174
|
+
console.error(`[TestRailClient] Executing ${method} request to ${endpoint}${attempt > 0 ? ` (Attempt ${attempt + 1})` : ''}`);
|
|
175
|
+
const startTime = Date.now();
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(url, params);
|
|
178
|
+
const duration = Date.now() - startTime;
|
|
179
|
+
console.error(`[TestRailClient] Received ${response.status} ${response.statusText} from ${method} ${endpoint} in ${duration}ms`);
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
if ([429, 500, 502, 503, 504].includes(response.status) && attempt < maxRetries) {
|
|
182
|
+
let delayMs = baseDelayMs * Math.pow(2, attempt);
|
|
183
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
184
|
+
if (retryAfter) {
|
|
185
|
+
const retryAfterSeconds = parseInt(retryAfter);
|
|
186
|
+
if (!isNaN(retryAfterSeconds)) {
|
|
187
|
+
delayMs = retryAfterSeconds * 1000;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
console.error(`[TestRailClient] Rate limited or server error (${response.status}). Retrying in ${delayMs}ms...`);
|
|
191
|
+
await this.delay(delayMs);
|
|
192
|
+
attempt++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
let errorMessage = `TestRail API Error: ${response.status} ${response.statusText}`;
|
|
196
|
+
const errorText = await response.text();
|
|
197
|
+
errorMessage += ` - ${errorText}`;
|
|
198
|
+
console.error(`[TestRailClient] Error Details: ${errorMessage}`);
|
|
199
|
+
throw new Error(errorMessage);
|
|
200
|
+
}
|
|
201
|
+
return await response.json();
|
|
187
202
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
203
|
+
catch (error) {
|
|
204
|
+
const networkError = error instanceof Error && !error.message.startsWith('TestRail API Error');
|
|
205
|
+
if (networkError && attempt < maxRetries) {
|
|
206
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt);
|
|
207
|
+
console.error(`[TestRailClient] Network error (${error.message}). Retrying in ${delayMs}ms...`);
|
|
208
|
+
await this.delay(delayMs);
|
|
209
|
+
attempt++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (networkError) {
|
|
213
|
+
console.error(`[TestRailClient] Request Failed: ${method} ${endpoint} - ${error.message}`);
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
193
216
|
}
|
|
194
|
-
throw error;
|
|
195
217
|
}
|
|
218
|
+
throw new Error(`[TestRailClient] Max retries (${maxRetries}) exceeded for ${method} ${endpoint}`);
|
|
219
|
+
}
|
|
220
|
+
delay(ms) {
|
|
221
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
196
222
|
}
|
|
197
223
|
}
|
package/dist/index.js
CHANGED
|
@@ -18,16 +18,21 @@ import { getTestsTool } from "./tools/get_tests.js";
|
|
|
18
18
|
import { addResultsTool } from "./tools/add_results.js";
|
|
19
19
|
import { addAttachmentToRunTool } from "./tools/add_attachment_to_run.js";
|
|
20
20
|
import { removeNullish } from "./utils/sanitizer.js";
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
import z from "zod";
|
|
22
|
+
const EnvSchema = z.object({
|
|
23
|
+
TESTRAIL_INSTANCE_URL: z.url('Must be a valid TestRail URL'),
|
|
24
|
+
TESTRAIL_USERNAME: z.email('Must be a valid email address'),
|
|
25
|
+
TESTRAIL_API_KEY: z.string().min(1, 'API key is required')
|
|
26
|
+
});
|
|
27
|
+
const parseResult = EnvSchema.safeParse(process.env);
|
|
28
|
+
if (!parseResult.success) {
|
|
29
|
+
console.error("Invalid TestRail environment configuration:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
|
|
26
30
|
process.exit(1);
|
|
27
31
|
}
|
|
32
|
+
const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY } = parseResult.data;
|
|
28
33
|
const server = new McpServer({
|
|
29
34
|
name: "TestRail MCP Server",
|
|
30
|
-
version: "1.
|
|
35
|
+
version: "1.6.0",
|
|
31
36
|
});
|
|
32
37
|
const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
|
|
33
38
|
const tools = [
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { validateCaseFields } from "../utils/validator.js";
|
|
2
3
|
const parameters = {
|
|
3
4
|
section_id: z.number().describe("The ID of the section where the case should be created. Use get_sections to find available sections"),
|
|
4
5
|
title: z.string().describe("The title of the test case"),
|
|
@@ -9,6 +10,9 @@ export const createCaseTool = {
|
|
|
9
10
|
description: "Create a new test case in TestRail",
|
|
10
11
|
parameters,
|
|
11
12
|
handler: async ({ section_id, title, fields }, client) => {
|
|
13
|
+
if (fields) {
|
|
14
|
+
validateCaseFields(fields, await client.getCaseFields());
|
|
15
|
+
}
|
|
12
16
|
const caseData = {
|
|
13
17
|
title,
|
|
14
18
|
...fields,
|
|
@@ -21,7 +21,7 @@ function getFieldTypeName(typeId) {
|
|
|
21
21
|
function isFieldRequired(field) {
|
|
22
22
|
return field.configs.some(config => config.options?.is_required === true);
|
|
23
23
|
}
|
|
24
|
-
function mapToFieldSchema(field) {
|
|
24
|
+
export function mapToFieldSchema(field) {
|
|
25
25
|
const schema = {
|
|
26
26
|
system_name: field.system_name,
|
|
27
27
|
label: field.label,
|
|
@@ -36,7 +36,7 @@ function mapToFieldSchema(field) {
|
|
|
36
36
|
}
|
|
37
37
|
return schema;
|
|
38
38
|
}
|
|
39
|
-
const SYSTEM_FIELDS = [
|
|
39
|
+
export const SYSTEM_FIELDS = [
|
|
40
40
|
{ system_name: "title", label: "Title", type: "String", is_required: true },
|
|
41
41
|
{ system_name: "section_id", label: "Section", type: "Integer", is_required: true },
|
|
42
42
|
{ system_name: "template_id", label: "Template", type: "Integer", is_required: false },
|
package/dist/tools/get_cases.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { validateCaseFields } from "../utils/validator.js";
|
|
2
3
|
const parameters = {
|
|
3
4
|
project_id: z.number().describe("The ID of the project. Use get_projects to find available projects"),
|
|
4
5
|
section: z.object({
|
|
@@ -15,6 +16,13 @@ export const getCasesTool = {
|
|
|
15
16
|
description: "Get all test cases for a project. Filter by section, API params (priority, type), or any field including custom fields via 'where'. Returns case IDs, titles, and any additional requested fields.",
|
|
16
17
|
parameters,
|
|
17
18
|
handler: async ({ project_id, section, filter, where, fields }, client) => {
|
|
19
|
+
const caseFields = await client.getCaseFields();
|
|
20
|
+
if (fields) {
|
|
21
|
+
validateCaseFields(fields, caseFields);
|
|
22
|
+
}
|
|
23
|
+
if (where) {
|
|
24
|
+
validateCaseFields(Object.keys(where), caseFields);
|
|
25
|
+
}
|
|
18
26
|
let cases;
|
|
19
27
|
if (section?.recursive) {
|
|
20
28
|
cases = await client.getCasesRecursively(project_id, section.id, filter, section.excludes);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { validateCaseFields } from "../utils/validator.js";
|
|
2
3
|
const parameters = {
|
|
3
4
|
case_id: z.string().describe("The ID of the test case to update (e.g. '123' or 'C123')"),
|
|
4
5
|
fields: z.record(z.string(), z.any()).describe("Object containing fields to update. Use get_case_fields to see available fields. Example: {\"title\": \"New title\", \"priority_id\": 2, \"custom_automation_priority\": 1}"),
|
|
@@ -8,6 +9,7 @@ export const updateCaseTool = {
|
|
|
8
9
|
description: "Update a test case in TestRail. Supports partial updates - only specify the fields you want to change",
|
|
9
10
|
parameters,
|
|
10
11
|
handler: async ({ case_id, fields }, client) => {
|
|
12
|
+
validateCaseFields(fields, await client.getCaseFields());
|
|
11
13
|
const idString = case_id.toUpperCase().startsWith("C") ? case_id.substring(1) : case_id;
|
|
12
14
|
const id = Number(idString);
|
|
13
15
|
const updatedCase = await client.updateCase(id, fields);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { validateCaseFields } from "../utils/validator.js";
|
|
2
3
|
const parameters = {
|
|
3
4
|
case_ids: z.array(z.number()).min(1).describe("Array of case IDs to update (e.g. [123, 456, 789])"),
|
|
4
5
|
fields: z.record(z.string(), z.any()).describe("Object containing fields to update for ALL specified cases. Use get_case_fields to see available fields. Example: {\"priority_id\": 2, \"type_id\": 1}"),
|
|
@@ -8,6 +9,7 @@ export const updateCasesTool = {
|
|
|
8
9
|
description: "Bulk update multiple test cases with the same field values. More efficient than calling update_case multiple times",
|
|
9
10
|
parameters,
|
|
10
11
|
handler: async ({ case_ids, fields }, client) => {
|
|
12
|
+
validateCaseFields(fields, await client.getCaseFields());
|
|
11
13
|
const caseData = await client.getCase(case_ids[0]);
|
|
12
14
|
const updatedCases = await client.updateCases(caseData.suite_id, case_ids, fields);
|
|
13
15
|
return {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { SYSTEM_FIELDS, mapToFieldSchema } from "../tools/get_case_fields.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validates that the provided fields exist in the TestRail case schema.
|
|
4
|
+
* Throws an Error if any field is invalid.
|
|
5
|
+
*
|
|
6
|
+
* @param fields A record of fields or array of field names to validate
|
|
7
|
+
* @param caseFields Available custom case fields from TestRail
|
|
8
|
+
*/
|
|
9
|
+
export function validateCaseFields(fields, caseFields) {
|
|
10
|
+
const fieldKeys = Array.isArray(fields) ? fields : Object.keys(fields);
|
|
11
|
+
if (fieldKeys.length === 0) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const customFieldSchemas = caseFields.filter(field => field.is_active).map(mapToFieldSchema);
|
|
15
|
+
const validFieldNames = new Set([...SYSTEM_FIELDS, ...customFieldSchemas].map(f => f.system_name));
|
|
16
|
+
validFieldNames.add('id');
|
|
17
|
+
validFieldNames.add('suite_id');
|
|
18
|
+
const invalidFields = [];
|
|
19
|
+
for (const key of fieldKeys) {
|
|
20
|
+
if (!validFieldNames.has(key)) {
|
|
21
|
+
invalidFields.push(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (invalidFields.length > 0) {
|
|
25
|
+
const validKeysList = Array.from(validFieldNames).sort().join(', ');
|
|
26
|
+
const invalidFieldsList = invalidFields.map(f => `'${f}'`).join(', ');
|
|
27
|
+
throw new Error(`Invalid fields provided: ${invalidFieldsList}. Available fields are: ${validKeysList}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/package.json
CHANGED