zephyr-scale-mcp-server 0.6.0 → 0.8.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 +34 -2
- package/build/index.js +6 -0
- package/build/tool-handlers.js +259 -6
- package/build/tool-schemas.js +103 -0
- package/build/utils.js +17 -8
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/tool-handlers.ts +304 -6
- package/src/tool-schemas.ts +103 -0
- package/src/types.ts +28 -0
- package/src/utils.ts +22 -10
package/README.md
CHANGED
|
@@ -107,17 +107,22 @@ The server provides access to various resources through URI schemes:
|
|
|
107
107
|
|
|
108
108
|
### Test Run Management
|
|
109
109
|
- `create_test_run`: Create a new test run.
|
|
110
|
-
- `get_test_run`: Get detailed information about a specific test run.
|
|
110
|
+
- `get_test_run`: Get detailed information about a specific test run, including resolved status name.
|
|
111
|
+
- `update_test_run`: Update an existing test cycle — set owner, name, description, dates, or status. *(Cloud only)*
|
|
111
112
|
- `get_test_run_cases`: Get test case keys from a test run.
|
|
112
113
|
- `add_test_cases_to_run`: Add test cases to an existing test run. *(Cloud only)*
|
|
113
114
|
|
|
114
115
|
### Test Execution & Search
|
|
115
116
|
- `get_test_execution`: Get detailed individual test execution results.
|
|
116
|
-
- `
|
|
117
|
+
- `list_executions_by_cycle`: List all test executions for a specific test cycle with status, executor, and date. *(Cloud only)*
|
|
118
|
+
- `update_test_execution`: Update a test case execution's status within a cycle (Pass/Fail/etc.), add a comment, and attach bug(s) as Jira issue links. Identify the execution by `execution_id`, or by `test_cycle_key` + `test_case_key`. *(Cloud only)*
|
|
119
|
+
- `search_test_cases_by_folder`: Search for test cases in a specific folder. Automatically paginates through all results.
|
|
117
120
|
- `search_test_runs`: Search for test runs by project key and/or folder path.
|
|
121
|
+
- `get_test_cycles_for_issue`: Get the Zephyr test cycles linked to a Jira issue (story/epic). Resolves each cycle ID to its key (e.g. `PROJ-R123`) and name so you can feed it straight into `list_executions_by_cycle` / `update_test_execution`. *(Cloud only)*
|
|
118
122
|
|
|
119
123
|
### Organization
|
|
120
124
|
- `create_folder`: Create a new folder in Zephyr Scale.
|
|
125
|
+
- `get_folders`: List folders, optionally filtered by project, type, and path. When `folder_path` is given, returns the matching folder and its full subtree at every depth.
|
|
121
126
|
|
|
122
127
|
## Usage Examples
|
|
123
128
|
|
|
@@ -159,6 +164,33 @@ The server provides access to various resources through URI schemes:
|
|
|
159
164
|
```
|
|
160
165
|
**Note**: The server will convert markdown-style BDD into Gherkin when possible and will preserve all other existing test case fields.
|
|
161
166
|
|
|
167
|
+
### Mark an Execution as Failed and Attach a Bug
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"test_cycle_key": "PROJ-R123",
|
|
171
|
+
"test_case_key": "PROJ-T456",
|
|
172
|
+
"status": "Fail",
|
|
173
|
+
"comment": "Login button unresponsive on submit.",
|
|
174
|
+
"bug_keys": ["PROJ-789"]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
Or target an execution directly by key:
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"execution_id": "PROJ-E123",
|
|
181
|
+
"status": "Pass"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
**Note**: `update_test_execution` is Cloud only. `bug_keys` requires `JIRA_USERNAME` and `JIRA_API_TOKEN`; link failures are reported as warnings while the status update still succeeds.
|
|
185
|
+
|
|
186
|
+
### Find the Test Cycle Linked to a Jira Ticket
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"issue_key": "PROJ-6752"
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
Returns the linked cycles with resolved keys, e.g. `[{ "id": "110702963", "key": "PROJ-R467", "name": "..." }]`. This is the bridge from a Jira ticket to its Zephyr cycle — the association is stored on the Zephyr side, not in Jira's issue fields. Chain it: `get_test_cycles_for_issue` → `list_executions_by_cycle` → `update_test_execution`. Pass `"resolve_keys": false` to skip the per-cycle key/name lookup and return raw IDs only. **Cloud only.**
|
|
193
|
+
|
|
162
194
|
## Authentication
|
|
163
195
|
|
|
164
196
|
### Jira Cloud Configuration
|
package/build/index.js
CHANGED
|
@@ -51,6 +51,8 @@ class ZephyrServer {
|
|
|
51
51
|
return await this.toolHandlers.updateTestCaseBdd(args);
|
|
52
52
|
case 'create_folder':
|
|
53
53
|
return await this.toolHandlers.createFolder(args);
|
|
54
|
+
case 'get_folders':
|
|
55
|
+
return await this.toolHandlers.getFolders(args);
|
|
54
56
|
case 'get_test_run_cases':
|
|
55
57
|
return await this.toolHandlers.getTestRunCases(args);
|
|
56
58
|
case 'delete_test_case':
|
|
@@ -73,6 +75,10 @@ class ZephyrServer {
|
|
|
73
75
|
return await this.toolHandlers.addTestCasesToRun(args);
|
|
74
76
|
case 'list_executions_by_cycle':
|
|
75
77
|
return await this.toolHandlers.listExecutionsByCycle(args);
|
|
78
|
+
case 'update_test_execution':
|
|
79
|
+
return await this.toolHandlers.updateTestExecution(args);
|
|
80
|
+
case 'get_test_cycles_for_issue':
|
|
81
|
+
return await this.toolHandlers.getTestCyclesForIssue(args);
|
|
76
82
|
default:
|
|
77
83
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
78
84
|
}
|
package/build/tool-handlers.js
CHANGED
|
@@ -743,6 +743,79 @@ export class ZephyrToolHandlers {
|
|
|
743
743
|
throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
|
|
744
744
|
}
|
|
745
745
|
}
|
|
746
|
+
async getFolders(args) {
|
|
747
|
+
const { project_key, folder_type, folder_path, max_results } = args;
|
|
748
|
+
try {
|
|
749
|
+
const pageSize = 1000;
|
|
750
|
+
const allFolders = [];
|
|
751
|
+
let startAt = 0;
|
|
752
|
+
let isLast = false;
|
|
753
|
+
while (!isLast) {
|
|
754
|
+
const params = { maxResults: pageSize, startAt };
|
|
755
|
+
if (project_key)
|
|
756
|
+
params.projectKey = project_key;
|
|
757
|
+
if (folder_type)
|
|
758
|
+
params.folderType = folder_type;
|
|
759
|
+
const response = await this.axiosInstance.get('/folders', { params });
|
|
760
|
+
const page = Array.isArray(response.data)
|
|
761
|
+
? response.data
|
|
762
|
+
: response.data?.values ?? [];
|
|
763
|
+
allFolders.push(...page);
|
|
764
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
765
|
+
startAt += page.length;
|
|
766
|
+
if (max_results && allFolders.length >= max_results)
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
let results;
|
|
770
|
+
if (folder_path) {
|
|
771
|
+
// Resolve the root folder ID from the path, then collect full subtree via BFS
|
|
772
|
+
const rootId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder_path, folder_type ?? 'TEST_CASE');
|
|
773
|
+
if (rootId === null) {
|
|
774
|
+
return {
|
|
775
|
+
content: [{
|
|
776
|
+
type: 'text',
|
|
777
|
+
text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}.`,
|
|
778
|
+
}],
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
// BFS over the already-fetched flat list — no extra API calls
|
|
782
|
+
const subtreeIds = new Set([rootId]);
|
|
783
|
+
const queue = [rootId];
|
|
784
|
+
while (queue.length > 0) {
|
|
785
|
+
const current = queue.shift();
|
|
786
|
+
for (const f of allFolders) {
|
|
787
|
+
if ((f.parentId ?? null) === current && !subtreeIds.has(f.id)) {
|
|
788
|
+
subtreeIds.add(f.id);
|
|
789
|
+
queue.push(f.id);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
results = allFolders.filter(f => subtreeIds.has(f.id));
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
results = allFolders;
|
|
797
|
+
}
|
|
798
|
+
if (max_results)
|
|
799
|
+
results = results.slice(0, max_results);
|
|
800
|
+
return {
|
|
801
|
+
content: [{
|
|
802
|
+
type: 'text',
|
|
803
|
+
text: JSON.stringify({
|
|
804
|
+
totalCount: results.length,
|
|
805
|
+
folders: results.map((f) => ({
|
|
806
|
+
id: f.id,
|
|
807
|
+
name: f.name,
|
|
808
|
+
parentId: f.parentId ?? null,
|
|
809
|
+
folderType: f.folderType,
|
|
810
|
+
})),
|
|
811
|
+
}, null, 2),
|
|
812
|
+
}],
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get folders: ${this.formatError(error)}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
746
819
|
async resolveStatusName(statusId) {
|
|
747
820
|
try {
|
|
748
821
|
const response = await this.axiosInstance.get(`/statuses/${statusId}`);
|
|
@@ -837,12 +910,27 @@ export class ZephyrToolHandlers {
|
|
|
837
910
|
}],
|
|
838
911
|
};
|
|
839
912
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
913
|
+
// Paginate through all results — the API returns up to 1000 per page
|
|
914
|
+
const pageSize = Math.min(max_results, 1000);
|
|
915
|
+
const allTestCases = [];
|
|
916
|
+
let startAt = 0;
|
|
917
|
+
let isLast = false;
|
|
918
|
+
while (!isLast && allTestCases.length < max_results) {
|
|
919
|
+
const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
|
|
920
|
+
params: { projectKey: project_key, folderId, maxResults: pageSize, startAt },
|
|
921
|
+
});
|
|
922
|
+
const page = Array.isArray(response.data)
|
|
923
|
+
? response.data
|
|
924
|
+
: response.data?.values ?? [];
|
|
925
|
+
allTestCases.push(...page);
|
|
926
|
+
// Stop if the API signals last page, or we got fewer results than requested
|
|
927
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
928
|
+
startAt += page.length;
|
|
929
|
+
// Safety: never exceed max_results
|
|
930
|
+
if (allTestCases.length >= max_results)
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
const testCases = allTestCases.slice(0, max_results);
|
|
846
934
|
return {
|
|
847
935
|
content: [{
|
|
848
936
|
type: 'text',
|
|
@@ -1063,6 +1151,171 @@ export class ZephyrToolHandlers {
|
|
|
1063
1151
|
throw new McpError(ErrorCode.InternalError, `Failed to list executions: ${this.formatError(error)}`);
|
|
1064
1152
|
}
|
|
1065
1153
|
}
|
|
1154
|
+
async updateTestExecution(args) {
|
|
1155
|
+
if (this.jiraConfig.type !== 'cloud') {
|
|
1156
|
+
throw new McpError(ErrorCode.InvalidRequest, 'update_test_execution is only supported on Zephyr Scale Cloud. The Data Center API (v1) uses a different test-result model.');
|
|
1157
|
+
}
|
|
1158
|
+
const { execution_id, test_cycle_key, test_case_key, project_key, status, comment, environment, execution_time, actual_end_date, executed_by_id, assigned_to_id, bug_keys, } = args;
|
|
1159
|
+
// Resolve which execution to update: explicit execution_id wins, otherwise cycle + test case.
|
|
1160
|
+
let execKey = execution_id;
|
|
1161
|
+
if (!execKey) {
|
|
1162
|
+
if (!test_cycle_key || !test_case_key) {
|
|
1163
|
+
throw new McpError(ErrorCode.InvalidParams, 'Provide either execution_id, or both test_cycle_key and test_case_key to identify the execution.');
|
|
1164
|
+
}
|
|
1165
|
+
execKey = await this.resolveExecutionByCycleAndCase(test_cycle_key, test_case_key, project_key);
|
|
1166
|
+
}
|
|
1167
|
+
// Build the update payload — the PUT ignores null/undefined and only touches provided fields.
|
|
1168
|
+
const payload = {};
|
|
1169
|
+
if (status !== undefined)
|
|
1170
|
+
payload.statusName = status;
|
|
1171
|
+
if (comment !== undefined)
|
|
1172
|
+
payload.comment = comment;
|
|
1173
|
+
if (environment !== undefined)
|
|
1174
|
+
payload.environmentName = environment;
|
|
1175
|
+
if (execution_time !== undefined)
|
|
1176
|
+
payload.executionTime = execution_time;
|
|
1177
|
+
if (actual_end_date !== undefined)
|
|
1178
|
+
payload.actualEndDate = actual_end_date;
|
|
1179
|
+
if (executed_by_id !== undefined)
|
|
1180
|
+
payload.executedById = executed_by_id;
|
|
1181
|
+
if (assigned_to_id !== undefined)
|
|
1182
|
+
payload.assignedToId = assigned_to_id;
|
|
1183
|
+
const hasBugs = Array.isArray(bug_keys) && bug_keys.length > 0;
|
|
1184
|
+
if (Object.keys(payload).length === 0 && !hasBugs) {
|
|
1185
|
+
throw new McpError(ErrorCode.InvalidParams, 'Nothing to update — provide at least one of: status, comment, environment, execution_time, actual_end_date, executed_by_id, assigned_to_id, or bug_keys.');
|
|
1186
|
+
}
|
|
1187
|
+
try {
|
|
1188
|
+
if (Object.keys(payload).length > 0) {
|
|
1189
|
+
await this.axiosInstance.put(`/testexecutions/${execKey}`, payload);
|
|
1190
|
+
}
|
|
1191
|
+
// Attach bugs / Jira issues. IssueLinkInput requires a numeric issueId, so resolve each
|
|
1192
|
+
// key via the Jira REST API (needs JIRA_USERNAME + JIRA_API_TOKEN on Cloud), mirroring create_test_case.
|
|
1193
|
+
const linkWarnings = [];
|
|
1194
|
+
let linkedCount = 0;
|
|
1195
|
+
if (hasBugs) {
|
|
1196
|
+
for (const bugKey of bug_keys) {
|
|
1197
|
+
try {
|
|
1198
|
+
const issueId = await this.resolveJiraIssueId(bugKey);
|
|
1199
|
+
await this.axiosInstance.post(`/testexecutions/${execKey}/links/issues`, { issueId });
|
|
1200
|
+
linkedCount++;
|
|
1201
|
+
}
|
|
1202
|
+
catch (e) {
|
|
1203
|
+
linkWarnings.push(`${bugKey}: ${this.formatError(e)}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const missingCreds = !process.env.JIRA_USERNAME || !process.env.JIRA_API_TOKEN;
|
|
1208
|
+
const credHint = missingCreds && linkWarnings.length > 0
|
|
1209
|
+
? '\n💡 Tip: Set JIRA_USERNAME and JIRA_API_TOKEN env vars to enable bug/issue linking on Cloud.'
|
|
1210
|
+
: '';
|
|
1211
|
+
const warningText = linkWarnings.length > 0
|
|
1212
|
+
? `\n⚠️ Some bug links failed:\n${linkWarnings.map(w => ` - ${w}`).join('\n')}${credHint}`
|
|
1213
|
+
: '';
|
|
1214
|
+
return {
|
|
1215
|
+
content: [{
|
|
1216
|
+
type: 'text',
|
|
1217
|
+
text: `✅ Updated test execution ${execKey} successfully.\n${JSON.stringify({
|
|
1218
|
+
executionKey: execKey,
|
|
1219
|
+
status: status ?? '(unchanged)',
|
|
1220
|
+
updatedFields: Object.keys(payload),
|
|
1221
|
+
linkedBugs: linkedCount,
|
|
1222
|
+
}, null, 2)}${warningText}`,
|
|
1223
|
+
}],
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
catch (error) {
|
|
1227
|
+
if (error instanceof McpError)
|
|
1228
|
+
throw error;
|
|
1229
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update test execution: ${this.formatError(error)}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
async getTestCyclesForIssue(args) {
|
|
1233
|
+
if (this.jiraConfig.type !== 'cloud') {
|
|
1234
|
+
throw new McpError(ErrorCode.InvalidRequest, 'get_test_cycles_for_issue is only supported on Zephyr Scale Cloud. The Data Center API (v1) does not expose issue-link lookups.');
|
|
1235
|
+
}
|
|
1236
|
+
const { issue_key, resolve_keys = true } = args;
|
|
1237
|
+
if (!issue_key) {
|
|
1238
|
+
throw new McpError(ErrorCode.InvalidParams, 'issue_key is required (e.g. "PROJ-123").');
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
// GET /issuelinks/{issueKey}/testcycles → TestCycleIdList: [{ id, self }, ...]
|
|
1242
|
+
const response = await this.axiosInstance.get(`/issuelinks/${issue_key}/testcycles`);
|
|
1243
|
+
const raw = Array.isArray(response.data)
|
|
1244
|
+
? response.data
|
|
1245
|
+
: response.data?.values ?? [];
|
|
1246
|
+
const cycleIds = raw
|
|
1247
|
+
.map((c) => c.id ?? c.self?.match(/testcycles\/(\d+)/)?.[1])
|
|
1248
|
+
.filter((id) => id !== undefined && id !== null)
|
|
1249
|
+
.map((id) => String(id));
|
|
1250
|
+
// Optionally resolve each numeric cycle ID to its human-readable key + name.
|
|
1251
|
+
let cycles;
|
|
1252
|
+
if (resolve_keys) {
|
|
1253
|
+
cycles = [];
|
|
1254
|
+
for (const id of cycleIds) {
|
|
1255
|
+
try {
|
|
1256
|
+
const cyc = await this.axiosInstance.get(`/testcycles/${id}`);
|
|
1257
|
+
cycles.push({ id, key: cyc.data?.key ?? null, name: cyc.data?.name ?? null });
|
|
1258
|
+
}
|
|
1259
|
+
catch (e) {
|
|
1260
|
+
cycles.push({ id, key: null, name: null, error: this.formatError(e) });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
cycles = cycleIds.map((id) => ({ id }));
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
content: [{
|
|
1269
|
+
type: 'text',
|
|
1270
|
+
text: `✅ Found ${cycles.length} test cycle(s) linked to ${issue_key}:\n${JSON.stringify({
|
|
1271
|
+
issueKey: issue_key,
|
|
1272
|
+
totalCount: cycles.length,
|
|
1273
|
+
testCycles: cycles,
|
|
1274
|
+
}, null, 2)}`,
|
|
1275
|
+
}],
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
if (error instanceof McpError)
|
|
1280
|
+
throw error;
|
|
1281
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get test cycles for issue: ${this.formatError(error)}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
/** Find the latest execution key for a test case within a cycle (Cloud). */
|
|
1285
|
+
async resolveExecutionByCycleAndCase(cycleKey, caseKey, projectKey) {
|
|
1286
|
+
const derivedProject = projectKey || cycleKey.split('-')[0];
|
|
1287
|
+
try {
|
|
1288
|
+
const response = await this.axiosInstance.get('/testexecutions', {
|
|
1289
|
+
params: {
|
|
1290
|
+
projectKey: derivedProject,
|
|
1291
|
+
testCycle: cycleKey,
|
|
1292
|
+
onlyLastExecutions: true,
|
|
1293
|
+
maxResults: 1000,
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
const executions = Array.isArray(response.data)
|
|
1297
|
+
? response.data
|
|
1298
|
+
: response.data?.values ?? [];
|
|
1299
|
+
const match = executions.find((ex) => {
|
|
1300
|
+
const key = ex.testCase?.self?.match(/testcases\/(.+?)\/versions/)?.[1]
|
|
1301
|
+
?? ex.testCase?.key;
|
|
1302
|
+
return key === caseKey;
|
|
1303
|
+
});
|
|
1304
|
+
if (!match) {
|
|
1305
|
+
throw new McpError(ErrorCode.InvalidParams, `No execution found for test case ${caseKey} in cycle ${cycleKey}. Ensure the test case is part of the cycle.`);
|
|
1306
|
+
}
|
|
1307
|
+
const execKey = match.key ?? (match.id !== undefined ? String(match.id) : undefined);
|
|
1308
|
+
if (!execKey) {
|
|
1309
|
+
throw new McpError(ErrorCode.InternalError, `Found a matching execution for ${caseKey} in ${cycleKey} but it has no key or id.`);
|
|
1310
|
+
}
|
|
1311
|
+
return execKey;
|
|
1312
|
+
}
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
if (error instanceof McpError)
|
|
1315
|
+
throw error;
|
|
1316
|
+
throw new McpError(ErrorCode.InternalError, `Failed to resolve execution for ${caseKey} in ${cycleKey}: ${this.formatError(error)}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1066
1319
|
async resolveJiraIssueId(issueKey) {
|
|
1067
1320
|
// The Zephyr API key is NOT valid for the Jira REST API — Jira Cloud requires
|
|
1068
1321
|
// Basic Auth: base64(email:api_token) via JIRA_USERNAME + JIRA_API_TOKEN env vars.
|
package/build/tool-schemas.js
CHANGED
|
@@ -196,6 +196,32 @@ export const toolSchemas = [
|
|
|
196
196
|
required: ['project_key', 'name'],
|
|
197
197
|
},
|
|
198
198
|
},
|
|
199
|
+
{
|
|
200
|
+
name: 'get_folders',
|
|
201
|
+
description: 'Get folders from Zephyr Scale. All parameters are optional. If folder_path is provided, returns the full subtree (the folder and all its descendants at every depth) under that path. Otherwise returns all folders matching the filters.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
project_key: {
|
|
206
|
+
type: 'string',
|
|
207
|
+
description: 'Jira project key filter (e.g., "PROJ"). Optional.',
|
|
208
|
+
},
|
|
209
|
+
folder_type: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Folder type filter (optional)',
|
|
212
|
+
enum: ['TEST_CASE', 'TEST_PLAN', 'TEST_CYCLE'],
|
|
213
|
+
},
|
|
214
|
+
folder_path: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'Folder path to filter by (e.g., "/CRM on Wechat"). Returns the matching folder and all folders nested beneath it at any depth. Requires project_key and folder_type to resolve the path.',
|
|
217
|
+
},
|
|
218
|
+
max_results: {
|
|
219
|
+
type: 'number',
|
|
220
|
+
description: 'Maximum total number of folders to return (optional). Defaults to all folders.',
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
199
225
|
{
|
|
200
226
|
name: 'get_test_run_cases',
|
|
201
227
|
description: 'Get test case keys from a test run',
|
|
@@ -440,6 +466,83 @@ export const toolSchemas = [
|
|
|
440
466
|
required: ['test_run_key'],
|
|
441
467
|
},
|
|
442
468
|
},
|
|
469
|
+
{
|
|
470
|
+
name: 'update_test_execution',
|
|
471
|
+
description: 'Update a test case execution\'s status within a test cycle, and optionally attach bug(s) as Jira issue links. Identify the execution either by execution_id directly, or by test_cycle_key + test_case_key (the latest execution is used). Cloud only.',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {
|
|
475
|
+
execution_id: {
|
|
476
|
+
type: 'string',
|
|
477
|
+
description: 'Test execution key or ID (e.g. "PROJ-E123" or 5805255). Takes precedence over test_cycle_key/test_case_key.',
|
|
478
|
+
},
|
|
479
|
+
test_cycle_key: {
|
|
480
|
+
type: 'string',
|
|
481
|
+
description: 'Test cycle key (e.g. "PROJ-R123"). Used together with test_case_key to locate the execution when execution_id is not given.',
|
|
482
|
+
},
|
|
483
|
+
test_case_key: {
|
|
484
|
+
type: 'string',
|
|
485
|
+
description: 'Test case key (e.g. "PROJ-T456"). Used together with test_cycle_key to locate the execution.',
|
|
486
|
+
},
|
|
487
|
+
project_key: {
|
|
488
|
+
type: 'string',
|
|
489
|
+
description: 'Project key for the cycle lookup (optional — derived from test_cycle_key when omitted).',
|
|
490
|
+
},
|
|
491
|
+
status: {
|
|
492
|
+
type: 'string',
|
|
493
|
+
description: 'New execution status name. Common values: "Pass", "Fail", "In Progress", "Blocked", "Not Executed". Must match a status configured in your Zephyr project.',
|
|
494
|
+
},
|
|
495
|
+
comment: {
|
|
496
|
+
type: 'string',
|
|
497
|
+
description: 'Comment against the overall execution (e.g. failure details).',
|
|
498
|
+
},
|
|
499
|
+
environment: {
|
|
500
|
+
type: 'string',
|
|
501
|
+
description: 'Environment name assigned to the execution (e.g. "Chrome Latest Version").',
|
|
502
|
+
},
|
|
503
|
+
execution_time: {
|
|
504
|
+
type: 'number',
|
|
505
|
+
description: 'Actual execution time in milliseconds (optional).',
|
|
506
|
+
},
|
|
507
|
+
actual_end_date: {
|
|
508
|
+
type: 'string',
|
|
509
|
+
description: 'Actual end date in ISO format, e.g. "2024-05-20T13:15:13Z" (optional).',
|
|
510
|
+
},
|
|
511
|
+
executed_by_id: {
|
|
512
|
+
type: 'string',
|
|
513
|
+
description: 'Jira Account ID of the user who executed the test (optional).',
|
|
514
|
+
},
|
|
515
|
+
assigned_to_id: {
|
|
516
|
+
type: 'string',
|
|
517
|
+
description: 'Jira Account ID of the user the execution is assigned to (optional).',
|
|
518
|
+
},
|
|
519
|
+
bug_keys: {
|
|
520
|
+
type: 'array',
|
|
521
|
+
description: 'Jira issue keys to attach to the execution as bugs (e.g. ["PROJ-789"]). Each key is resolved to a numeric ID via the Jira REST API (requires JIRA_USERNAME + JIRA_API_TOKEN) and linked via POST /testexecutions/{key}/links/issues. Failures are reported as warnings and do not fail the call.',
|
|
522
|
+
items: { type: 'string' },
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: 'get_test_cycles_for_issue',
|
|
529
|
+
description: 'Get the Zephyr test cycles linked to a Jira issue (e.g. a story or epic). Calls GET /issuelinks/{issueKey}/testcycles and, by default, resolves each numeric cycle ID to its key (e.g. "PROJ-R123") and name. Use this to discover the test cycle referenced by a Jira ticket, then feed the key into list_executions_by_cycle / update_test_execution. Cloud only.',
|
|
530
|
+
inputSchema: {
|
|
531
|
+
type: 'object',
|
|
532
|
+
properties: {
|
|
533
|
+
issue_key: {
|
|
534
|
+
type: 'string',
|
|
535
|
+
description: 'Jira issue key whose linked test cycles to fetch (e.g. "PROJ-123").',
|
|
536
|
+
},
|
|
537
|
+
resolve_keys: {
|
|
538
|
+
type: 'boolean',
|
|
539
|
+
description: 'When true (default), resolve each cycle ID to its key + name via GET /testcycles/{id}. Set false to return raw numeric IDs only (faster, no extra calls).',
|
|
540
|
+
default: true,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
required: ['issue_key'],
|
|
544
|
+
},
|
|
545
|
+
},
|
|
443
546
|
{
|
|
444
547
|
name: 'delete_test_run',
|
|
445
548
|
description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
|
package/build/utils.js
CHANGED
|
@@ -4,18 +4,27 @@ export async function resolveFolderIdByPath(axiosInstance, projectKey, folderPat
|
|
|
4
4
|
if (segments.length === 0)
|
|
5
5
|
return null;
|
|
6
6
|
try {
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
// Paginate through all folders for the project + type
|
|
8
|
+
const pageSize = 1000;
|
|
9
|
+
const allFolders = [];
|
|
10
|
+
let startAt = 0;
|
|
11
|
+
let isLast = false;
|
|
12
|
+
while (!isLast) {
|
|
13
|
+
const response = await axiosInstance.get('/folders', {
|
|
14
|
+
params: { projectKey, folderType, maxResults: pageSize, startAt },
|
|
15
|
+
});
|
|
16
|
+
const page = Array.isArray(response.data)
|
|
17
|
+
? response.data
|
|
18
|
+
: response.data?.values ?? [];
|
|
19
|
+
allFolders.push(...page);
|
|
20
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
21
|
+
startAt += page.length;
|
|
22
|
+
}
|
|
14
23
|
// Walk segments top-down, matching by name under the correct parent
|
|
15
24
|
let parentId = null;
|
|
16
25
|
let matchedId = null;
|
|
17
26
|
for (const segment of segments) {
|
|
18
|
-
const match =
|
|
27
|
+
const match = allFolders.find((f) => f.name === segment && (f.parentId ?? null) === parentId);
|
|
19
28
|
if (!match)
|
|
20
29
|
return null;
|
|
21
30
|
matchedId = match.id;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zephyr-scale-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Model Context Protocol (MCP) server for Zephyr Scale test case management with comprehensive STEP_BY_STEP, PLAIN_TEXT, and BDD support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./build/index.js",
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,8 @@ class ZephyrServer {
|
|
|
71
71
|
return await this.toolHandlers.updateTestCaseBdd(args as any);
|
|
72
72
|
case 'create_folder':
|
|
73
73
|
return await this.toolHandlers.createFolder(args as any);
|
|
74
|
+
case 'get_folders':
|
|
75
|
+
return await this.toolHandlers.getFolders(args as any);
|
|
74
76
|
case 'get_test_run_cases':
|
|
75
77
|
return await this.toolHandlers.getTestRunCases(args);
|
|
76
78
|
case 'delete_test_case':
|
|
@@ -93,6 +95,10 @@ class ZephyrServer {
|
|
|
93
95
|
return await this.toolHandlers.addTestCasesToRun(args as any);
|
|
94
96
|
case 'list_executions_by_cycle':
|
|
95
97
|
return await this.toolHandlers.listExecutionsByCycle(args as any);
|
|
98
|
+
case 'update_test_execution':
|
|
99
|
+
return await this.toolHandlers.updateTestExecution(args as any);
|
|
100
|
+
case 'get_test_cycles_for_issue':
|
|
101
|
+
return await this.toolHandlers.getTestCyclesForIssue(args as any);
|
|
96
102
|
default:
|
|
97
103
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
98
104
|
}
|
package/src/tool-handlers.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
SearchTestRunsArgs,
|
|
12
12
|
GetTestExecutionArgs,
|
|
13
13
|
ListExecutionsByCycleArgs,
|
|
14
|
+
UpdateTestExecutionArgs,
|
|
15
|
+
GetTestCyclesForIssueArgs,
|
|
14
16
|
JiraConfig
|
|
15
17
|
} from './types.js';
|
|
16
18
|
import { convertToGherkin, resolveFolderIdByPath, getAccountIdFromApiKey } from './utils.js';
|
|
@@ -800,6 +802,90 @@ export class ZephyrToolHandlers {
|
|
|
800
802
|
}
|
|
801
803
|
}
|
|
802
804
|
|
|
805
|
+
async getFolders(args: any) {
|
|
806
|
+
const { project_key, folder_type, folder_path, max_results } = args;
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
const pageSize = 1000;
|
|
810
|
+
const allFolders: any[] = [];
|
|
811
|
+
let startAt = 0;
|
|
812
|
+
let isLast = false;
|
|
813
|
+
|
|
814
|
+
while (!isLast) {
|
|
815
|
+
const params: Record<string, any> = { maxResults: pageSize, startAt };
|
|
816
|
+
if (project_key) params.projectKey = project_key;
|
|
817
|
+
if (folder_type) params.folderType = folder_type;
|
|
818
|
+
|
|
819
|
+
const response = await this.axiosInstance.get('/folders', { params });
|
|
820
|
+
|
|
821
|
+
const page: any[] = Array.isArray(response.data)
|
|
822
|
+
? response.data
|
|
823
|
+
: response.data?.values ?? [];
|
|
824
|
+
|
|
825
|
+
allFolders.push(...page);
|
|
826
|
+
|
|
827
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
828
|
+
startAt += page.length;
|
|
829
|
+
|
|
830
|
+
if (max_results && allFolders.length >= max_results) break;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let results: any[];
|
|
834
|
+
|
|
835
|
+
if (folder_path) {
|
|
836
|
+
// Resolve the root folder ID from the path, then collect full subtree via BFS
|
|
837
|
+
const rootId = await resolveFolderIdByPath(
|
|
838
|
+
this.axiosInstance, project_key, folder_path, folder_type ?? 'TEST_CASE'
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
if (rootId === null) {
|
|
842
|
+
return {
|
|
843
|
+
content: [{
|
|
844
|
+
type: 'text',
|
|
845
|
+
text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}.`,
|
|
846
|
+
}],
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// BFS over the already-fetched flat list — no extra API calls
|
|
851
|
+
const subtreeIds = new Set<number>([rootId]);
|
|
852
|
+
const queue = [rootId];
|
|
853
|
+
while (queue.length > 0) {
|
|
854
|
+
const current = queue.shift()!;
|
|
855
|
+
for (const f of allFolders) {
|
|
856
|
+
if ((f.parentId ?? null) === current && !subtreeIds.has(f.id)) {
|
|
857
|
+
subtreeIds.add(f.id);
|
|
858
|
+
queue.push(f.id);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
results = allFolders.filter(f => subtreeIds.has(f.id));
|
|
864
|
+
} else {
|
|
865
|
+
results = allFolders;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (max_results) results = results.slice(0, max_results);
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
content: [{
|
|
872
|
+
type: 'text',
|
|
873
|
+
text: JSON.stringify({
|
|
874
|
+
totalCount: results.length,
|
|
875
|
+
folders: results.map((f: any) => ({
|
|
876
|
+
id: f.id,
|
|
877
|
+
name: f.name,
|
|
878
|
+
parentId: f.parentId ?? null,
|
|
879
|
+
folderType: f.folderType,
|
|
880
|
+
})),
|
|
881
|
+
}, null, 2),
|
|
882
|
+
}],
|
|
883
|
+
};
|
|
884
|
+
} catch (error) {
|
|
885
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get folders: ${this.formatError(error)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
803
889
|
private async resolveStatusName(statusId: number): Promise<string | null> {
|
|
804
890
|
try {
|
|
805
891
|
const response = await this.axiosInstance.get(`/statuses/${statusId}`);
|
|
@@ -908,13 +994,32 @@ export class ZephyrToolHandlers {
|
|
|
908
994
|
};
|
|
909
995
|
}
|
|
910
996
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
997
|
+
// Paginate through all results — the API returns up to 1000 per page
|
|
998
|
+
const pageSize = Math.min(max_results, 1000);
|
|
999
|
+
const allTestCases: any[] = [];
|
|
1000
|
+
let startAt = 0;
|
|
1001
|
+
let isLast = false;
|
|
914
1002
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1003
|
+
while (!isLast && allTestCases.length < max_results) {
|
|
1004
|
+
const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
|
|
1005
|
+
params: { projectKey: project_key, folderId, maxResults: pageSize, startAt },
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
const page = Array.isArray(response.data)
|
|
1009
|
+
? response.data
|
|
1010
|
+
: response.data?.values ?? [];
|
|
1011
|
+
|
|
1012
|
+
allTestCases.push(...page);
|
|
1013
|
+
|
|
1014
|
+
// Stop if the API signals last page, or we got fewer results than requested
|
|
1015
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
1016
|
+
startAt += page.length;
|
|
1017
|
+
|
|
1018
|
+
// Safety: never exceed max_results
|
|
1019
|
+
if (allTestCases.length >= max_results) break;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const testCases = allTestCases.slice(0, max_results);
|
|
918
1023
|
|
|
919
1024
|
return {
|
|
920
1025
|
content: [{
|
|
@@ -1156,6 +1261,199 @@ export class ZephyrToolHandlers {
|
|
|
1156
1261
|
}
|
|
1157
1262
|
}
|
|
1158
1263
|
|
|
1264
|
+
async updateTestExecution(args: UpdateTestExecutionArgs) {
|
|
1265
|
+
if (this.jiraConfig.type !== 'cloud') {
|
|
1266
|
+
throw new McpError(
|
|
1267
|
+
ErrorCode.InvalidRequest,
|
|
1268
|
+
'update_test_execution is only supported on Zephyr Scale Cloud. The Data Center API (v1) uses a different test-result model.'
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const {
|
|
1273
|
+
execution_id, test_cycle_key, test_case_key, project_key,
|
|
1274
|
+
status, comment, environment, execution_time, actual_end_date,
|
|
1275
|
+
executed_by_id, assigned_to_id, bug_keys,
|
|
1276
|
+
} = args;
|
|
1277
|
+
|
|
1278
|
+
// Resolve which execution to update: explicit execution_id wins, otherwise cycle + test case.
|
|
1279
|
+
let execKey = execution_id;
|
|
1280
|
+
if (!execKey) {
|
|
1281
|
+
if (!test_cycle_key || !test_case_key) {
|
|
1282
|
+
throw new McpError(
|
|
1283
|
+
ErrorCode.InvalidParams,
|
|
1284
|
+
'Provide either execution_id, or both test_cycle_key and test_case_key to identify the execution.'
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
execKey = await this.resolveExecutionByCycleAndCase(test_cycle_key, test_case_key, project_key);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Build the update payload — the PUT ignores null/undefined and only touches provided fields.
|
|
1291
|
+
const payload: any = {};
|
|
1292
|
+
if (status !== undefined) payload.statusName = status;
|
|
1293
|
+
if (comment !== undefined) payload.comment = comment;
|
|
1294
|
+
if (environment !== undefined) payload.environmentName = environment;
|
|
1295
|
+
if (execution_time !== undefined) payload.executionTime = execution_time;
|
|
1296
|
+
if (actual_end_date !== undefined) payload.actualEndDate = actual_end_date;
|
|
1297
|
+
if (executed_by_id !== undefined) payload.executedById = executed_by_id;
|
|
1298
|
+
if (assigned_to_id !== undefined) payload.assignedToId = assigned_to_id;
|
|
1299
|
+
|
|
1300
|
+
const hasBugs = Array.isArray(bug_keys) && bug_keys.length > 0;
|
|
1301
|
+
if (Object.keys(payload).length === 0 && !hasBugs) {
|
|
1302
|
+
throw new McpError(
|
|
1303
|
+
ErrorCode.InvalidParams,
|
|
1304
|
+
'Nothing to update — provide at least one of: status, comment, environment, execution_time, actual_end_date, executed_by_id, assigned_to_id, or bug_keys.'
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
if (Object.keys(payload).length > 0) {
|
|
1310
|
+
await this.axiosInstance.put(`/testexecutions/${execKey}`, payload);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Attach bugs / Jira issues. IssueLinkInput requires a numeric issueId, so resolve each
|
|
1314
|
+
// key via the Jira REST API (needs JIRA_USERNAME + JIRA_API_TOKEN on Cloud), mirroring create_test_case.
|
|
1315
|
+
const linkWarnings: string[] = [];
|
|
1316
|
+
let linkedCount = 0;
|
|
1317
|
+
if (hasBugs) {
|
|
1318
|
+
for (const bugKey of bug_keys!) {
|
|
1319
|
+
try {
|
|
1320
|
+
const issueId = await this.resolveJiraIssueId(bugKey);
|
|
1321
|
+
await this.axiosInstance.post(`/testexecutions/${execKey}/links/issues`, { issueId });
|
|
1322
|
+
linkedCount++;
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
linkWarnings.push(`${bugKey}: ${this.formatError(e)}`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const missingCreds = !process.env.JIRA_USERNAME || !process.env.JIRA_API_TOKEN;
|
|
1330
|
+
const credHint = missingCreds && linkWarnings.length > 0
|
|
1331
|
+
? '\n💡 Tip: Set JIRA_USERNAME and JIRA_API_TOKEN env vars to enable bug/issue linking on Cloud.'
|
|
1332
|
+
: '';
|
|
1333
|
+
const warningText = linkWarnings.length > 0
|
|
1334
|
+
? `\n⚠️ Some bug links failed:\n${linkWarnings.map(w => ` - ${w}`).join('\n')}${credHint}`
|
|
1335
|
+
: '';
|
|
1336
|
+
|
|
1337
|
+
return {
|
|
1338
|
+
content: [{
|
|
1339
|
+
type: 'text',
|
|
1340
|
+
text: `✅ Updated test execution ${execKey} successfully.\n${JSON.stringify({
|
|
1341
|
+
executionKey: execKey,
|
|
1342
|
+
status: status ?? '(unchanged)',
|
|
1343
|
+
updatedFields: Object.keys(payload),
|
|
1344
|
+
linkedBugs: linkedCount,
|
|
1345
|
+
}, null, 2)}${warningText}`,
|
|
1346
|
+
}],
|
|
1347
|
+
};
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
if (error instanceof McpError) throw error;
|
|
1350
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update test execution: ${this.formatError(error)}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async getTestCyclesForIssue(args: GetTestCyclesForIssueArgs) {
|
|
1355
|
+
if (this.jiraConfig.type !== 'cloud') {
|
|
1356
|
+
throw new McpError(
|
|
1357
|
+
ErrorCode.InvalidRequest,
|
|
1358
|
+
'get_test_cycles_for_issue is only supported on Zephyr Scale Cloud. The Data Center API (v1) does not expose issue-link lookups.'
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const { issue_key, resolve_keys = true } = args;
|
|
1363
|
+
if (!issue_key) {
|
|
1364
|
+
throw new McpError(ErrorCode.InvalidParams, 'issue_key is required (e.g. "PROJ-123").');
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
try {
|
|
1368
|
+
// GET /issuelinks/{issueKey}/testcycles → TestCycleIdList: [{ id, self }, ...]
|
|
1369
|
+
const response = await this.axiosInstance.get(`/issuelinks/${issue_key}/testcycles`);
|
|
1370
|
+
const raw = Array.isArray(response.data)
|
|
1371
|
+
? response.data
|
|
1372
|
+
: response.data?.values ?? [];
|
|
1373
|
+
|
|
1374
|
+
const cycleIds: string[] = raw
|
|
1375
|
+
.map((c: any) => c.id ?? c.self?.match(/testcycles\/(\d+)/)?.[1])
|
|
1376
|
+
.filter((id: any) => id !== undefined && id !== null)
|
|
1377
|
+
.map((id: any) => String(id));
|
|
1378
|
+
|
|
1379
|
+
// Optionally resolve each numeric cycle ID to its human-readable key + name.
|
|
1380
|
+
let cycles: any[];
|
|
1381
|
+
if (resolve_keys) {
|
|
1382
|
+
cycles = [];
|
|
1383
|
+
for (const id of cycleIds) {
|
|
1384
|
+
try {
|
|
1385
|
+
const cyc = await this.axiosInstance.get(`/testcycles/${id}`);
|
|
1386
|
+
cycles.push({ id, key: cyc.data?.key ?? null, name: cyc.data?.name ?? null });
|
|
1387
|
+
} catch (e) {
|
|
1388
|
+
cycles.push({ id, key: null, name: null, error: this.formatError(e) });
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
} else {
|
|
1392
|
+
cycles = cycleIds.map((id) => ({ id }));
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
return {
|
|
1396
|
+
content: [{
|
|
1397
|
+
type: 'text',
|
|
1398
|
+
text: `✅ Found ${cycles.length} test cycle(s) linked to ${issue_key}:\n${JSON.stringify({
|
|
1399
|
+
issueKey: issue_key,
|
|
1400
|
+
totalCount: cycles.length,
|
|
1401
|
+
testCycles: cycles,
|
|
1402
|
+
}, null, 2)}`,
|
|
1403
|
+
}],
|
|
1404
|
+
};
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
if (error instanceof McpError) throw error;
|
|
1407
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get test cycles for issue: ${this.formatError(error)}`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Find the latest execution key for a test case within a cycle (Cloud). */
|
|
1412
|
+
private async resolveExecutionByCycleAndCase(
|
|
1413
|
+
cycleKey: string, caseKey: string, projectKey?: string
|
|
1414
|
+
): Promise<string> {
|
|
1415
|
+
const derivedProject = projectKey || cycleKey.split('-')[0];
|
|
1416
|
+
try {
|
|
1417
|
+
const response = await this.axiosInstance.get('/testexecutions', {
|
|
1418
|
+
params: {
|
|
1419
|
+
projectKey: derivedProject,
|
|
1420
|
+
testCycle: cycleKey,
|
|
1421
|
+
onlyLastExecutions: true,
|
|
1422
|
+
maxResults: 1000,
|
|
1423
|
+
},
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
const executions = Array.isArray(response.data)
|
|
1427
|
+
? response.data
|
|
1428
|
+
: response.data?.values ?? [];
|
|
1429
|
+
|
|
1430
|
+
const match = executions.find((ex: any) => {
|
|
1431
|
+
const key = ex.testCase?.self?.match(/testcases\/(.+?)\/versions/)?.[1]
|
|
1432
|
+
?? ex.testCase?.key;
|
|
1433
|
+
return key === caseKey;
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
if (!match) {
|
|
1437
|
+
throw new McpError(
|
|
1438
|
+
ErrorCode.InvalidParams,
|
|
1439
|
+
`No execution found for test case ${caseKey} in cycle ${cycleKey}. Ensure the test case is part of the cycle.`
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const execKey = match.key ?? (match.id !== undefined ? String(match.id) : undefined);
|
|
1444
|
+
if (!execKey) {
|
|
1445
|
+
throw new McpError(
|
|
1446
|
+
ErrorCode.InternalError,
|
|
1447
|
+
`Found a matching execution for ${caseKey} in ${cycleKey} but it has no key or id.`
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
return execKey;
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (error instanceof McpError) throw error;
|
|
1453
|
+
throw new McpError(ErrorCode.InternalError, `Failed to resolve execution for ${caseKey} in ${cycleKey}: ${this.formatError(error)}`);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1159
1457
|
private async resolveJiraIssueId(issueKey: string): Promise<number> {
|
|
1160
1458
|
// The Zephyr API key is NOT valid for the Jira REST API — Jira Cloud requires
|
|
1161
1459
|
// Basic Auth: base64(email:api_token) via JIRA_USERNAME + JIRA_API_TOKEN env vars.
|
package/src/tool-schemas.ts
CHANGED
|
@@ -196,6 +196,32 @@ export const toolSchemas = [
|
|
|
196
196
|
required: ['project_key', 'name'],
|
|
197
197
|
},
|
|
198
198
|
},
|
|
199
|
+
{
|
|
200
|
+
name: 'get_folders',
|
|
201
|
+
description: 'Get folders from Zephyr Scale. All parameters are optional. If folder_path is provided, returns the full subtree (the folder and all its descendants at every depth) under that path. Otherwise returns all folders matching the filters.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
project_key: {
|
|
206
|
+
type: 'string',
|
|
207
|
+
description: 'Jira project key filter (e.g., "PROJ"). Optional.',
|
|
208
|
+
},
|
|
209
|
+
folder_type: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Folder type filter (optional)',
|
|
212
|
+
enum: ['TEST_CASE', 'TEST_PLAN', 'TEST_CYCLE'],
|
|
213
|
+
},
|
|
214
|
+
folder_path: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'Folder path to filter by (e.g., "/CRM on Wechat"). Returns the matching folder and all folders nested beneath it at any depth. Requires project_key and folder_type to resolve the path.',
|
|
217
|
+
},
|
|
218
|
+
max_results: {
|
|
219
|
+
type: 'number',
|
|
220
|
+
description: 'Maximum total number of folders to return (optional). Defaults to all folders.',
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
199
225
|
{
|
|
200
226
|
name: 'get_test_run_cases',
|
|
201
227
|
description: 'Get test case keys from a test run',
|
|
@@ -440,6 +466,83 @@ export const toolSchemas = [
|
|
|
440
466
|
required: ['test_run_key'],
|
|
441
467
|
},
|
|
442
468
|
},
|
|
469
|
+
{
|
|
470
|
+
name: 'update_test_execution',
|
|
471
|
+
description: 'Update a test case execution\'s status within a test cycle, and optionally attach bug(s) as Jira issue links. Identify the execution either by execution_id directly, or by test_cycle_key + test_case_key (the latest execution is used). Cloud only.',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {
|
|
475
|
+
execution_id: {
|
|
476
|
+
type: 'string',
|
|
477
|
+
description: 'Test execution key or ID (e.g. "PROJ-E123" or 5805255). Takes precedence over test_cycle_key/test_case_key.',
|
|
478
|
+
},
|
|
479
|
+
test_cycle_key: {
|
|
480
|
+
type: 'string',
|
|
481
|
+
description: 'Test cycle key (e.g. "PROJ-R123"). Used together with test_case_key to locate the execution when execution_id is not given.',
|
|
482
|
+
},
|
|
483
|
+
test_case_key: {
|
|
484
|
+
type: 'string',
|
|
485
|
+
description: 'Test case key (e.g. "PROJ-T456"). Used together with test_cycle_key to locate the execution.',
|
|
486
|
+
},
|
|
487
|
+
project_key: {
|
|
488
|
+
type: 'string',
|
|
489
|
+
description: 'Project key for the cycle lookup (optional — derived from test_cycle_key when omitted).',
|
|
490
|
+
},
|
|
491
|
+
status: {
|
|
492
|
+
type: 'string',
|
|
493
|
+
description: 'New execution status name. Common values: "Pass", "Fail", "In Progress", "Blocked", "Not Executed". Must match a status configured in your Zephyr project.',
|
|
494
|
+
},
|
|
495
|
+
comment: {
|
|
496
|
+
type: 'string',
|
|
497
|
+
description: 'Comment against the overall execution (e.g. failure details).',
|
|
498
|
+
},
|
|
499
|
+
environment: {
|
|
500
|
+
type: 'string',
|
|
501
|
+
description: 'Environment name assigned to the execution (e.g. "Chrome Latest Version").',
|
|
502
|
+
},
|
|
503
|
+
execution_time: {
|
|
504
|
+
type: 'number',
|
|
505
|
+
description: 'Actual execution time in milliseconds (optional).',
|
|
506
|
+
},
|
|
507
|
+
actual_end_date: {
|
|
508
|
+
type: 'string',
|
|
509
|
+
description: 'Actual end date in ISO format, e.g. "2024-05-20T13:15:13Z" (optional).',
|
|
510
|
+
},
|
|
511
|
+
executed_by_id: {
|
|
512
|
+
type: 'string',
|
|
513
|
+
description: 'Jira Account ID of the user who executed the test (optional).',
|
|
514
|
+
},
|
|
515
|
+
assigned_to_id: {
|
|
516
|
+
type: 'string',
|
|
517
|
+
description: 'Jira Account ID of the user the execution is assigned to (optional).',
|
|
518
|
+
},
|
|
519
|
+
bug_keys: {
|
|
520
|
+
type: 'array',
|
|
521
|
+
description: 'Jira issue keys to attach to the execution as bugs (e.g. ["PROJ-789"]). Each key is resolved to a numeric ID via the Jira REST API (requires JIRA_USERNAME + JIRA_API_TOKEN) and linked via POST /testexecutions/{key}/links/issues. Failures are reported as warnings and do not fail the call.',
|
|
522
|
+
items: { type: 'string' },
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: 'get_test_cycles_for_issue',
|
|
529
|
+
description: 'Get the Zephyr test cycles linked to a Jira issue (e.g. a story or epic). Calls GET /issuelinks/{issueKey}/testcycles and, by default, resolves each numeric cycle ID to its key (e.g. "PROJ-R123") and name. Use this to discover the test cycle referenced by a Jira ticket, then feed the key into list_executions_by_cycle / update_test_execution. Cloud only.',
|
|
530
|
+
inputSchema: {
|
|
531
|
+
type: 'object',
|
|
532
|
+
properties: {
|
|
533
|
+
issue_key: {
|
|
534
|
+
type: 'string',
|
|
535
|
+
description: 'Jira issue key whose linked test cycles to fetch (e.g. "PROJ-123").',
|
|
536
|
+
},
|
|
537
|
+
resolve_keys: {
|
|
538
|
+
type: 'boolean',
|
|
539
|
+
description: 'When true (default), resolve each cycle ID to its key + name via GET /testcycles/{id}. Set false to return raw numeric IDs only (faster, no extra calls).',
|
|
540
|
+
default: true,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
required: ['issue_key'],
|
|
544
|
+
},
|
|
545
|
+
},
|
|
443
546
|
{
|
|
444
547
|
name: 'delete_test_run',
|
|
445
548
|
description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,34 @@ export interface ListExecutionsByCycleArgs {
|
|
|
102
102
|
max_results?: number;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
export interface UpdateTestExecutionArgs {
|
|
106
|
+
/** Direct execution key/ID (e.g. "PROJ-E123" or 5805255). Takes precedence over cycle+case lookup. */
|
|
107
|
+
execution_id?: string;
|
|
108
|
+
/** Test cycle key (e.g. "PROJ-R123") — used with test_case_key to locate the execution. */
|
|
109
|
+
test_cycle_key?: string;
|
|
110
|
+
/** Test case key (e.g. "PROJ-T456") — used with test_cycle_key to locate the execution. */
|
|
111
|
+
test_case_key?: string;
|
|
112
|
+
/** Project key for the cycle lookup. Derived from test_cycle_key when omitted. */
|
|
113
|
+
project_key?: string;
|
|
114
|
+
/** New execution status name (e.g. "Pass", "Fail", "In Progress", "Blocked", "Not Executed"). */
|
|
115
|
+
status?: string;
|
|
116
|
+
comment?: string;
|
|
117
|
+
environment?: string;
|
|
118
|
+
execution_time?: number;
|
|
119
|
+
actual_end_date?: string;
|
|
120
|
+
executed_by_id?: string;
|
|
121
|
+
assigned_to_id?: string;
|
|
122
|
+
/** Jira issue keys to link to the execution as bugs (e.g. ["PROJ-789"]). Resolved to numeric IDs via the Jira REST API. */
|
|
123
|
+
bug_keys?: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface GetTestCyclesForIssueArgs {
|
|
127
|
+
/** Jira issue key whose linked test cycles to fetch (e.g. "PROJ-123"). */
|
|
128
|
+
issue_key: string;
|
|
129
|
+
/** When true (default), resolve each cycle ID to its key + name via GET /testcycles/{id}. */
|
|
130
|
+
resolve_keys?: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
export type JiraType = 'cloud' | 'datacenter';
|
|
106
134
|
|
|
107
135
|
export interface ApiEndpoints {
|
package/src/utils.ts
CHANGED
|
@@ -11,22 +11,34 @@ export async function resolveFolderIdByPath(
|
|
|
11
11
|
if (segments.length === 0) return null;
|
|
12
12
|
|
|
13
13
|
try {
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
:
|
|
14
|
+
// Paginate through all folders for the project + type
|
|
15
|
+
const pageSize = 1000;
|
|
16
|
+
const allFolders: Array<{ id: number; parentId: number | null; name: string }> = [];
|
|
17
|
+
let startAt = 0;
|
|
18
|
+
let isLast = false;
|
|
19
|
+
|
|
20
|
+
while (!isLast) {
|
|
21
|
+
const response = await axiosInstance.get('/folders', {
|
|
22
|
+
params: { projectKey, folderType, maxResults: pageSize, startAt },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const page: Array<{ id: number; parentId: number | null; name: string }> =
|
|
26
|
+
Array.isArray(response.data)
|
|
27
|
+
? response.data
|
|
28
|
+
: response.data?.values ?? [];
|
|
29
|
+
|
|
30
|
+
allFolders.push(...page);
|
|
31
|
+
|
|
32
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
33
|
+
startAt += page.length;
|
|
34
|
+
}
|
|
23
35
|
|
|
24
36
|
// Walk segments top-down, matching by name under the correct parent
|
|
25
37
|
let parentId: number | null = null;
|
|
26
38
|
let matchedId: number | null = null;
|
|
27
39
|
|
|
28
40
|
for (const segment of segments) {
|
|
29
|
-
const match =
|
|
41
|
+
const match = allFolders.find(
|
|
30
42
|
(f) => f.name === segment && (f.parentId ?? null) === parentId
|
|
31
43
|
);
|
|
32
44
|
if (!match) return null;
|