@uarlouski/testrail-mcp-server 3.3.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 +1 -0
- package/dist/client/testrail.js +46 -36
- package/dist/utils/testrail_url.js +99 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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://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>
|
|
20
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>
|
|
21
22
|
</p>
|
|
22
23
|
|
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
|
}
|
|
@@ -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.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.
|
|
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",
|