@uarlouski/testrail-mcp-server 3.1.0 → 3.3.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
@@ -17,6 +17,7 @@
17
17
  <a href="https://github.com/uarlouski/testrail-mcp-server"><img src="https://img.shields.io/github/stars/uarlouski/testrail-mcp-server.svg?style=social&label=Star" alt="GitHub stars"></a>
18
18
  <a href="https://glama.ai/mcp/servers/uarlouski/testrail-mcp-server"><img src="https://glama.ai/mcp/servers/uarlouski/testrail-mcp-server/badges/score.svg" alt="Score Badge"></a>
19
19
  <a href="https://m8ven.ai/mcp/uarlouski/testrail-mcp-server"><img src="https://m8ven.ai/badge/mcp/uarlouski/testrail-mcp-server" alt="M8ven Score"></a>
20
+ <a href="https://smithery.ai/servers/uarlouski/testrail-mcp-server"><img src="https://smithery.ai/badge/uarlouski/testrail-mcp-server" alt="Smithery badge"></a>
20
21
  </p>
21
22
 
22
23
  <p align="center">
@@ -42,7 +43,7 @@
42
43
 
43
44
  The **TestRail MCP Server** is a free, open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI assistants direct, structured access to a [TestRail](https://www.testrail.com/) instance through the TestRail API v2. Once configured, an assistant such as **Claude Desktop, Cursor, Windsurf, or GitHub Copilot in VS Code** can search test cases, draft new ones, start test runs, record results, and upload attachments on your behalf — without you leaving the chat window.
44
45
 
45
- It exposes **34 tools**, runs locally on **Node.js 18+** over the MCP stdio transport, and is licensed under **Apache 2.0**. There is nothing to host or deploy: your MCP client launches it on demand with `npx`.
46
+ It exposes **35 tools**, runs locally on **Node.js 18+** over the MCP stdio transport, and is licensed under **Apache 2.0**. There is nothing to host or deploy: your MCP client launches it on demand with `npx`.
46
47
 
47
48
  **No context switching. No tedious copy-pasting. Just ask your AI.**
48
49
 
@@ -279,19 +280,19 @@ See the [CLI & CI/CD guide](https://uarlouski.github.io/testrail-mcp-server/guid
279
280
  | `TESTRAIL_ENABLE_DEPRECATED_TOOLS` | Preserved for backward compatibility with existing host configurations. | | `true` |
280
281
  | `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. | | - |
281
282
 
282
- With only the three required credentials, **26 of the 34 tools** are registered. Ready-made read-only and least-privilege configurations are in the [Configuration guide](https://uarlouski.github.io/testrail-mcp-server/guide/configuration).
283
+ With only the three required credentials, **27 of the 35 tools** are registered. Ready-made read-only and least-privilege configurations are in the [Configuration guide](https://uarlouski.github.io/testrail-mcp-server/guide/configuration).
283
284
 
284
285
  ---
285
286
 
286
287
  ## 🧰 Available Tools
287
288
 
288
- All 34 tools, grouped by area. Each declares a `read`, `write`, or `delete` mode, which the server surfaces to clients as MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`).
289
+ All 35 tools, grouped by area. Each declares a `read`, `write`, or `delete` mode, which the server surfaces to clients as MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).
289
290
 
290
291
  | Area | Tools | Reference |
291
292
  |------|-------|-----------|
292
293
  | **Discovery & Navigation** | `query_project`, `query_suite`, `mutate_suite`, `query_section`, `mutate_section`, `get_users` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/discovery) |
293
294
  | **Test Case Management** | `get_case`, `get_cases`, `add_case`, `update_case`, `update_cases`, `get_case_fields`, `resolve_case_field`, `get_case_history`, `export_cases_for_rag` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/cases) |
294
- | **Execution & Tracking** | `query_run`, `mutate_run`, `get_tests`, `get_results`, `add_results`, `add_results_for_cases` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/execution) |
295
+ | **Execution & Tracking** | `query_run`, `mutate_run`, `query_test`, `get_tests`, `get_results`, `add_results`, `add_results_for_cases` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/execution) |
295
296
  | **Attachments & Media** | `add_attachment`, `query_attachment` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/attachments) |
296
297
  | **Shared Steps** | `get_shared_step`, `get_shared_steps`, `get_shared_step_history`, `add_shared_step`, `update_shared_step` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/shared-steps) |
