@uarlouski/testrail-mcp-server 2.5.0 → 2.7.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,10 +151,12 @@ 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
155
  | `TESTRAIL_ALLOW_WRITE_OPERATIONS` | Allow write operations (e.g. adding/updating test cases, test runs, sections) | | `true` |
155
156
  | `TESTRAIL_ALLOW_READ_OPERATIONS` | Allow read operations (e.g. retrieving projects, test cases, templates) | | `true` |
156
157
  | `TESTRAIL_ALLOW_DELETE_OPERATIONS` | Allow delete operations (e.g. deleting cases or shared steps). Enabled strictly via `true`. | | `false` |
157
158
  | `TESTRAIL_ENABLE_DEPRECATED_TOOLS` | Enable deprecated tools for backward compatibility. Set to `false` to reduce context token overhead. | | `true` |
159
+ | `TESTRAIL_DISABLED_TOOLS` | Comma-separated list of specific tool names to disable (e.g., `mutate_suite,delete_entity`). Fails if invalid tool names are specified. | | - |
158
160
 
159
161
  ### ⚠️ Deprecation Lifecycle & Features Scheduled for Removal
160
162
 
@@ -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
@@ -12,30 +12,41 @@ const EnvSchema = z.object({
12
12
  TESTRAIL_API_KEY: z.string().min(1, 'API key is required'),
13
13
  TESTRAIL_ENABLE_SHARED_STEPS: z.string().optional().transform(val => val === 'true'),
14
14
  TESTRAIL_ENABLE_CASE_HISTORY: z.string().optional().transform(val => val === 'true'),
15
+ TESTRAIL_ENABLE_RAG_TOOLS: z.string().optional().transform(val => val === 'true'),
15
16
  TESTRAIL_ALLOW_WRITE_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
16
17
  TESTRAIL_ALLOW_READ_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
17
18
  TESTRAIL_ALLOW_DELETE_OPERATIONS: z.string().optional().transform(val => val === 'true'),
18
- TESTRAIL_ENABLE_DEPRECATED_TOOLS: z.string().optional().transform(val => val === undefined ? true : val === 'true')
19
+ TESTRAIL_ENABLE_DEPRECATED_TOOLS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
20
+ TESTRAIL_DISABLED_TOOLS: z.string().optional().transform(val => val ? val.split(',').map(s => s.trim()).filter(Boolean) : [])
19
21
  });
20
22
  const parseResult = EnvSchema.safeParse(process.env);
21
23
  if (!parseResult.success) {
22
24
  console.error("Invalid TestRail environment configuration:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
23
25
  process.exit(1);
24
26
  }
25
- const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ENABLE_CASE_HISTORY, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS, TESTRAIL_ENABLE_DEPRECATED_TOOLS } = parseResult.data;
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;
26
28
  const server = new McpServer({
27
29
  name: "TestRail MCP Server",
28
- version: "2.5.0",
30
+ version: "2.7.0",
29
31
  });
30
32
  const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
