@uarlouski/testrail-mcp-server 2.7.0 → 2.9.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 +2 -1
- package/dist/index.js +1 -1
- package/dist/tools/cases/export_cases_for_rag.js +90 -43
- package/dist/tools/cases/get_cases.js +41 -27
- package/dist/tools/sections/get_sections.js +5 -1
- package/dist/tools/sections/index.js +3 -0
- package/dist/tools/sections/query_section.js +70 -0
- package/dist/tools/sections/types.js +14 -0
- package/dist/utils/mapper.js +31 -21
- package/package.json +5 -7
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ Once configured, turbo-charge your QA workflow by asking your AI assistant:
|
|
|
151
151
|
| `TESTRAIL_API_KEY` | Your TestRail API key ([Guide](https://support.testrail.com/hc/en-us/articles/7077039051412-Accessing-the-TestRail-API)) | ✅ | |
|
|
152
152
|
| `TESTRAIL_ENABLE_SHARED_STEPS` | Set to `true` to enable Shared Steps management tools | | `false` |
|
|
153
153
|
| `TESTRAIL_ENABLE_CASE_HISTORY` | Set to `true` to enable Case History and revision tracking tools | | `false` |
|
|
154
|
-
| `TESTRAIL_ENABLE_RAG_TOOLS` | Set to `true` to enable Knowledge Base / RAG export tools (`export_cases_for_rag`) | | `false` |
|
|
154
|
+
| `TESTRAIL_ENABLE_RAG_TOOLS` | Set to `true` to enable experimental Knowledge Base / RAG export tools (`export_cases_for_rag`). Subject to breaking API changes. | | `false` |
|
|
155
155
|
| `TESTRAIL_ALLOW_WRITE_OPERATIONS` | Allow write operations (e.g. adding/updating test cases, test runs, sections) | | `true` |
|
|
156
156
|
| `TESTRAIL_ALLOW_READ_OPERATIONS` | Allow read operations (e.g. retrieving projects, test cases, templates) | | `true` |
|
|
157
157
|
| `TESTRAIL_ALLOW_DELETE_OPERATIONS` | Allow delete operations (e.g. deleting cases or shared steps). Enabled strictly via `true`. | | `false` |
|
|
@@ -165,6 +165,7 @@ To ensure smooth transitions, deprecated tools remain available by default (`TES
|
|
|
165
165
|
| Deprecated Tool | Replacement | Status |
|
|
166
166
|
|-----------------|-------------|--------|
|
|
167
167
|
| `add_attachment_to_run` | `add_attachment` (`entity_type: "case" \| "run"`) | Deprecated in `2.3.0`, scheduled for removal in `3.0.0` |
|
|
168
|
+
| `get_sections` | `query_section` (`action: "many"`) | Deprecated in `2.8.0`, scheduled for removal in `3.0.0` |
|
|
168
169
|
|
|
169
170
|
> **💡 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!
|
|
170
171
|
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@ if (!parseResult.success) {
|
|
|
27
27
|
const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ENABLE_CASE_HISTORY, TESTRAIL_ENABLE_RAG_TOOLS, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS, TESTRAIL_ENABLE_DEPRECATED_TOOLS, TESTRAIL_DISABLED_TOOLS } = parseResult.data;
|
|
28
28
|
const server = new McpServer({
|
|
29
29
|
name: "TestRail MCP Server",
|
|
30
|
-
version: "2.
|
|
30
|
+
version: "2.9.0",
|
|
31
31
|
});
|
|
32
32
|
const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
|
|
33
33
|
let tools;
|
|
@@ -2,12 +2,18 @@ import { z } from "zod";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { normalizeEntityId, sanitizeValue } from "../../utils/sanitizer.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveCustomFieldValue } from "../../utils/mapper.js";
|
|
6
6
|
import { getBaseDirectory } from "../../utils/fs.js";
|
|
7
7
|
import { CaseFieldTypeId, getFieldType } from "./fields.js";
|
|
8
|
+
import { fetchFilteredCases } from "./get_cases.js";
|
|
8
9
|
const parameters = {
|
|
9
|
-
case_ids: z.array(z.union([z.string(), z.number()])).
|
|
10
|
+
case_ids: z.array(z.union([z.string(), z.number()])).optional().describe("List of test case IDs (e.g. ['C123', 456]). Provide either case_ids OR project_id."),
|
|
11
|
+
project_id: z.number().optional().describe("The ID of the project to export cases from. Required if case_ids is not provided."),
|
|
12
|
+
suite_id: z.number().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=2 or 3)."),
|
|
13
|
+
filter: z.record(z.string(), z.string()).optional().describe("Optional API-side filters (e.g. priority_id, type_id, milestone_id, refs, created_after, etc.)."),
|
|
14
|
+
where: z.record(z.string(), z.any()).optional().describe("Optional client-side filter for any field including custom fields (filters after fetching cases). Supports exact value matching. Example: {\"custom_automation_status\": 1}"),
|
|
10
15
|
output_dir: z.string().optional().describe("Directory to save exported files. Defaults to an auto-generated directory in the current working directory."),
|
|
16
|
+
ignored_fields: z.array(z.string()).optional().describe("Optional list of custom field names or system names to ignore/exclude from export (e.g. ['custom_review_status', 'review_status']). Supports both full system_name and stripped field names. Core metadata attributes (case_id, revision_id, title, section, priority, references, labels) cannot be ignored."),
|
|
11
17
|
};
|
|
12
18
|
function formatStepsSeparated(steps) {
|
|
13
19
|
return steps.map((step, idx) => {
|
|
@@ -41,8 +47,16 @@ function isMarkdownField(fieldDef, value) {
|
|
|
41
47
|
return typeof value === "string" && value.includes("\n");
|
|
42
48
|
}
|
|
43
49
|
function sanitizeMetadataValue(val) {
|
|
50
|
+
if (typeof val === "boolean") {
|
|
51
|
+
return String(val);
|
|
52
|
+
}
|
|
44
53
|
if (Array.isArray(val)) {
|
|
45
|
-
return val.map(item =>
|
|
54
|
+
return val.map(item => {
|
|
55
|
+
if (item !== null && typeof item === "object") {
|
|
56
|
+
return item.title || item.name || item.label || JSON.stringify(item);
|
|
57
|
+
}
|
|
58
|
+
return String(item);
|
|
59
|
+
});
|
|
46
60
|
}
|
|
47
61
|
return val;
|
|
48
62
|
}
|
|
@@ -51,47 +65,41 @@ const IGNORED_METADATA_FIELDS = new Set([
|
|
|
51
65
|
"updated_by",
|
|
52
66
|
"display_order",
|
|
53
67
|
"is_deleted",
|
|
54
|
-
"review_status",
|
|
55
|
-
"reviewer",
|
|
56
68
|
]);
|
|
57
69
|
function buildFieldDefinitionMap(caseFields) {
|
|
58
70
|
const fieldDefMap = new Map();
|
|
59
71
|
for (const cf of caseFields) {
|
|
60
72
|
fieldDefMap.set(cf.system_name, cf);
|
|
61
|
-
if (cf.name) {
|
|
62
|
-
fieldDefMap.set(cf.name, cf);
|
|
63
|
-
}
|
|
64
|
-
const clean = cf.system_name.replace(/^custom_/, "");
|
|
65
|
-
fieldDefMap.set(clean, cf);
|
|
66
|
-
if (cf.label) {
|
|
67
|
-
fieldDefMap.set(cf.label, cf);
|
|
68
|
-
const snakeLabel = cf.label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
|
69
|
-
fieldDefMap.set(snakeLabel, cf);
|
|
70
|
-
}
|
|
71
73
|
}
|
|
72
74
|
return fieldDefMap;
|
|
73
75
|
}
|
|
74
|
-
function categorizeCustomFields(
|
|
76
|
+
function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet) {
|
|
75
77
|
const markdownFields = [];
|
|
76
78
|
const metadataFields = [];
|
|
77
|
-
const
|
|
78
|
-
for (const [
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const fieldDefMap = buildFieldDefinitionMap(applicableFieldDefs);
|
|
80
|
+
for (const [systemName, rawValue] of Object.entries(testCase)) {
|
|
81
|
+
if (!systemName.startsWith("custom_")) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const strippedName = systemName.replace(/^custom_/, "");
|
|
88
|
+
if (ignoredFieldsSet.has(systemName) || ignoredFieldsSet.has(strippedName)) {
|
|
81
89
|
continue;
|
|
82
90
|
}
|
|
83
|
-
|
|
91
|
+
const fieldDef = fieldDefMap.get(systemName);
|
|
92
|
+
if (!fieldDef) {
|
|
84
93
|
continue;
|
|
85
94
|
}
|
|
86
|
-
|
|
87
|
-
seenKeys.add(key);
|
|
88
|
-
const fieldDef = fieldDefMap.get(key) || fieldDefMap.get(cleanKey) || fieldDefMap.get(`custom_${cleanKey}`);
|
|
95
|
+
const val = resolveCustomFieldValue(fieldDef, rawValue);
|
|
89
96
|
if (isMarkdownField(fieldDef, val)) {
|
|
90
|
-
const header = fieldDef
|
|
97
|
+
const header = fieldDef.label || systemName.replace(/^custom_/, "");
|
|
91
98
|
markdownFields.push({ header, value: val, fieldDef });
|
|
92
99
|
}
|
|
93
100
|
else {
|
|
94
|
-
|
|
101
|
+
const metaKey = fieldDef.name || systemName.replace(/^custom_/, "");
|
|
102
|
+
metadataFields.push({ key: metaKey, value: val, fieldDef });
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
105
|
return { markdownFields, metadataFields };
|
|
@@ -114,9 +122,10 @@ function buildMarkdownBody(testCase, sectionName, markdownFields) {
|
|
|
114
122
|
}
|
|
115
123
|
return mdLines.join("\n").trim() + "\n";
|
|
116
124
|
}
|
|
117
|
-
function buildMetadataAttributes(testCase, sectionName, priority, metadataFields) {
|
|
125
|
+
function buildMetadataAttributes(testCase, sectionName, priority, metadataFields, revisionId) {
|
|
118
126
|
const metadataAttributes = {
|
|
119
127
|
case_id: testCase.id,
|
|
128
|
+
revision_id: revisionId,
|
|
120
129
|
title: testCase.title,
|
|
121
130
|
section: sectionName,
|
|
122
131
|
priority: priority,
|
|
@@ -141,25 +150,55 @@ function buildMetadataAttributes(testCase, sectionName, priority, metadataFields
|
|
|
141
150
|
export const exportCasesForRagTool = {
|
|
142
151
|
name: "export_cases_for_rag",
|
|
143
152
|
mode: "read",
|
|
144
|
-
description: "Export test cases as formatted Markdown documents with companion JSON metadata sidecar files for Knowledge Base and RAG ingestion.",
|
|
153
|
+
description: "Export test cases as formatted Markdown documents with companion JSON metadata sidecar files for Knowledge Base and RAG ingestion. Provide either 'case_ids' to export specific cases, or 'project_id' (with optional 'suite_id', 'filter', 'where') to query and export cases from TestRail. If exporting more than 25 cases, batch them into chunks of ~25 and execute in parallel with a shared output_dir to prevent tool call timeouts.",
|
|
145
154
|
parameters,
|
|
146
|
-
handler: async ({ case_ids, output_dir }, client) => {
|
|
155
|
+
handler: async ({ case_ids, project_id, suite_id, filter, where, output_dir, ignored_fields }, client) => {
|
|
156
|
+
if ((!case_ids || case_ids.length === 0) && project_id === undefined) {
|
|
157
|
+
throw new Error("Either 'case_ids' or 'project_id' must be provided.");
|
|
158
|
+
}
|
|
159
|
+
const [priorities, caseFields] = await Promise.all([
|
|
160
|
+
client.getPriorities().catch(() => []),
|
|
161
|
+
client.getCaseFields().catch(() => []),
|
|
162
|
+
]);
|
|
163
|
+
let testCases = [];
|
|
164
|
+
if (case_ids && case_ids.length > 0) {
|
|
165
|
+
for (const rawId of case_ids) {
|
|
166
|
+
const id = normalizeEntityId(rawId);
|
|
167
|
+
const testCase = await client.getCase(id);
|
|
168
|
+
testCases.push(testCase);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (project_id !== undefined) {
|
|
172
|
+
testCases = await fetchFilteredCases(client, {
|
|
173
|
+
project_id,
|
|
174
|
+
suite_id,
|
|
175
|
+
filter,
|
|
176
|
+
where,
|
|
177
|
+
caseFields,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
147
180
|
const baseDir = getBaseDirectory();
|
|
148
181
|
const targetDir = output_dir
|
|
149
182
|
? (path.isAbsolute(output_dir) ? output_dir : path.resolve(baseDir, output_dir))
|
|
150
183
|
: path.resolve(baseDir, `rag_export_${Date.now()}`);
|
|
151
184
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
const ignoredFieldsSet = new Set(IGNORED_METADATA_FIELDS);
|
|
186
|
+
if (ignored_fields) {
|
|
187
|
+
for (const field of ignored_fields) {
|
|
188
|
+
ignoredFieldsSet.add(field);
|
|
189
|
+
if (field.startsWith("custom_")) {
|
|
190
|
+
ignoredFieldsSet.add(field.replace(/^custom_/, ""));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
ignoredFieldsSet.add(`custom_${field}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
156
197
|
const writtenFiles = [];
|
|
157
|
-
for (const
|
|
158
|
-
const id = normalizeEntityId(rawId);
|
|
159
|
-
const testCase = await client.getCase(id);
|
|
198
|
+
for (const testCase of testCases) {
|
|
160
199
|
let sectionName = "Unknown";
|
|
161
200
|
if (testCase.section_id) {
|
|
162
|
-
const section = await client.getSection(Number(testCase.section_id));
|
|
201
|
+
const section = await client.getSection(Number(testCase.section_id)).catch(() => null);
|
|
163
202
|
if (section?.name) {
|
|
164
203
|
sectionName = section.name;
|
|
165
204
|
}
|
|
@@ -167,11 +206,19 @@ export const exportCasesForRagTool = {
|
|
|
167
206
|
const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
|
|
168
207
|
const templateId = testCase.template_id;
|
|
169
208
|
const applicableFields = caseFields.filter(field => field.include_all || (templateId !== null && templateId !== undefined && field.template_ids.includes(templateId)));
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
209
|
+
const { markdownFields, metadataFields } = categorizeCustomFields(testCase, applicableFields, ignoredFieldsSet);
|
|
210
|
+
let revisionId = null;
|
|
211
|
+
try {
|
|
212
|
+
const history = await client.getCaseHistory(testCase.id);
|
|
213
|
+
if (history && history.length > 0) {
|
|
214
|
+
revisionId = Math.max(...history.map(h => h.id));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
revisionId = null;
|
|
219
|
+
}
|
|
173
220
|
const markdownContent = buildMarkdownBody(testCase, sectionName, markdownFields);
|
|
174
|
-
const metadataAttributes = buildMetadataAttributes(testCase, sectionName, priority, metadataFields);
|
|
221
|
+
const metadataAttributes = buildMetadataAttributes(testCase, sectionName, priority, metadataFields, revisionId);
|
|
175
222
|
const docFilename = `C${testCase.id}.md`;
|
|
176
223
|
const metaFilename = `C${testCase.id}.md.metadata.json`;
|
|
177
224
|
await fs.promises.writeFile(path.join(targetDir, docFilename), markdownContent, "utf-8");
|
|
@@ -180,10 +227,10 @@ export const exportCasesForRagTool = {
|
|
|
180
227
|
}
|
|
181
228
|
return {
|
|
182
229
|
success: true,
|
|
183
|
-
exported_count:
|
|
230
|
+
exported_count: testCases.length,
|
|
184
231
|
output_dir: targetDir,
|
|
185
232
|
files: writtenFiles,
|
|
186
|
-
message: `Successfully exported ${
|
|
233
|
+
message: `Successfully exported ${testCases.length} test case(s) for Knowledge Base ingestion.`,
|
|
187
234
|
};
|
|
188
235
|
},
|
|
189
236
|
};
|
|
@@ -14,43 +14,57 @@ const parameters = {
|
|
|
14
14
|
fields: z.array(z.string()).optional().describe("Additional fields to include in response beyond id, title, and suite_id. Use get_case_fields to see available fields. Example: [\"priority_id\", \"type_id\", \"custom_automation_status\"]"),
|
|
15
15
|
output_file: z.string().optional().describe("Absolute file path to save the JSON response to. Use this for large datasets to avoid blowing up context limits."),
|
|
16
16
|
};
|
|
17
|
+
export async function fetchFilteredCases(client, options) {
|
|
18
|
+
const { project_id, suite_id, section, where } = options;
|
|
19
|
+
let filter = options.filter;
|
|
20
|
+
await validateSuiteId(client, project_id, suite_id);
|
|
21
|
+
const caseFields = options.caseFields ?? (await client.getCaseFields());
|
|
22
|
+
if (where) {
|
|
23
|
+
validateCaseFields(Object.keys(where), caseFields);
|
|
24
|
+
}
|
|
25
|
+
// Inject suite_id into filter for multi-suite projects
|
|
26
|
+
if (suite_id) {
|
|
27
|
+
filter = { ...filter, suite_id: suite_id.toString() };
|
|
28
|
+
}
|
|
29
|
+
let cases;
|
|
30
|
+
if (section?.recursive) {
|
|
31
|
+
cases = await client.getCasesRecursively(project_id, section.id, filter, section.excludes);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
cases = await client.getCases(project_id, section?.id, filter);
|
|
35
|
+
}
|
|
36
|
+
// Client-side filtering for custom fields and other unsupported API filters
|
|
37
|
+
if (where) {
|
|
38
|
+
cases = cases.filter(c => {
|
|
39
|
+
for (const [key, value] of Object.entries(where)) {
|
|
40
|
+
const caseValue = c[key];
|
|
41
|
+
if (caseValue !== value) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return cases;
|
|
49
|
+
}
|
|
17
50
|
export const getCasesTool = {
|
|
18
51
|
name: "get_cases",
|
|
19
52
|
mode: "read",
|
|
20
53
|
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.",
|
|
21
54
|
parameters,
|
|
22
55
|
handler: async ({ project_id, suite_id, section, filter, where, fields, output_file }, client) => {
|
|
23
|
-
await validateSuiteId(client, project_id, suite_id);
|
|
24
56
|
const caseFields = await client.getCaseFields();
|
|
25
57
|
if (fields) {
|
|
26
58
|
validateCaseFields(fields, caseFields);
|
|
27
59
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (section?.recursive) {
|
|
37
|
-
cases = await client.getCasesRecursively(project_id, section.id, filter, section.excludes);
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
cases = await client.getCases(project_id, section?.id, filter);
|
|
41
|
-
}
|
|
42
|
-
// Client-side filtering for custom fields and other unsupported API filters
|
|
43
|
-
if (where) {
|
|
44
|
-
cases = cases.filter(c => {
|
|
45
|
-
for (const [key, value] of Object.entries(where)) {
|
|
46
|
-
const caseValue = c[key];
|
|
47
|
-
if (caseValue !== value) {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return true;
|
|
52
|
-
});
|
|
53
|
-
}
|
|
60
|
+
const cases = await fetchFilteredCases(client, {
|
|
61
|
+
project_id,
|
|
62
|
+
suite_id,
|
|
63
|
+
section,
|
|
64
|
+
filter,
|
|
65
|
+
where,
|
|
66
|
+
caseFields,
|
|
67
|
+
});
|
|
54
68
|
const response = {
|
|
55
69
|
cases: cases.map(c => {
|
|
56
70
|
const result = {
|
|
@@ -7,10 +7,14 @@ const parameters = {
|
|
|
7
7
|
suite_id: z.number().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=3). Use get_suites to find available suites"),
|
|
8
8
|
output_file: z.string().optional().describe("Absolute file path to save the JSON response to. Use this for large datasets to avoid blowing up context limits."),
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* @deprecated Use `query_section` with payload.action = 'many' instead.
|
|
12
|
+
*/
|
|
10
13
|
export const getSectionsTool = {
|
|
11
14
|
name: "get_sections",
|
|
12
15
|
mode: "read",
|
|
13
|
-
|
|
16
|
+
deprecated: true,
|
|
17
|
+
description: "Get all sections for a project. (Deprecated: Prefer query_section tool). Returns section IDs and names that can be used with add_case",
|
|
14
18
|
parameters,
|
|
15
19
|
handler: async ({ project_id, suite_id, output_file }, client) => {
|
|
16
20
|
await validateSuiteId(client, project_id, suite_id);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getSectionsTool } from "./get_sections.js";
|
|
2
|
+
import { querySectionTool } from "./query_section.js";
|
|
2
3
|
import { mutateSectionTool } from "./mutate_section.js";
|
|
3
4
|
export const sectionsTools = [
|
|
5
|
+
querySectionTool,
|
|
4
6
|
getSectionsTool,
|
|
5
7
|
mutateSectionTool,
|
|
6
8
|
];
|
|
9
|
+
export { getSectionsTool, querySectionTool, mutateSectionTool };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { SectionSchema, GetOneSectionSchema, GetManySectionsSchema } from "./types.js";
|
|
4
|
+
import { handleQuery } from "../../utils/query_handler.js";
|
|
5
|
+
import { validateSuiteId } from "../../utils/validator.js";
|
|
6
|
+
const parameters = {
|
|
7
|
+
payload: z.discriminatedUnion("action", [
|
|
8
|
+
GetOneSectionSchema,
|
|
9
|
+
GetManySectionsSchema,
|
|
10
|
+
]).describe("The payload containing the action ('one' or 'many') and corresponding parameters"),
|
|
11
|
+
};
|
|
12
|
+
export function buildSectionTree(parent, allSections) {
|
|
13
|
+
const children = allSections.filter(s => s.parent_id === parent.id);
|
|
14
|
+
return {
|
|
15
|
+
...SectionSchema.parse(parent),
|
|
16
|
+
children: children.map(child => buildSectionTree(child, allSections)),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export const querySectionTool = {
|
|
20
|
+
name: "query_section",
|
|
21
|
+
mode: "read",
|
|
22
|
+
description: "Retrieve a single section or all sections for a project in TestRail. Set payload.action to 'one' or 'many' to specify the operation.",
|
|
23
|
+
parameters,
|
|
24
|
+
handler: async (args, client) => {
|
|
25
|
+
return handleQuery(args.payload, async (p) => {
|
|
26
|
+
if (p.include_child) {
|
|
27
|
+
if (!p.project_id) {
|
|
28
|
+
throw new Error("project_id is required when include_child is true to resolve child sections");
|
|
29
|
+
}
|
|
30
|
+
await validateSuiteId(client, p.project_id, p.suite_id);
|
|
31
|
+
const allSections = await client.getSections(p.project_id, p.suite_id);
|
|
32
|
+
let targetSection = allSections.find(s => s.id === p.section_id);
|
|
33
|
+
if (!targetSection) {
|
|
34
|
+
targetSection = await client.getSection(p.section_id);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
section: buildSectionTree(targetSection, allSections),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const section = await client.getSection(p.section_id);
|
|
41
|
+
return {
|
|
42
|
+
section: SectionSchema.parse(section),
|
|
43
|
+
};
|
|
44
|
+
}, async (p) => {
|
|
45
|
+
await validateSuiteId(client, p.project_id, p.suite_id);
|
|
46
|
+
let sections = await client.getSections(p.project_id, p.suite_id);
|
|
47
|
+
if (p.name_pattern) {
|
|
48
|
+
try {
|
|
49
|
+
const regex = new RegExp(p.name_pattern, "i");
|
|
50
|
+
sections = sections.filter(s => regex.test(s.name));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new Error(`Invalid regex pattern provided for name_pattern: '${p.name_pattern}'`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const response = {
|
|
57
|
+
sections: sections.map(s => SectionSchema.parse(s)),
|
|
58
|
+
};
|
|
59
|
+
if (p.output_file) {
|
|
60
|
+
await fs.promises.writeFile(p.output_file, JSON.stringify(response), "utf-8");
|
|
61
|
+
return {
|
|
62
|
+
success: true,
|
|
63
|
+
message: `Successfully exported ${response.sections.length} sections to ${p.output_file}`,
|
|
64
|
+
file: p.output_file,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return response;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -20,3 +20,17 @@ export const UpdateSectionSchema = BaseSectionFields.partial().extend({
|
|
|
20
20
|
action: z.literal("update").describe("The operation to perform: update an existing section"),
|
|
21
21
|
section_id: z.number().describe("The ID of the section to update"),
|
|
22
22
|
});
|
|
23
|
+
export const GetOneSectionSchema = z.object({
|
|
24
|
+
action: z.literal("one").describe("Retrieve a single section by ID"),
|
|
25
|
+
section_id: z.number().int().describe("The ID of the section"),
|
|
26
|
+
project_id: z.number().int().optional().describe("The ID of the project (required when include_child is true to fetch child sections)"),
|
|
27
|
+
suite_id: z.number().int().optional().describe("The ID of the test suite (for multi-suite projects). Use query_suite to find available suites"),
|
|
28
|
+
include_child: z.boolean().optional().default(false).describe("Whether to include child/descendant sections in the response as a nested tree. Defaults to false"),
|
|
29
|
+
});
|
|
30
|
+
export const GetManySectionsSchema = z.object({
|
|
31
|
+
action: z.literal("many").describe("Retrieve all sections for a project"),
|
|
32
|
+
project_id: z.number().int().describe("The ID of the project. Use query_project to find available projects"),
|
|
33
|
+
suite_id: z.number().int().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=2 or 3). Use query_suite to find available suites"),
|
|
34
|
+
name_pattern: z.string().optional().describe("Optional regex pattern to filter sections by name (e.g. 'auth.*' or 'login|signup'). Case-insensitive."),
|
|
35
|
+
output_file: z.string().optional().describe("Absolute file path to save the JSON response to. Use this for large datasets to avoid blowing up context limits."),
|
|
36
|
+
});
|
package/dist/utils/mapper.js
CHANGED
|
@@ -31,31 +31,41 @@ export function processCustomFields(testCase, caseFields) {
|
|
|
31
31
|
continue;
|
|
32
32
|
}
|
|
33
33
|
const outputKey = fieldNameMap.get(key);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
34
|
+
const field = applicableFields.find(f => f.system_name === key);
|
|
35
|
+
const options = dropdownOptionsMap.get(key);
|
|
36
|
+
result[outputKey] = resolveCustomFieldValue(field, value, options);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolves a custom field value against its field definition:
|
|
42
|
+
* - Maps dropdown option IDs to their display labels
|
|
43
|
+
* - Normalizes multi-select values into arrays
|
|
44
|
+
* - Sanitizes HTML formatting in strings
|
|
45
|
+
*/
|
|
46
|
+
export function resolveCustomFieldValue(field, value, optionsMap) {
|
|
47
|
+
if (value === null || value === undefined) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
const options = optionsMap ?? parseDropdownOptions(field);
|
|
51
|
+
let finalValue = value;
|
|
52
|
+
if (options.size > 0) {
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
finalValue = value.map(v => options.get(String(v)) || v);
|
|
55
|
+
}
|
|
56
|
+
else if (field.type_id === CaseFieldTypeId.MultiSelect) {
|
|
57
|
+
const resolved = options.get(String(value)) || value;
|
|
58
|
+
finalValue = [resolved];
|
|
49
59
|
}
|
|
50
60
|
else {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
finalValue = Array.isArray(value) ? value : [value];
|
|
54
|
-
}
|
|
61
|
+
const optionKey = String(value);
|
|
62
|
+
finalValue = options.get(optionKey) || value;
|
|
55
63
|
}
|
|
56
|
-
result[outputKey] = sanitizeValue(finalValue);
|
|
57
64
|
}
|
|
58
|
-
|
|
65
|
+
else if (field.type_id === CaseFieldTypeId.MultiSelect) {
|
|
66
|
+
finalValue = Array.isArray(value) ? value : [value];
|
|
67
|
+
}
|
|
68
|
+
return sanitizeValue(finalValue);
|
|
59
69
|
}
|
|
60
70
|
/**
|
|
61
71
|
* Parse dropdown options from field configs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uarlouski/testrail-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.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",
|
|
@@ -55,9 +55,7 @@
|
|
|
55
55
|
"rimraf": "^6.1.3",
|
|
56
56
|
"ts-jest": "^29.4.6",
|
|
57
57
|
"ts-node": "^10.9.2",
|
|
58
|
-
"typescript": "^5.9.3"
|
|
59
|
-
"vitepress": "^1.6.4",
|
|
60
|
-
"vue": "^3.5.41"
|
|
58
|
+
"typescript": "^5.9.3"
|
|
61
59
|
},
|
|
62
60
|
"devEngines": {
|
|
63
61
|
"packageManager": {
|
|
@@ -72,8 +70,8 @@
|
|
|
72
70
|
"dev": "ts-node src/index.ts",
|
|
73
71
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
74
72
|
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
75
|
-
"docs:dev": "
|
|
76
|
-
"docs:build": "
|
|
77
|
-
"docs:preview": "
|
|
73
|
+
"docs:dev": "pnpm --prefix docs dev",
|
|
74
|
+
"docs:build": "pnpm --prefix docs build",
|
|
75
|
+
"docs:preview": "pnpm --prefix docs preview"
|
|
78
76
|
}
|
|
79
77
|
}
|