297
298
  | **System Metadata** | `get_statuses`, `get_priorities`, `get_case_fields`, `get_templates`, `get_labels`, `get_configurations` | [Docs](https://uarlouski.github.io/testrail-mcp-server/reference/metadata) |
@@ -355,14 +356,13 @@ For a comprehensive guide, detailed configuration options, and a complete breakd
355
356
 
356
357
  - 🚀 **[Getting Started](https://uarlouski.github.io/testrail-mcp-server/guide/getting-started)**: Per-client setup for Claude, Cursor, Windsurf, and VS Code.
357
358
  - ⚙️ **[Configuration](https://uarlouski.github.io/testrail-mcp-server/guide/configuration)**: Every environment variable, permission, and feature flag.
358
- - 🧰 **[All Tools](https://uarlouski.github.io/testrail-mcp-server/reference/)**: All 34 tools with modes and feature flags in one table.
359
+ - 🧰 **[All Tools](https://uarlouski.github.io/testrail-mcp-server/reference/)**: All 35 tools with modes and feature flags in one table.
359
360
  - 🔭 **[Discovery & Navigation](https://uarlouski.github.io/testrail-mcp-server/reference/discovery)**: Exploring projects, suites, and sections.
360
361
  - 📋 **[Test Case Management](https://uarlouski.github.io/testrail-mcp-server/reference/cases)**: Fetching, creating, and bulk-updating test cases.
361
362
  - ▶️ **[Execution & Tracking](https://uarlouski.github.io/testrail-mcp-server/reference/execution)**: Managing test runs and submitting test results.
362
363
  - 📎 **[Attachments](https://uarlouski.github.io/testrail-mcp-server/reference/attachments)**: Automatically zipping and uploading files or directories.
363
364
  - 🔗 **[Shared Steps](https://uarlouski.github.io/testrail-mcp-server/reference/shared-steps)**: Managing reusable step definitions.
364
365
  - 🖥️ **[CLI & CI/CD](https://uarlouski.github.io/testrail-mcp-server/guide/cli)**: Pipeline automation without an LLM.
365
-
366
366
  ---
367
367
 
368
368
  ## 🤝 Contributing
package/dist/cli.js CHANGED
@@ -29,7 +29,17 @@ export function resolveClient(options, env) {
29
29
  console.error("or supply them via --url, --username, and --api-key options.");
30
30
  return null;
31
31
  }
32
- return new TestRailClient(credResult.data.url, credResult.data.username, credResult.data.apiKey);
32
+ const timeoutEnv = env.TESTRAIL_REQUEST_TIMEOUT_MS;
33
+ let timeoutMs;
34
+ if (timeoutEnv) {
35
+ const parsed = parseInt(timeoutEnv, 10);
36
+ if (!isNaN(parsed) && parsed > 0) {
37
+ timeoutMs = parsed;
38
+ }
39
+ }
40
+ return new TestRailClient(credResult.data.url, credResult.data.username, credResult.data.apiKey, {
41
+ timeoutMs,
42
+ });
33
43
  }
34
44
  export function validateToolArgs(tool, flags) {
35
45
  const coercedArgs = coerceFlagsForTool(flags, tool.parameters);
@@ -7,6 +7,7 @@ export class TestRailClient {
7
7
  baseUrl;
8
8
  headers;
9
9
  auth;
10
+ timeoutMs;
10
11
  prioritiesPromise = null;
11
12
  caseTypesPromise = null;
12
13
  caseFieldsPromise = null;
@@ -14,7 +15,7 @@ export class TestRailClient {
14
15
  projectsPromise = null;
15
16
  templatesPromiseMap = new Map();
16
17
  sectionsPromiseMap = new Map();
17
- constructor(baseUrl, email, apiKey) {
18
+ constructor(baseUrl, email, apiKey, options) {
18
19
  this.baseUrl = baseUrl.replace(/\/$/, "");
19
20
  const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
20
21
  this.auth = `Basic ${auth}`;
@@ -22,6 +23,7 @@ export class TestRailClient {
22
23
  "Content-Type": "application/json",
23
24
  "Authorization": this.auth,
24
25
  };
26
+ this.timeoutMs = options?.timeoutMs ?? 30_000;
25
27
  }
26
28
  async getCase(caseId) {
27
29
  return this.get(`${API_BASE_V2}/get_case/${caseId}`);
@@ -173,6 +175,9 @@ export class TestRailClient {
173
175
  const url = `${API_BASE_V2}/get_labels/${projectId}`;
174
176
  return this.paginateAll(url, 'labels');
175
177
  }
178
+ async getTest(testId) {
179
+ return this.get(`${API_BASE_V2}/get_test/${testId}`);
180
+ }
176
181
  async getTests(runId, statusId) {
177
182
  let url = `${API_BASE_V2}/get_tests/${runId}`;
178
183
  if (statusId && statusId.length > 0) {
@@ -283,17 +288,19 @@ export class TestRailClient {
283
288
  }
284
289
  async _executeRequest(method, endpoint, headers, body, responseType = 'json') {
285
290
  const url = `${this.baseUrl}${endpoint}`;
286
- const params = {
287
- method,
288
- headers,
289
- body,
290
- };
291
291
  const maxRetries = 3;
292
292
  const baseDelayMs = 1000;
293
293
  let attempt = 0;
294
294
  while (attempt <= maxRetries) {
295
295
  console.error(`[TestRailClient] Executing ${method} request to ${endpoint}${attempt > 0 ? ` (Attempt ${attempt + 1})` : ''}`);
296
296
  const startTime = Date.now();
297
+ const signal = AbortSignal.timeout(this.timeoutMs);
298
+ const params = {
299
+ method,
300
+ headers,
301
+ body,
302
+ signal,
303
+ };
297
304
  try {
298
305
  const response = await fetch(url, params);
299
306
  const duration = Date.now() - startTime;
@@ -329,10 +336,20 @@ export class TestRailClient {
329
336
  return await response.text();
330
337
  }
331
338
  catch (error) {
332
- const networkError = error instanceof Error && !error.message.startsWith('TestRail API Error');
339
+ const errName = error?.name;
340
+ const errMsg = typeof error?.message === 'string' ? error.message : '';
341
+ const isTimeout = signal.aborted ||
342
+ errName === 'TimeoutError' ||
343
+ signal.reason?.name === 'TimeoutError';
344
+ if (isTimeout) {
345
+ const timeoutMessage = `TestRail request timed out after ${this.timeoutMs}ms: ${method} ${endpoint}`;
346
+ console.error(`[TestRailClient] Request Failed: ${method} ${endpoint} - ${timeoutMessage}`);
347
+ throw new Error(timeoutMessage);
348
+ }
349
+ const networkError = !errMsg.startsWith('TestRail API Error');
333
350
  if (networkError && attempt < maxRetries) {
334
351
  const delayMs = baseDelayMs * Math.pow(2, attempt);
335
- console.error(`[TestRailClient] Network error (${error.message}). Retrying in ${delayMs}ms...`);
352
+ console.error(`[TestRailClient] Network error (${errMsg}). Retrying in ${delayMs}ms...`);
336
353
  await this.delay(delayMs);
337
354
  attempt++;
338
355
  continue;
package/dist/index.js CHANGED
@@ -27,19 +27,27 @@ const EnvSchema = z.object({
27
27
  TESTRAIL_ALLOW_READ_OPERATIONS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
28
28
  TESTRAIL_ALLOW_DELETE_OPERATIONS: z.string().optional().transform(val => val === 'true'),
29
29
  TESTRAIL_ENABLE_DEPRECATED_TOOLS: z.string().optional().transform(val => val === undefined ? true : val === 'true'),
30
- TESTRAIL_DISABLED_TOOLS: z.string().optional().transform(val => val ? val.split(',').map(s => s.trim()).filter(Boolean) : [])
30
+ TESTRAIL_DISABLED_TOOLS: z.string().optional().transform(val => val ? val.split(',').map(s => s.trim()).filter(Boolean) : []),
31
+ TESTRAIL_REQUEST_TIMEOUT_MS: z.string().optional().refine(val => {
32
+ if (!val)
33
+ return true;
34
+ const num = Number(val);
35
+ return Number.isInteger(num) && num > 0;
36
+ }, { message: 'Must be a positive integer' }).transform(val => val ? parseInt(val, 10) : undefined),
31
37
  });
32
38
  const parseResult = EnvSchema.safeParse(process.env);
33
39
  if (!parseResult.success) {
34
40
  console.error("Invalid TestRail environment configuration:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
35
41
  process.exit(1);
36
42
  }
37
- 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;
43
+ 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, TESTRAIL_REQUEST_TIMEOUT_MS, } = parseResult.data;
38
44
  const server = new McpServer({
39
45
  name: "TestRail MCP Server",
40
46
  version: VERSION,
41
47
  });
42
- const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
48
+ const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, {
49
+ timeoutMs: TESTRAIL_REQUEST_TIMEOUT_MS,
50
+ });
43
51
  let tools;
44
52
  try {
45
53
  tools = getToolsToRegister({
@@ -6,6 +6,8 @@ import { resolveCustomFieldValue } from "../../utils/mapper.js";
6
6
  import { getBaseDirectory } from "../../utils/fs.js";
7
7
  import { CaseFieldTypeId, getFieldType } from "./fields.js";
8
8
  import { fetchFilteredCases } from "./get_cases.js";
9
+ import { createSectionLookup, formatSectionPath, resolveSectionPath } from "../../utils/section_path.js";
10
+ import { computeContentHash } from "../../utils/content_hash.js";
9
11
  const parameters = {
10
12
  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
13
  project_id: z.number().optional().describe("The ID of the project to export cases from. Required if case_ids is not provided."),
@@ -13,7 +15,7 @@ const parameters = {
13
15
  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
16
  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}"),
15
17
  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, updated_on, title, section, priority, references, labels) cannot be ignored."),
18
+ 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, updated_on, title, section, section_path, priority, references, labels, content_hash) cannot be ignored."),
17
19
  };
18
20
  function formatStepsSeparated(steps) {
19
21
  return steps.map((step, idx) => {
@@ -81,9 +83,6 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
81
83
  if (!systemName.startsWith("custom_")) {
82
84
  continue;
83
85
  }
84
- if (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0)) {
85
- continue;
86
- }
87
86
  const strippedName = systemName.replace(/^custom_/, "");
88
87
  if (ignoredFieldsSet.has(systemName) || ignoredFieldsSet.has(strippedName)) {
89
88
  continue;
@@ -92,6 +91,10 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
92
91
  if (!fieldDef) {
93
92
  continue;
94
93
  }
94
+ const isCheckbox = fieldDef.type_id === CaseFieldTypeId.Checkbox;
95
+ if (!isCheckbox && (rawValue === null || rawValue === undefined || rawValue === "" || (Array.isArray(rawValue) && rawValue.length === 0))) {
96
+ continue;
97
+ }
95
98
  const val = resolveCustomFieldValue(fieldDef, rawValue);
96
99
  if (isMarkdownField(fieldDef, val)) {
97
100
  const header = fieldDef.label || systemName.replace(/^custom_/, "");
@@ -104,11 +107,11 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
104
107
  }
105
108
  return { markdownFields, metadataFields };
106
109
  }
107
- function buildMarkdownBody(testCase, sectionName, markdownFields) {
110
+ function buildMarkdownBody(testCase, sectionPath, markdownFields) {
108
111
  const mdLines = [];
109
112
  mdLines.push(`# [C${testCase.id}] ${sanitizeValue(testCase.title)}`);
110
113
  mdLines.push("");
111
- mdLines.push(`**Section**: ${sectionName}`);
114
+ mdLines.push(`**Section**: ${sectionPath.length > 0 ? formatSectionPath(sectionPath) : "Unknown"}`);
112
115
  mdLines.push("");
113
116
  for (const { header, value, fieldDef } of markdownFields) {
114
117
  mdLines.push(`## ${header}`);
@@ -122,27 +125,44 @@ function buildMarkdownBody(testCase, sectionName, markdownFields) {
122
125
  }
123
126
  return mdLines.join("\n").trim() + "\n";
124
127
  }
125
- function buildMetadataAttributes(testCase, sectionName, priority, metadataFields) {
128
+ function buildMetadataAttributes(testCase, sectionPath, priority, metadataFields) {
126
129
  const metadataAttributes = {
127
130
  case_id: testCase.id,
128
131
  updated_on: testCase.updated_on,
129
132
  title: testCase.title,
130
- section: sectionName,
133
+ section: sectionPath.length > 0 ? sectionPath[sectionPath.length - 1] : "Unknown",
131
134
  priority: priority,
132
135
  };
136
+ if (sectionPath.length > 0) {
137
+ metadataAttributes.section_path = sectionPath;
138
+ }
133
139
  if (testCase.refs) {
134
140
  metadataAttributes.references = testCase.refs;
135
141
  }
136
142
  if (testCase.labels && testCase.labels.length > 0) {
137
143
  metadataAttributes.labels = testCase.labels.map(l => l.title);
138
144
  }
145
+ const CORE_METADATA_KEYS = new Set([
146
+ "case_id",
147
+ "updated_on",
148
+ "title",
149
+ "section",
150
+ "section_path",
151
+ "priority",
152
+ "references",
153
+ "labels",
154
+ "content_hash",
155
+ ]);
139
156
  for (const { key, value, fieldDef } of metadataFields) {
157
+ const targetKey = CORE_METADATA_KEYS.has(key)
158
+ ? (fieldDef?.system_name || `custom_${key}`)
159
+ : key;
140
160
  if (fieldDef?.type_id === CaseFieldTypeId.MultiSelect) {
141
161
  const listVal = Array.isArray(value) ? value : [value];
142
- metadataAttributes[key] = sanitizeMetadataValue(listVal.map(item => String(item)));
162
+ metadataAttributes[targetKey] = sanitizeMetadataValue(listVal.map(item => String(item)));
143
163
  }
144
164
  else {
145
- metadataAttributes[key] = sanitizeMetadataValue(value);
165
+ metadataAttributes[targetKey] = sanitizeMetadataValue(value);
146
166
  }
147
167
  }
148
168
  return metadataAttributes;
@@ -194,21 +214,23 @@ export const exportCasesForRagTool = {
194
214
  }
195
215
  }
196
216
  }
217
+ let preloadedSections = [];
218
+ if (testCases.length > 0) {
219
+ preloadedSections = await client.getSections(project_id, suite_id).catch(() => []);
220
+ }
221
+ const sectionLookup = createSectionLookup(client, preloadedSections);
197
222
  const writtenFiles = [];
198
223
  for (const testCase of testCases) {
199
- let sectionName = "Unknown";
200
- if (testCase.section_id) {
201
- const section = await client.getSection(Number(testCase.section_id)).catch(() => null);
202
- if (section?.name) {
203
- sectionName = section.name;
204
- }
205
- }
224
+ const sectionPath = testCase.section_id
225
+ ? await resolveSectionPath(Number(testCase.section_id), sectionLookup)
226
+ : [];
206
227
  const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
207
228
  const templateId = testCase.template_id;
208
229
  const applicableFields = caseFields.filter(field => field.include_all || (templateId !== null && templateId !== undefined && field.template_ids.includes(templateId)));
209
230
  const { markdownFields, metadataFields } = categorizeCustomFields(testCase, applicableFields, ignoredFieldsSet);
210
- const markdownContent = buildMarkdownBody(testCase, sectionName, markdownFields);
211
- const metadataAttributes = buildMetadataAttributes(testCase, sectionName, priority, metadataFields);
231
+ const markdownContent = buildMarkdownBody(testCase, sectionPath, markdownFields);
232
+ const metadataAttributes = buildMetadataAttributes(testCase, sectionPath, priority, metadataFields);
233
+ metadataAttributes.content_hash = computeContentHash(markdownContent, metadataAttributes);
212
234
  const docFilename = `C${testCase.id}.md`;
213
235
  const metaFilename = `C${testCase.id}.md.metadata.json`;
214
236
  await fs.promises.writeFile(path.join(targetDir, docFilename), markdownContent, "utf-8");
@@ -55,3 +55,13 @@ export const READONLY_CASE_FIELDS = [
55
55
  "estimate_forecast",
56
56
  "is_deleted",
57
57
  ];
58
+ /**
59
+ * Standard case property names that must not be overwritten by custom fields.
60
+ * If a custom field's converted label collides with one of these names (e.g. "labels", "title"),
61
+ * the custom field retains its `system_name` (e.g. `custom_labels`, `custom_title`) to prevent collisions.
62
+ */
63
+ export const RESERVED_CASE_FIELDS = new Set([
64
+ ...SYSTEM_FIELDS.map(f => f.label.toLowerCase()),
65
+ ...SYSTEM_FIELDS.map(f => f.system_name),
66
+ ...READONLY_CASE_FIELDS,
67
+ ]);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { processCustomFields } from "../../utils/mapper.js";
3
3
  import { normalizeEntityId } from "../../utils/sanitizer.js";
4
- import { LabelSchema } from "../commons/types.js";
4
+ import { CaseLabelSchema } from "../commons/types.js";
5
5
  const parameters = {
6
6
  case_id: z.string().describe("The ID of the test case (e.g. '123' or 'C123')"),
7
7
  };
@@ -23,15 +23,15 @@ export const getCaseTool = {
23
23
  const priority = priorities.find(p => p.id === testCase.priority_id);
24
24
  const customFields = processCustomFields(testCase, caseFields);
25
25
  const response = {
26
+ ...customFields,
26
27
  id: testCase.id,
27
28
  title: testCase.title,
28
29
  section: section.name,
29
30
  type: caseType ? caseType.name : "Unknown",
30
31
  priority: priority ? priority.name : "Unknown",
31
- labels: (testCase.labels || []).map((label) => LabelSchema.parse(label)),
32
+ labels: (testCase.labels || []).map((label) => CaseLabelSchema.parse(label)),
32
33
  references: testCase.refs,
33
34
  updated_on: testCase.updated_on,
34
- ...customFields
35
35
  };
36
36
  return response;
37
37
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { LabelSchema } from "../commons/types.js";
2
+ import { CaseLabelSchema } from "../commons/types.js";
3
3
  import { CaseFieldTypeId } from "./fields.js";
4
4
  export { CaseFieldTypeId };
5
5
  export const CaseFieldConfigSchema = z.object({
@@ -42,7 +42,7 @@ export const CaseSchema = z.object({
42
42
  estimate_forecast: z.string().nullable().optional(),
43
43
  suite_id: z.number(),
44
44
  is_deleted: z.number().optional(),
45
- labels: z.array(LabelSchema),
45
+ labels: z.array(CaseLabelSchema),
46
46
  }).loose();
47
47
  export const CaseHistoryChangeSchema = z.object({
48
48
  field: z.string(),
@@ -6,7 +6,7 @@ const parameters = {
6
6
  export const getLabelsTool = {
7
7
  name: "get_labels",
8
8
  mode: "read",
9
- description: "Get all available test case labels (sometimes called tags) for a project. Returns label IDs and titles that can be used when creating or updating test cases.",
9
+ description: "Get all available test case labels (sometimes called tags) for a project. Returns label IDs and names that can be used when creating or updating test cases.",
10
10
  parameters,
11
11
  handler: async ({ project_id }, client) => {
12
12
  const labels = await client.getLabels(project_id);
@@ -4,7 +4,6 @@ import { getLabelsTool } from "./get_labels.js";
4
4
  import { getPrioritiesTool } from "./get_priorities.js";
5
5
  import { getStatusesTool } from "./get_statuses.js";
6
6
  import { getTemplatesTool } from "./get_templates.js";
7
- import { getTestsTool } from "./get_tests.js";
8
7
  import { getUsersTool } from "./get_users.js";
9
8
  export const commonsTools = [
10
9
  deleteEntityTool,
@@ -13,6 +12,5 @@ export const commonsTools = [
13
12
  getPrioritiesTool,
14
13
  getStatusesTool,
15
14
  getTemplatesTool,
16
- getTestsTool,
17
15
  getUsersTool,
18
16
  ];
@@ -1,5 +1,11 @@
1
1
  import { z } from "zod";
2
+ /** Label as returned by the `get_labels` endpoint (uses `name`). */
2
3
  export const LabelSchema = z.object({
4
+ id: z.number(),
5
+ name: z.string(),
6
+ });
7
+ /** Label as embedded in a case payload (`get_case`, `get_cases`); uses `title`. */
8
+ export const CaseLabelSchema = z.object({
3
9
  id: z.number(),
4
10
  title: z.string(),
5
11
  });
@@ -23,14 +29,7 @@ export const StatusSchema = z.object({
23
29
  name: z.string(),
24
30
  label: z.string(),
25
31
  });
26
- export const TestSchema = z.object({
27
- id: z.number(),
28
- case_id: z.number(),
29
- status_id: z.number(),
30
- title: z.string(),
31
- run_id: z.number(),
32
- });
33
- export const TestsSchema = z.array(TestSchema);
32
+ export { TestSchema, TestsSchema } from "../tests/types.js";
34
33
  export const UserSchema = z.object({
35
34
  id: z.number(),
36
35
  name: z.string(),
@@ -7,6 +7,7 @@ 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
+ import { testsTools } from "./tests/index.js";
10
11
  export const ALL_TOOLS = [
11
12
  ...projectsTools,
12
13
  ...suitesTools,
@@ -15,6 +16,7 @@ export const ALL_TOOLS = [
15
16
  exportCasesForRagTool,
16
17
  ...sectionsTools,
17
18
  ...runsTools,
19
+ ...testsTools,
18
20
  ...resultsTools,
19
21
  ...attachmentsTools,
20
22
  ...commonsTools,
@@ -34,6 +36,7 @@ export function getToolsToRegister(config) {
34
36
  ...casesTools,
35
37
  ...sectionsTools,
36
38
  ...runsTools,
39
+ ...testsTools,
37
40
  ...resultsTools,
38
41
  ...attachmentsTools,
39
42
  ...commonsTools,
@@ -68,6 +71,7 @@ export function getToolsToRegister(config) {
68
71
  readOnlyHint: tool.mode === 'read',
69
72
  destructiveHint: tool.mode === 'delete',
70
73
  idempotentHint: tool.mode === 'read',
74
+ openWorldHint: false,
71
75
  };
72
76
  return {
73
77
  ...tool,
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- import { TestSchema } from "./types.js";
3
2
  const parameters = {
4
3
  run_id: z.number().describe("The ID of the test run"),
5
4
  status_id: z.array(z.number()).optional().describe("Optional array of status IDs to filter by. Use get_statuses to retrieve available status IDs"),
@@ -7,12 +6,19 @@ const parameters = {
7
6
  export const getTestsTool = {
8
7
  name: "get_tests",
9
8
  mode: "read",
10
- description: "Get tests for a test run, optionally filtered by status",
9
+ deprecated: true,
10
+ description: "Get tests for a test run, optionally filtered by status (Deprecated: use query_test with action 'many' instead)",
11
11
  parameters,
12
12
  handler: async ({ run_id, status_id }, client) => {
13
13
  const tests = await client.getTests(run_id, status_id);
14
14
  return {
15
- tests: tests.map(test => TestSchema.parse(test)),
15
+ tests: tests.map(test => ({
16
+ id: test.id,
17
+ case_id: test.case_id,
18
+ status_id: test.status_id,
19
+ title: test.title,
20
+ run_id: test.run_id,
21
+ })),
16
22
  };
17
23
  },
18
24
  };
@@ -0,0 +1,7 @@
1
+ import { queryTestTool } from "./query_test.js";
2
+ import { getTestsTool } from "./get_tests.js";
3
+ export const testsTools = [
4
+ queryTestTool,
5
+ getTestsTool,
6
+ ];
7
+ export { queryTestTool, getTestsTool };
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import fs from "fs";
3
+ import { GetOneTestSchema, GetManyTestsSchema, TestSchema } from "./types.js";
4
+ import { handleQuery } from "../../utils/query_handler.js";
5
+ import { normalizeEntityId } from "../../utils/sanitizer.js";
6
+ const parameters = {
7
+ payload: z.discriminatedUnion("action", [
8
+ GetOneTestSchema,
9
+ GetManyTestsSchema,
10
+ ]).describe("The payload containing the action ('one' or 'many') and corresponding parameters"),
11
+ };
12
+ export const queryTestTool = {
13
+ name: "query_test",
14
+ mode: "read",
15
+ description: "Retrieve a single test by ID or all tests for a test run in TestRail. Set payload.action to 'one' or 'many' to specify the operation.",
16
+ parameters,
17
+ handler: async (args, client) => {
18
+ return handleQuery(args.payload, async (p) => {
19
+ const testId = normalizeEntityId(p.test_id);
20
+ const test = await client.getTest(testId);
21
+ return {
22
+ test: TestSchema.parse(test),
23
+ };
24
+ }, async (p) => {
25
+ const tests = await client.getTests(p.run_id, p.status_id);
26
+ const response = {
27
+ tests: tests.map(t => {
28
+ const baseTest = {
29
+ id: t.id,
30
+ case_id: t.case_id,
31
+ status_id: t.status_id,
32
+ title: t.title,
33
+ run_id: t.run_id,
34
+ };
35
+ if (p.fields && p.fields.length > 0) {
36
+ for (const field of p.fields) {
37
+ if (field in t) {
38
+ baseTest[field] = t[field];
39
+ }
40
+ }
41
+ }
42
+ return baseTest;
43
+ }),
44
+ };
45
+ if (p.output_file) {
46
+ await fs.promises.writeFile(p.output_file, JSON.stringify(response), "utf-8");
47
+ return {
48
+ success: true,
49
+ message: `Successfully exported ${response.tests.length} tests to ${p.output_file}`,
50
+ file: p.output_file,
51
+ };
52
+ }
53
+ return response;
54
+ });
55
+ }
56
+ };
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ export const TestSchema = z.looseObject({
3
+ id: z.number(),
4
+ case_id: z.number(),
5
+ status_id: z.number(),
6
+ title: z.string(),
7
+ run_id: z.number(),
8
+ });
9
+ export const TestsSchema = z.array(TestSchema);
10
+ export const GetOneTestSchema = z.object({
11
+ action: z.literal("one").describe("Retrieve a single test by ID"),
12
+ test_id: z.union([z.number(), z.string()]).describe("The ID of the test (e.g. 123 or 'T123')"),
13
+ });
14
+ export const GetManyTestsSchema = z.object({
15
+ action: z.literal("many").describe("Retrieve tests for a test run"),
16
+ run_id: z.number().int().describe("The ID of the test run"),
17
+ status_id: z.array(z.number()).optional().describe("Optional array of status IDs to filter by. Use get_statuses to retrieve available status IDs"),
18
+ fields: z.array(z.string()).optional().describe("Additional fields to include in response beyond default fields (id, case_id, status_id, title, run_id). Example: ['assignedto_id', 'priority_id', 'refs']"),
19
+ 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."),
20
+ });
@@ -0,0 +1,47 @@
1
+ import * as crypto from "crypto";
2
+ export const DEFAULT_VOLATILE_KEYS = new Set([
3
+ "content_hash",
4
+ "export_time",
5
+ "exported_at",
6
+ "output_path",
7
+ "output_dir",
8
+ ]);
9
+ /**
10
+ * Serializes a value to canonical JSON with sorted object keys,
11
+ * normalized \n line endings, and volatile keys omitted.
12
+ */
13
+ export function canonicalizeJson(val, volatileKeys = DEFAULT_VOLATILE_KEYS) {
14
+ if (val === undefined) {
15
+ return "null";
16
+ }
17
+ if (val === null || typeof val !== "object") {
18
+ if (typeof val === "string") {
19
+ return JSON.stringify(val.replace(/\r\n/g, "\n"));
20
+ }
21
+ return JSON.stringify(val);
22
+ }
23
+ if (Array.isArray(val)) {
24
+ return "[" + val.map(item => canonicalizeJson(item, volatileKeys)).join(",") + "]";
25
+ }
26
+ const sortedKeys = Object.keys(val)
27
+ .filter(key => !volatileKeys.has(key) && val[key] !== undefined)
28
+ .sort();
29
+ const entries = sortedKeys.map(key => {
30
+ return JSON.stringify(key) + ":" + canonicalizeJson(val[key], volatileKeys);
31
+ });
32
+ return "{" + entries.join(",") + "}";
33
+ }
34
+ /**
35
+ * Computes a deterministic SHA-256 content hash of the exported Markdown body
36
+ * and its companion metadataAttributes (excluding content_hash and volatile keys).
37
+ *
38
+ * Formula: sha256( markdown body + "\n" + canonical JSON of metadataAttributes, excluding content_hash )
39
+ * Format: "sha256:<hex>"
40
+ */
41
+ export function computeContentHash(markdownBody, metadataAttributes, volatileKeys = DEFAULT_VOLATILE_KEYS) {
42
+ const normalizedMarkdown = markdownBody.replace(/\r\n/g, "\n");
43
+ const canonicalJson = canonicalizeJson(metadataAttributes, volatileKeys);
44
+ const hashInput = `${normalizedMarkdown}\n${canonicalJson}`;
45
+ const digest = crypto.createHash("sha256").update(hashInput, "utf-8").digest("hex");
46
+ return `sha256:${digest}`;
47
+ }
@@ -1,5 +1,7 @@
1
1
  import { CaseFieldTypeId } from "../tools/cases/types.js";
2
+ import { RESERVED_CASE_FIELDS } from "../tools/cases/fields.js";
2
3
  import { sanitizeValue } from "./sanitizer.js";
4
+ export { RESERVED_CASE_FIELDS };
3
5
  /**
4
6
  * Processes custom fields from a test case, converting system names to human-readable labels
5
7
  * and mapping dropdown values to their display text.
@@ -14,7 +16,9 @@ export function processCustomFields(testCase, caseFields) {
14
16
  const fieldNameMap = new Map();
15
17
  const dropdownOptionsMap = new Map();
16
18
  for (const field of applicableFields) {
17
- fieldNameMap.set(field.system_name, toSnakeCase(field.label));
19
+ const snakeLabel = toSnakeCase(field.label);
20
+ const outputKey = RESERVED_CASE_FIELDS.has(snakeLabel) ? field.system_name : snakeLabel;
21
+ fieldNameMap.set(field.system_name, outputKey);
18
22
  const optionsMap = parseDropdownOptions(field);
19
23
  if (optionsMap.size > 0) {
20
24
  dropdownOptionsMap.set(field.system_name, optionsMap);
@@ -23,15 +27,23 @@ export function processCustomFields(testCase, caseFields) {
23
27
  for (const [key, value] of Object.entries(testCase)) {
24
28
  if (!key.startsWith("custom_"))
25
29
  continue;
26
- if (value === null || value === undefined)
27
- continue;
28
30
  if (!fieldNameMap.has(key)) {
31
+ if (value === null || value === undefined)
32
+ continue;
29
33
  console.error(`No field mapping found for: ${key}`);
30
34
  result[key] = value;
31
35
  continue;
32
36
  }
33
- const outputKey = fieldNameMap.get(key);
34
37
  const field = applicableFields.find(f => f.system_name === key);
38
+ const isCheckbox = field.type_id === CaseFieldTypeId.Checkbox;
39
+ if (value === null || value === undefined) {
40
+ if (isCheckbox) {
41
+ const outputKey = fieldNameMap.get(key);
42
+ result[outputKey] = resolveCustomFieldValue(field, value);
43
+ }
44
+ continue;
45
+ }
46
+ const outputKey = fieldNameMap.get(key);
35
47
  const options = dropdownOptionsMap.get(key);
36
48
  result[outputKey] = resolveCustomFieldValue(field, value, options);
37
49
  }
@@ -41,9 +53,16 @@ export function processCustomFields(testCase, caseFields) {
41
53
  * Resolves a custom field value against its field definition:
42
54
  * - Maps dropdown option IDs to their display labels
43
55
  * - Normalizes multi-select values into arrays
56
+ * - Normalizes checkbox / boolean values (defaults null/undefined to false)
44
57
  * - Sanitizes HTML formatting in strings
45
58
  */
46
59
  export function resolveCustomFieldValue(field, value, optionsMap) {
60
+ if (field.type_id === CaseFieldTypeId.Checkbox) {
61
+ if (value === null || value === undefined) {
62
+ return false;
63
+ }
64
+ return value === true || value === 1 || value === "1" || value === "true";
65
+ }
47
66
  if (value === null || value === undefined) {
48
67
  return value;
49
68
  }
@@ -12,6 +12,31 @@ function decodeHtmlEntities(str) {
12
12
  .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
13
13
  .replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
14
14
  }
15
+ const KNOWN_HTML_TAGS = new Set([
16
+ 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
17
+ 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
18
+ 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
19
+ 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
20
+ 'em', 'embed',
21
+ 'fieldset', 'figcaption', 'figure', 'footer', 'form',
22
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
23
+ 'i', 'iframe', 'img', 'input', 'ins',
24
+ 'kbd',
25
+ 'label', 'legend', 'li', 'link',
26
+ 'main', 'map', 'mark', 'menu', 'meta', 'meter',
27
+ 'nav', 'noscript',
28
+ 'object', 'ol', 'optgroup', 'option', 'output',
29
+ 'p', 'picture', 'pre', 'progress',
30
+ 'q',
31
+ 'rp', 'rt', 'ruby',
32
+ 's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub',
33
+ 'summary', 'sup', 'svg',
34
+ 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
35
+ 'u', 'ul',
36
+ 'var', 'video',
37
+ 'wbr',
38
+ 'font', 'center', 'strike', 'tt', 'big', 'marquee', 'nobr', 'o:p'
39
+ ]);
15
40
  /**
16
41
  * Converts TestRail HTML markup to clean, structured Markdown / plain text.
17
42
  */
@@ -78,8 +103,20 @@ export function htmlToMarkdown(html) {
78
103
  });
79
104
  return `\n${items}\n`;
80
105
  });
81
- // Strip remaining HTML tags
82
- text = text.replace(/<[^>]+>/g, '');
106
+ // Strip scripts and styles along with their content
107
+ text = text.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
108
+ text = text.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
109
+ // Strip HTML comments, CDATA, and DOCTYPE declarations
110
+ text = text.replace(/<!--[\s\S]*?-->/g, '');
111
+ text = text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gi, '$1');
112
+ text = text.replace(/<!DOCTYPE[^>]*>/gi, '');
113
+ // Strip remaining known HTML tags while preserving comparison signs and non-HTML angle brackets
114
+ text = text.replace(/<\/?([a-zA-Z][a-zA-Z0-9:-]*)(?:\s+[a-zA-Z_:@][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*\s*\/?>/gi, (match, tagName) => {
115
+ if (KNOWN_HTML_TAGS.has(tagName.toLowerCase())) {
116
+ return '';
117
+ }
118
+ return match;
119
+ });
83
120
  // Decode HTML entities
84
121
  text = decodeHtmlEntities(text);
85
122
  // Clean up whitespace and newlines
@@ -145,12 +182,15 @@ export function isActive(value) {
145
182
  return activeFlag === 1 || activeFlag === true || activeFlag === '1' || activeFlag === 'true';
146
183
  }
147
184
  /**
148
- * Normalizes an entity ID (e.g. case ID, run ID, attachment ID).
149
- * Handles string IDs with 'C' prefixes (e.g. 'C123' -> 123), numeric IDs, and trims whitespace.
185
+ * Normalizes an entity ID (e.g. case ID, test ID, run ID, attachment ID).
186
+ * Handles string IDs with 'C', 'T', or 'R' prefixes (e.g. 'C123' -> 123, 'T456' -> 456), numeric IDs, and trims whitespace.
150
187
  */
151
188
  export function normalizeEntityId(id) {
152
189
  const idString = typeof id === "string" ? id.trim() : String(id);
153
- const cleaned = idString.toUpperCase().startsWith("C") ? idString.substring(1) : idString;
190
+ const upper = idString.toUpperCase();
191
+ const cleaned = (upper.startsWith("C") || upper.startsWith("T") || upper.startsWith("R"))
192
+ ? idString.substring(1)
193
+ : idString;
154
194
  const parsed = Number(cleaned);
155
195
  if (isNaN(parsed)) {
156
196
  throw new Error(`Invalid entity ID: ${id}`);
@@ -0,0 +1,60 @@
1
+ export const SECTION_PATH_SEPARATOR = " > ";
2
+ export const MAX_SECTION_DEPTH = 50;
3
+ /**
4
+ * Creates a memoized section lookup. Sections passed in `preloaded` (e.g. from a single
5
+ * `getSections` call) are served from memory; anything missing is fetched lazily via
6
+ * `client.getSection`. Failed lookups resolve to `null`.
7
+ */
8
+ export function createSectionLookup(client, preloaded = []) {
9
+ const cache = new Map();
10
+ for (const section of preloaded) {
11
+ cache.set(section.id, section);
12
+ }
13
+ return async (sectionId) => {
14
+ const cached = cache.get(sectionId);
15
+ if (cached) {
16
+ return cached;
17
+ }
18
+ const section = await client.getSection(sectionId).catch(() => null);
19
+ if (section) {
20
+ cache.set(sectionId, section);
21
+ }
22
+ return section ?? null;
23
+ };
24
+ }
25
+ /**
26
+ * Resolves the section hierarchy from root to leaf by walking `parent_id`.
27
+ *
28
+ * - Returns `[]` if the leaf section itself cannot be resolved (or has no name).
29
+ * - Falls back to `[leafName]` if any ancestor cannot be resolved, a cycle is detected,
30
+ * or the hierarchy exceeds `MAX_SECTION_DEPTH`.
31
+ */
32
+ export async function resolveSectionPath(sectionId, lookup) {
33
+ const leaf = await lookup(sectionId);
34
+ if (!leaf?.name) {
35
+ return [];
36
+ }
37
+ const names = [leaf.name];
38
+ const visited = new Set([sectionId]);
39
+ let parentId = leaf.parent_id;
40
+ while (parentId !== null && parentId !== undefined) {
41
+ if (visited.has(parentId) || names.length >= MAX_SECTION_DEPTH) {
42
+ return [leaf.name];
43
+ }
44
+ visited.add(parentId);
45
+ const parent = await lookup(parentId);
46
+ if (!parent?.name) {
47
+ return [leaf.name];
48
+ }
49
+ names.unshift(parent.name);
50
+ parentId = parent.parent_id;
51
+ }
52
+ return names;
53
+ }
54
+ /**
55
+ * Formats a section path for Markdown output. Literal `>` characters inside section names
56
+ * are escaped as `\>` so they cannot be confused with the path separator.
57
+ */
58
+ export function formatSectionPath(names) {
59
+ return names.map(name => name.replace(/>/g, "\\>")).join(SECTION_PATH_SEPARATOR);
60
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "3.1.0";
1
+ export const VERSION = "3.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uarlouski/testrail-mcp-server",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "mcpName": "io.github.uarlouski/testrail-mcp-server",
5
5
  "description": "Model Context Protocol (MCP) server for TestRail: let Claude, Cursor, and Windsurf manage test cases, runs, results, and attachments.",
6
6
  "main": "dist/index.js",
@@ -19,6 +19,8 @@
19
19
  "testrail",
20
20
  "testrail-api",
21
21
  "testrail-mcp",
22
+ "testrail-mcp-server",
23
+ "mcp-testrail",
22
24
  "testrail-integration",
23
25
  "test-management",
24
26
  "test-case-management",
@@ -37,10 +39,14 @@
37
39
  "ai-agents",
38
40
  "llm",
39
41
  "claude",
42
+ "claude-desktop",
43
+ "claude-code",
40
44
  "cursor",
41
45
  "windsurf",
42
46
  "vscode",
43
47
  "copilot",
48
+ "github-copilot",
49
+ "test-case-generation",
44
50
  "rag",
45
51
  "typescript",
46
52
  "cli"
@@ -53,10 +59,10 @@
53
59
  "bugs": {
54
60
  "url": "https://github.com/uarlouski/testrail-mcp-server/issues"
55
61
  },
56
- "homepage": "https://github.com/uarlouski/testrail-mcp-server#readme",
62
+ "homepage": "https://uarlouski.github.io/testrail-mcp-server/",
57
63
  "license": "Apache-2.0",
58
64
  "dependencies": {
59
- "@modelcontextprotocol/sdk": "^1.30.0",
65
+ "@modelcontextprotocol/sdk": "^1.32.1",
60
66
  "archiver": "^7.0.1",
61
67
  "dotenv": "^17.4.2",
62
68
  "zod": "^4.6.5"