31
- const tools = getToolsToRegister({
32
- enableSharedSteps: TESTRAIL_ENABLE_SHARED_STEPS,
33
- enableCaseHistory: TESTRAIL_ENABLE_CASE_HISTORY,
34
- allowWrite: TESTRAIL_ALLOW_WRITE_OPERATIONS,
35
- allowRead: TESTRAIL_ALLOW_READ_OPERATIONS,
36
- allowDelete: TESTRAIL_ALLOW_DELETE_OPERATIONS,
37
- enableDeprecatedTools: TESTRAIL_ENABLE_DEPRECATED_TOOLS
38
- });
33
+ let tools;
34
+ try {
35
+ tools = getToolsToRegister({
36
+ enableSharedSteps: TESTRAIL_ENABLE_SHARED_STEPS,
37
+ enableCaseHistory: TESTRAIL_ENABLE_CASE_HISTORY,
38
+ enableRagTools: TESTRAIL_ENABLE_RAG_TOOLS,
39
+ allowWrite: TESTRAIL_ALLOW_WRITE_OPERATIONS,
40
+ allowRead: TESTRAIL_ALLOW_READ_OPERATIONS,
41
+ allowDelete: TESTRAIL_ALLOW_DELETE_OPERATIONS,
42
+ enableDeprecatedTools: TESTRAIL_ENABLE_DEPRECATED_TOOLS,
43
+ disabledTools: TESTRAIL_DISABLED_TOOLS
44
+ });
45
+ }
46
+ catch (error) {
47
+ console.error("Invalid TestRail environment configuration:", error.message);
48
+ process.exit(1);
49
+ }
39
50
  for (const tool of tools) {
40
51
  server.registerTool(tool.name, {
41
52
  description: tool.description,
@@ -0,0 +1,189 @@
1
+ import { z } from "zod";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { normalizeEntityId, sanitizeValue } from "../../utils/sanitizer.js";
5
+ import { processCustomFields } from "../../utils/mapper.js";
6
+ import { getBaseDirectory } from "../../utils/fs.js";
7
+ import { CaseFieldTypeId, getFieldType } from "./fields.js";
8
+ 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
+ output_dir: z.string().optional().describe("Directory to save exported files. Defaults to an auto-generated directory in the current working directory."),
11
+ };
12
+ function formatStepsSeparated(steps) {
13
+ return steps.map((step, idx) => {
14
+ const stepNum = idx + 1;
15
+ const content = typeof step.content === "string" ? sanitizeValue(step.content).trim() : "";
16
+ const expected = typeof step.expected === "string" ? sanitizeValue(step.expected).trim() : "";
17
+ const additionalInfo = typeof step.additional_info === "string" ? sanitizeValue(step.additional_info).trim() : "";
18
+ let stepText = `${stepNum}. **Action**: ${content}`;
19
+ if (additionalInfo) {
20
+ stepText += `\n - **Info**: ${additionalInfo}`;
21
+ }
22
+ if (expected) {
23
+ stepText += `\n - **Expected**: ${expected}`;
24
+ }
25
+ return stepText;
26
+ }).join("\n\n");
27
+ }
28
+ function isMarkdownField(fieldDef, value) {
29
+ if (fieldDef) {
30
+ const typeInfo = getFieldType(fieldDef.type_id);
31
+ if (!typeInfo.isStructured) {
32
+ return true;
33
+ }
34
+ // If string type, check if content is multi-line
35
+ if (fieldDef.type_id === CaseFieldTypeId.String) {
36
+ return typeof value === "string" && value.includes("\n");
37
+ }
38
+ return false;
39
+ }
40
+ // Fallback if no field schema definition is found
41
+ return typeof value === "string" && value.includes("\n");
42
+ }
43
+ function sanitizeMetadataValue(val) {
44
+ if (Array.isArray(val)) {
45
+ return val.map(item => (item !== null && typeof item === "object") ? (item.title || item.name || item.label || JSON.stringify(item)) : item);
46
+ }
47
+ return val;
48
+ }
49
+ const IGNORED_METADATA_FIELDS = new Set([
50
+ "created_by",
51
+ "updated_by",
52
+ "display_order",
53
+ "is_deleted",
54
+ "review_status",
55
+ "reviewer",
56
+ ]);
57
+ function buildFieldDefinitionMap(caseFields) {
58
+ const fieldDefMap = new Map();
59
+ for (const cf of caseFields) {
60
+ 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
+ }
72
+ return fieldDefMap;
73
+ }
74
+ function categorizeCustomFields(customFields, fieldDefMap) {
75
+ const markdownFields = [];
76
+ 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)) {
81
+ continue;
82
+ }
83
+ if (val === null || val === undefined || val === "" || (Array.isArray(val) && val.length === 0)) {
84
+ continue;
85
+ }
86
+ seenKeys.add(cleanKey);
87
+ seenKeys.add(key);
88
+ const fieldDef = fieldDefMap.get(key) || fieldDefMap.get(cleanKey) || fieldDefMap.get(`custom_${cleanKey}`);
89
+ if (isMarkdownField(fieldDef, val)) {
90
+ const header = fieldDef?.label || cleanKey.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
91
+ markdownFields.push({ header, value: val, fieldDef });
92
+ }
93
+ else {
94
+ metadataFields.push({ key: cleanKey, value: val, fieldDef });
95
+ }
96
+ }
97
+ return { markdownFields, metadataFields };
98
+ }
99
+ function buildMarkdownBody(testCase, sectionName, markdownFields) {
100
+ const mdLines = [];
101
+ mdLines.push(`# [C${testCase.id}] ${sanitizeValue(testCase.title)}`);
102
+ mdLines.push("");
103
+ mdLines.push(`**Section**: ${sectionName}`);
104
+ mdLines.push("");
105
+ for (const { header, value, fieldDef } of markdownFields) {
106
+ mdLines.push(`## ${header}`);
107
+ if (fieldDef?.type_id === CaseFieldTypeId.Steps || (Array.isArray(value) && value[0]?.content !== undefined)) {
108
+ mdLines.push(formatStepsSeparated(value));
109
+ }
110
+ else {
111
+ mdLines.push(typeof value === "object" ? JSON.stringify(value, null, 2) : sanitizeValue(String(value)).trim());
112
+ }
113
+ mdLines.push("");
114
+ }
115
+ return mdLines.join("\n").trim() + "\n";
116
+ }
117
+ function buildMetadataAttributes(testCase, sectionName, priority, metadataFields) {
118
+ const metadataAttributes = {
119
+ case_id: testCase.id,
120
+ title: testCase.title,
121
+ section: sectionName,
122
+ priority: priority,
123
+ };
124
+ if (testCase.refs) {
125
+ metadataAttributes.references = testCase.refs;
126
+ }
127
+ if (testCase.labels && testCase.labels.length > 0) {
128
+ metadataAttributes.labels = testCase.labels.map(l => l.title);
129
+ }
130
+ for (const { key, value, fieldDef } of metadataFields) {
131
+ if (fieldDef?.type_id === CaseFieldTypeId.MultiSelect) {
132
+ const listVal = Array.isArray(value) ? value : [value];
133
+ metadataAttributes[key] = sanitizeMetadataValue(listVal.map(item => String(item)));
134
+ }
135
+ else {
136
+ metadataAttributes[key] = sanitizeMetadataValue(value);
137
+ }
138
+ }
139
+ return metadataAttributes;
140
+ }
141
+ export const exportCasesForRagTool = {
142
+ name: "export_cases_for_rag",
143
+ mode: "read",
144
+ description: "Export test cases as formatted Markdown documents with companion JSON metadata sidecar files for Knowledge Base and RAG ingestion.",
145
+ parameters,
146
+ handler: async ({ case_ids, output_dir }, client) => {
147
+ const baseDir = getBaseDirectory();
148
+ const targetDir = output_dir
149
+ ? (path.isAbsolute(output_dir) ? output_dir : path.resolve(baseDir, output_dir))
150
+ : path.resolve(baseDir, `rag_export_${Date.now()}`);
151
+ await fs.promises.mkdir(targetDir, { recursive: true });
152
+ const [priorities, caseFields] = await Promise.all([
153
+ client.getPriorities().catch(() => []),
154
+ client.getCaseFields().catch(() => []),
155
+ ]);
156
+ const writtenFiles = [];
157
+ for (const rawId of case_ids) {
158
+ const id = normalizeEntityId(rawId);
159
+ const testCase = await client.getCase(id);
160
+ let sectionName = "Unknown";
161
+ if (testCase.section_id) {
162
+ const section = await client.getSection(Number(testCase.section_id));
163
+ if (section?.name) {
164
+ sectionName = section.name;
165
+ }
166
+ }
167
+ const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
168
+ const templateId = testCase.template_id;
169
+ 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);
173
+ const markdownContent = buildMarkdownBody(testCase, sectionName, markdownFields);
174
+ const metadataAttributes = buildMetadataAttributes(testCase, sectionName, priority, metadataFields);
175
+ const docFilename = `C${testCase.id}.md`;
176
+ const metaFilename = `C${testCase.id}.md.metadata.json`;
177
+ await fs.promises.writeFile(path.join(targetDir, docFilename), markdownContent, "utf-8");
178
+ await fs.promises.writeFile(path.join(targetDir, metaFilename), JSON.stringify({ metadataAttributes }, null, 2), "utf-8");
179
+ writtenFiles.push(docFilename, metaFilename);
180
+ }
181
+ return {
182
+ success: true,
183
+ exported_count: case_ids.length,
184
+ output_dir: targetDir,
185
+ files: writtenFiles,
186
+ message: `Successfully exported ${case_ids.length} test case(s) for Knowledge Base ingestion.`,
187
+ };
188
+ },
189
+ };
@@ -0,0 +1,47 @@
1
+ export const CaseFieldTypeId = {
2
+ String: 1,
3
+ Integer: 2,
4
+ Text: 3,
5
+ URL: 4,
6
+ Checkbox: 5,
7
+ Dropdown: 6,
8
+ User: 7,
9
+ Date: 8,
10
+ Milestone: 9,
11
+ Steps: 10,
12
+ StepResults: 11,
13
+ MultiSelect: 12,
14
+ Scenarios: 13,
15
+ List: 14,
16
+ };
17
+ export const FIELD_TYPES = [
18
+ { typeId: CaseFieldTypeId.String, name: "String", isStructured: true },
19
+ { typeId: CaseFieldTypeId.Integer, name: "Integer", isStructured: true },
20
+ { typeId: CaseFieldTypeId.Text, name: "Text", isStructured: false },
21
+ { typeId: CaseFieldTypeId.URL, name: "URL", isStructured: true },
22
+ { typeId: CaseFieldTypeId.Checkbox, name: "Checkbox", description: "Boolean value, either true or false.", isStructured: true },
23
+ { typeId: CaseFieldTypeId.Dropdown, name: "Dropdown", isStructured: true },
24
+ { typeId: CaseFieldTypeId.User, name: "User", isStructured: true },
25
+ { typeId: CaseFieldTypeId.Date, name: "Date", isStructured: true },
26
+ { typeId: CaseFieldTypeId.Milestone, name: "Milestone", isStructured: true },
27
+ { typeId: CaseFieldTypeId.Steps, name: "Steps", isStructured: false },
28
+ { typeId: CaseFieldTypeId.StepResults, name: "Step Results", isStructured: false },
29
+ { typeId: CaseFieldTypeId.MultiSelect, name: "Multi-select", isStructured: true },
30
+ { typeId: CaseFieldTypeId.Scenarios, name: "Scenarios", isStructured: false },
31
+ { typeId: CaseFieldTypeId.List, name: "List", isStructured: true },
32
+ ];
33
+ export function getFieldType(typeId) {
34
+ const found = FIELD_TYPES.find(t => t.typeId === typeId);
35
+ return found ?? { typeId, name: `Unknown (${typeId})`, isStructured: true };
36
+ }
37
+ export const SYSTEM_FIELDS = [
38
+ { system_name: "title", label: "Title", type: getFieldType(CaseFieldTypeId.String).name, is_required: true, project_scope: { scope: "global" } },
39
+ { system_name: "section_id", label: "Section", type: getFieldType(CaseFieldTypeId.Integer).name, is_required: true, project_scope: { scope: "global" } },
40
+ { system_name: "template_id", label: "Template", type: getFieldType(CaseFieldTypeId.Integer).name, is_required: false, project_scope: { scope: "global" } },
41
+ { system_name: "type_id", label: "Type", type: getFieldType(CaseFieldTypeId.Integer).name, is_required: false, project_scope: { scope: "global" } },
42
+ { system_name: "priority_id", label: "Priority", type: getFieldType(CaseFieldTypeId.Integer).name, is_required: false, project_scope: { scope: "global" } },
43
+ { system_name: "estimate", label: "Estimate", type: getFieldType(CaseFieldTypeId.String).name, is_required: false, project_scope: { scope: "global" } },
44
+ { system_name: "milestone_id", label: "Milestone", type: getFieldType(CaseFieldTypeId.Integer).name, is_required: false, project_scope: { scope: "global" } },
45
+ { system_name: "refs", label: "References", type: getFieldType(CaseFieldTypeId.String).name, is_required: false, project_scope: { scope: "global" } },
46
+ { system_name: "labels", label: "Labels", type: getFieldType(CaseFieldTypeId.List).name, is_required: false, project_scope: { scope: "global" }, description: "Use get_labels tool to get available labels/tags." },
47
+ ];
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { CaseFieldTypeId, FIELD_TYPES, getFieldType, SYSTEM_FIELDS } from "./fields.js";
2
3
  import { parseDropdownOptions } from "../../utils/mapper.js";
