@uarlouski/testrail-mcp-server 2.7.0 → 2.8.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 +1 -1
- package/dist/index.js +1 -1
- package/dist/tools/cases/export_cases_for_rag.js +77 -41
- 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 +1 -1
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` |
|
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.8.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, 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)) {
|
|
81
85
|
continue;
|
|
82
86
|
}
|
|
83
|
-
|
|
87
|
+
const strippedName = systemName.replace(/^custom_/, "");
|
|
88
|
+
if (ignoredFieldsSet.has(systemName) || ignoredFieldsSet.has(strippedName)) {
|
|
84
89
|
continue;
|
|
85
90
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
const fieldDef = fieldDefMap.get(systemName);
|
|
92
|
+
if (!fieldDef) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
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 };
|
|
@@ -141,25 +149,55 @@ function buildMetadataAttributes(testCase, sectionName, priority, metadataFields
|
|
|
141
149
|
export const exportCasesForRagTool = {
|
|
142
150
|
name: "export_cases_for_rag",
|
|
143
151
|
mode: "read",
|
|
144
|
-
description: "Export test cases as formatted Markdown documents with companion JSON metadata sidecar files for Knowledge Base and RAG ingestion.",
|
|
152
|
+
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
153
|
parameters,
|
|
146
|
-
handler: async ({ case_ids, output_dir }, client) => {
|
|
154
|
+
handler: async ({ case_ids, project_id, suite_id, filter, where, output_dir, ignored_fields }, client) => {
|
|
155
|
+
if ((!case_ids || case_ids.length === 0) && project_id === undefined) {
|
|
156
|
+
throw new Error("Either 'case_ids' or 'project_id' must be provided.");
|
|
157
|
+
}
|
|
158
|
+
const [priorities, caseFields] = await Promise.all([
|
|
159
|
+
client.getPriorities().catch(() => []),
|
|
160
|
+
client.getCaseFields().catch(() => []),
|
|
161
|
+
]);
|
|
162
|
+
let testCases = [];
|
|
163
|
+
if (case_ids && case_ids.length > 0) {
|
|
164
|
+
for (const rawId of case_ids) {
|
|
165
|
+
const id = normalizeEntityId(rawId);
|
|
166
|
+
const testCase = await client.getCase(id);
|
|
167
|
+
testCases.push(testCase);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (project_id !== undefined) {
|
|
171
|
+
testCases = await fetchFilteredCases(client, {
|
|
172
|
+
project_id,
|
|
173
|
+
suite_id,
|
|
174
|
+
filter,
|
|
175
|
+
where,
|
|
176
|
+
caseFields,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
147
179
|
const baseDir = getBaseDirectory();
|
|
148
180
|
const targetDir = output_dir
|
|
149
181
|
? (path.isAbsolute(output_dir) ? output_dir : path.resolve(baseDir, output_dir))
|
|
150
182
|
: path.resolve(baseDir, `rag_export_${Date.now()}`);
|
|
151
183
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
184
|
+
const ignoredFieldsSet = new Set(IGNORED_METADATA_FIELDS);
|
|
185
|
+
if (ignored_fields) {
|
|
186
|
+
for (const field of ignored_fields) {
|
|
187
|
+
ignoredFieldsSet.add(field);
|
|
188
|
+
if (field.startsWith("custom_")) {
|
|
189
|
+
ignoredFieldsSet.add(field.replace(/^custom_/, ""));
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
ignoredFieldsSet.add(`custom_${field}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
156
196
|
const writtenFiles = [];
|
|
157
|
-
for (const
|
|
158
|
-
const id = normalizeEntityId(rawId);
|
|
159
|
-
const testCase = await client.getCase(id);
|
|
197
|
+
for (const testCase of testCases) {
|
|
160
198
|
let sectionName = "Unknown";
|
|
161
199
|
if (testCase.section_id) {
|
|
162
|
-
const section = await client.getSection(Number(testCase.section_id));
|
|
200
|
+
const section = await client.getSection(Number(testCase.section_id)).catch(() => null);
|
|
163
201
|
if (section?.name) {
|
|
164
202
|
sectionName = section.name;
|
|
165
203
|
}
|
|
@@ -167,9 +205,7 @@ export const exportCasesForRagTool = {
|
|
|
167
205
|
const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
|
|
168
206
|
const templateId = testCase.template_id;
|
|
169
207
|
const applicableFields = caseFields.filter(field => field.include_all || (templateId !== null && templateId !== undefined && field.template_ids.includes(templateId)));
|
|
170
|
-
const
|
|
171
|
-
const customFields = processCustomFields(testCase, caseFields);
|
|
172
|
-
const { markdownFields, metadataFields } = categorizeCustomFields(customFields, fieldDefMap);
|
|
208
|
+
const { markdownFields, metadataFields } = categorizeCustomFields(testCase, applicableFields, ignoredFieldsSet);
|
|
173
209
|
const markdownContent = buildMarkdownBody(testCase, sectionName, markdownFields);
|
|
174
210
|
const metadataAttributes = buildMetadataAttributes(testCase, sectionName, priority, metadataFields);
|
|
175
211
|
const docFilename = `C${testCase.id}.md`;
|
|
@@ -180,10 +216,10 @@ export const exportCasesForRagTool = {
|
|
|
180
216
|
}
|
|
181
217
|
return {
|
|
182
218
|
success: true,
|
|
183
|
-
exported_count:
|
|
219
|
+
exported_count: testCases.length,
|
|
184
220
|
output_dir: targetDir,
|
|
185
221
|
files: writtenFiles,
|
|
186
|
-
message: `Successfully exported ${
|
|
222
|
+
message: `Successfully exported ${testCases.length} test case(s) for Knowledge Base ingestion.`,
|
|
187
223
|
};
|
|
188
224
|
},
|
|
189
225
|
};
|
|
@@ -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.8.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",
|