@uarlouski/testrail-mcp-server 3.2.0 → 3.3.1
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 +2 -0
- package/dist/client/testrail.js +46 -36
- package/dist/tools/cases/export_cases_for_rag.js +37 -16
- package/dist/tools/cases/fields.js +10 -0
- package/dist/tools/cases/get_case.js +3 -3
- package/dist/tools/cases/types.js +2 -2
- package/dist/tools/commons/get_labels.js +1 -1
- package/dist/tools/commons/types.js +6 -0
- package/dist/utils/content_hash.js +47 -0
- package/dist/utils/mapper.js +5 -1
- package/dist/utils/sanitizer.js +39 -2
- package/dist/utils/section_path.js +60 -0
- package/dist/utils/testrail_url.js +99 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
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://m8ven.ai/mcp/uarlouski-testrail-mcp-server-xt4olk?s=readme"><img src="https://m8ven.ai/badge/mcp/uarlouski-testrail-mcp-server-xt4olk?variant=verified&v=c5887a2e4d6d35e25160ec11bdc4e593" alt="M8ven Verified"></a>
|
|
21
|
+
<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
22
|
</p>
|
|
21
23
|
|
|
22
24
|
<p align="center">
|
package/dist/client/testrail.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isActive } from "../utils/sanitizer.js";
|
|
2
|
+
import { TestRailUrl } from "../utils/testrail_url.js";
|
|
2
3
|
import * as fs from "fs";
|
|
3
4
|
import * as path from "path";
|
|
4
5
|
const API_INDEX = '/index.php?';
|
|
@@ -62,41 +63,54 @@ export class TestRailClient {
|
|
|
62
63
|
}
|
|
63
64
|
return this.paginateAll(url, 'cases');
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Shares one in-flight lookup among concurrent callers. A resolved value stays
|
|
68
|
+
* cached. A rejection is removed so the next call issues a new request.
|
|
69
|
+
*/
|
|
70
|
+
memoize(read, write, factory) {
|
|
71
|
+
const current = read();
|
|
72
|
+
if (current) {
|
|
73
|
+
return current;
|
|
68
74
|
}
|
|
69
|
-
|
|
75
|
+
let promise;
|
|
76
|
+
promise = factory().catch((error) => {
|
|
77
|
+
if (read() === promise) {
|
|
78
|
+
write(null);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
});
|
|
82
|
+
write(promise);
|
|
83
|
+
return promise;
|
|
84
|
+
}
|
|
85
|
+
memoizeMap(map, key, factory) {
|
|
86
|
+
return this.memoize(() => map.get(key), (next) => {
|
|
87
|
+
if (next) {
|
|
88
|
+
map.set(key, next);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
map.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}, factory);
|
|
94
|
+
}
|
|
95
|
+
async getSection(sectionId) {
|
|
96
|
+
const key = sectionId.toString();
|
|
97
|
+
return this.memoizeMap(this.sectionsPromiseMap, key, () => this.get(`${API_BASE_V2}/get_section/${sectionId}`));
|
|
70
98
|
}
|
|
71
99
|
async getPriorities() {
|
|
72
|
-
|
|
73
|
-
this.prioritiesPromise = this.get(`${API_BASE_V2}/get_priorities`);
|
|
74
|
-
}
|
|
75
|
-
return this.prioritiesPromise;
|
|
100
|
+
return this.memoize(() => this.prioritiesPromise, (promise) => { this.prioritiesPromise = promise; }, () => this.get(`${API_BASE_V2}/get_priorities`));
|
|
76
101
|
}
|
|
77
102
|
async getCaseTypes() {
|
|
78
|
-
|
|
79
|
-
this.caseTypesPromise = this.get(`${API_BASE_V2}/get_case_types`);
|
|
80
|
-
}
|
|
81
|
-
return this.caseTypesPromise;
|
|
103
|
+
return this.memoize(() => this.caseTypesPromise, (promise) => { this.caseTypesPromise = promise; }, () => this.get(`${API_BASE_V2}/get_case_types`));
|
|
82
104
|
}
|
|
83
105
|
async getCaseFields() {
|
|
84
|
-
|
|
85
|
-
this.caseFieldsPromise = this.get(`${API_BASE_V2}/get_case_fields`);
|
|
86
|
-
}
|
|
87
|
-
return this.caseFieldsPromise;
|
|
106
|
+
return this.memoize(() => this.caseFieldsPromise, (promise) => { this.caseFieldsPromise = promise; }, () => this.get(`${API_BASE_V2}/get_case_fields`));
|
|
88
107
|
}
|
|
89
108
|
async getStatuses() {
|
|
90
|
-
|
|
91
|
-
this.statusesPromise = this.get(`${API_BASE_V2}/get_statuses`);
|
|
92
|
-
}
|
|
93
|
-
return this.statusesPromise;
|
|
109
|
+
return this.memoize(() => this.statusesPromise, (promise) => { this.statusesPromise = promise; }, () => this.get(`${API_BASE_V2}/get_statuses`));
|
|
94
110
|
}
|
|
95
111
|
async getTemplates(projectId) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
return this.templatesPromiseMap.get(projectId.toString());
|
|
112
|
+
const key = projectId.toString();
|
|
113
|
+
return this.memoizeMap(this.templatesPromiseMap, key, () => this.get(`${API_BASE_V2}/get_templates/${projectId}`));
|
|
100
114
|
}
|
|
101
115
|
async updateCase(caseId, fields) {
|
|
102
116
|
return this.post(`${API_BASE_V2}/update_case/${caseId}`, fields);
|
|
@@ -166,10 +180,7 @@ export class TestRailClient {
|
|
|
166
180
|
return this.get(`${API_BASE_V2}/get_project/${projectId}`);
|
|
167
181
|
}
|
|
168
182
|
async getProjects() {
|
|
169
|
-
|
|
170
|
-
this.projectsPromise = this.paginateAll(`${API_BASE_V2}/get_projects`, 'projects');
|
|
171
|
-
}
|
|
172
|
-
return this.projectsPromise;
|
|
183
|
+
return this.memoize(() => this.projectsPromise, (promise) => { this.projectsPromise = promise; }, () => this.paginateAll(`${API_BASE_V2}/get_projects`, 'projects'));
|
|
173
184
|
}
|
|
174
185
|
async getLabels(projectId) {
|
|
175
186
|
const url = `${API_BASE_V2}/get_labels/${projectId}`;
|
|
@@ -254,22 +265,21 @@ export class TestRailClient {
|
|
|
254
265
|
}
|
|
255
266
|
async paginateAll(url, dataKey) {
|
|
256
267
|
const allItems = [];
|
|
257
|
-
let
|
|
258
|
-
while (
|
|
259
|
-
const
|
|
268
|
+
let page = TestRailUrl.parse(url).withValidPageLimit();
|
|
269
|
+
while (page) {
|
|
270
|
+
const requestedUrl = page.toString();
|
|
271
|
+
const response = await this.get(requestedUrl);
|
|
260
272
|
// Handle older TestRail versions (pre-7.x) that return flat arrays
|
|
261
273
|
// instead of paginated objects like { "suites": [...], "_links": {...} }
|
|
262
274
|
if (Array.isArray(response)) {
|
|
263
275
|
allItems.push(...response);
|
|
264
276
|
break;
|
|
265
277
|
}
|
|
266
|
-
if (response[dataKey] && Array.isArray(response[dataKey])) {
|
|
278
|
+
if (response?.[dataKey] && Array.isArray(response[dataKey])) {
|
|
267
279
|
allItems.push(...response[dataKey]);
|
|
268
280
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
nextUrl = `${API_INDEX}${nextUrl}`;
|
|
272
|
-
}
|
|
281
|
+
const rawNext = typeof response?._links?.next === 'string' ? response._links.next : '';
|
|
282
|
+
page = page.follow(rawNext, response?.limit, response?.size);
|
|
273
283
|
}
|
|
274
284
|
return allItems;
|
|
275
285
|
}
|
|
@@ -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) => {
|
|
@@ -105,11 +107,11 @@ function categorizeCustomFields(testCase, applicableFieldDefs, ignoredFieldsSet)
|
|
|
105
107
|
}
|
|
106
108
|
return { markdownFields, metadataFields };
|
|
107
109
|
}
|
|
108
|
-
function buildMarkdownBody(testCase,
|
|
110
|
+
function buildMarkdownBody(testCase, sectionPath, markdownFields) {
|
|
109
111
|
const mdLines = [];
|
|
110
112
|
mdLines.push(`# [C${testCase.id}] ${sanitizeValue(testCase.title)}`);
|
|
111
113
|
mdLines.push("");
|
|
112
|
-
mdLines.push(`**Section**: ${
|
|
114
|
+
mdLines.push(`**Section**: ${sectionPath.length > 0 ? formatSectionPath(sectionPath) : "Unknown"}`);
|
|
113
115
|
mdLines.push("");
|
|
114
116
|
for (const { header, value, fieldDef } of markdownFields) {
|
|
115
117
|
mdLines.push(`## ${header}`);
|
|
@@ -123,27 +125,44 @@ function buildMarkdownBody(testCase, sectionName, markdownFields) {
|
|
|
123
125
|
}
|
|
124
126
|
return mdLines.join("\n").trim() + "\n";
|
|
125
127
|
}
|
|
126
|
-
function buildMetadataAttributes(testCase,
|
|
128
|
+
function buildMetadataAttributes(testCase, sectionPath, priority, metadataFields) {
|
|
127
129
|
const metadataAttributes = {
|
|
128
130
|
case_id: testCase.id,
|
|
129
131
|
updated_on: testCase.updated_on,
|
|
130
132
|
title: testCase.title,
|
|
131
|
-
section:
|
|
133
|
+
section: sectionPath.length > 0 ? sectionPath[sectionPath.length - 1] : "Unknown",
|
|
132
134
|
priority: priority,
|
|
133
135
|
};
|
|
136
|
+
if (sectionPath.length > 0) {
|
|
137
|
+
metadataAttributes.section_path = sectionPath;
|
|
138
|
+
}
|
|
134
139
|
if (testCase.refs) {
|
|
135
140
|
metadataAttributes.references = testCase.refs;
|
|
136
141
|
}
|
|
137
142
|
if (testCase.labels && testCase.labels.length > 0) {
|
|
138
143
|
metadataAttributes.labels = testCase.labels.map(l => l.title);
|
|
139
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
|
+
]);
|
|
140
156
|
for (const { key, value, fieldDef } of metadataFields) {
|
|
157
|
+
const targetKey = CORE_METADATA_KEYS.has(key)
|
|
158
|
+
? (fieldDef?.system_name || `custom_${key}`)
|
|
159
|
+
: key;
|
|
141
160
|
if (fieldDef?.type_id === CaseFieldTypeId.MultiSelect) {
|
|
142
161
|
const listVal = Array.isArray(value) ? value : [value];
|
|
143
|
-
metadataAttributes[
|
|
162
|
+
metadataAttributes[targetKey] = sanitizeMetadataValue(listVal.map(item => String(item)));
|
|
144
163
|
}
|
|
145
164
|
else {
|
|
146
|
-
metadataAttributes[
|
|
165
|
+
metadataAttributes[targetKey] = sanitizeMetadataValue(value);
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
168
|
return metadataAttributes;
|
|
@@ -195,21 +214,23 @@ export const exportCasesForRagTool = {
|
|
|
195
214
|
}
|
|
196
215
|
}
|
|
197
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);
|
|
198
222
|
const writtenFiles = [];
|
|
199
223
|
for (const testCase of testCases) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (section?.name) {
|
|
204
|
-
sectionName = section.name;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
224
|
+
const sectionPath = testCase.section_id
|
|
225
|
+
? await resolveSectionPath(Number(testCase.section_id), sectionLookup)
|
|
226
|
+
: [];
|
|
207
227
|
const priority = priorities.find(p => p.id === testCase.priority_id)?.name || "Unknown";
|
|
208
228
|
const templateId = testCase.template_id;
|
|
209
229
|
const applicableFields = caseFields.filter(field => field.include_all || (templateId !== null && templateId !== undefined && field.template_ids.includes(templateId)));
|
|
210
230
|
const { markdownFields, metadataFields } = categorizeCustomFields(testCase, applicableFields, ignoredFieldsSet);
|
|
211
|
-
const markdownContent = buildMarkdownBody(testCase,
|
|
212
|
-
const metadataAttributes = buildMetadataAttributes(testCase,
|
|
231
|
+
const markdownContent = buildMarkdownBody(testCase, sectionPath, markdownFields);
|
|
232
|
+
const metadataAttributes = buildMetadataAttributes(testCase, sectionPath, priority, metadataFields);
|
|
233
|
+
metadataAttributes.content_hash = computeContentHash(markdownContent, metadataAttributes);
|
|
213
234
|
const docFilename = `C${testCase.id}.md`;
|
|
214
235
|
const metaFilename = `C${testCase.id}.md.metadata.json`;
|
|
215
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 {
|
|
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) =>
|
|
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 {
|
|
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(
|
|
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
|
|
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);
|
|
@@ -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
|
});
|
|
@@ -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
|
+
}
|
package/dist/utils/mapper.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/utils/sanitizer.js
CHANGED
|
@@ -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
|
|
82
|
-
text = text.replace(/<[^>]
|
|
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
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const API_INDEX = "/index.php?";
|
|
2
|
+
/** TestRail rejects `limit` below 1 and caps a page at 250. */
|
|
3
|
+
export const TESTRAIL_MAX_PAGE_LIMIT = 250;
|
|
4
|
+
/**
|
|
5
|
+
* TestRail list URL.
|
|
6
|
+
*
|
|
7
|
+
* The route sits after `?` and parameters follow with `&`
|
|
8
|
+
* (`/index.php?/api/v2/get_cases/1&suite_id=5&limit=250`). `_links.next`
|
|
9
|
+
* omits the `/index.php?` prefix. Parameter values are read with `URLSearchParams`.
|
|
10
|
+
* Other parameters are left as TestRail sent them, so rewriting `limit` does
|
|
11
|
+
* not re-encode the rest of the link.
|
|
12
|
+
*
|
|
13
|
+
* Workaround for https://github.com/uarlouski/testrail-mcp-server/issues/139:
|
|
14
|
+
* some self-hosted instances answer a list call with `limit=0` and a next
|
|
15
|
+
* link that repeats it. Requesting that link fails with 400 "Field :limit is
|
|
16
|
+
* too small (minimum 1)". `withValidPageLimit` sends 250 when `limit` is
|
|
17
|
+
* missing or below 1. `follow` skips a next link that does not advance `offset`.
|
|
18
|
+
*/
|
|
19
|
+
export class TestRailUrl {
|
|
20
|
+
raw;
|
|
21
|
+
params;
|
|
22
|
+
constructor(raw, params) {
|
|
23
|
+
this.raw = raw;
|
|
24
|
+
this.params = params;
|
|
25
|
+
}
|
|
26
|
+
static parse(url) {
|
|
27
|
+
const queryAt = url.indexOf("&");
|
|
28
|
+
const query = queryAt === -1 ? "" : url.slice(queryAt + 1);
|
|
29
|
+
return new TestRailUrl(url, new URLSearchParams(query));
|
|
30
|
+
}
|
|
31
|
+
get(name) {
|
|
32
|
+
return this.params.has(name) ? this.params.get(name) : null;
|
|
33
|
+
}
|
|
34
|
+
offset() {
|
|
35
|
+
return readNonNegativeInt(this.get("offset")) ?? 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Returns this URL with a `limit` TestRail will accept.
|
|
39
|
+
* An explicit limit of 1 or more is kept.
|
|
40
|
+
*/
|
|
41
|
+
withValidPageLimit(limit = TESTRAIL_MAX_PAGE_LIMIT) {
|
|
42
|
+
const current = readNonNegativeInt(this.get("limit"));
|
|
43
|
+
if (current !== null && current >= 1) {
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
return TestRailUrl.parse(this.writeLimit(limit));
|
|
47
|
+
}
|
|
48
|
+
/** Request path, prefixing `/index.php?` onto a `_links.next` value. */
|
|
49
|
+
toString() {
|
|
50
|
+
if (this.raw.startsWith(API_INDEX) || this.raw.includes("://")) {
|
|
51
|
+
return this.raw;
|
|
52
|
+
}
|
|
53
|
+
return `${API_INDEX}${this.raw}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Next page to request, or `null` when there is no next link, this page is
|
|
57
|
+
* already shorter than its limit, or `offset` does not move forward.
|
|
58
|
+
*/
|
|
59
|
+
follow(nextLink, reportedLimit, reportedSize) {
|
|
60
|
+
if (!nextLink || isLastPage(reportedLimit, reportedSize)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const next = TestRailUrl.parse(nextLink).withValidPageLimit();
|
|
64
|
+
if (next.offset() <= this.offset() || next.toString() === this.toString()) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
writeLimit(limit) {
|
|
70
|
+
if (!this.params.has("limit")) {
|
|
71
|
+
return `${this.raw}&limit=${limit}`;
|
|
72
|
+
}
|
|
73
|
+
const queryAt = this.raw.indexOf("&");
|
|
74
|
+
if (queryAt === -1) {
|
|
75
|
+
return `${this.raw}&limit=${limit}`;
|
|
76
|
+
}
|
|
77
|
+
const route = this.raw.slice(0, queryAt);
|
|
78
|
+
const parts = this.raw.slice(queryAt + 1).split("&").map((part) => {
|
|
79
|
+
const eq = part.indexOf("=");
|
|
80
|
+
const key = eq === -1 ? part : part.slice(0, eq);
|
|
81
|
+
return key === "limit" ? `limit=${limit}` : part;
|
|
82
|
+
});
|
|
83
|
+
return `${route}&${parts.join("&")}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function isLastPage(reportedLimit, reportedSize) {
|
|
87
|
+
const limit = readNonNegativeInt(reportedLimit);
|
|
88
|
+
const size = readNonNegativeInt(reportedSize);
|
|
89
|
+
return limit !== null && size !== null && limit >= 1 && size > 0 && size < limit;
|
|
90
|
+
}
|
|
91
|
+
function readNonNegativeInt(value) {
|
|
92
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
96
|
+
return Number(value);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "3.
|
|
1
|
+
export const VERSION = "3.3.1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uarlouski/testrail-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.1",
|
|
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",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"homepage": "https://uarlouski.github.io/testrail-mcp-server/",
|
|
63
63
|
"license": "Apache-2.0",
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
65
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
66
66
|
"archiver": "^7.0.1",
|
|
67
67
|
"dotenv": "^17.4.2",
|
|
68
68
|
"zod": "^4.6.5"
|