3
4
  import { isActive } from "../../utils/sanitizer.js";
4
5
  // Field remains optional for backward compatibility with previous implementation.
@@ -13,26 +14,7 @@ Example: {"priority_id": 2, "template_id": 1, "labels": [1, 2]}
13
14
  Call get_case_fields with project_id first if field names are not already known.
14
15
  Using an unknown field name (e.g. 'label_ids') will result in an error.
15
16
  `;
16
- export const FIELD_TYPES = [
17
- { typeId: 1, name: "String" },
18
- { typeId: 2, name: "Integer" },
19
- { typeId: 3, name: "Text" },
20
- { typeId: 4, name: "URL" },
21
- { typeId: 5, name: "Checkbox", description: "Boolean value, either true or false." },
22
- { typeId: 6, name: "Dropdown" },
23
- { typeId: 7, name: "User" },
24
- { typeId: 8, name: "Date" },
25
- { typeId: 9, name: "Milestone" },
26
- { typeId: 10, name: "Steps" },
27
- { typeId: 11, name: "Step Results" },
28
- { typeId: 12, name: "Multi-select" },
29
- { typeId: 13, name: "Scenarios" },
30
- { typeId: 14, name: "List" },
31
- ];
32
- function getFieldType(typeId) {
33
- const found = FIELD_TYPES.find(t => t.typeId === typeId);
34
- return found ?? { typeId, name: `Unknown (${typeId})` };
35
- }
17
+ export { CaseFieldTypeId, FIELD_TYPES, getFieldType, SYSTEM_FIELDS };
36
18
  function isFieldRequired(field) {
37
19
  return field.configs.some(config => config.options?.is_required === true);
38
20
  }
@@ -51,7 +33,7 @@ export function mapToFieldSchema(field) {
51
33
  if (!field.include_all && field.template_ids.length > 0) {
52
34
  schema.template_ids = field.template_ids;
53
35
  }
54
- if (field.type_id === 6 || field.type_id === 12) {
36
+ if (field.type_id === CaseFieldTypeId.Dropdown || field.type_id === CaseFieldTypeId.MultiSelect) {
55
37
  schema.options = Array.from(parseDropdownOptions(field).values());
56
38
  }
57
39
  const contexts = field.configs.map(c => c.context).filter(Boolean);
@@ -69,21 +51,11 @@ export function mapToFieldSchema(field) {
69
51
  }
70
52
  export function isFieldForProject(field, projectId) {
71
53
  const contexts = field.configs.map(c => c.context).filter(Boolean);
72
- if (contexts.length === 0)
54
+ if (contexts.length === 0) {
73
55
  return true;
56
+ }
74
57
  return contexts.some(ctx => ctx.is_global || ctx.project_ids.includes(projectId));
75
58
  }
76
- export const SYSTEM_FIELDS = [
77
- { system_name: "title", label: "Title", type: getFieldType(1).name, is_required: true, project_scope: { scope: "global" } },
78
- { system_name: "section_id", label: "Section", type: getFieldType(2).name, is_required: true, project_scope: { scope: "global" } },
79
- { system_name: "template_id", label: "Template", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
80
- { system_name: "type_id", label: "Type", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
81
- { system_name: "priority_id", label: "Priority", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
82
- { system_name: "estimate", label: "Estimate", type: getFieldType(1).name, is_required: false, project_scope: { scope: "global" } },
83
- { system_name: "milestone_id", label: "Milestone", type: getFieldType(2).name, is_required: false, project_scope: { scope: "global" } },
84
- { system_name: "refs", label: "References", type: getFieldType(1).name, is_required: false, project_scope: { scope: "global" } },
85
- { system_name: "labels", label: "Labels", type: getFieldType(14).name, is_required: false, project_scope: { scope: "global" }, description: "Use get_labels tool to get available labels/tags." },
86
- ];
87
59
  const description = `
