@uarlouski/testrail-mcp-server 2.2.0 → 2.4.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 CHANGED
@@ -36,6 +36,9 @@ Managing test cases manually is tedious and error-prone. With the **TestRail MCP
36
36
 
37
37
  **No context switching. No tedious copy-pasting. Just ask your AI.**
38
38
 
39
+ > [!NOTE]
40
+ > **Compatibility Baseline**: The primary baseline version this MCP server is tested and validated against is **TestRail 10.6.2** (API v2). Older TestRail instances (including pre-7.x pagination) are also supported via built-in backward compatibility.
41
+
39
42
  ## ✨ Key Features & Capabilities
40
43
 
41
44
  | Capability | Description |
@@ -147,72 +150,37 @@ Once configured, turbo-charge your QA workflow by asking your AI assistant:
147
150
  | `TESTRAIL_USERNAME` | Your TestRail user email address | ✅ | |
148
151
  | `TESTRAIL_API_KEY` | Your TestRail API key ([Guide](https://support.testrail.com/hc/en-us/articles/7077039051412-Accessing-the-TestRail-API)) | ✅ | |
149
152
  | `TESTRAIL_ENABLE_SHARED_STEPS` | Set to `true` to enable Shared Steps management tools | | `false` |
153
+ | `TESTRAIL_ENABLE_CASE_HISTORY` | Set to `true` to enable Case History and revision tracking tools | | `false` |
150
154
  | `TESTRAIL_ALLOW_WRITE_OPERATIONS` | Allow write operations (e.g. adding/updating test cases, test runs, sections) | | `true` |
151
155
  | `TESTRAIL_ALLOW_READ_OPERATIONS` | Allow read operations (e.g. retrieving projects, test cases, templates) | | `true` |
152
156
  | `TESTRAIL_ALLOW_DELETE_OPERATIONS` | Allow delete operations (e.g. deleting cases or shared steps). Enabled strictly via `true`. | | `false` |
157
+ | `TESTRAIL_ENABLE_DEPRECATED_TOOLS` | Enable deprecated tools for backward compatibility. Set to `false` to reduce context token overhead. | | `true` |
158
+
159
+ ### ⚠️ Deprecation Lifecycle & Features Scheduled for Removal
160
+
161
+ To ensure smooth transitions, deprecated tools remain available by default (`TESTRAIL_ENABLE_DEPRECATED_TOOLS=true`) and will be removed in future major releases:
162
+
163
+ | Deprecated Tool | Replacement | Status |
164
+ |-----------------|-------------|--------|
165
+ | `add_attachment_to_run` | `add_attachment` (`entity_type: "case" \| "run"`) | Deprecated in `2.3.0`, scheduled for removal in `3.0.0` |
166
+
167
+ > **💡 Token Tip**: If you are not using legacy tools, set `TESTRAIL_ENABLE_DEPRECATED_TOOLS=false` in your environment to eliminate deprecated tool definitions from the LLM prompt and save tokens!
153
168
 
154
169
  ---
155
170
 
156
- ## 🛠️ Complete Tool Reference
157
-
158
- The TestRail MCP Server provides heavily typed, descriptive tools designed specifically for LLM interaction:
159
-
160
- > **💡 Pro Tip: Handling Large Data Sets & Permissions**
161
- > - **Large Data Sets**: For list-based tools like `get_cases` and `get_sections`, you can supply the `output_file` parameter with an absolute file path. The server will bypass the LLM context window by writing the raw JSON directly to disk and returning a concise summary, allowing you to run external scripts against the export!
162
- > - **Intelligent User Fallback**: When fetching users using `get_users` without admin rights, TestRail denies access globally. The server dynamically catches this permission restriction and falls back to polling all active projects in parallel, retrieving and deduplicating active users so assignees and reviewers can always be mapped.
163
-
164
- ### 🔭 Discovery & Navigation
165
- | Tool | Functionality |
166
- |------|-------------|
167
- | `query_project` | Retrieve a single project (`action: "one"`) or all active projects (`action: "many"`). |
168
- | `query_suite` | Retrieve a single test suite (`action: "one"`) or all test suites for a project (`action: "many"`). |
169
- | `get_sections` | Navigate the precise folder/section hierarchy of any test suite. |
170
- | `mutate_suite` | Create a new test suite or update an existing one in TestRail. |
171
- | `mutate_section` | Create a new section or update an existing section in TestRail. |
172
- | `get_users` | Retrieve active users. Handles per-project filtering and automatic fallback polling for non-admin accounts. |
173
-
174
- ### 📋 Test Case Management
175
- | Tool | Functionality |
176
- |------|-------------|
177
- | `get_cases` | Query test cases with advanced filtering (priority, template, type, etc.) and pagination. |
178
- | `get_case` | Fetch complete, structured details of a specific test case, including custom steps and fields. |
179
- | `add_case` | Seamlessly create a new test case equipped with robust custom field validation. |
180
- | `update_case` | Modify an existing test case's steps, metadata, or titles. |
181
- | `update_cases` | Execute bulk-updates on multiple test cases simultaneously to save time. |
182
-
183
- ### ▶️ Test Execution & Tracking
184
- | Tool | Functionality |
185
- |------|-------------|
186
- | `query_run` | Retrieve a single test run (`action: "one"`) or all test runs for a project (`action: "many"`, supporting filters). |
187
- | `mutate_run` | Create a new test run or update an existing one in TestRail. |
188
- | `get_tests` | Retrieve individual tests for a specific test run, with optional status filtering. |
189
- | `get_results` | Retrieve paginated results for a specific test ID. |
190
- | `add_results` | Submit test results to a test run using the specific `test_id`. |
191
- | `add_results_for_cases` | Submits results to a run mapping directly to `case_id`s, streamlining automation workflows. |
192
- | `add_attachment_to_run` | Attach logs, files, or zipped artifacts directly to an ongoing test run. |
193
-
194
- ### 🔗 Shared Steps (Optional)
195
- | Tool | Functionality |
196
- |------|-------------|
197
- | `get_shared_steps` | List shared test steps for a project with optional reference filtering. |
198
- | `get_shared_step` | Retrieve precise details of a specific shared test step set. |
199
- | `get_shared_step_history` | View the complete audit trail and version history of a shared step set. |
200
- | `add_shared_step` | Create a new reusable set of shared test steps. |
201
- | `update_shared_step` | Modify an existing shared step set (changes propagate to all linked test cases). |
202
-
203
- ### 🗑️ Deletion
204
- | Tool | Functionality |
205
- |------|-------------|
206
- | `delete_entity` | Deletes a specified TestRail entity (supports `"case"` or `"shared_step"`) by its ID. Requires `TESTRAIL_ALLOW_DELETE_OPERATIONS` to be set to `true` in your environment. |
207
-
208
- ### 🧠 System Metadata
209
- | Tool | Functionality |
210
- |------|-------------|
211
- | `get_statuses` | Systematically list all configured test statuses (Passed, Failed, Blocked, Retest, etc.). |
212
- | `get_priorities` | Retrieve priority levels configured within your instance structure. |
213
- | `get_case_fields` | Discover custom field definitions, formats, and UI dropdown options. Supports filtering by `project_id`. |
214
- | `get_templates` | Identify available case templates to mandate correct AI structuring. |
215
- | `get_configurations` | Retrieve all configuration groups and configurations for a project. |
171
+ ## 📚 Documentation & Complete Tool Reference
172
+
173
+ For a comprehensive guide, detailed configuration options, and a complete breakdown of all available tools, please visit our official documentation site:
174
+
175
+ **👉 [TestRail MCP Server Documentation](https://uarlouski.github.io/testrail-mcp-server/)**
176
+
177
+ The documentation includes detailed explanations for:
178
+ - 🔭 **Discovery & Navigation**: Exploring projects, suites, and sections.
179
+ - 📋 **Test Case Management**: Fetching, creating, and bulk-updating test cases.
180
+ - ▶️ **Execution & Tracking**: Managing test runs and submitting test results.
181
+ - 📎 **Attachments**: Automatically zipping and uploading files or directories.
182
+ - 🔗 **Shared Steps**: Managing reusable step definitions.
183
+
216
184
 
217
185
  ---
218
186
 
@@ -1,5 +1,6 @@
1
1
  import { isActive } from "../utils/sanitizer.js";
2
2
  import * as fs from "fs";
3
+ import * as path from "path";
3
4
  const API_INDEX = '/index.php?';
4
5
  const API_BASE_V2 = `${API_INDEX}/api/v2`;
5
6
  export class TestRailClient {
@@ -24,6 +25,9 @@ export class TestRailClient {
24
25
  async getCase(caseId) {
25
26
  return this.get(`${API_BASE_V2}/get_case/${caseId}`);
26
27
  }
28
+ async getCaseHistory(caseId) {
29
+ return this.paginateAll(`${API_BASE_V2}/get_history_for_case/${caseId}`, 'history');
30
+ }
27
31
  async getCasesRecursively(projectId, sectionId, filter, excludedSectionNames) {
28
32
  // Extract suite_id from filter if present, for getSections call
29
33
  const suiteId = filter?.suite_id ? parseInt(filter.suite_id) : undefined;
@@ -178,7 +182,7 @@ export class TestRailClient {
178
182
  async addResultsForCases(runId, results) {
179
183
  return this.post(`${API_BASE_V2}/add_results_for_cases/${runId}`, { results });
180
184
  }
181
- async addAttachmentToRun(runId, filePath, filename) {
185
+ async addAttachment(entityType, entityId, filePath, filename) {
182
186
  const fileBuffer = await fs.promises.readFile(filePath);
183
187
  const blob = new Blob([fileBuffer]);
184
188
  const formData = new FormData();
@@ -186,7 +190,11 @@ export class TestRailClient {
186
190
  const headers = {
187
191
  'Authorization': this.auth,
188
192
  };
189
- return this._executeRequest('POST', `${API_BASE_V2}/add_attachment_to_run/${runId}`, headers, formData);
193
+ return this._executeRequest('POST', `${API_BASE_V2}/add_attachment_to_${entityType}/${entityId}`, headers, formData);
194
+ }
195
+ async getAttachments(entityType, entityId) {
196
+ const url = `${API_BASE_V2}/get_attachments_for_${entityType}/${entityId}`;
197
+ return this.paginateAll(url, 'attachments');
190
198
  }
191
199
  async getSharedSteps(projectId, options) {
192
200
  let url = `${API_BASE_V2}/get_shared_steps/${projectId}`;
@@ -207,11 +215,26 @@ export class TestRailClient {
207
215
  async updateSharedStep(sharedStepId, data) {
208
216
  return this.post(`${API_BASE_V2}/update_shared_step/${sharedStepId}`, data);
209
217
  }
218
+ async getAttachment(attachmentId, outputFilePath) {
219
+ const dir = path.dirname(outputFilePath);
220
+ if (dir && !fs.existsSync(dir)) {
221
+ await fs.promises.mkdir(dir, { recursive: true });
222
+ }
223
+ const buffer = await this._executeRequest('GET', `${API_BASE_V2}/get_attachment/${attachmentId}`, this.headers, undefined, 'buffer');
224
+ await fs.promises.writeFile(outputFilePath, buffer);
225
+ return {
226
+ file: outputFilePath,
227
+ size: buffer.byteLength,
228
+ };
229
+ }
210
230
  async deleteCase(caseId) {
211
- return this._executeRequest('POST', `${API_BASE_V2}/delete_case/${caseId}`, this.headers, JSON.stringify({}), false);
231
+ return this._executeRequest('POST', `${API_BASE_V2}/delete_case/${caseId}`, this.headers, JSON.stringify({}), 'text');
212
232
  }
213
233
  async deleteSharedStep(sharedStepId) {
214
- return this._executeRequest('POST', `${API_BASE_V2}/delete_shared_step/${sharedStepId}`, this.headers, JSON.stringify({}), false);
234
+ return this._executeRequest('POST', `${API_BASE_V2}/delete_shared_step/${sharedStepId}`, this.headers, JSON.stringify({}), 'text');
235
+ }
236
+ async deleteAttachment(attachmentId) {
237
+ return this._executeRequest('POST', `${API_BASE_V2}/delete_attachment/${attachmentId}`, this.headers, JSON.stringify({}), 'text');
215
238
  }
216
239
  async getUsers(projectId) {
217
240
  const url = projectId
@@ -254,7 +277,7 @@ export class TestRailClient {
254
277
  }
255
278
  return this._executeRequest(method, endpoint, this.headers, jsonData);
256
279
  }
257
- async _executeRequest(method, endpoint, headers, body, parseBody = true) {
280
+ async _executeRequest(method, endpoint, headers, body, responseType = 'json') {
258
281
  const url = `${this.baseUrl}${endpoint}`;
259
282
  const params = {
260
283
  method,
@@ -292,9 +315,13 @@ export class TestRailClient {
292
315
  console.error(`[TestRailClient] Error Details: ${errorMessage}`);
293
316
  throw new Error(errorMessage);
294
317
  }
295
- if (parseBody) {
318
+ if (responseType === 'json') {
296
319
  return await response.json();
297
320
  }
321
+ else if (responseType === 'buffer') {
322
+ const arrayBuffer = await response.arrayBuffer();
323
+ return Buffer.from(arrayBuffer);
324
+ }
298
325
  return await response.text();
299
326
  }
300
327
  catch (error) {
package/dist/index.js CHANGED
@@ -11,26 +11,30 @@ const EnvSchema = z.object({
11
11
  TESTRAIL_USERNAME: z.email('Must be a valid email address'),
12
12
  TESTRAIL_API_KEY: z.string().min(1, 'API key is required'),
13
13
  TESTRAIL_ENABLE_SHARED_STEPS: z.string().optional().transform(val => val === 'true'),
14
+ TESTRAIL_ENABLE_CASE_HISTORY: z.string().optional().transform(val => val === 'true'),
14
15
  TESTRAIL_ALLOW_WRITE_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
15
16
  TESTRAIL_ALLOW_READ_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
16
- TESTRAIL_ALLOW_DELETE_OPERATIONS: z.string().optional().transform(val => val === 'true')
17
+ TESTRAIL_ALLOW_DELETE_OPERATIONS: z.string().optional().transform(val => val === 'true'),
18
+ TESTRAIL_ENABLE_DEPRECATED_TOOLS: z.string().optional().transform(val => val === undefined ? true : val === 'true')
17
19
  });
18
20
  const parseResult = EnvSchema.safeParse(process.env);
19
21
  if (!parseResult.success) {
20
22
  console.error("Invalid TestRail environment configuration:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
21
23
  process.exit(1);
22
24
  }
23
- const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS } = parseResult.data;
25
+ const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ENABLE_CASE_HISTORY, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS, TESTRAIL_ENABLE_DEPRECATED_TOOLS } = parseResult.data;
24
26
  const server = new McpServer({
25
27
  name: "TestRail MCP Server",
26
- version: "2.2.0",
28
+ version: "2.4.0",
27
29
  });
28
30
  const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
29
31
  const tools = getToolsToRegister({
30
32
  enableSharedSteps: TESTRAIL_ENABLE_SHARED_STEPS,
33
+ enableCaseHistory: TESTRAIL_ENABLE_CASE_HISTORY,
31
34
  allowWrite: TESTRAIL_ALLOW_WRITE_OPERATIONS,
32
35
  allowRead: TESTRAIL_ALLOW_READ_OPERATIONS,
33
- allowDelete: TESTRAIL_ALLOW_DELETE_OPERATIONS
36
+ allowDelete: TESTRAIL_ALLOW_DELETE_OPERATIONS,
37
+ enableDeprecatedTools: TESTRAIL_ENABLE_DEPRECATED_TOOLS
34
38
  });
35
39
  for (const tool of tools) {
36
40
  server.registerTool(tool.name, {
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+ import { AttachmentSchema } from "./types.js";
3
+ import { normalizeEntityId, prepareUploadFile } from "../../utils/attachment_helper.js";
4
+ const parameters = {
5
+ entity_type: z.enum(["case", "run"]).describe("The type of entity to attach the file to ('case' or 'run')"),
6
+ entity_id: z.union([z.number(), z.string()]).describe("The ID of the entity to attach the file to (e.g. 123 or 'C123' for cases, or numeric run ID)"),
7
+ file_path: z.string().describe("The path to the file or directory to attach. Directories will be automatically zipped."),
8
+ };
9
+ const description = `
10
+ Add an attachment to a test case or test run in TestRail.
11
+ If the file_path points to a directory, it will be automatically zipped before uploading.
12
+ Maximum upload size is 256MB.
13
+ `;
14
+ export const addAttachmentTool = {
15
+ name: "add_attachment",
16
+ mode: "write",
17
+ description: description.trim(),
18
+ parameters,
19
+ handler: async ({ entity_type, entity_id, file_path }, client) => {
20
+ const normalizedId = normalizeEntityId(entity_id);
21
+ return prepareUploadFile(file_path, async (uploadPath, filename) => {
22
+ const result = await client.addAttachment(entity_type, normalizedId, uploadPath, filename);
23
+ return AttachmentSchema.parse(result);
24
+ });
25
+ },
26
+ };
@@ -0,0 +1,6 @@
1
+ import { addAttachmentTool } from "./add_attachment.js";
2
+ import { queryAttachmentTool } from "./query_attachment.js";
3
+ export const attachmentsTools = [
4
+ addAttachmentTool,
5
+ queryAttachmentTool,
6
+ ];
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import * as fs from "fs";
3
+ import { AttachmentItemSchema, GetOneAttachmentSchema, GetManyAttachmentsSchema } from "./types.js";
4
+ import { normalizeEntityId } from "../../utils/attachment_helper.js";
5
+ import { handleQuery } from "../../utils/query_handler.js";
6
+ const parameters = {
7
+ payload: z.discriminatedUnion("action", [
8
+ GetOneAttachmentSchema,
9
+ GetManyAttachmentsSchema,
10
+ ]).describe("The payload containing the action ('one' or 'many') and corresponding parameters"),
11
+ };
12
+ export const queryAttachmentTool = {
13
+ name: "query_attachment",
14
+ mode: "read",
15
+ description: "Download a single attachment or list all attachments for a test case or 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 downloadResult = await client.getAttachment(p.attachment_id, p.output_file);
20
+ return {
21
+ success: true,
22
+ message: `Successfully downloaded attachment ${p.attachment_id} (${downloadResult.size} bytes) to ${downloadResult.file}`,
23
+ file: downloadResult.file,
24
+ size: downloadResult.size,
25
+ };
26
+ }, async (p) => {
27
+ const normalizedId = normalizeEntityId(p.entity_id);
28
+ const attachments = await client.getAttachments(p.entity_type, normalizedId);
29
+ const parsedAttachments = attachments.map(item => AttachmentItemSchema.parse(item));
30
+ const response = {
31
+ attachments: parsedAttachments,
32
+ };
33
+ if (p.output_file) {
34
+ await fs.promises.writeFile(p.output_file, JSON.stringify(response), "utf-8");
35
+ return {
36
+ success: true,
37
+ message: `Successfully exported ${response.attachments.length} attachments to ${p.output_file}`,
38
+ file: p.output_file,
39
+ };
40
+ }
41
+ return response;
42
+ });
43
+ },
44
+ };
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ export const AttachmentSchema = z.object({
3
+ attachment_id: z.union([z.number(), z.string()]),
4
+ });
5
+ export const AttachmentItemSchema = z.object({
6
+ id: z.union([z.number(), z.string()]),
7
+ filename: z.string().optional(),
8
+ size: z.number().optional(),
9
+ file_type: z.string().optional(),
10
+ is_image: z.union([z.boolean(), z.number()]).optional(),
11
+ });
12
+ export const AddAttachmentSchema = z.object({
13
+ entity_type: z.enum(["case", "run"]).describe("The type of entity to attach the file to ('case' or 'run')"),
14
+ entity_id: z.union([z.number(), z.string()]).describe("The ID of the entity to attach the file to (e.g. 123 or 'C123' for cases, or numeric run ID)"),
15
+ file_path: z.string().describe("The path to the file or directory to attach. Directories will be automatically zipped."),
16
+ });
17
+ export const GetOneAttachmentSchema = z.object({
18
+ action: z.literal("one").describe("Download a single attachment by ID and save to local filesystem"),
19
+ attachment_id: z.union([z.number(), z.string()]).describe("The ID of the attachment to download (numeric ID or UUID)"),
20
+ output_file: z.string().describe("Absolute local file path where the downloaded attachment will be saved"),
21
+ });
22
+ export const GetManyAttachmentsSchema = z.object({
23
+ action: z.literal("many").describe("Retrieve attachment metadata for a test case or test run"),
24
+ entity_type: z.enum(["case", "run"]).describe("The type of entity to get attachments for ('case' or 'run')"),
25
+ entity_id: z.union([z.number(), z.string()]).describe("The ID of the entity (e.g. 123 or 'C123' for cases, or numeric run ID)"),
26
+ output_file: z.string().optional().describe("Optional absolute file path to export large metadata JSON responses"),
27
+ });
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { processCustomFields } from "../../utils/mapper.js";
3
+ import { normalizeEntityId } from "../../utils/sanitizer.js";
3
4
  import { LabelSchema } from "../commons/types.js";
4
5
  const parameters = {
5
6
  case_id: z.string().describe("The ID of the test case (e.g. '123' or 'C123')"),
@@ -10,8 +11,7 @@ export const getCaseTool = {
10
11
  description: "Get detailed information about a test case including its custom fields",
11
12
  parameters,
12
13
  handler: async ({ case_id }, client) => {
13
- const idString = case_id.toUpperCase().startsWith("C") ? case_id.substring(1) : case_id;
14
- const id = Number(idString);
14
+ const id = normalizeEntityId(case_id);
15
15
  const testCase = await client.getCase(id);
16
16
  const [section, caseTypes, priorities, caseFields] = await Promise.all([
17
17
  client.getSection(Number(testCase.section_id)),
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { normalizeEntityId } from "../../utils/sanitizer.js";
5
+ import { CaseHistoryEntrySchema } from "./types.js";
6
+ const parameters = {
7
+ case_id: z.string().describe("The ID of the test case (e.g. '123' or 'C123')"),
8
+ limit: z.number().min(1).optional().describe("Maximum number of revisions to return (e.g. 5 for the last 5 revisions)"),
9
+ after_revision: z.number().optional().describe("Filter to only include revisions strictly newer than this revision ID (id > after_revision)"),
10
+ after_timestamp: z.number().optional().describe("Filter to only include revisions created after this unix timestamp (created_on > after_timestamp)"),
11
+ order: z.enum(["desc", "asc"]).optional().default("desc").describe("Sort order for the returned history entries ('desc' for newest-to-oldest, 'asc' for oldest-to-newest). Default is 'desc'"),
12
+ output_file: z.string().optional().describe("Absolute file path to save the JSON response to. Useful for large history change logs to avoid context limits."),
13
+ };
14
+ export const getCaseHistoryTool = {
15
+ name: "get_case_history",
16
+ mode: "read",
17
+ description: "Returns the revision and change history of a test case. Useful for tracking case updates, checking changes since a specific revision or timestamp, or getting the last N revisions.",
18
+ parameters,
19
+ handler: async ({ case_id, limit, after_revision, after_timestamp, order = "desc", output_file }, client) => {
20
+ const id = normalizeEntityId(case_id);
21
+ const allHistory = await client.getCaseHistory(id);
22
+ let latestRevision = null;
23
+ if (allHistory.length > 0) {
24
+ const sortedByRevisionDesc = [...allHistory].sort((a, b) => b.id - a.id);
25
+ latestRevision = sortedByRevisionDesc[0].id;
26
+ }
27
+ let filtered = [...allHistory];
28
+ if (after_revision !== undefined) {
29
+ filtered = filtered.filter(entry => entry.id > after_revision);
30
+ }
31
+ if (after_timestamp !== undefined) {
32
+ filtered = filtered.filter(entry => (entry.created_on ?? 0) > after_timestamp);
33
+ }
34
+ filtered.sort((a, b) => {
35
+ if (order === "asc") {
36
+ return a.id - b.id;
37
+ }
38
+ return b.id - a.id;
39
+ });
40
+ if (limit !== undefined) {
41
+ filtered = filtered.slice(0, limit);
42
+ }
43
+ const parsedHistory = filtered.map(h => CaseHistoryEntrySchema.parse(h));
44
+ const response = {
45
+ case_id: id,
46
+ latest_revision: latestRevision,
47
+ has_changes: parsedHistory.length > 0,
48
+ history: parsedHistory,
49
+ };
50
+ if (output_file) {
51
+ const dir = path.dirname(output_file);
52
+ if (dir && !fs.existsSync(dir)) {
53
+ await fs.promises.mkdir(dir, { recursive: true });
54
+ }
55
+ await fs.promises.writeFile(output_file, JSON.stringify(response, null, 2), "utf-8");
56
+ return {
57
+ success: true,
58
+ message: `Successfully exported ${response.history.length} history records to ${output_file}`,
59
+ file: output_file,
60
+ latest_revision: latestRevision,
61
+ has_changes: response.has_changes,
62
+ };
63
+ }
64
+ return response;
65
+ },
66
+ };
@@ -4,6 +4,8 @@ import { getCaseFieldsTool } from "./get_case_fields.js";
4
4
  import { updateCaseTool } from "./update_case.js";
5
5
  import { updateCasesTool } from "./update_cases.js";
6
6
  import { addCaseTool } from "./add_case.js";
7
+ import { getCaseHistoryTool } from "./get_case_history.js";
8
+ export { getCaseHistoryTool };
7
9
  export const casesTools = [
8
10
  getCaseTool,
9
11
  getCasesTool,
@@ -38,3 +38,19 @@ export const CaseSchema = z.object({
38
38
  suite_id: z.number(),
39
39
  labels: z.array(LabelSchema),
40
40
  }).loose();
41
+ export const CaseHistoryChangeSchema = z.object({
42
+ field: z.string(),
43
+ old_value: z.any().nullable().optional(),
44
+ new_value: z.any().nullable().optional(),
45
+ });
46
+ export const CaseHistoryEntrySchema = z.object({
47
+ id: z.number(),
48
+ created_on: z.number().optional(),
49
+ changes: z.array(CaseHistoryChangeSchema).optional().default([]),
50
+ });
51
+ export const CaseHistoryResponseSchema = z.object({
52
+ case_id: z.number(),
53
+ latest_revision: z.number().nullable(),
54
+ has_changes: z.boolean(),
55
+ history: z.array(CaseHistoryEntrySchema),
56
+ });
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { validateCaseFields } from "../../utils/validator.js";
3
+ import { normalizeEntityId } from "../../utils/sanitizer.js";
3
4
  import { CASE_FIELDS_PARAM_DESCRIPTION } from "./get_case_fields.js";
4
5
  const parameters = {
5
6
  case_id: z.string().describe("The ID of the test case to update (e.g. '123' or 'C123')"),
@@ -17,8 +18,7 @@ export const updateCaseTool = {
17
18
  parameters,
18
19
  handler: async ({ case_id, fields }, client) => {
19
20
  validateCaseFields(fields, await client.getCaseFields());
20
- const idString = case_id.toUpperCase().startsWith("C") ? case_id.substring(1) : case_id;
21
- const id = Number(idString);
21
+ const id = normalizeEntityId(case_id);
22
22
  const updatedCase = await client.updateCase(id, fields);
23
23
  return {
24
24
  success: true,
@@ -1,22 +1,28 @@
1
1
  import { z } from "zod";
2
2
  const parameters = {
3
- entity_type: z.enum(["case", "shared_step"]).describe("The type of the entity to delete"),
3
+ entity_type: z.enum(["case", "shared_step", "attachment"]).describe("The type of the entity to delete"),
4
4
  entity_id: z.number().describe("The ID of the entity to delete"),
5
5
  };
6
6
  export const deleteEntityTool = {
7
7
  name: "delete_entity",
8
8
  mode: "delete",
9
- description: "Deletes a specified TestRail entity (case or shared_step) by its ID.",
9
+ description: "Deletes a specified TestRail entity (case, shared_step, or attachment) by its ID.",
10
10
  parameters,
11
11
  handler: async (args, client) => {
12
12
  const { entity_type, entity_id } = args;
13
13
  switch (entity_type) {
14
- case "case":
14
+ case "case": {
15
15
  await client.deleteCase(entity_id);
16
16
  return { message: `Case ${entity_id} deleted successfully.` };
17
- case "shared_step":
17
+ }
18
+ case "shared_step": {
18
19
  await client.deleteSharedStep(entity_id);
19
20
  return { message: `Shared step ${entity_id} deleted successfully.` };
21
+ }
22
+ case "attachment": {
23
+ await client.deleteAttachment(entity_id);
24
+ return { message: `Attachment ${entity_id} deleted successfully.` };
25
+ }
20
26
  default:
21
27
  throw new Error(`Unsupported entity type: ${entity_type}`);
22
28
  }
@@ -1,4 +1,4 @@
1
- import { casesTools } from "./cases/index.js";
1
+ import { casesTools, getCaseHistoryTool } from "./cases/index.js";
2
2
  import { projectsTools } from "./projects/index.js";
3
3
  import { suitesTools } from "./suites/index.js";
4
4
  import { sectionsTools } from "./sections/index.js";
@@ -6,6 +6,7 @@ import { runsTools } from "./runs/index.js";
6
6
  import { sharedStepsTools } from "./shared_steps/index.js";
7
7
  import { resultsTools } from "./results/index.js";
8
8
  import { commonsTools } from "./commons/index.js";
9
+ import { attachmentsTools } from "./attachments/index.js";
9
10
  export function getToolsToRegister(config) {
10
11
  const tools = [
11
12
  ...projectsTools,
@@ -14,15 +15,22 @@ export function getToolsToRegister(config) {
14
15
  ...sectionsTools,
15
16
  ...runsTools,
16
17
  ...resultsTools,
18
+ ...attachmentsTools,
17
19
  ...commonsTools,
18
20
  ];
19
21
  if (config.enableSharedSteps) {
20
22
  tools.push(...sharedStepsTools);
21
23
  }
24
+ if (config.enableCaseHistory) {
25
+ tools.push(getCaseHistoryTool);
26
+ }
22
27
  const allowWrite = config.allowWrite !== false;
23
28
  const allowRead = config.allowRead !== false;
24
29
  const allowDelete = config.allowDelete === true;
30
+ const enableDeprecatedTools = config.enableDeprecatedTools !== false;
25
31
  const filteredTools = tools.filter(tool => {
32
+ if (tool.deprecated && !enableDeprecatedTools)
33
+ return false;
26
34
  if (tool.mode === 'write')
27
35
  return allowWrite;
28
36
  if (tool.mode === 'read')
@@ -1,53 +1,23 @@
1
1
  import { z } from "zod";
2
- import { AttachmentSchema } from "./types.js";
3
- import * as fs from "fs";
4
- import * as path from "path";
5
- import * as os from "os";
6
- import archiver from "archiver";
2
+ import { AttachmentSchema } from "../attachments/types.js";
3
+ import { prepareUploadFile } from "../../utils/attachment_helper.js";
7
4
  const parameters = {
8
5
  run_id: z.number().describe("The ID of the test run to attach the file to"),
9
6
  file_path: z.string().describe("The path to the file or directory to attach. Directories will be automatically zipped."),
10
7
  };
8
+ /**
9
+ * @deprecated Use `add_attachment` tool from the attachments domain instead.
10
+ */
11
11
  export const addAttachmentToRunTool = {
12
12
  name: "add_attachment_to_run",
13
13
  mode: "write",
14
- description: "Add an attachment to a test run in TestRail. If the file_path points to a directory, it will be automatically zipped before uploading. Maximum upload size is 256MB.",
14
+ deprecated: true,
15
+ description: "Add an attachment to a test run in TestRail. (Deprecated: Prefer add_attachment tool). Maximum upload size is 256MB.",
15
16
  parameters,
16
17
  handler: async ({ run_id, file_path }, client) => {
17
- if (!fs.existsSync(file_path)) {
18
- throw new Error(`File or directory not found: ${file_path}`);
19
- }
20
- const stats = fs.statSync(file_path);
21
- let uploadPath = file_path;
22
- let filename = path.basename(file_path);
23
- let isTemporary = false;
24
- if (stats.isDirectory()) {
25
- const basename = path.basename(file_path);
26
- const tempDir = os.tmpdir();
27
- const zipPath = path.join(tempDir, `${basename}-${Date.now()}.zip`);
28
- await new Promise((resolve, reject) => {
29
- const output = fs.createWriteStream(zipPath);
30
- const archive = archiver('zip', {
31
- zlib: { level: 9 }
32
- });
33
- output.on('close', () => resolve());
34
- archive.on('error', (err) => reject(err));
35
- archive.pipe(output);
36
- archive.directory(file_path, basename);
37
- archive.finalize();
38
- });
39
- uploadPath = zipPath;
40
- filename = `${basename}.zip`;
41
- isTemporary = true;
42
- }
43
- try {
44
- const result = await client.addAttachmentToRun(run_id, uploadPath, filename);
18
+ return prepareUploadFile(file_path, async (uploadPath, filename) => {
19
+ const result = await client.addAttachment('run', run_id, uploadPath, filename);
45
20
  return AttachmentSchema.parse(result);
46
- }
47
- finally {
48
- if (isTemporary && fs.existsSync(uploadPath)) {
49
- fs.unlinkSync(uploadPath);
50
- }
51
- }
21
+ });
52
22
  },
53
23
  };
@@ -1,6 +1,6 @@
1
+ import { addAttachmentToRunTool } from "./add_attachment_to_run.js";
1
2
  import { mutateRunTool } from "./mutate_run.js";
2
3
  import { queryRunTool } from "./query_run.js";
3
- import { addAttachmentToRunTool } from "./add_attachment_to_run.js";
4
4
  export const runsTools = [
5
5
  mutateRunTool,
6
6
  queryRunTool,
@@ -0,0 +1,46 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import archiver from "archiver";
5
+ export { normalizeEntityId } from "./sanitizer.js";
6
+ /**
7
+ * Prepares a file or directory for upload.
8
+ * If the path points to a directory, it will automatically be zipped to a temporary archive.
9
+ * Ensures temporary zip files are cleaned up in a finally block.
10
+ */
11
+ export async function prepareUploadFile(filePath, uploadCallback) {
12
+ if (!fs.existsSync(filePath)) {
13
+ throw new Error(`File or directory not found: ${filePath}`);
14
+ }
15
+ const stats = fs.statSync(filePath);
16
+ let uploadPath = filePath;
17
+ let filename = path.basename(filePath);
18
+ let isTemporary = false;
19
+ if (stats.isDirectory()) {
20
+ const basename = path.basename(filePath);
21
+ const tempDir = os.tmpdir();
22
+ const zipPath = path.join(tempDir, `${basename}-${Date.now()}.zip`);
23
+ await new Promise((resolve, reject) => {
24
+ const output = fs.createWriteStream(zipPath);
25
+ const archive = archiver("zip", {
26
+ zlib: { level: 9 },
27
+ });
28
+ output.on("close", () => resolve());
29
+ archive.on("error", (err) => reject(err));
30
+ archive.pipe(output);
31
+ archive.directory(filePath, basename);
32
+ archive.finalize();
33
+ });
34
+ uploadPath = zipPath;
35
+ filename = `${basename}.zip`;
36
+ isTemporary = true;
37
+ }
38
+ try {
39
+ return await uploadCallback(uploadPath, filename);
40
+ }
41
+ finally {
42
+ if (isTemporary && fs.existsSync(uploadPath)) {
43
+ fs.unlinkSync(uploadPath);
44
+ }
45
+ }
46
+ }
@@ -55,3 +55,16 @@ export function isActive(value) {
55
55
  const activeFlag = (typeof value === 'object') ? value.is_active : value;
56
56
  return activeFlag === 1 || activeFlag === true || activeFlag === '1' || activeFlag === 'true';
57
57
  }
58
+ /**
59
+ * Normalizes an entity ID (e.g. case ID, run ID, attachment ID).
60
+ * Handles string IDs with 'C' prefixes (e.g. 'C123' -> 123), numeric IDs, and trims whitespace.
61
+ */
62
+ export function normalizeEntityId(id) {
63
+ const idString = typeof id === "string" ? id.trim() : String(id);
64
+ const cleaned = idString.toUpperCase().startsWith("C") ? idString.substring(1) : idString;
65
+ const parsed = Number(cleaned);
66
+ if (isNaN(parsed)) {
67
+ throw new Error(`Invalid entity ID: ${id}`);
68
+ }
69
+ return parsed;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uarlouski/testrail-mcp-server",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "mcpName": "io.github.uarlouski/testrail-mcp-server",
5
5
  "description": "Model Context Protocol (MCP) server for TestRail integration, enabling LLMs to fetch test cases and metadata.",
6
6
  "main": "dist/index.js",
@@ -14,13 +14,21 @@
14
14
  "keywords": [
15
15
  "mcp",
16
16
  "model-context-protocol",
17
+ "mcp-server",
17
18
  "testrail",
18
19
  "server",
19
20
  "typescript",
20
21
  "llm",
21
22
  "ai",
23
+ "ai-agents",
22
24
  "testing",
23
- "automation"
25
+ "automation",
26
+ "qa-automation",
27
+ "qa-tools",
28
+ "test-management",
29
+ "claude",
30
+ "cursor",
31
+ "windsurf"
24
32
  ],
25
33
  "author": "Uladzislau Arlouski",
26
34
  "repository": {
@@ -39,6 +47,7 @@
39
47
  "zod": "^4.4.3"
40
48
  },
41
49
  "devDependencies": {
50
+ "@jest/globals": "^29.7.0",
42
51
  "@types/archiver": "^7.0.0",
43
52
  "@types/jest": "^30.0.0",
44
53
  "@types/node": "^25.5.0",
@@ -47,7 +56,8 @@
47
56
  "ts-jest": "^29.4.6",
48
57
  "ts-node": "^10.9.2",
49
58
  "typescript": "^5.9.3",
50
- "@jest/globals": "^29.7.0"
59
+ "vitepress": "^1.6.4",
60
+ "vue": "^3.5.41"
51
61
  },
52
62
  "devEngines": {
53
63
  "packageManager": {
@@ -61,6 +71,9 @@
61
71
  "start": "node dist/index.js",
62
72
  "dev": "ts-node src/index.ts",
63
73
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
64
- "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
74
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
75
+ "docs:dev": "vitepress dev docs",
76
+ "docs:build": "vitepress build docs",
77
+ "docs:preview": "vitepress preview docs"
65
78
  }
66
79
  }