@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 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.7.0",
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 { processCustomFields } from "../../utils/mapper.js";
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()])).min(1).describe("List of test case IDs (e.g. ['C123', 456])"),
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 => (item !== null && typeof item === "object") ? (item.title || item.name || item.label || JSON.stringify(item)) : 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(customFields, fieldDefMap) {
76
+ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet) {
75
77
  const markdownFields = [];
76
78
  const metadataFields = [];
77
- const seenKeys = new Set();
78
- for (const [key, val] of Object.entries(customFields)) {
79
- const cleanKey = key.replace(/^custom_/, "");
80
- if (seenKeys.has(cleanKey) || seenKeys.has(key) || IGNORED_METADATA_FIELDS.has(cleanKey) || IGNORED_METADATA_FIELDS.has(key)) {
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
- if (val === null || val === undefined || val === "" || (Array.isArray(val) && val.length === 0)) {
91
+ const fieldDef = fieldDefMap.get(systemName);
92
+ if (!fieldDef) {
84
93
  continue;
85
94
  }
86
- seenKeys.add(cleanKey);
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?.label || cleanKey.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
97
+ const header = fieldDef.label || systemName.replace(/^custom_/, "");
91
98
  markdownFields.push({ header, value: val, fieldDef });
92
99
  }
93
100
  else {
94
- metadataFields.push({ key: cleanKey, value: val, fieldDef });
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 [priorities, caseFields] = await Promise.all([
153
- client.getPriorities().catch(() => []),
154
- client.getCaseFields().catch(() => []),
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 rawId of case_ids) {
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 fieldDefMap = buildFieldDefinitionMap(applicableFields);
171
- const customFields = processCustomFields(testCase, caseFields);
172
- const { markdownFields, metadataFields } = categorizeCustomFields(customFields, fieldDefMap);
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: case_ids.length,
230
+ exported_count: testCases.length,
184
231
  output_dir: targetDir,
185
232
  files: writtenFiles,
186
- message: `Successfully exported ${case_ids.length} test case(s) for Knowledge Base ingestion.`,
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
- if (where) {
29
- validateCaseFields(Object.keys(where), caseFields);
30
- }
31
- // Inject suite_id into filter for multi-suite projects
32
- if (suite_id) {
33
- filter = { ...filter, suite_id: suite_id.toString() };
34
- }
35
- let cases;
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
- description: "Get all sections for a project. Returns section IDs and names that can be used with add_case",
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
+ });
@@ -31,31 +31,41 @@ export function processCustomFields(testCase, caseFields) {
31
31
  continue;
32
32
  }
33
33
  const outputKey = fieldNameMap.get(key);
34
- let finalValue = value;
35
- if (dropdownOptionsMap.has(key)) {
36
- const options = dropdownOptionsMap.get(key);
37
- const field = applicableFields.find(f => f.system_name === key);
38
- if (Array.isArray(value)) {
39
- finalValue = value.map(v => options.get(String(v)) || v);
40
- }
41
- else if (field?.type_id === CaseFieldTypeId.MultiSelect) {
42
- const resolved = options.get(String(value)) || value;
43
- finalValue = [resolved];
44
- }
45
- else {
46
- const optionKey = String(value);
47
- finalValue = options.get(optionKey) || value;
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 field = applicableFields.find(f => f.system_name === key);
52
- if (field?.type_id === CaseFieldTypeId.MultiSelect) {
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
- return result;
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.7.0",
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": "vitepress dev docs",
76
- "docs:build": "vitepress build docs",
77
- "docs:preview": "vitepress preview docs"
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
  }