zephyr-scale-mcp-server 0.6.0 → 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 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,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':
@@ -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
- const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
841
- params: { projectKey: project_key, folderId, maxResults: max_results },
842
- });
843
- const testCases = Array.isArray(response.data)
844
- ? response.data
845
- : response.data?.values ?? [];
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',
@@ -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',
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
- // Fetch all folders for the project + type (up to 1000)
8
- const response = await axiosInstance.get('/folders', {
9
- params: { projectKey, folderType, maxResults: 1000 },
10
- });
11
- const folders = Array.isArray(response.data)
12
- ? response.data
13
- : response.data?.values ?? [];
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 = folders.find((f) => f.name === segment && (f.parentId ?? null) === parentId);
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.6.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,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':
@@ -800,6 +800,90 @@ export class ZephyrToolHandlers {
800
800
  }
801
801
  }
802
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
+
803
887
  private async resolveStatusName(statusId: number): Promise<string | null> {
804
888
  try {
805
889
  const response = await this.axiosInstance.get(`/statuses/${statusId}`);
@@ -908,13 +992,32 @@ export class ZephyrToolHandlers {
908
992
  };
909
993
  }
910
994
 
911
- const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
912
- params: { projectKey: project_key, folderId, maxResults: max_results },
913
- });
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;
914
1000
 
915
- const testCases = Array.isArray(response.data)
916
- ? response.data
917
- : response.data?.values ?? [];
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);
918
1021
 
919
1022
  return {
920
1023
  content: [{
@@ -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',
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
- // Fetch all folders for the project + type (up to 1000)
15
- const response = await axiosInstance.get('/folders', {
16
- params: { projectKey, folderType, maxResults: 1000 },
17
- });
18
-
19
- const folders: Array<{ id: number; parentId: number | null; name: string }> =
20
- Array.isArray(response.data)
21
- ? response.data
22
- : response.data?.values ?? [];
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 = folders.find(
41
+ const match = allFolders.find(
30
42
  (f) => f.name === segment && (f.parentId ?? null) === parentId
31
43
  );
32
44
  if (!match) return null;