@uarlouski/testrail-mcp-server 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/dist/client/testrail.js +30 -6
- package/dist/index.js +6 -4
- package/dist/tools/attachments/add_attachment.js +26 -0
- package/dist/tools/attachments/index.js +6 -0
- package/dist/tools/attachments/query_attachment.js +44 -0
- package/dist/tools/attachments/types.js +27 -0
- package/dist/tools/cases/get_case_fields.js +34 -28
- package/dist/tools/commons/delete_entity.js +10 -4
- package/dist/tools/registry.js +5 -0
- package/dist/tools/runs/add_attachment_to_run.js +10 -40
- package/dist/tools/runs/index.js +1 -1
- package/dist/tools/sections/mutate_section.js +2 -0
- package/dist/tools/sections/types.js +1 -0
- package/dist/utils/attachment_helper.js +58 -0
- package/dist/utils/validator.js +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,17 @@ Once configured, turbo-charge your QA workflow by asking your AI assistant:
|
|
|
150
150
|
| `TESTRAIL_ALLOW_WRITE_OPERATIONS` | Allow write operations (e.g. adding/updating test cases, test runs, sections) | | `true` |
|
|
151
151
|
| `TESTRAIL_ALLOW_READ_OPERATIONS` | Allow read operations (e.g. retrieving projects, test cases, templates) | | `true` |
|
|
152
152
|
| `TESTRAIL_ALLOW_DELETE_OPERATIONS` | Allow delete operations (e.g. deleting cases or shared steps). Enabled strictly via `true`. | | `false` |
|
|
153
|
+
| `TESTRAIL_ENABLE_DEPRECATED_TOOLS` | Enable deprecated tools for backward compatibility. Set to `false` to reduce context token overhead. | | `true` |
|
|
154
|
+
|
|
155
|
+
### ⚠️ Deprecation Lifecycle & Features Scheduled for Removal
|
|
156
|
+
|
|
157
|
+
To ensure smooth transitions, deprecated tools remain available by default (`TESTRAIL_ENABLE_DEPRECATED_TOOLS=true`) and will be removed in future major releases:
|
|
158
|
+
|
|
159
|
+
| Deprecated Tool | Replacement | Status |
|
|
160
|
+
|-----------------|-------------|--------|
|
|
161
|
+
| `add_attachment_to_run` | `add_attachment` (`entity_type: "case" \| "run"`) | Deprecated in `2.3.0`, scheduled for removal in `3.0.0` |
|
|
162
|
+
|
|
163
|
+
> **💡 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
164
|
|
|
154
165
|
---
|
|
155
166
|
|
|
@@ -189,7 +200,12 @@ The TestRail MCP Server provides heavily typed, descriptive tools designed speci
|
|
|
189
200
|
| `get_results` | Retrieve paginated results for a specific test ID. |
|
|
190
201
|
| `add_results` | Submit test results to a test run using the specific `test_id`. |
|
|
191
202
|
| `add_results_for_cases` | Submits results to a run mapping directly to `case_id`s, streamlining automation workflows. |
|
|
192
|
-
|
|
203
|
+
|
|
204
|
+
### 📎 Attachments & Media
|
|
205
|
+
| Tool | Functionality |
|
|
206
|
+
|------|-------------|
|
|
207
|
+
| `add_attachment` | Add an attachment (screenshot, log, file, or auto-zipped directory) to a test case or test run (`entity_type: "case" \| "run"`). |
|
|
208
|
+
| `query_attachment` | Download an attachment to a local file (`action: "one"`) or retrieve attachment metadata for a test case or test run (`action: "many"`, supporting `output_file` export). |
|
|
193
209
|
|
|
194
210
|
### 🔗 Shared Steps (Optional)
|
|
195
211
|
| Tool | Functionality |
|
|
@@ -203,7 +219,7 @@ The TestRail MCP Server provides heavily typed, descriptive tools designed speci
|
|
|
203
219
|
### 🗑️ Deletion
|
|
204
220
|
| Tool | Functionality |
|
|
205
221
|
|------|-------------|
|
|
206
|
-
| `delete_entity` | Deletes a specified TestRail entity (supports `"case"` or `"
|
|
222
|
+
| `delete_entity` | Deletes a specified TestRail entity (supports `"case"`, `"shared_step"`, or `"attachment"`) by its ID. Requires `TESTRAIL_ALLOW_DELETE_OPERATIONS` to be set to `true` in your environment. |
|
|
207
223
|
|
|
208
224
|
### 🧠 System Metadata
|
|
209
225
|
| Tool | Functionality |
|
package/dist/client/testrail.js
CHANGED
|
@@ -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 {
|
|
@@ -178,7 +179,7 @@ export class TestRailClient {
|
|
|
178
179
|
async addResultsForCases(runId, results) {
|
|
179
180
|
return this.post(`${API_BASE_V2}/add_results_for_cases/${runId}`, { results });
|
|
180
181
|
}
|
|
181
|
-
async
|
|
182
|
+
async addAttachment(entityType, entityId, filePath, filename) {
|
|
182
183
|
const fileBuffer = await fs.promises.readFile(filePath);
|
|
183
184
|
const blob = new Blob([fileBuffer]);
|
|
184
185
|
const formData = new FormData();
|
|
@@ -186,7 +187,11 @@ export class TestRailClient {
|
|
|
186
187
|
const headers = {
|
|
187
188
|
'Authorization': this.auth,
|
|
188
189
|
};
|
|
189
|
-
return this._executeRequest('POST', `${API_BASE_V2}/
|
|
190
|
+
return this._executeRequest('POST', `${API_BASE_V2}/add_attachment_to_${entityType}/${entityId}`, headers, formData);
|
|
191
|
+
}
|
|
192
|
+
async getAttachments(entityType, entityId) {
|
|
193
|
+
const url = `${API_BASE_V2}/get_attachments_for_${entityType}/${entityId}`;
|
|
194
|
+
return this.paginateAll(url, 'attachments');
|
|
190
195
|
}
|
|
191
196
|
async getSharedSteps(projectId, options) {
|
|
192
197
|
let url = `${API_BASE_V2}/get_shared_steps/${projectId}`;
|
|
@@ -207,11 +212,26 @@ export class TestRailClient {
|
|
|
207
212
|
async updateSharedStep(sharedStepId, data) {
|
|
208
213
|
return this.post(`${API_BASE_V2}/update_shared_step/${sharedStepId}`, data);
|
|
209
214
|
}
|
|
215
|
+
async getAttachment(attachmentId, outputFilePath) {
|
|
216
|
+
const dir = path.dirname(outputFilePath);
|
|
217
|
+
if (dir && !fs.existsSync(dir)) {
|
|
218
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
const buffer = await this._executeRequest('GET', `${API_BASE_V2}/get_attachment/${attachmentId}`, this.headers, undefined, 'buffer');
|
|
221
|
+
await fs.promises.writeFile(outputFilePath, buffer);
|
|
222
|
+
return {
|
|
223
|
+
file: outputFilePath,
|
|
224
|
+
size: buffer.byteLength,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
210
227
|
async deleteCase(caseId) {
|
|
211
|
-
return this._executeRequest('POST', `${API_BASE_V2}/delete_case/${caseId}`, this.headers, JSON.stringify({}),
|
|
228
|
+
return this._executeRequest('POST', `${API_BASE_V2}/delete_case/${caseId}`, this.headers, JSON.stringify({}), 'text');
|
|
212
229
|
}
|
|
213
230
|
async deleteSharedStep(sharedStepId) {
|
|
214
|
-
return this._executeRequest('POST', `${API_BASE_V2}/delete_shared_step/${sharedStepId}`, this.headers, JSON.stringify({}),
|
|
231
|
+
return this._executeRequest('POST', `${API_BASE_V2}/delete_shared_step/${sharedStepId}`, this.headers, JSON.stringify({}), 'text');
|
|
232
|
+
}
|
|
233
|
+
async deleteAttachment(attachmentId) {
|
|
234
|
+
return this._executeRequest('POST', `${API_BASE_V2}/delete_attachment/${attachmentId}`, this.headers, JSON.stringify({}), 'text');
|
|
215
235
|
}
|
|
216
236
|
async getUsers(projectId) {
|
|
217
237
|
const url = projectId
|
|
@@ -254,7 +274,7 @@ export class TestRailClient {
|
|
|
254
274
|
}
|
|
255
275
|
return this._executeRequest(method, endpoint, this.headers, jsonData);
|
|
256
276
|
}
|
|
257
|
-
async _executeRequest(method, endpoint, headers, body,
|
|
277
|
+
async _executeRequest(method, endpoint, headers, body, responseType = 'json') {
|
|
258
278
|
const url = `${this.baseUrl}${endpoint}`;
|
|
259
279
|
const params = {
|
|
260
280
|
method,
|
|
@@ -292,9 +312,13 @@ export class TestRailClient {
|
|
|
292
312
|
console.error(`[TestRailClient] Error Details: ${errorMessage}`);
|
|
293
313
|
throw new Error(errorMessage);
|
|
294
314
|
}
|
|
295
|
-
if (
|
|
315
|
+
if (responseType === 'json') {
|
|
296
316
|
return await response.json();
|
|
297
317
|
}
|
|
318
|
+
else if (responseType === 'buffer') {
|
|
319
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
320
|
+
return Buffer.from(arrayBuffer);
|
|
321
|
+
}
|
|
298
322
|
return await response.text();
|
|
299
323
|
}
|
|
300
324
|
catch (error) {
|
package/dist/index.js
CHANGED
|
@@ -13,24 +13,26 @@ const EnvSchema = z.object({
|
|
|
13
13
|
TESTRAIL_ENABLE_SHARED_STEPS: z.string().optional().transform(val => val === 'true'),
|
|
14
14
|
TESTRAIL_ALLOW_WRITE_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
|
|
15
15
|
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')
|
|
16
|
+
TESTRAIL_ALLOW_DELETE_OPERATIONS: z.string().optional().transform(val => val === 'true'),
|
|
17
|
+
TESTRAIL_ENABLE_DEPRECATED_TOOLS: z.string().optional().transform(val => val === undefined ? true : val === 'true')
|
|
17
18
|
});
|
|
18
19
|
const parseResult = EnvSchema.safeParse(process.env);
|
|
19
20
|
if (!parseResult.success) {
|
|
20
21
|
console.error("Invalid TestRail environment configuration:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
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;
|
|
24
|
+
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, TESTRAIL_ENABLE_DEPRECATED_TOOLS } = parseResult.data;
|
|
24
25
|
const server = new McpServer({
|
|
25
26
|
name: "TestRail MCP Server",
|
|
26
|
-
version: "2.
|
|
27
|
+
version: "2.3.0",
|
|
27
28
|
});
|
|
28
29
|
const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
|
|
29
30
|
const tools = getToolsToRegister({
|
|
30
31
|
enableSharedSteps: TESTRAIL_ENABLE_SHARED_STEPS,
|
|
31
32
|
allowWrite: TESTRAIL_ALLOW_WRITE_OPERATIONS,
|
|
32
33
|
allowRead: TESTRAIL_ALLOW_READ_OPERATIONS,
|
|
33
|
-
allowDelete: TESTRAIL_ALLOW_DELETE_OPERATIONS
|
|
34
|
+
allowDelete: TESTRAIL_ALLOW_DELETE_OPERATIONS,
|
|
35
|
+
enableDeprecatedTools: TESTRAIL_ENABLE_DEPRECATED_TOOLS
|
|
34
36
|
});
|
|
35
37
|
for (const tool of tools) {
|
|
36
38
|
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,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
|
+
});
|
|
@@ -13,35 +13,41 @@ Example: {"priority_id": 2, "template_id": 1, "labels": [1, 2]}
|
|
|
13
13
|
Call get_case_fields with project_id first if field names are not already known.
|
|
14
14
|
Using an unknown field name (e.g. 'label_ids') will result in an error.
|
|
15
15
|
`;
|
|
16
|
-
const
|
|
17
|
-
1: "String",
|
|
18
|
-
2: "Integer",
|
|
19
|
-
3: "Text",
|
|
20
|
-
4: "URL",
|
|
21
|
-
5: "Checkbox",
|
|
22
|
-
6: "Dropdown",
|
|
23
|
-
7: "User",
|
|
24
|
-
8: "Date",
|
|
25
|
-
9: "Milestone",
|
|
26
|
-
10: "Steps",
|
|
27
|
-
11: "Step Results",
|
|
28
|
-
12: "Multi-select",
|
|
29
|
-
13: "Scenarios",
|
|
30
|
-
14: "List",
|
|
31
|
-
|
|
32
|
-
function
|
|
33
|
-
|
|
16
|
+
export const FIELD_TYPES = [
|
|
17
|
+
{ typeId: 1, name: "String" },
|
|
18
|
+
{ typeId: 2, name: "Integer" },
|
|
19
|
+
{ typeId: 3, name: "Text" },
|
|
20
|
+
{ typeId: 4, name: "URL" },
|
|
21
|
+
{ typeId: 5, name: "Checkbox", description: "Boolean value, either true or false." },
|
|
22
|
+
{ typeId: 6, name: "Dropdown" },
|
|
23
|
+
{ typeId: 7, name: "User" },
|
|
24
|
+
{ typeId: 8, name: "Date" },
|
|
25
|
+
{ typeId: 9, name: "Milestone" },
|
|
26
|
+
{ typeId: 10, name: "Steps" },
|
|
27
|
+
{ typeId: 11, name: "Step Results" },
|
|
28
|
+
{ typeId: 12, name: "Multi-select" },
|
|
29
|
+
{ typeId: 13, name: "Scenarios" },
|
|
30
|
+
{ typeId: 14, name: "List" },
|
|
31
|
+
];
|
|
32
|
+
function getFieldType(typeId) {
|
|
33
|
+
const found = FIELD_TYPES.find(t => t.typeId === typeId);
|
|
34
|
+
return found ?? { typeId, name: `Unknown (${typeId})` };
|
|
34
35
|
}
|
|
35
36
|
function isFieldRequired(field) {
|
|
36
37
|
return field.configs.some(config => config.options?.is_required === true);
|
|
37
38
|
}
|
|
38
39
|
export function mapToFieldSchema(field) {
|
|
40
|
+
const fieldType = getFieldType(field.type_id);
|
|
41
|
+
const descriptions = [field.description, fieldType.description].filter(Boolean);
|
|
39
42
|
const schema = {
|
|
40
43
|
system_name: field.system_name,
|
|
41
44
|
label: field.label,
|
|
42
|
-
type:
|
|
45
|
+
type: fieldType.name,
|
|
43
46
|
is_required: isFieldRequired(field),
|
|
44
47
|
};
|
|
48
|
+
if (descriptions.length > 0) {
|
|
49
|
+
schema.description = descriptions.join(" ");
|
|
50
|
+
}
|
|
45
51
|
if (!field.include_all && field.template_ids.length > 0) {
|
|
46
52
|
schema.template_ids = field.template_ids;
|
|
47
53
|
}
|
|
@@ -68,15 +74,15 @@ function isFieldForProject(field, projectId) {
|
|
|
68
74
|
return contexts.some(ctx => ctx.is_global || ctx.project_ids.includes(projectId));
|
|
69
75
|
}
|
|
70
76
|
export const SYSTEM_FIELDS = [
|
|
71
|
-
{ system_name: "title", label: "Title", type:
|
|
72
|
-
{ system_name: "section_id", label: "Section", type:
|
|
73
|
-
{ system_name: "template_id", label: "Template", type:
|
|
74
|
-
{ system_name: "type_id", label: "Type", type:
|
|
75
|
-
{ system_name: "priority_id", label: "Priority", type:
|
|
76
|
-
{ system_name: "estimate", label: "Estimate", type:
|
|
77
|
-
{ system_name: "milestone_id", label: "Milestone", type:
|
|
78
|
-
{ system_name: "refs", label: "References", type:
|
|
79
|
-
{ system_name: "labels", label: "Labels", type:
|
|
77
|
+
{ system_name: "title", label: "Title", type: getFieldType(1).name, is_required: true, project_scope: { scope: "global" } },
|
|
78
|
+
{ system_name: "section_id", label: "Section", type: getFieldType(2).name, is_required: true, project_scope: { scope: "global" } },
|
|
79
|
+
{ system_name: "template_id", label: "Template", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
|
|
80
|
+
{ system_name: "type_id", label: "Type", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
|
|
81
|
+
{ system_name: "priority_id", label: "Priority", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
|
|
82
|
+
{ system_name: "estimate", label: "Estimate", type: getFieldType(1).name, is_required: false, project_scope: { scope: "global" } },
|
|
83
|
+
{ system_name: "milestone_id", label: "Milestone", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
|
|
84
|
+
{ system_name: "refs", label: "References", type: getFieldType(1).name, is_required: false, project_scope: { scope: "global" } },
|
|
85
|
+
{ system_name: "labels", label: "Labels", type: getFieldType(14).name, is_required: false, project_scope: { scope: "global" }, description: "Use get_labels tool to get available labels/tags." },
|
|
80
86
|
];
|
|
81
87
|
const description = `
|
|
82
88
|
Get the field schema for test cases for a specific project.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -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,6 +15,7 @@ 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) {
|
|
@@ -22,7 +24,10 @@ export function getToolsToRegister(config) {
|
|
|
22
24
|
const allowWrite = config.allowWrite !== false;
|
|
23
25
|
const allowRead = config.allowRead !== false;
|
|
24
26
|
const allowDelete = config.allowDelete === true;
|
|
27
|
+
const enableDeprecatedTools = config.enableDeprecatedTools !== false;
|
|
25
28
|
const filteredTools = tools.filter(tool => {
|
|
29
|
+
if (tool.deprecated && !enableDeprecatedTools)
|
|
30
|
+
return false;
|
|
26
31
|
if (tool.mode === 'write')
|
|
27
32
|
return allowWrite;
|
|
28
33
|
if (tool.mode === 'read')
|
|
@@ -1,53 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { AttachmentSchema } from "
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
};
|
package/dist/tools/runs/index.js
CHANGED
|
@@ -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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { SectionSchema, CreateSectionSchema, UpdateSectionSchema } from "./types.js";
|
|
3
3
|
import { handleMutate } from "../../utils/mutate_handler.js";
|
|
4
|
+
import { validateSuiteId } from "../../utils/validator.js";
|
|
4
5
|
const parameters = {
|
|
5
6
|
payload: z.discriminatedUnion("action", [
|
|
6
7
|
CreateSectionSchema,
|
|
@@ -15,6 +16,7 @@ export const mutateSectionTool = {
|
|
|
15
16
|
handler: async (args, client) => {
|
|
16
17
|
const { payload } = args;
|
|
17
18
|
return handleMutate(payload, async (createPayload) => {
|
|
19
|
+
await validateSuiteId(client, createPayload.project_id, createPayload.suite_id);
|
|
18
20
|
const { project_id, action, ...data } = createPayload;
|
|
19
21
|
return client.addSection(project_id, data);
|
|
20
22
|
}, async (updatePayload) => {
|
|
@@ -13,6 +13,7 @@ export const BaseSectionFields = z.object({
|
|
|
13
13
|
export const CreateSectionSchema = BaseSectionFields.extend({
|
|
14
14
|
action: z.literal("create").describe("The operation to perform: create a new section"),
|
|
15
15
|
project_id: z.number().describe("The ID of the project the section should be added to"),
|
|
16
|
+
suite_id: z.number().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=2 or 3). Use get_suites to find available suites"),
|
|
16
17
|
parent_id: z.number().optional().describe("The ID of the parent section (to build section hierarchies)"),
|
|
17
18
|
});
|
|
18
19
|
export const UpdateSectionSchema = BaseSectionFields.partial().extend({
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import archiver from "archiver";
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes an entity ID (case ID or run ID).
|
|
7
|
+
* Handles string IDs with 'C' prefixes (e.g. 'C123' -> 123) and numeric IDs.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeEntityId(id) {
|
|
10
|
+
const idString = typeof id === "string" ? id.trim() : String(id);
|
|
11
|
+
const cleaned = idString.toUpperCase().startsWith("C") ? idString.substring(1) : idString;
|
|
12
|
+
const parsed = Number(cleaned);
|
|
13
|
+
if (isNaN(parsed)) {
|
|
14
|
+
throw new Error(`Invalid entity ID: ${id}`);
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Prepares a file or directory for upload.
|
|
20
|
+
* If the path points to a directory, it will automatically be zipped to a temporary archive.
|
|
21
|
+
* Ensures temporary zip files are cleaned up in a finally block.
|
|
22
|
+
*/
|
|
23
|
+
export async function prepareUploadFile(filePath, uploadCallback) {
|
|
24
|
+
if (!fs.existsSync(filePath)) {
|
|
25
|
+
throw new Error(`File or directory not found: ${filePath}`);
|
|
26
|
+
}
|
|
27
|
+
const stats = fs.statSync(filePath);
|
|
28
|
+
let uploadPath = filePath;
|
|
29
|
+
let filename = path.basename(filePath);
|
|
30
|
+
let isTemporary = false;
|
|
31
|
+
if (stats.isDirectory()) {
|
|
32
|
+
const basename = path.basename(filePath);
|
|
33
|
+
const tempDir = os.tmpdir();
|
|
34
|
+
const zipPath = path.join(tempDir, `${basename}-${Date.now()}.zip`);
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
const output = fs.createWriteStream(zipPath);
|
|
37
|
+
const archive = archiver("zip", {
|
|
38
|
+
zlib: { level: 9 },
|
|
39
|
+
});
|
|
40
|
+
output.on("close", () => resolve());
|
|
41
|
+
archive.on("error", (err) => reject(err));
|
|
42
|
+
archive.pipe(output);
|
|
43
|
+
archive.directory(filePath, basename);
|
|
44
|
+
archive.finalize();
|
|
45
|
+
});
|
|
46
|
+
uploadPath = zipPath;
|
|
47
|
+
filename = `${basename}.zip`;
|
|
48
|
+
isTemporary = true;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return await uploadCallback(uploadPath, filename);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (isTemporary && fs.existsSync(uploadPath)) {
|
|
55
|
+
fs.unlinkSync(uploadPath);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/utils/validator.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { SYSTEM_FIELDS, mapToFieldSchema } from "../tools/cases/get_case_fields.js";
|
|
2
2
|
import { isActive } from "./sanitizer.js";
|
|
3
|
+
const SUITE_MODE_SINGLE_WITH_BASELINES = 2;
|
|
3
4
|
const SUITE_MODE_MULTI = 3;
|
|
4
5
|
/**
|
|
5
|
-
* Validates that suite_id is provided when the project uses multiple test suites (suite_mode=3).
|
|
6
|
+
* Validates that suite_id is provided when the project uses multiple test suites or baselines (suite_mode=2 or 3).
|
|
6
7
|
* Throws a descriptive Error if suite_id is required but missing.
|
|
7
8
|
*/
|
|
8
9
|
export async function validateSuiteId(client, projectId, suiteId) {
|
|
9
10
|
if (suiteId)
|
|
10
11
|
return;
|
|
11
12
|
const project = await client.getProject(projectId);
|
|
12
|
-
if (project.suite_mode === SUITE_MODE_MULTI) {
|
|
13
|
-
throw new Error(`Project "${project.name}" (id: ${project.id}) uses multiple test suites (suite_mode
|
|
13
|
+
if (project.suite_mode === SUITE_MODE_MULTI || project.suite_mode === SUITE_MODE_SINGLE_WITH_BASELINES) {
|
|
14
|
+
throw new Error(`Project "${project.name}" (id: ${project.id}) uses multiple test suites/baselines (suite_mode=${project.suite_mode}). ` +
|
|
14
15
|
`The suite_id parameter is required. Use get_suites to find available suites for this project.`);
|
|
15
16
|
}
|
|
16
17
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uarlouski/testrail-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|