@uarlouski/testrail-mcp-server 2.6.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 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` |
@@ -13,6 +13,7 @@ export class TestRailClient {
13
13
  statusesPromise = null;
14
14
  projectsPromise = null;
15
15
  templatesPromiseMap = new Map();
16
+ sectionsPromiseMap = new Map();
16
17
  constructor(baseUrl, email, apiKey) {
17
18
  this.baseUrl = baseUrl.replace(/\/$/, "");
18
19
  const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
@@ -60,7 +61,10 @@ export class TestRailClient {
60
61
  return this.paginateAll(url, 'cases');
61
62
  }
62
63
  async getSection(sectionId) {
63
- return this.get(`${API_BASE_V2}/get_section/${sectionId}`);
64
+ if (!this.sectionsPromiseMap.has(sectionId.toString())) {
65
+ this.sectionsPromiseMap.set(sectionId.toString(), this.get(`${API_BASE_V2}/get_section/${sectionId}`));
66
+ }
67
+ return this.sectionsPromiseMap.get(sectionId.toString());
64
68
  }
65
69
  async getPriorities() {
66
70
  if (!this.prioritiesPromise) {
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.6.0",
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;
@@ -1,13 +1,19 @@
1
1
  import { z } from "zod";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
- import * as os from "os";
5
4
  import { normalizeEntityId, sanitizeValue } from "../../utils/sanitizer.js";
6
- import { processCustomFields } from "../../utils/mapper.js";
5
+ import { resolveCustomFieldValue } from "../../utils/mapper.js";
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, 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_")) {
81
82
  continue;
82
83
  }
83
- if (val === null || val === undefined || val === "" || (Array.isArray(val) && val.length === 0)) {
84
+ if (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0)) {
84
85
  continue;
85
86
  }
86
- seenKeys.add(cleanKey);
87
- seenKeys.add(key);
88
- const fieldDef = fieldDefMap.get(key) || fieldDefMap.get(cleanKey) || fieldDefMap.get(`custom_${cleanKey}`);
87
+ const strippedName = systemName.replace(/^custom_/, "");
88
+ if (ignoredFieldsSet.has(systemName) || ignoredFieldsSet.has(strippedName)) {
89
+ continue;
90
+ }
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?.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 };
@@ -141,31 +149,63 @@ 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) => {
147
- const baseDir = (process.cwd() && process.cwd() !== "/") ? process.cwd() : (process.env.PWD || os.homedir());
148
- const targetDir = output_dir ? path.resolve(output_dir) : path.resolve(baseDir, `rag_export_${Date.now()}`);
149
- await fs.promises.mkdir(targetDir, { recursive: true });
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
+ }
150
158
  const [priorities, caseFields] = await Promise.all([
151
159
  client.getPriorities().catch(() => []),
152
160
  client.getCaseFields().catch(() => []),
153
161
  ]);
154
- const fieldDefMap = buildFieldDefinitionMap(caseFields);
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
+ }
179
+ const baseDir = getBaseDirectory();
180
+ const targetDir = output_dir
181
+ ? (path.isAbsolute(output_dir) ? output_dir : path.resolve(baseDir, output_dir))
182
+ : path.resolve(baseDir, `rag_export_${Date.now()}`);
183
+ await fs.promises.mkdir(targetDir, { recursive: true });
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
+ }
155
196
  const writtenFiles = [];
156
- for (const rawId of case_ids) {
157
- const id = normalizeEntityId(rawId);
158
- const testCase = await client.getCase(id);
197
+ for (const testCase of testCases) {
159
198
  let sectionName = "Unknown";
160
199
  if (testCase.section_id) {
161
- const section = await client.getSection(Number(testCase.section_id));
200
+ const section = await client.getSection(Number(testCase.section_id)).catch(() => null);
162
201
  if (section?.name) {
163
202
  sectionName = section.name;
164
203
  }
165
204
  }
166
205
  const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
167
- const customFields = processCustomFields(testCase, caseFields);
168
- const { markdownFields, metadataFields } = categorizeCustomFields(customFields, fieldDefMap);
206
+ const templateId = testCase.template_id;
207
+ const applicableFields = caseFields.filter(field => field.include_all || (templateId !== null && templateId !== undefined && field.template_ids.includes(templateId)));
208
+ const { markdownFields, metadataFields } = categorizeCustomFields(testCase, applicableFields, ignoredFieldsSet);
169
209
  const markdownContent = buildMarkdownBody(testCase, sectionName, markdownFields);
170
210
  const metadataAttributes = buildMetadataAttributes(testCase, sectionName, priority, metadataFields);
171
211
  const docFilename = `C${testCase.id}.md`;
@@ -176,10 +216,10 @@ export const exportCasesForRagTool = {
176
216
  }
177
217
  return {
178
218
  success: true,
179
- exported_count: case_ids.length,
219
+ exported_count: testCases.length,
180
220
  output_dir: targetDir,
181
221
  files: writtenFiles,
182
- message: `Successfully exported ${case_ids.length} test case(s) for Knowledge Base ingestion.`,
222
+ message: `Successfully exported ${testCases.length} test case(s) for Knowledge Base ingestion.`,
183
223
  };
184
224
  },
185
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
- 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
+ });
@@ -0,0 +1,36 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ /**
4
+ * Resolves a safe, writable base directory for exports and file operations.
5
+ *
6
+ * When the MCP server is spawned by host applications (IDE daemons, npx, Claude Desktop, Cursor, Gemini),
7
+ * `process.cwd()` or `process.env.PWD` often resolves to the root filesystem (`"/"`), where direct writes
8
+ * fail with ENOENT / permission errors (especially on macOS read-only APFS volumes).
9
+ *
10
+ * This function handles multi-environment fallbacks:
11
+ * 1. `process.env.INIT_CWD`: Set by package managers (npm/npx/pnpm) to the original caller's working directory.
12
+ * 2. `process.cwd()` / `process.env.PWD`: Used if pointing to a valid non-root directory.
13
+ * 3. `os.homedir()`: User home directory fallback if the working directory is root or invalid.
14
+ * 4. `os.tmpdir()`: Final safety fallback.
15
+ */
16
+ export function getBaseDirectory() {
17
+ const candidates = [
18
+ process.env.INIT_CWD,
19
+ process.cwd(),
20
+ process.env.PWD,
21
+ os.homedir(),
22
+ ];
23
+ for (const candidate of candidates) {
24
+ if (candidate && candidate !== "/" && candidate.trim() !== "") {
25
+ try {
26
+ if (fs.existsSync(candidate)) {
27
+ return candidate;
28
+ }
29
+ }
30
+ catch {
31
+ // Ignore and try next candidate
32
+ }
33
+ }
34
+ }
35
+ return os.tmpdir();
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.6.0",
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",