@uarlouski/testrail-mcp-server 1.9.0 → 1.11.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
@@ -15,6 +15,7 @@
15
15
  <a href="https://opensource.org/licenses/Apache-2.0"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License"></a>
16
16
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-blue.svg" alt="TypeScript"></a>
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
+ <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>
18
19
  </p>
19
20
 
20
21
  <p align="center">
@@ -130,6 +131,7 @@ Any MCP-compliant client can utilize this server. The pattern is universal—poi
130
131
  Once configured, turbo-charge your QA workflow by asking your AI assistant:
131
132
 
132
133
  - *"List all projects in TestRail to find the latest active project."*
134
+ - *"Show me all active users in the project to find the right assignee."*
133
135
  - *"Show me all test cases in section 5 of project 3."*
134
136
  - *"Create a comprehensive test case for 'Login Validation' with detailed steps."*
135
137
  - *"Start a new test run containing cases from section 5."*
@@ -152,11 +154,16 @@ Once configured, turbo-charge your QA workflow by asking your AI assistant:
152
154
 
153
155
  The TestRail MCP Server provides heavily typed, descriptive tools designed specifically for LLM interaction:
154
156
 
157
+ > **💡 Pro Tip: Handling Large Data Sets & Permissions**
158
+ > - **Large Data Sets**: For list-based tools like `get_cases` and `get_sections`, you can supply the `output_file` parameter with an absolute file path. The server will bypass the LLM context window by writing the raw JSON directly to disk and returning a concise summary, allowing you to run external scripts against the export!
159
+ > - **Intelligent User Fallback**: When fetching users using `get_users` without admin rights, TestRail denies access globally. The server dynamically catches this permission restriction and falls back to polling all active projects in parallel, retrieving and deduplicating active users so assignees and reviewers can always be mapped.
160
+
155
161
  ### 🔭 Discovery & Navigation
156
162
  | Tool | Functionality |
157
163
  |------|-------------|
158
164
  | `get_projects` | List all available active and completed projects in your instance. |
159
165
  | `get_sections` | Navigate the precise folder/section hierarchy of any test suite. |
166
+ | `get_users` | Retrieve active users. Handles per-project filtering and automatic fallback polling for non-admin accounts. |
160
167
 
161
168
  ### 📋 Test Case Management
162
169
  | Tool | Functionality |
@@ -1,3 +1,4 @@
1
+ import { isActive } from "../utils/sanitizer.js";
1
2
  import * as fs from "fs";
2
3
  const API_INDEX = '/index.php?';
3
4
  const API_BASE_V2 = `${API_INDEX}/api/v2`;
@@ -161,6 +162,13 @@ export class TestRailClient {
161
162
  async deleteSharedStep(sharedStepId) {
162
163
  return this._executeRequest('POST', `${API_BASE_V2}/delete_shared_step/${sharedStepId}`, this.headers, JSON.stringify({}), false);
163
164
  }
165
+ async getUsers(projectId) {
166
+ const url = projectId
167
+ ? `${API_BASE_V2}/get_users/${projectId}`
168
+ : `${API_BASE_V2}/get_users`;
169
+ const allUsers = await this.paginateAll(url, 'users');
170
+ return allUsers.filter(isActive);
171
+ }
164
172
  async paginateAll(url, dataKey) {
165
173
  const allItems = [];
166
174
  let nextUrl = url;
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { addResultsTool } from "./tools/add_results.js";
20
20
  import { addAttachmentToRunTool } from "./tools/add_attachment_to_run.js";
21
21
  import { addResultsForCasesTool } from "./tools/add_results_for_cases.js";
22
22
  import { getLabelsTool } from "./tools/get_labels.js";
23
+ import { getUsersTool } from "./tools/get_users.js";
23
24
  import { getSharedStepsTool } from "./tools/shared_steps/get_shared_steps.js";
24
25
  import { getSharedStepTool } from "./tools/shared_steps/get_shared_step.js";
25
26
  import { getSharedStepHistoryTool } from "./tools/shared_steps/get_shared_step_history.js";
@@ -42,7 +43,7 @@ if (!parseResult.success) {
42
43
  const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS } = parseResult.data;
43
44
  const server = new McpServer({
44
45
  name: "TestRail MCP Server",
45
- version: "1.9.0",
46
+ version: "1.11.0",
46
47
  });
47
48
  const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
48
49
  const tools = [
@@ -63,6 +64,7 @@ const tools = [
63
64
  addResultsForCasesTool,
64
65
  addAttachmentToRunTool,
65
66
  getLabelsTool,
67
+ getUsersTool,
66
68
  ];
67
69
  if (TESTRAIL_ENABLE_SHARED_STEPS) {
68
70
  tools.push(getSharedStepsTool);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { parseDropdownOptions } from "../utils/mapper.js";
3
+ import { isActive } from "../utils/sanitizer.js";
3
4
  // Field remains optional for backward compatibility with previous implementation.
4
5
  // However, it is recommended to always provide project_id to get fields applicable to the project.
5
6
  const parameters = {
@@ -88,7 +89,7 @@ export const getCaseFieldsTool = {
88
89
  parameters,
89
90
  handler: async ({ project_id }, client) => {
90
91
  const caseFields = await client.getCaseFields();
91
- const activeFields = caseFields.filter(field => field.is_active);
92
+ const activeFields = caseFields.filter(isActive);
92
93
  const filtered = project_id !== undefined
93
94
  ? activeFields.filter(field => isFieldForProject(field, project_id))
94
95
  : activeFields;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import fs from "fs";
2
3
  import { validateCaseFields } from "../utils/validator.js";
3
4
  const parameters = {
4
5
  project_id: z.number().describe("The ID of the project. Use get_projects to find available projects"),
@@ -10,12 +11,13 @@ const parameters = {
10
11
  filter: z.record(z.string(), z.string()).optional().describe("Optional API-side filters (more efficient for large datasets). Supported: priority_id, type_id, created_by, updated_by, milestone_id, refs, created_after, created_before, updated_after, updated_before. Use comma-separated values for IDs. Example: {\"priority_id\": \"1,2\", \"type_id\": \"3\"}"),
11
12
  where: z.record(z.string(), z.any()).optional().describe("Optional client-side filter for any field including custom fields (filters after fetching all cases). Supports exact value matching. Example: {\"custom_automation_status\": 1, \"priority_id\": 2}"),
12
13
  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\"]"),
14
+ 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."),
13
15
  };
14
16
  export const getCasesTool = {
15
17
  name: "get_cases",
16
18
  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.",
17
19
  parameters,
18
- handler: async ({ project_id, section, filter, where, fields }, client) => {
20
+ handler: async ({ project_id, section, filter, where, fields, output_file }, client) => {
19
21
  const caseFields = await client.getCaseFields();
20
22
  if (fields) {
21
23
  validateCaseFields(fields, caseFields);
@@ -59,6 +61,14 @@ export const getCasesTool = {
59
61
  return result;
60
62
  }),
61
63
  };
64
+ if (output_file) {
65
+ await fs.promises.writeFile(output_file, JSON.stringify(response), "utf-8");
66
+ return {
67
+ success: true,
68
+ message: `Successfully exported ${response.cases.length} cases to ${output_file}`,
69
+ file: output_file
70
+ };
71
+ }
62
72
  return response;
63
73
  }
64
74
  };
@@ -1,16 +1,27 @@
1
1
  import { z } from "zod";
2
+ import fs from "fs";
2
3
  import { SectionSchema } from "../types/testrail.js";
3
4
  const parameters = {
4
5
  project_id: z.number().describe("The ID of the project. Use get_projects to find available projects"),
6
+ 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."),
5
7
  };
6
8
  export const getSectionsTool = {
7
9
  name: "get_sections",
8
10
  description: "Get all sections for a project. Returns section IDs and names that can be used with add_case",
9
11
  parameters,
10
- handler: async ({ project_id }, client) => {
12
+ handler: async ({ project_id, output_file }, client) => {
11
13
  const sections = await client.getSections(project_id);
12
- return {
14
+ const response = {
13
15
  sections: sections.map(s => SectionSchema.parse(s)),
14
16
  };
17
+ if (output_file) {
18
+ await fs.promises.writeFile(output_file, JSON.stringify(response), "utf-8");
19
+ return {
20
+ success: true,
21
+ message: `Successfully exported ${response.sections.length} sections to ${output_file}`,
22
+ file: output_file
23
+ };
24
+ }
25
+ return response;
15
26
  }
16
27
  };
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { UserSchema } from "../types/testrail.js";
3
+ const parameters = {
4
+ project_id: z.number().optional().describe("Optional ID of the project to retrieve users for. If omitted, attempts to fetch all users globally."),
5
+ fallback_all_projects: z.boolean().optional().default(true).describe("If true and global user retrieval is forbidden (non-admin), automatically falls back to fetching users across all active projects."),
6
+ };
7
+ async function getUsersFallbackAllProjects(client) {
8
+ const projects = await client.getProjects();
9
+ const activeProjects = projects.filter(p => !p.is_completed);
10
+ const userPromises = activeProjects.map(project => client.getUsers(project.id).catch(err => {
11
+ console.error(`[get_users] Failed to fetch users for project ${project.id} (${project.name}): ${err.message}`);
12
+ return [];
13
+ }));
14
+ const results = await Promise.all(userPromises);
15
+ const allUsers = results.flat();
16
+ const uniqueUsersMap = new Map();
17
+ for (const user of allUsers) {
18
+ uniqueUsersMap.set(user.id, user);
19
+ }
20
+ return Array.from(uniqueUsersMap.values());
21
+ }
22
+ export const getUsersTool = {
23
+ name: "get_users",
24
+ description: "Get active users from TestRail. Resolves active users globally or per-project. If global fetch is forbidden (for non-admin accounts), falls back to merging users across all active projects so users referenced as reviewers or assignees can be resolved.",
25
+ parameters,
26
+ handler: async ({ project_id, fallback_all_projects }, client) => {
27
+ let users;
28
+ try {
29
+ users = await client.getUsers(project_id);
30
+ }
31
+ catch (err) {
32
+ const isPermissionError = err.message?.includes("Access Denied");
33
+ if (!project_id && fallback_all_projects !== false && isPermissionError) {
34
+ console.error("[get_users] Global fetch failed (Access Denied). Falling back to multi-project polling...");
35
+ users = await getUsersFallbackAllProjects(client);
36
+ }
37
+ else {
38
+ throw err;
39
+ }
40
+ }
41
+ return {
42
+ users: users.map(user => UserSchema.parse(user))
43
+ };
44
+ }
45
+ };
@@ -123,3 +123,8 @@ export const SharedStepHistorySchema = z.object({
123
123
  timestamp: z.number().optional(),
124
124
  custom_steps_separated: z.array(SharedStepContentSchema).optional(),
125
125
  });
126
+ export const UserSchema = z.object({
127
+ id: z.number(),
128
+ name: z.string(),
129
+ email: z.string(),
130
+ });
@@ -44,3 +44,14 @@ export function removeNullish(value) {
44
44
  }
45
45
  return value;
46
46
  }
47
+ /**
48
+ * Checks if an entity is active by checking its active flag.
49
+ * Supports passing either the direct primitive value or the parent object containing `.is_active`.
50
+ */
51
+ export function isActive(value) {
52
+ if (value == null) {
53
+ return false;
54
+ }
55
+ const activeFlag = (typeof value === 'object') ? value.is_active : value;
56
+ return activeFlag === 1 || activeFlag === true || activeFlag === '1' || activeFlag === 'true';
57
+ }
@@ -1,4 +1,5 @@
1
1
  import { SYSTEM_FIELDS, mapToFieldSchema } from "../tools/get_case_fields.js";
2
+ import { isActive } from "./sanitizer.js";
2
3
  /**
3
4
  * Validates that the provided fields exist in the TestRail case schema.
4
5
  * Throws an Error if any field is invalid.
@@ -11,7 +12,7 @@ export function validateCaseFields(fields, caseFields) {
11
12
  if (fieldKeys.length === 0) {
12
13
  return;
13
14
  }
14
- const customFieldSchemas = caseFields.filter(field => field.is_active).map(mapToFieldSchema);
15
+ const customFieldSchemas = caseFields.filter(isActive).map(mapToFieldSchema);
15
16
  const validFieldNames = new Set([...SYSTEM_FIELDS, ...customFieldSchemas].map(f => f.system_name));
16
17
  validFieldNames.add('id');
17
18
  validFieldNames.add('suite_id');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uarlouski/testrail-mcp-server",
3
- "version": "1.9.0",
3
+ "version": "1.11.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",
@@ -11,13 +11,6 @@
11
11
  "dist"
12
12
  ],
13
13
  "type": "module",
14
- "scripts": {
15
- "build": "rimraf dist && tsc",
16
- "start": "node dist/index.js",
17
- "dev": "ts-node src/index.ts",
18
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
19
- "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
20
- },
21
14
  "keywords": [
22
15
  "mcp",
23
16
  "model-context-protocol",
@@ -40,10 +33,10 @@
40
33
  "homepage": "https://github.com/uarlouski/testrail-mcp-server#readme",
41
34
  "license": "Apache-2.0",
42
35
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.27.1",
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
44
37
  "archiver": "^7.0.1",
45
- "dotenv": "^17.3.1",
46
- "zod": "^4.3.6"
38
+ "dotenv": "^17.4.2",
39
+ "zod": "^4.4.3"
47
40
  },
48
41
  "devDependencies": {
49
42
  "@types/archiver": "^7.0.0",
@@ -53,6 +46,21 @@
53
46
  "rimraf": "^6.1.3",
54
47
  "ts-jest": "^29.4.6",
55
48
  "ts-node": "^10.9.2",
56
- "typescript": "^5.9.3"
49
+ "typescript": "^5.9.3",
50
+ "@jest/globals": "^29.7.0"
51
+ },
52
+ "devEngines": {
53
+ "packageManager": {
54
+ "name": "pnpm",
55
+ "version": "11.1.2",
56
+ "onFail": "error"
57
+ }
58
+ },
59
+ "scripts": {
60
+ "build": "rimraf dist && tsc",
61
+ "start": "node dist/index.js",
62
+ "dev": "ts-node src/index.ts",
63
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
64
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
57
65
  }
58
66
  }