zephyr-scale-mcp-server 0.5.1 → 0.7.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 +3 -1
- package/build/index.js +4 -0
- package/build/tool-handlers.js +183 -16
- package/build/tool-schemas.js +64 -0
- package/build/utils.js +40 -8
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/tool-handlers.ts +197 -14
- package/src/tool-schemas.ts +64 -0
- package/src/utils.ts +43 -10
package/README.md
CHANGED
|
@@ -107,12 +107,14 @@ 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.
|
|
117
|
+
- `list_executions_by_cycle`: List all test executions for a specific test cycle with status, executor, and date. *(Cloud only)*
|
|
116
118
|
- `search_test_cases_by_folder`: Search for test cases in a specific folder.
|
|
117
119
|
- `search_test_runs`: Search for test runs by project key and/or folder path.
|
|
118
120
|
|
package/build/index.js
CHANGED
|
@@ -51,10 +51,14 @@ 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':
|
|
57
59
|
return await this.toolHandlers.deleteTestCase(args);
|
|
60
|
+
case 'update_test_run':
|
|
61
|
+
return await this.toolHandlers.updateTestRun(args);
|
|
58
62
|
case 'delete_test_run':
|
|
59
63
|
return await this.toolHandlers.deleteTestRun(args);
|
|
60
64
|
case 'create_test_run':
|
package/build/tool-handlers.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { convertToGherkin, resolveFolderIdByPath } from './utils.js';
|
|
3
|
+
import { convertToGherkin, resolveFolderIdByPath, getAccountIdFromApiKey } from './utils.js';
|
|
4
4
|
export class ZephyrToolHandlers {
|
|
5
5
|
axiosInstance;
|
|
6
6
|
jiraConfig;
|
|
@@ -61,9 +61,10 @@ export class ZephyrToolHandlers {
|
|
|
61
61
|
payload.labels = labels;
|
|
62
62
|
if (custom_fields)
|
|
63
63
|
payload.customFields = custom_fields;
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// Default ownerId to the account ID embedded in the Zephyr API key JWT
|
|
65
|
+
const resolvedOwner = owner_id ?? getAccountIdFromApiKey();
|
|
66
|
+
if (resolvedOwner)
|
|
67
|
+
payload.ownerId = resolvedOwner;
|
|
67
68
|
if (component_id)
|
|
68
69
|
payload.componentId = component_id;
|
|
69
70
|
// Resolve folder path → folderId
|
|
@@ -500,6 +501,69 @@ export class ZephyrToolHandlers {
|
|
|
500
501
|
throw new McpError(ErrorCode.InternalError, `Failed to get test run cases: ${this.formatError(error)}`);
|
|
501
502
|
}
|
|
502
503
|
}
|
|
504
|
+
async updateTestRun(args) {
|
|
505
|
+
const { test_run_key, owner, name, description, planned_start_date, planned_end_date, status_id } = args;
|
|
506
|
+
if (this.jiraConfig.type !== 'cloud') {
|
|
507
|
+
throw new McpError(ErrorCode.InvalidRequest, 'update_test_run is only supported on Zephyr Scale Cloud.');
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
// Fetch current cycle to preserve required fields
|
|
511
|
+
const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
|
|
512
|
+
const current = getResponse.data;
|
|
513
|
+
const payload = {
|
|
514
|
+
id: current.id,
|
|
515
|
+
key: test_run_key,
|
|
516
|
+
name: name ?? current.name,
|
|
517
|
+
project: current.project,
|
|
518
|
+
status: status_id ? { id: status_id } : current.status,
|
|
519
|
+
};
|
|
520
|
+
if (description !== undefined)
|
|
521
|
+
payload.description = description;
|
|
522
|
+
else if (current.description)
|
|
523
|
+
payload.description = current.description;
|
|
524
|
+
if (planned_start_date !== undefined)
|
|
525
|
+
payload.plannedStartDate = planned_start_date;
|
|
526
|
+
else if (current.plannedStartDate)
|
|
527
|
+
payload.plannedStartDate = current.plannedStartDate;
|
|
528
|
+
if (planned_end_date !== undefined)
|
|
529
|
+
payload.plannedEndDate = planned_end_date;
|
|
530
|
+
else if (current.plannedEndDate)
|
|
531
|
+
payload.plannedEndDate = current.plannedEndDate;
|
|
532
|
+
if (owner !== undefined)
|
|
533
|
+
payload.owner = { accountId: owner };
|
|
534
|
+
else if (current.owner)
|
|
535
|
+
payload.owner = current.owner;
|
|
536
|
+
if (current.jiraProjectVersion)
|
|
537
|
+
payload.jiraProjectVersion = current.jiraProjectVersion;
|
|
538
|
+
if (current.folder)
|
|
539
|
+
payload.folder = current.folder;
|
|
540
|
+
if (current.customFields && Object.keys(current.customFields).length > 0) {
|
|
541
|
+
payload.customFields = current.customFields;
|
|
542
|
+
}
|
|
543
|
+
await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`, payload);
|
|
544
|
+
// Resolve status name for the response
|
|
545
|
+
const projectKey = test_run_key.replace(/-R\d+$/, '');
|
|
546
|
+
const statusName = payload.status?.id
|
|
547
|
+
? await this.resolveStatusName(payload.status.id)
|
|
548
|
+
: null;
|
|
549
|
+
return {
|
|
550
|
+
content: [{
|
|
551
|
+
type: 'text',
|
|
552
|
+
text: `✅ Updated test cycle ${test_run_key} successfully.\n${JSON.stringify({
|
|
553
|
+
key: test_run_key,
|
|
554
|
+
name: payload.name,
|
|
555
|
+
owner: payload.owner ?? null,
|
|
556
|
+
status: statusName ? { id: payload.status?.id, name: statusName } : payload.status,
|
|
557
|
+
}, null, 2)}`,
|
|
558
|
+
}],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
if (error instanceof McpError)
|
|
563
|
+
throw error;
|
|
564
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update test run: ${this.formatError(error)}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
503
567
|
async deleteTestCase(args) {
|
|
504
568
|
if (this.jiraConfig.type === 'cloud') {
|
|
505
569
|
throw new McpError(ErrorCode.InvalidRequest, 'delete_test_case is not supported by the Zephyr Scale Cloud v2 API.');
|
|
@@ -558,9 +622,10 @@ export class ZephyrToolHandlers {
|
|
|
558
622
|
payload.plannedEndDate = planned_end_date;
|
|
559
623
|
if (custom_fields)
|
|
560
624
|
payload.customFields = custom_fields;
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
625
|
+
// Default ownerId to the account ID embedded in the Zephyr API key JWT
|
|
626
|
+
const resolvedOwner = owner ?? getAccountIdFromApiKey();
|
|
627
|
+
if (resolvedOwner)
|
|
628
|
+
payload.ownerId = resolvedOwner;
|
|
564
629
|
// Link to a Jira project version/release (integer ID)
|
|
565
630
|
if (jira_project_version)
|
|
566
631
|
payload.jiraProjectVersion = jira_project_version;
|
|
@@ -678,14 +743,101 @@ export class ZephyrToolHandlers {
|
|
|
678
743
|
throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
|
|
679
744
|
}
|
|
680
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
|
+
}
|
|
819
|
+
async resolveStatusName(statusId) {
|
|
820
|
+
try {
|
|
821
|
+
const response = await this.axiosInstance.get(`/statuses/${statusId}`);
|
|
822
|
+
return response.data?.name ?? null;
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
681
828
|
async getTestRun(args) {
|
|
682
829
|
const { test_run_key } = args;
|
|
683
|
-
// Both Cloud (/testcycles/{key}) and DC (/rest/atm/1.0/testrun/{key}) handled
|
|
684
|
-
// via apiEndpoints.testrun which now correctly maps to /testcycles for Cloud
|
|
685
830
|
try {
|
|
686
831
|
const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
|
|
832
|
+
const data = response.data;
|
|
833
|
+
// Resolve status name — extract project key from the cycle key (e.g. DDCN-R377 → DDCN)
|
|
834
|
+
if (data?.status?.id) {
|
|
835
|
+
const statusName = await this.resolveStatusName(data.status.id);
|
|
836
|
+
if (statusName)
|
|
837
|
+
data.status.name = statusName;
|
|
838
|
+
}
|
|
687
839
|
return {
|
|
688
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
840
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
689
841
|
};
|
|
690
842
|
}
|
|
691
843
|
catch (error) {
|
|
@@ -758,12 +910,27 @@ export class ZephyrToolHandlers {
|
|
|
758
910
|
}],
|
|
759
911
|
};
|
|
760
912
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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);
|
|
767
934
|
return {
|
|
768
935
|
content: [{
|
|
769
936
|
type: 'text',
|
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',
|
|
@@ -402,6 +428,44 @@ export const toolSchemas = [
|
|
|
402
428
|
required: ['test_cycle_key', 'project_key'],
|
|
403
429
|
},
|
|
404
430
|
},
|
|
431
|
+
{
|
|
432
|
+
name: 'update_test_run',
|
|
433
|
+
description: 'Update an existing test cycle — set owner, name, description, dates, or status. Unspecified fields are preserved. Cloud only.',
|
|
434
|
+
inputSchema: {
|
|
435
|
+
type: 'object',
|
|
436
|
+
properties: {
|
|
437
|
+
test_run_key: {
|
|
438
|
+
type: 'string',
|
|
439
|
+
description: 'Test cycle key to update (e.g., PROJ-R123)',
|
|
440
|
+
},
|
|
441
|
+
owner: {
|
|
442
|
+
type: 'string',
|
|
443
|
+
description: 'Jira Account ID of the new owner (e.g., "6269ee89494f17007056d8f0")',
|
|
444
|
+
},
|
|
445
|
+
name: {
|
|
446
|
+
type: 'string',
|
|
447
|
+
description: 'New name for the test cycle (optional)',
|
|
448
|
+
},
|
|
449
|
+
description: {
|
|
450
|
+
type: 'string',
|
|
451
|
+
description: 'New description (optional)',
|
|
452
|
+
},
|
|
453
|
+
planned_start_date: {
|
|
454
|
+
type: 'string',
|
|
455
|
+
description: 'Planned start date in ISO format (optional)',
|
|
456
|
+
},
|
|
457
|
+
planned_end_date: {
|
|
458
|
+
type: 'string',
|
|
459
|
+
description: 'Planned end date in ISO format (optional)',
|
|
460
|
+
},
|
|
461
|
+
status_id: {
|
|
462
|
+
type: 'number',
|
|
463
|
+
description: 'Numeric status ID to set (optional)',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
required: ['test_run_key'],
|
|
467
|
+
},
|
|
468
|
+
},
|
|
405
469
|
{
|
|
406
470
|
name: 'delete_test_run',
|
|
407
471
|
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;
|
|
@@ -83,6 +92,29 @@ export const priorityMapping = {
|
|
|
83
92
|
'Medium': 'High',
|
|
84
93
|
'Low': 'High'
|
|
85
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* Decodes the Atlassian Account ID from the Zephyr JWT API key.
|
|
97
|
+
* The JWT payload contains context.user.accountId — no extra API call needed.
|
|
98
|
+
* Returns null if the token is missing or malformed.
|
|
99
|
+
*/
|
|
100
|
+
export function getAccountIdFromApiKey(apiKey) {
|
|
101
|
+
try {
|
|
102
|
+
const token = apiKey ?? process.env.ZEPHYR_API_KEY;
|
|
103
|
+
if (!token)
|
|
104
|
+
return null;
|
|
105
|
+
const parts = token.split('.');
|
|
106
|
+
if (parts.length < 2)
|
|
107
|
+
return null;
|
|
108
|
+
// Base64url decode the payload (add padding as needed)
|
|
109
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
110
|
+
const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
|
|
111
|
+
const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
112
|
+
return decoded?.context?.user?.accountId ?? null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
86
118
|
/**
|
|
87
119
|
* Detects whether the Jira instance is Cloud or Data Center based on the base URL.
|
|
88
120
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zephyr-scale-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,10 +71,14 @@ 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':
|
|
77
79
|
return await this.toolHandlers.deleteTestCase(args);
|
|
80
|
+
case 'update_test_run':
|
|
81
|
+
return await this.toolHandlers.updateTestRun(args);
|
|
78
82
|
case 'delete_test_run':
|
|
79
83
|
return await this.toolHandlers.deleteTestRun(args);
|
|
80
84
|
case 'create_test_run':
|
package/src/tool-handlers.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
ListExecutionsByCycleArgs,
|
|
14
14
|
JiraConfig
|
|
15
15
|
} from './types.js';
|
|
16
|
-
import { convertToGherkin, resolveFolderIdByPath } from './utils.js';
|
|
16
|
+
import { convertToGherkin, resolveFolderIdByPath, getAccountIdFromApiKey } from './utils.js';
|
|
17
17
|
|
|
18
18
|
export class ZephyrToolHandlers {
|
|
19
19
|
constructor(
|
|
@@ -74,8 +74,9 @@ export class ZephyrToolHandlers {
|
|
|
74
74
|
if (estimated_time) payload.estimatedTime = estimated_time;
|
|
75
75
|
if (labels && labels.length > 0) payload.labels = labels;
|
|
76
76
|
if (custom_fields) payload.customFields = custom_fields;
|
|
77
|
-
//
|
|
78
|
-
|
|
77
|
+
// Default ownerId to the account ID embedded in the Zephyr API key JWT
|
|
78
|
+
const resolvedOwner = owner_id ?? getAccountIdFromApiKey();
|
|
79
|
+
if (resolvedOwner) payload.ownerId = resolvedOwner;
|
|
79
80
|
if (component_id) payload.componentId = component_id;
|
|
80
81
|
|
|
81
82
|
// Resolve folder path → folderId
|
|
@@ -544,6 +545,69 @@ export class ZephyrToolHandlers {
|
|
|
544
545
|
}
|
|
545
546
|
}
|
|
546
547
|
|
|
548
|
+
async updateTestRun(args: any) {
|
|
549
|
+
const { test_run_key, owner, name, description, planned_start_date, planned_end_date, status_id } = args;
|
|
550
|
+
|
|
551
|
+
if (this.jiraConfig.type !== 'cloud') {
|
|
552
|
+
throw new McpError(ErrorCode.InvalidRequest, 'update_test_run is only supported on Zephyr Scale Cloud.');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
// Fetch current cycle to preserve required fields
|
|
557
|
+
const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
|
|
558
|
+
const current = getResponse.data;
|
|
559
|
+
|
|
560
|
+
const payload: any = {
|
|
561
|
+
id: current.id,
|
|
562
|
+
key: test_run_key,
|
|
563
|
+
name: name ?? current.name,
|
|
564
|
+
project: current.project,
|
|
565
|
+
status: status_id ? { id: status_id } : current.status,
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
if (description !== undefined) payload.description = description;
|
|
569
|
+
else if (current.description) payload.description = current.description;
|
|
570
|
+
|
|
571
|
+
if (planned_start_date !== undefined) payload.plannedStartDate = planned_start_date;
|
|
572
|
+
else if (current.plannedStartDate) payload.plannedStartDate = current.plannedStartDate;
|
|
573
|
+
|
|
574
|
+
if (planned_end_date !== undefined) payload.plannedEndDate = planned_end_date;
|
|
575
|
+
else if (current.plannedEndDate) payload.plannedEndDate = current.plannedEndDate;
|
|
576
|
+
|
|
577
|
+
if (owner !== undefined) payload.owner = { accountId: owner };
|
|
578
|
+
else if (current.owner) payload.owner = current.owner;
|
|
579
|
+
|
|
580
|
+
if (current.jiraProjectVersion) payload.jiraProjectVersion = current.jiraProjectVersion;
|
|
581
|
+
if (current.folder) payload.folder = current.folder;
|
|
582
|
+
if (current.customFields && Object.keys(current.customFields).length > 0) {
|
|
583
|
+
payload.customFields = current.customFields;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`, payload);
|
|
587
|
+
|
|
588
|
+
// Resolve status name for the response
|
|
589
|
+
const projectKey = test_run_key.replace(/-R\d+$/, '');
|
|
590
|
+
const statusName = payload.status?.id
|
|
591
|
+
? await this.resolveStatusName(payload.status.id)
|
|
592
|
+
: null;
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
content: [{
|
|
596
|
+
type: 'text',
|
|
597
|
+
text: `✅ Updated test cycle ${test_run_key} successfully.\n${JSON.stringify({
|
|
598
|
+
key: test_run_key,
|
|
599
|
+
name: payload.name,
|
|
600
|
+
owner: payload.owner ?? null,
|
|
601
|
+
status: statusName ? { id: payload.status?.id, name: statusName } : payload.status,
|
|
602
|
+
}, null, 2)}`,
|
|
603
|
+
}],
|
|
604
|
+
};
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error instanceof McpError) throw error;
|
|
607
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update test run: ${this.formatError(error)}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
547
611
|
async deleteTestCase(args: any) {
|
|
548
612
|
if (this.jiraConfig.type === 'cloud') {
|
|
549
613
|
throw new McpError(
|
|
@@ -614,8 +678,9 @@ export class ZephyrToolHandlers {
|
|
|
614
678
|
if (planned_start_date) payload.plannedStartDate = planned_start_date;
|
|
615
679
|
if (planned_end_date) payload.plannedEndDate = planned_end_date;
|
|
616
680
|
if (custom_fields) payload.customFields = custom_fields;
|
|
617
|
-
//
|
|
618
|
-
|
|
681
|
+
// Default ownerId to the account ID embedded in the Zephyr API key JWT
|
|
682
|
+
const resolvedOwner = owner ?? getAccountIdFromApiKey();
|
|
683
|
+
if (resolvedOwner) payload.ownerId = resolvedOwner;
|
|
619
684
|
// Link to a Jira project version/release (integer ID)
|
|
620
685
|
if (jira_project_version) payload.jiraProjectVersion = jira_project_version;
|
|
621
686
|
if (folder) {
|
|
@@ -735,14 +800,113 @@ export class ZephyrToolHandlers {
|
|
|
735
800
|
}
|
|
736
801
|
}
|
|
737
802
|
|
|
803
|
+
async getFolders(args: any) {
|
|
804
|
+
const { project_key, folder_type, folder_path, max_results } = args;
|
|
805
|
+
|
|
806
|
+
try {
|
|
807
|
+
const pageSize = 1000;
|
|
808
|
+
const allFolders: any[] = [];
|
|
809
|
+
let startAt = 0;
|
|
810
|
+
let isLast = false;
|
|
811
|
+
|
|
812
|
+
while (!isLast) {
|
|
813
|
+
const params: Record<string, any> = { maxResults: pageSize, startAt };
|
|
814
|
+
if (project_key) params.projectKey = project_key;
|
|
815
|
+
if (folder_type) params.folderType = folder_type;
|
|
816
|
+
|
|
817
|
+
const response = await this.axiosInstance.get('/folders', { params });
|
|
818
|
+
|
|
819
|
+
const page: any[] = Array.isArray(response.data)
|
|
820
|
+
? response.data
|
|
821
|
+
: response.data?.values ?? [];
|
|
822
|
+
|
|
823
|
+
allFolders.push(...page);
|
|
824
|
+
|
|
825
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
826
|
+
startAt += page.length;
|
|
827
|
+
|
|
828
|
+
if (max_results && allFolders.length >= max_results) break;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let results: any[];
|
|
832
|
+
|
|
833
|
+
if (folder_path) {
|
|
834
|
+
// Resolve the root folder ID from the path, then collect full subtree via BFS
|
|
835
|
+
const rootId = await resolveFolderIdByPath(
|
|
836
|
+
this.axiosInstance, project_key, folder_path, folder_type ?? 'TEST_CASE'
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
if (rootId === null) {
|
|
840
|
+
return {
|
|
841
|
+
content: [{
|
|
842
|
+
type: 'text',
|
|
843
|
+
text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}.`,
|
|
844
|
+
}],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// BFS over the already-fetched flat list — no extra API calls
|
|
849
|
+
const subtreeIds = new Set<number>([rootId]);
|
|
850
|
+
const queue = [rootId];
|
|
851
|
+
while (queue.length > 0) {
|
|
852
|
+
const current = queue.shift()!;
|
|
853
|
+
for (const f of allFolders) {
|
|
854
|
+
if ((f.parentId ?? null) === current && !subtreeIds.has(f.id)) {
|
|
855
|
+
subtreeIds.add(f.id);
|
|
856
|
+
queue.push(f.id);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
results = allFolders.filter(f => subtreeIds.has(f.id));
|
|
862
|
+
} else {
|
|
863
|
+
results = allFolders;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (max_results) results = results.slice(0, max_results);
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
content: [{
|
|
870
|
+
type: 'text',
|
|
871
|
+
text: JSON.stringify({
|
|
872
|
+
totalCount: results.length,
|
|
873
|
+
folders: results.map((f: any) => ({
|
|
874
|
+
id: f.id,
|
|
875
|
+
name: f.name,
|
|
876
|
+
parentId: f.parentId ?? null,
|
|
877
|
+
folderType: f.folderType,
|
|
878
|
+
})),
|
|
879
|
+
}, null, 2),
|
|
880
|
+
}],
|
|
881
|
+
};
|
|
882
|
+
} catch (error) {
|
|
883
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get folders: ${this.formatError(error)}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private async resolveStatusName(statusId: number): Promise<string | null> {
|
|
888
|
+
try {
|
|
889
|
+
const response = await this.axiosInstance.get(`/statuses/${statusId}`);
|
|
890
|
+
return response.data?.name ?? null;
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
738
896
|
async getTestRun(args: any) {
|
|
739
897
|
const { test_run_key } = args;
|
|
740
|
-
// Both Cloud (/testcycles/{key}) and DC (/rest/atm/1.0/testrun/{key}) handled
|
|
741
|
-
// via apiEndpoints.testrun which now correctly maps to /testcycles for Cloud
|
|
742
898
|
try {
|
|
743
899
|
const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
|
|
900
|
+
const data = response.data;
|
|
901
|
+
|
|
902
|
+
// Resolve status name — extract project key from the cycle key (e.g. DDCN-R377 → DDCN)
|
|
903
|
+
if (data?.status?.id) {
|
|
904
|
+
const statusName = await this.resolveStatusName(data.status.id);
|
|
905
|
+
if (statusName) data.status.name = statusName;
|
|
906
|
+
}
|
|
907
|
+
|
|
744
908
|
return {
|
|
745
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
909
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
746
910
|
};
|
|
747
911
|
} catch (error) {
|
|
748
912
|
throw new McpError(ErrorCode.InternalError, `Failed to get test run: ${this.formatError(error)}`);
|
|
@@ -828,13 +992,32 @@ export class ZephyrToolHandlers {
|
|
|
828
992
|
};
|
|
829
993
|
}
|
|
830
994
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
995
|
+
// Paginate through all results — the API returns up to 1000 per page
|
|
996
|
+
const pageSize = Math.min(max_results, 1000);
|
|
997
|
+
const allTestCases: any[] = [];
|
|
998
|
+
let startAt = 0;
|
|
999
|
+
let isLast = false;
|
|
834
1000
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1001
|
+
while (!isLast && allTestCases.length < max_results) {
|
|
1002
|
+
const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
|
|
1003
|
+
params: { projectKey: project_key, folderId, maxResults: pageSize, startAt },
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
const page = Array.isArray(response.data)
|
|
1007
|
+
? response.data
|
|
1008
|
+
: response.data?.values ?? [];
|
|
1009
|
+
|
|
1010
|
+
allTestCases.push(...page);
|
|
1011
|
+
|
|
1012
|
+
// Stop if the API signals last page, or we got fewer results than requested
|
|
1013
|
+
isLast = response.data?.isLast === true || page.length < pageSize;
|
|
1014
|
+
startAt += page.length;
|
|
1015
|
+
|
|
1016
|
+
// Safety: never exceed max_results
|
|
1017
|
+
if (allTestCases.length >= max_results) break;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const testCases = allTestCases.slice(0, max_results);
|
|
838
1021
|
|
|
839
1022
|
return {
|
|
840
1023
|
content: [{
|
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',
|
|
@@ -402,6 +428,44 @@ export const toolSchemas = [
|
|
|
402
428
|
required: ['test_cycle_key', 'project_key'],
|
|
403
429
|
},
|
|
404
430
|
},
|
|
431
|
+
{
|
|
432
|
+
name: 'update_test_run',
|
|
433
|
+
description: 'Update an existing test cycle — set owner, name, description, dates, or status. Unspecified fields are preserved. Cloud only.',
|
|
434
|
+
inputSchema: {
|
|
435
|
+
type: 'object',
|
|
436
|
+
properties: {
|
|
437
|
+
test_run_key: {
|
|
438
|
+
type: 'string',
|
|
439
|
+
description: 'Test cycle key to update (e.g., PROJ-R123)',
|
|
440
|
+
},
|
|
441
|
+
owner: {
|
|
442
|
+
type: 'string',
|
|
443
|
+
description: 'Jira Account ID of the new owner (e.g., "6269ee89494f17007056d8f0")',
|
|
444
|
+
},
|
|
445
|
+
name: {
|
|
446
|
+
type: 'string',
|
|
447
|
+
description: 'New name for the test cycle (optional)',
|
|
448
|
+
},
|
|
449
|
+
description: {
|
|
450
|
+
type: 'string',
|
|
451
|
+
description: 'New description (optional)',
|
|
452
|
+
},
|
|
453
|
+
planned_start_date: {
|
|
454
|
+
type: 'string',
|
|
455
|
+
description: 'Planned start date in ISO format (optional)',
|
|
456
|
+
},
|
|
457
|
+
planned_end_date: {
|
|
458
|
+
type: 'string',
|
|
459
|
+
description: 'Planned end date in ISO format (optional)',
|
|
460
|
+
},
|
|
461
|
+
status_id: {
|
|
462
|
+
type: 'number',
|
|
463
|
+
description: 'Numeric status ID to set (optional)',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
required: ['test_run_key'],
|
|
467
|
+
},
|
|
468
|
+
},
|
|
405
469
|
{
|
|
406
470
|
name: 'delete_test_run',
|
|
407
471
|
description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
|
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;
|
|
@@ -102,6 +114,27 @@ export const priorityMapping: { [key: string]: string } = {
|
|
|
102
114
|
'Low': 'High'
|
|
103
115
|
};
|
|
104
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Decodes the Atlassian Account ID from the Zephyr JWT API key.
|
|
119
|
+
* The JWT payload contains context.user.accountId — no extra API call needed.
|
|
120
|
+
* Returns null if the token is missing or malformed.
|
|
121
|
+
*/
|
|
122
|
+
export function getAccountIdFromApiKey(apiKey?: string): string | null {
|
|
123
|
+
try {
|
|
124
|
+
const token = apiKey ?? process.env.ZEPHYR_API_KEY;
|
|
125
|
+
if (!token) return null;
|
|
126
|
+
const parts = token.split('.');
|
|
127
|
+
if (parts.length < 2) return null;
|
|
128
|
+
// Base64url decode the payload (add padding as needed)
|
|
129
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
130
|
+
const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
|
|
131
|
+
const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
132
|
+
return decoded?.context?.user?.accountId ?? null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
/**
|
|
106
139
|
* Detects whether the Jira instance is Cloud or Data Center based on the base URL.
|
|
107
140
|
*/
|