@uarlouski/testrail-mcp-server 1.4.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.
@@ -50,17 +50,7 @@ export class TestRailClient {
50
50
  url += `&${key}=${encodeURIComponent(value)}`;
51
51
  }
52
52
  }
53
- const allCases = [];
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 response = await this.get(`${API_BASE_V2}/get_sections/${projectId}`);
116
- return response.sections;
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
- const allTests = [];
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,13 +167,57 @@ export class TestRailClient {
172
167
  headers,
173
168
  body,
174
169
  };
175
- const response = await fetch(url, params);
176
- if (!response.ok) {
177
- let errorMessage = `TestRail: ${response.status} ${response.statusText}`;
178
- const errorText = await response.text();
179
- errorMessage += ` - ${errorText}`;
180
- throw new Error(errorMessage);
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();
202
+ }
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;
216
+ }
181
217
  }
182
- return await response.json();
218
+ throw new Error(`[TestRailClient] Max retries (${maxRetries}) exceeded for ${method} ${endpoint}`);
219
+ }
220
+ delay(ms) {
221
+ return new Promise(resolve => setTimeout(resolve, ms));
183
222
  }
184
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
- const TESTRAIL_INSTANCE_URL = process.env.TESTRAIL_INSTANCE_URL;
22
- const TESTRAIL_USERNAME = process.env.TESTRAIL_USERNAME;
23
- const TESTRAIL_API_KEY = process.env.TESTRAIL_API_KEY;
24
- if (!TESTRAIL_INSTANCE_URL || !TESTRAIL_USERNAME || !TESTRAIL_API_KEY) {
25
- console.error("Error: TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, and TESTRAIL_API_KEY environment variables are required.");
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.4.0",
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 = [
@@ -4,7 +4,7 @@ const parameters = {
4
4
  project_id: z.number().describe("The ID of the project. Use get_projects to find available projects"),
5
5
  name: z.string().optional().describe("The name of the test run"),
6
6
  description: z.string().optional().describe("The description of the test run"),
7
- case_ids: z.array(z.number()).describe("Array of case IDs to include in the run. After creating the run, use get_tests with the returned run_id to retrieve test IDs for result submission")
7
+ case_ids: z.array(z.number()).min(1).describe("Array of case IDs to include in the run. After creating the run, use get_tests with the returned run_id to retrieve test IDs for result submission")
8
8
  };
9
9
  export const addRunTool = {
10
10
  name: "add_run",
@@ -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 },
@@ -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,6 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { validateCaseFields } from "../utils/validator.js";
2
3
  const parameters = {
3
- case_ids: z.array(z.number()).describe("Array of case IDs to update (e.g. [123, 456, 789])"),
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}"),
5
6
  };
6
7
  export const updateCasesTool = {
@@ -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 {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Recursively ыtrips inline style attributes from HTML tags contained in string values
2
+ * Recursively strips inline style attributes from HTML tags contained in string values
3
3
  * in an object or array. Useful for cleaning up TestRail markdown content before sending
4
4
  * to LLMs.
5
5
  */
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uarlouski/testrail-mcp-server",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Model Context Protocol (MCP) server for TestRail integration, enabling LLMs to fetch test cases and metadata.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {