@uarlouski/testrail-mcp-server 2.0.0 → 2.2.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.
@@ -25,7 +25,9 @@ export class TestRailClient {
25
25
  return this.get(`${API_BASE_V2}/get_case/${caseId}`);
26
26
  }
27
27
  async getCasesRecursively(projectId, sectionId, filter, excludedSectionNames) {
28
- const sections = await this.getSections(projectId);
28
+ // Extract suite_id from filter if present, for getSections call
29
+ const suiteId = filter?.suite_id ? parseInt(filter.suite_id) : undefined;
30
+ const sections = await this.getSections(projectId, suiteId);
29
31
  const sectionIds = this.getSubSectionIds(sections, sectionId, excludedSectionNames);
30
32
  sectionIds.push(sectionId);
31
33
  const promises = sectionIds.map(id => this.getCases(projectId, id, filter));
@@ -105,8 +107,11 @@ export class TestRailClient {
105
107
  async updateRun(runId, fields) {
106
108
  return this.post(`${API_BASE_V2}/update_run/${runId}`, fields);
107
109
  }
108
- async getSections(projectId) {
109
- const url = `${API_BASE_V2}/get_sections/${projectId}`;
110
+ async getSections(projectId, suiteId) {
111
+ let url = `${API_BASE_V2}/get_sections/${projectId}`;
112
+ if (suiteId) {
113
+ url += `&suite_id=${suiteId}`;
114
+ }
110
115
  return this.paginateAll(url, 'sections');
111
116
  }
112
117
  async addSection(projectId, data) {
@@ -152,8 +157,7 @@ export class TestRailClient {
152
157
  }
153
158
  async getProjects() {
154
159
  if (!this.projectsPromise) {
155
- this.projectsPromise = this.get(`${API_BASE_V2}/get_projects`)
156
- .then(response => response.projects);
160
+ this.projectsPromise = this.paginateAll(`${API_BASE_V2}/get_projects`, 'projects');
157
161
  }
158
162
  return this.projectsPromise;
159
163
  }
@@ -221,6 +225,12 @@ export class TestRailClient {
221
225
  let nextUrl = url;
222
226
  while (nextUrl) {
223
227
  const response = await this.get(nextUrl);
228
+ // Handle older TestRail versions (pre-7.x) that return flat arrays
229
+ // instead of paginated objects like { "suites": [...], "_links": {...} }
230
+ if (Array.isArray(response)) {
231
+ allItems.push(...response);
232
+ break;
233
+ }
224
234
  if (response[dataKey] && Array.isArray(response[dataKey])) {
225
235
  allItems.push(...response[dataKey]);
226
236
  }
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ if (!parseResult.success) {
23
23
  const { TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY, TESTRAIL_ENABLE_SHARED_STEPS, TESTRAIL_ALLOW_WRITE_OPERATIONS, TESTRAIL_ALLOW_READ_OPERATIONS, TESTRAIL_ALLOW_DELETE_OPERATIONS } = parseResult.data;
24
24
  const server = new McpServer({
25
25
  name: "TestRail MCP Server",
26
- version: "2.0.0",
26
+ version: "2.2.0",
27
27
  });
28
28
  const client = new TestRailClient(TESTRAIL_INSTANCE_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY);
29
29
  const tools = getToolsToRegister({
@@ -13,35 +13,41 @@ Example: {"priority_id": 2, "template_id": 1, "labels": [1, 2]}
13
13
  Call get_case_fields with project_id first if field names are not already known.
14
14
  Using an unknown field name (e.g. 'label_ids') will result in an error.
15
15
  `;
16
- const FIELD_TYPE_NAMES = {
17
- 1: "String",
18
- 2: "Integer",
19
- 3: "Text",
20
- 4: "URL",
21
- 5: "Checkbox",
22
- 6: "Dropdown",
23
- 7: "User",
24
- 8: "Date",
25
- 9: "Milestone",
26
- 10: "Steps",
27
- 11: "Step Results",
28
- 12: "Multi-select",
29
- 13: "Scenarios",
30
- 14: "List",
31
- };
32
- function getFieldTypeName(typeId) {
33
- return FIELD_TYPE_NAMES[typeId] || `Unknown (${typeId})`;
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})` };
34
35
  }
35
36
  function isFieldRequired(field) {
36
37
  return field.configs.some(config => config.options?.is_required === true);
37
38
  }
38
39
  export function mapToFieldSchema(field) {
40
+ const fieldType = getFieldType(field.type_id);
41
+ const descriptions = [field.description, fieldType.description].filter(Boolean);
39
42
  const schema = {
40
43
  system_name: field.system_name,
41
44
  label: field.label,
42
- type: getFieldTypeName(field.type_id),
45
+ type: fieldType.name,
43
46
  is_required: isFieldRequired(field),
44
47
  };
48
+ if (descriptions.length > 0) {
49
+ schema.description = descriptions.join(" ");
50
+ }
45
51
  if (!field.include_all && field.template_ids.length > 0) {
46
52
  schema.template_ids = field.template_ids;
47
53
  }
@@ -68,15 +74,15 @@ function isFieldForProject(field, projectId) {
68
74
  return contexts.some(ctx => ctx.is_global || ctx.project_ids.includes(projectId));
69
75
  }
70
76
  export const SYSTEM_FIELDS = [
71
- { system_name: "title", label: "Title", type: "String", is_required: true, project_scope: { scope: "global" } },
72
- { system_name: "section_id", label: "Section", type: "Integer", is_required: true, project_scope: { scope: "global" } },
73
- { system_name: "template_id", label: "Template", type: "Integer", is_required: false, project_scope: { scope: "global" } },
74
- { system_name: "type_id", label: "Type", type: "Integer", is_required: false, project_scope: { scope: "global" } },
75
- { system_name: "priority_id", label: "Priority", type: "Integer", is_required: false, project_scope: { scope: "global" } },
76
- { system_name: "estimate", label: "Estimate", type: "String", is_required: false, project_scope: { scope: "global" } },
77
- { system_name: "milestone_id", label: "Milestone", type: "Integer", is_required: false, project_scope: { scope: "global" } },
78
- { system_name: "refs", label: "References", type: "String", is_required: false, project_scope: { scope: "global" } },
79
- { system_name: "labels", label: "Labels", type: "List", is_required: false, project_scope: { scope: "global" }, comment: "Use get_labels tool to get available labels/tags." },
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." },
80
86
  ];
81
87
  const description = `
82
88
  Get the field schema for test cases for a specific project.
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import fs from "fs";
3
- import { validateCaseFields } from "../../utils/validator.js";
3
+ import { validateCaseFields, validateSuiteId } from "../../utils/validator.js";
4
4
  const parameters = {
5
5
  project_id: z.number().describe("The ID of the project. Use get_projects to find available projects"),
6
+ suite_id: z.number().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=3). Use get_suites to find available suites"),
6
7
  section: z.object({
7
8
  id: z.number(),
8
9
  recursive: z.boolean().optional().default(false).describe("If true, fetches cases from the section and all its child sections"),
@@ -18,7 +19,8 @@ export const getCasesTool = {
18
19
  mode: "read",
19
20
  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.",
20
21
  parameters,
21
- handler: async ({ project_id, section, filter, where, fields, output_file }, client) => {
22
+ handler: async ({ project_id, suite_id, section, filter, where, fields, output_file }, client) => {
23
+ await validateSuiteId(client, project_id, suite_id);
22
24
  const caseFields = await client.getCaseFields();
23
25
  if (fields) {
24
26
  validateCaseFields(fields, caseFields);
@@ -26,6 +28,10 @@ export const getCasesTool = {
26
28
  if (where) {
27
29
  validateCaseFields(Object.keys(where), caseFields);
28
30
  }
31
+ // Inject suite_id into filter for multi-suite projects
32
+ if (suite_id) {
33
+ filter = { ...filter, suite_id: suite_id.toString() };
34
+ }
29
35
  let cases;
30
36
  if (section?.recursive) {
31
37
  cases = await client.getCasesRecursively(project_id, section.id, filter, section.excludes);
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import fs from "fs";
3
3
  import { SectionSchema } from "./types.js";
4
+ import { validateSuiteId } from "../../utils/validator.js";
4
5
  const parameters = {
5
6
  project_id: z.number().describe("The ID of the project. Use get_projects to find available projects"),
7
+ suite_id: z.number().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=3). Use get_suites to find available suites"),
6
8
  output_file: z.string().optional().describe("Absolute file path to save the JSON response to. Use this for large datasets to avoid blowing up context limits."),
7
9
  };
8
10
  export const getSectionsTool = {
@@ -10,8 +12,9 @@ export const getSectionsTool = {
10
12
  mode: "read",
11
13
  description: "Get all sections for a project. Returns section IDs and names that can be used with add_case",
12
14
  parameters,
13
- handler: async ({ project_id, output_file }, client) => {
14
- const sections = await client.getSections(project_id);
15
+ handler: async ({ project_id, suite_id, output_file }, client) => {
16
+ await validateSuiteId(client, project_id, suite_id);
17
+ const sections = await client.getSections(project_id, suite_id);
15
18
  const response = {
16
19
  sections: sections.map(s => SectionSchema.parse(s)),
17
20
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { SectionSchema, CreateSectionSchema, UpdateSectionSchema } from "./types.js";
3
3
  import { handleMutate } from "../../utils/mutate_handler.js";
4
+ import { validateSuiteId } from "../../utils/validator.js";
4
5
  const parameters = {
5
6
  payload: z.discriminatedUnion("action", [
6
7
  CreateSectionSchema,
@@ -15,6 +16,7 @@ export const mutateSectionTool = {
15
16
  handler: async (args, client) => {
16
17
  const { payload } = args;
17
18
  return handleMutate(payload, async (createPayload) => {
19
+ await validateSuiteId(client, createPayload.project_id, createPayload.suite_id);
18
20
  const { project_id, action, ...data } = createPayload;
19
21
  return client.addSection(project_id, data);
20
22
  }, async (updatePayload) => {
@@ -13,6 +13,7 @@ export const BaseSectionFields = z.object({
13
13
  export const CreateSectionSchema = BaseSectionFields.extend({
14
14
  action: z.literal("create").describe("The operation to perform: create a new section"),
15
15
  project_id: z.number().describe("The ID of the project the section should be added to"),
16
+ suite_id: z.number().optional().describe("The ID of the test suite (required for multi-suite projects, i.e. suite_mode=2 or 3). Use get_suites to find available suites"),
16
17
  parent_id: z.number().optional().describe("The ID of the parent section (to build section hierarchies)"),
17
18
  });
18
19
  export const UpdateSectionSchema = BaseSectionFields.partial().extend({
@@ -1,5 +1,20 @@
1
1
  import { SYSTEM_FIELDS, mapToFieldSchema } from "../tools/cases/get_case_fields.js";
2
2
  import { isActive } from "./sanitizer.js";
3
+ const SUITE_MODE_SINGLE_WITH_BASELINES = 2;
4
+ const SUITE_MODE_MULTI = 3;
5
+ /**
6
+ * Validates that suite_id is provided when the project uses multiple test suites or baselines (suite_mode=2 or 3).
7
+ * Throws a descriptive Error if suite_id is required but missing.
8
+ */
9
+ export async function validateSuiteId(client, projectId, suiteId) {
10
+ if (suiteId)
11
+ return;
12
+ const project = await client.getProject(projectId);
13
+ if (project.suite_mode === SUITE_MODE_MULTI || project.suite_mode === SUITE_MODE_SINGLE_WITH_BASELINES) {
14
+ throw new Error(`Project "${project.name}" (id: ${project.id}) uses multiple test suites/baselines (suite_mode=${project.suite_mode}). ` +
15
+ `The suite_id parameter is required. Use get_suites to find available suites for this project.`);
16
+ }
17
+ }
3
18
  /**
4
19
  * Validates that the provided fields exist in the TestRail case schema.
5
20
  * Throws an Error if any field is invalid.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uarlouski/testrail-mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",