88
60
  Get the field schema for test cases for a specific project.
89
61
  You should normally provide project_id to get fields applicable to your project.
@@ -6,7 +6,8 @@ import { updateCasesTool } from "./update_cases.js";
6
6
  import { addCaseTool } from "./add_case.js";
7
7
  import { getCaseHistoryTool } from "./get_case_history.js";
8
8
  import { resolveCaseFieldTool } from "./resolve_case_field.js";
9
- export { getCaseHistoryTool, resolveCaseFieldTool };
9
+ import { exportCasesForRagTool } from "./export_cases_for_rag.js";
10
+ export { getCaseHistoryTool, resolveCaseFieldTool, exportCasesForRagTool };
10
11
  export const casesTools = [
11
12
  getCaseTool,
12
13
  getCasesTool,
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { parseDropdownOptions } from "../../utils/mapper.js";
3
3
  import { isActive } from "../../utils/sanitizer.js";
4
- import { isFieldForProject } from "./get_case_fields.js";
4
+ import { isFieldForProject, CaseFieldTypeId } from "./get_case_fields.js";
5
5
  const parameters = {
6
6
  project_id: z.number().describe("The ID of the project to resolve field values for"),
7
7
  field_name: z.string().describe("The system name of the Multi-select field (e.g. 'custom_case_feature_tags')"),
@@ -22,7 +22,7 @@ export const resolveCaseFieldTool = {
22
22
  if (!targetField) {
23
23
  throw new Error(`Field '${field_name}' not found in active case fields.`);
24
24
  }
25
- if (targetField.type_id !== 12) {
25
+ if (targetField.type_id !== CaseFieldTypeId.MultiSelect) {
26
26
  throw new Error(`Field '${targetField.system_name}' is of type_id ${targetField.type_id}, but resolve_case_field only supports Multi-select fields (type_id: 12).`);
27
27
  }
28
28
  if (!isFieldForProject(targetField, project_id)) {
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { LabelSchema } from "../commons/types.js";
3
+ import { CaseFieldTypeId } from "./fields.js";
4
+ export { CaseFieldTypeId };
3
5
  export const CaseFieldConfigSchema = z.object({
4
6
  context: z.object({
5
7
  is_global: z.boolean(),
@@ -1,4 +1,4 @@
1
- import { casesTools, getCaseHistoryTool } from "./cases/index.js";
1
+ import { casesTools, getCaseHistoryTool, exportCasesForRagTool } from "./cases/index.js";
2
2
  import { projectsTools } from "./projects/index.js";
3
3
  import { suitesTools } from "./suites/index.js";
4
4
  import { sectionsTools } from "./sections/index.js";
@@ -7,7 +7,27 @@ import { sharedStepsTools } from "./shared_steps/index.js";
7
7
  import { resultsTools } from "./results/index.js";
8
8
  import { commonsTools } from "./commons/index.js";
9
9
  import { attachmentsTools } from "./attachments/index.js";
10
+ export const ALL_TOOLS = [
11
+ ...projectsTools,
12
+ ...suitesTools,
13
+ ...casesTools,
14
+ getCaseHistoryTool,
15
+ exportCasesForRagTool,
16
+ ...sectionsTools,
17
+ ...runsTools,
18
+ ...resultsTools,
19
+ ...attachmentsTools,
20
+ ...commonsTools,
21
+ ...sharedStepsTools,
22
+ ];
23
+ const ALL_TOOL_NAMES = new Set(ALL_TOOLS.map(t => t.name));
10
24
  export function getToolsToRegister(config) {
25
+ if (config.disabledTools && config.disabledTools.length > 0) {
26
+ const unknownTools = config.disabledTools.filter(name => !ALL_TOOL_NAMES.has(name));
27
+ if (unknownTools.length > 0) {
28
+ throw new Error(`Cannot disable non-existent tool(s): ${unknownTools.join(', ')}`);
29
+ }
30
+ }
11
31
  const tools = [
12
32
  ...projectsTools,
13
33
  ...suitesTools,
@@ -24,11 +44,17 @@ export function getToolsToRegister(config) {
24
44
  if (config.enableCaseHistory) {
25
45
  tools.push(getCaseHistoryTool);
26
46
  }
47
+ if (config.enableRagTools) {
48
+ tools.push(exportCasesForRagTool);
49
+ }
27
50
  const allowWrite = config.allowWrite !== false;
28
51
  const allowRead = config.allowRead !== false;
29
52
  const allowDelete = config.allowDelete === true;
30
53
  const enableDeprecatedTools = config.enableDeprecatedTools !== false;
54
+ const disabledSet = new Set(config.disabledTools ?? []);
31
55
  const filteredTools = tools.filter(tool => {
56
+ if (disabledSet.has(tool.name))
57
+ return false;
32
58
  if (tool.deprecated && !enableDeprecatedTools)
33
59
  return false;
34
60
  if (tool.mode === 'write')
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { CaseFieldTypeId } from "../tools/cases/types.js";
1
2
  import { sanitizeValue } from "./sanitizer.js";
2
3
  /**
3
4
  * Processes custom fields from a test case, converting system names to human-readable labels
@@ -33,8 +34,24 @@ export function processCustomFields(testCase, caseFields) {
33
34
  let finalValue = value;
34
35
  if (dropdownOptionsMap.has(key)) {
35
36
  const options = dropdownOptionsMap.get(key);
36
- const optionKey = String(value);
37
- finalValue = options.get(optionKey) || value;
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
+ }
49
+ }
50
+ 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
+ }
38
55
  }
39
56
  result[outputKey] = sanitizeValue(finalValue);
40
57
  }
@@ -1,11 +1,100 @@
1
1
  /**
2
- * Recursively strips inline style attributes from HTML tags contained in string values
3
- * in an object or array. Useful for cleaning up TestRail markdown content before sending
4
- * to LLMs.
2
+ * Decodes standard and numeric HTML entities.
3
+ */
4
+ function decodeHtmlEntities(str) {
5
+ return str
6
+ .replace(/ /gi, ' ')
7
+ .replace(/&/gi, '&')
8
+ .replace(/&lt;/gi, '<')
9
+ .replace(/&gt;/gi, '>')
10
+ .replace(/&quot;/gi, '"')
11
+ .replace(/&#39;|&apos;/gi, "'")
12
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
13
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
14
+ }
15
+ /**
16
+ * Converts TestRail HTML markup to clean, structured Markdown / plain text.
17
+ */
18
+ export function htmlToMarkdown(html) {
19
+ if (!html || typeof html !== 'string') {
20
+ return html;
21
+ }
22
+ // Quick path if no HTML tags or entities
23
+ if (!html.includes('<') && !html.includes('&')) {
24
+ return html.trim();
25
+ }
26
+ let text = html;
27
+ // Normalize line breaks
28
+ text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
29
+ // Process images / attachments (e.g. data-attachment-id, attachment get URL, or general image)
30
+ text = text.replace(/<span[^>]*class="[^"]*markdown-img-container[^"]*"[^>]*>([\s\S]*?)<\/span>/gi, '$1');
31
+ text = text.replace(/<img\b([^>]*)>/gi, (_, attrs) => {
32
+ const attachmentIdMatch = attrs.match(/data-attachment-id=["']([^"']+)["']/i)
33
+ || attrs.match(/id=["']attachment-([^"']+)["']/i)
34
+ || attrs.match(/src=["'][^"']*attachments\/get\/([a-zA-Z0-9-]+)[^"']*["']/i);
35
+ if (attachmentIdMatch) {
36
+ return `[Attachment: ${attachmentIdMatch[1]}]`;
37
+ }
38
+ const srcMatch = attrs.match(/src=["']([^"']+)["']/i);
39
+ const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
40
+ if (srcMatch) {
41
+ const alt = altMatch ? altMatch[1] : 'Image';
42
+ return `![${alt}](${srcMatch[1]})`;
43
+ }
44
+ return '[Image]';
45
+ });
46
+ // Headings <h1> - <h6>
47
+ text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, content) => {
48
+ return `\n${'#'.repeat(Number(level))} ${content.trim()}\n\n`;
49
+ });
50
+ // Preformatted & code blocks
51
+ text = text.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => `\n\`\`\`\n${code}\n\`\`\`\n`);
52
+ text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, code) => `\n\`\`\`\n${code}\n\`\`\`\n`);
53
+ text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
54
+ // Formatting: Bold, Italic, Strikethrough
55
+ text = text.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
56
+ text = text.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
57
+ text = text.replace(/<(del|s|strike)\b[^>]*>([\s\S]*?)<\/\1>/gi, '~~$2~~');
58
+ // Links
59
+ text = text.replace(/<a\b[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
60
+ // Paragraphs, breaks, dividers, divs (process before lists so li content is cleanly trimmed)
61
+ text = text.replace(/<hr\b[^>]*\/?>/gi, '\n---\n');
62
+ text = text.replace(/<br\b[^>]*\/?>/gi, '\n');
63
+ text = text.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n');
64
+ text = text.replace(/<div\b[^>]*>([\s\S]*?)<\/div>/gi, '\n$1\n');
65
+ // Ordered & Unordered Lists
66
+ text = text.replace(/<ol\b[^>]*>([\s\S]*?)<\/ol>/gi, (_, listContent) => {
67
+ let index = 1;
68
+ const items = listContent.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (__, itemContent) => {
69
+ const cleaned = itemContent.trim();
70
+ return `\n${index++}. ${cleaned}`;
71
+ });
72
+ return `\n${items}\n`;
73
+ });
74
+ text = text.replace(/<ul\b[^>]*>([\s\S]*?)<\/ul>/gi, (_, listContent) => {
75
+ const items = listContent.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (__, itemContent) => {
76
+ const cleaned = itemContent.trim();
77
+ return `\n- ${cleaned}`;
78
+ });
79
+ return `\n${items}\n`;
80
+ });
81
+ // Strip remaining HTML tags
82
+ text = text.replace(/<[^>]+>/g, '');
83
+ // Decode HTML entities
84
+ text = decodeHtmlEntities(text);
85
+ // Clean up whitespace and newlines
86
+ const lines = text.split('\n').map(line => line.trimEnd());
87
+ text = lines.join('\n');
88
+ text = text.replace(/\n{3,}/g, '\n\n');
89
+ return text.trim();
90
+ }
91
+ /**
92
+ * Recursively cleans HTML from TestRail strings, converting HTML markup
93
+ * to clean Markdown/plaintext and decoding entities.
5
94
  */
6
95
  export function sanitizeValue(value) {
7
96
  if (typeof value === 'string') {
8
- return value.replace(/\s*style\s*=\s*["'][^"']*["']/gi, '');
97
+ return htmlToMarkdown(value);
9
98
  }
10
99
  if (Array.isArray(value)) {
11
100
  return value.map(sanitizeValue);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uarlouski/testrail-mcp-server",
3
- "version": "2.5.0",
3
+ "version": "2.7.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",