zephyr-scale-mcp-server 0.3.2 → 0.3.3

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.
@@ -1,5 +1,5 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
- import { convertToGherkin } from './utils.js';
2
+ import { convertToGherkin, resolveFolderIdByPath } from './utils.js';
3
3
  export class ZephyrToolHandlers {
4
4
  axiosInstance;
5
5
  jiraConfig;
@@ -16,17 +16,83 @@ export class ZephyrToolHandlers {
16
16
  };
17
17
  }
18
18
  catch (error) {
19
- throw new McpError(ErrorCode.InternalError, `Failed to get test case: ${error instanceof Error ? error.message : String(error)}`);
19
+ throw new McpError(ErrorCode.InternalError, `Failed to get test case: ${this.formatError(error)}`);
20
20
  }
21
21
  }
22
22
  async createTestCase(args) {
23
+ if (this.jiraConfig.type === 'cloud') {
24
+ return this.createTestCaseCloud(args);
25
+ }
26
+ return this.createTestCaseDC(args);
27
+ }
28
+ async createTestCaseCloud(args) {
29
+ const { project_key, name, test_script, folder, priority, precondition, objective, estimated_time, labels, custom_fields, } = args;
30
+ const payload = { projectKey: project_key, name };
31
+ // Cloud v2 uses statusName/priorityName (strings), folderId (integer)
32
+ payload.statusName = 'Draft';
33
+ if (priority)
34
+ payload.priorityName = priority;
35
+ if (precondition)
36
+ payload.precondition = precondition;
37
+ if (objective)
38
+ payload.objective = objective;
39
+ if (estimated_time)
40
+ payload.estimatedTime = estimated_time;
41
+ if (labels && labels.length > 0)
42
+ payload.labels = labels;
43
+ if (custom_fields)
44
+ payload.customFields = custom_fields;
45
+ // Resolve folder path → folderId
46
+ if (folder) {
47
+ const folderId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder, 'TEST_CASE');
48
+ if (folderId !== null)
49
+ payload.folderId = folderId;
50
+ }
51
+ try {
52
+ const response = await this.axiosInstance.post(this.jiraConfig.apiEndpoints.testcase, payload);
53
+ if (response.status !== 201) {
54
+ throw new Error(`Unexpected status code: ${response.status}`);
55
+ }
56
+ const testKey = response.data.key || 'Unknown';
57
+ // Step 2: add test script via dedicated endpoint
58
+ if (test_script) {
59
+ await this.upsertTestScriptCloud(testKey, test_script);
60
+ }
61
+ return {
62
+ content: [{
63
+ type: 'text',
64
+ text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none' }, null, 2)}`,
65
+ }],
66
+ };
67
+ }
68
+ catch (error) {
69
+ throw new McpError(ErrorCode.InternalError, `Failed to create test case: ${this.formatError(error)}`);
70
+ }
71
+ }
72
+ async upsertTestScriptCloud(testKey, test_script) {
73
+ if (!test_script)
74
+ return;
75
+ if (test_script.type === 'STEP_BY_STEP' && test_script.steps && test_script.steps.length > 0) {
76
+ const items = test_script.steps.map((step) => ({
77
+ inline: {
78
+ description: step.description || '',
79
+ testData: step.testData || null,
80
+ expectedResult: step.expectedResult || null,
81
+ },
82
+ }));
83
+ await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testcase}/${testKey}/teststeps`, { mode: 'OVERWRITE', items });
84
+ }
85
+ else if (test_script.type === 'BDD' && test_script.text) {
86
+ const gherkin = convertToGherkin(test_script.text) || test_script.text;
87
+ await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testcase}/${testKey}/testscript`, { type: 'bdd', text: gherkin });
88
+ }
89
+ else if (test_script.type === 'PLAIN_TEXT' && test_script.text) {
90
+ await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testcase}/${testKey}/testscript`, { type: 'plain', text: test_script.text });
91
+ }
92
+ }
93
+ async createTestCaseDC(args) {
23
94
  const { project_key, name, test_script, folder, status, priority, precondition, objective, component, owner, estimated_time, labels, issue_links, custom_fields, parameters } = args;
24
- // Build the basic payload
25
- const payload = {
26
- projectKey: project_key,
27
- name: name
28
- };
29
- // Add optional fields
95
+ const payload = { projectKey: project_key, name };
30
96
  if (folder)
31
97
  payload.folder = folder;
32
98
  if (status)
@@ -51,87 +117,113 @@ export class ZephyrToolHandlers {
51
117
  payload.customFields = custom_fields;
52
118
  if (parameters)
53
119
  payload.parameters = parameters;
54
- // Handle test script
55
120
  if (test_script) {
56
- payload.testScript = {
57
- type: test_script.type
58
- };
121
+ payload.testScript = { type: test_script.type };
59
122
  if (test_script.type === 'STEP_BY_STEP' && test_script.steps) {
60
123
  payload.testScript.steps = test_script.steps.map((step) => {
61
- const stepObj = {};
124
+ const s = {};
62
125
  if (step.description)
63
- stepObj.description = step.description;
126
+ s.description = step.description;
64
127
  if (step.testData)
65
- stepObj.testData = step.testData;
128
+ s.testData = step.testData;
66
129
  if (step.expectedResult)
67
- stepObj.expectedResult = step.expectedResult;
130
+ s.expectedResult = step.expectedResult;
68
131
  if (step.testCaseKey)
69
- stepObj.testCaseKey = step.testCaseKey;
70
- return stepObj;
132
+ s.testCaseKey = step.testCaseKey;
133
+ return s;
71
134
  });
72
135
  }
73
136
  else if ((test_script.type === 'PLAIN_TEXT' || test_script.type === 'BDD') && test_script.text) {
74
137
  if (test_script.type === 'BDD') {
75
- const gherkinContent = convertToGherkin(test_script.text);
76
- payload.testScript.text = gherkinContent || test_script.text;
138
+ const gherkin = convertToGherkin(test_script.text);
139
+ payload.testScript.text = gherkin || test_script.text;
77
140
  }
78
141
  else {
79
142
  payload.testScript.text = test_script.text;
80
143
  }
81
144
  }
82
145
  }
83
- // Always set status to Draft for new test cases
146
+ // Always Draft for new test cases
84
147
  payload.status = 'Draft';
85
148
  try {
86
149
  const response = await this.axiosInstance.post(this.jiraConfig.apiEndpoints.testcase, payload);
87
- if (response.status === 201) {
88
- const testKey = response.data.key || 'Unknown';
89
- return {
90
- content: [
91
- {
92
- type: 'text',
93
- text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({
94
- key: testKey,
95
- type: test_script?.type || 'none',
96
- hasSteps: test_script?.type === 'STEP_BY_STEP' ? test_script.steps?.length || 0 : undefined,
97
- hasText: (test_script?.type === 'PLAIN_TEXT' || test_script?.type === 'BDD') ? !!test_script.text : undefined
98
- }, null, 2)}`,
99
- },
100
- ],
101
- };
102
- }
103
- else {
150
+ if (response.status !== 201)
104
151
  throw new Error(`Unexpected status code: ${response.status}`);
105
- }
152
+ const testKey = response.data.key || 'Unknown';
153
+ return {
154
+ content: [{
155
+ type: 'text',
156
+ text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none' }, null, 2)}`,
157
+ }],
158
+ };
106
159
  }
107
160
  catch (error) {
108
- let errorMessage = 'Unknown error';
109
- if (error instanceof Error && 'response' in error) {
110
- const axiosError = error;
111
- errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
112
- }
113
- else if (error instanceof Error) {
114
- errorMessage = error.message;
115
- }
116
- else {
117
- errorMessage = String(error);
118
- }
119
- throw new McpError(ErrorCode.InternalError, `Failed to create test case: ${errorMessage}`);
161
+ throw new McpError(ErrorCode.InternalError, `Failed to create test case: ${this.formatError(error)}`);
120
162
  }
121
163
  }
122
164
  async updateTestCaseBdd(args) {
165
+ if (this.jiraConfig.type === 'cloud') {
166
+ return this.updateTestCaseBddCloud(args);
167
+ }
168
+ return this.updateTestCaseBddDC(args);
169
+ }
170
+ async updateTestCaseBddCloud(args) {
171
+ const { test_case_key, bdd_content, name } = args;
172
+ try {
173
+ // Step 1: GET existing test case to extract required fields for PUT
174
+ const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
175
+ const tc = getResponse.data;
176
+ // Cloud v2 PUT requires: id, key, name, project, priority, status
177
+ const requiredFields = ['id', 'key', 'name', 'project', 'priority', 'status'];
178
+ for (const field of requiredFields) {
179
+ if (tc[field] === undefined || tc[field] === null) {
180
+ throw new McpError(ErrorCode.InternalError, `Existing test case is missing required field '${field}' needed for Cloud v2 update.`);
181
+ }
182
+ }
183
+ const putPayload = {
184
+ id: tc.id,
185
+ key: tc.key,
186
+ name: typeof name === 'string' && name.trim().length > 0 ? name : tc.name,
187
+ project: tc.project,
188
+ priority: tc.priority,
189
+ status: tc.status,
190
+ };
191
+ // Preserve optional fields
192
+ for (const field of ['objective', 'precondition', 'estimatedTime', 'folder', 'component', 'owner']) {
193
+ if (tc[field] !== undefined)
194
+ putPayload[field] = tc[field];
195
+ }
196
+ if (Array.isArray(tc.labels))
197
+ putPayload.labels = tc.labels;
198
+ if (tc.customFields)
199
+ putPayload.customFields = tc.customFields;
200
+ // Step 2: PUT to update metadata
201
+ await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`, putPayload);
202
+ // Step 3: POST testscript with BDD content
203
+ const converted = convertToGherkin(bdd_content);
204
+ const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
205
+ await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}/testscript`, { type: 'bdd', text: finalText });
206
+ return {
207
+ content: [{
208
+ type: 'text',
209
+ text: `✅ Updated ${test_case_key} with BDD content successfully (Cloud v2)`,
210
+ }],
211
+ };
212
+ }
213
+ catch (error) {
214
+ if (error instanceof McpError)
215
+ throw error;
216
+ throw new McpError(ErrorCode.InternalError, `Failed to update test case BDD: ${this.formatError(error)}`);
217
+ }
218
+ }
219
+ async updateTestCaseBddDC(args) {
123
220
  const { test_case_key, bdd_content, name } = args;
124
221
  try {
125
- // First, get the existing test case data
126
222
  const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
127
223
  const testCaseData = getResponse.data;
128
- // Convert incoming BDD markdown (fallback to raw content if conversion returns empty)
129
224
  const converted = convertToGherkin(bdd_content);
130
225
  const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
131
- // Build a payload aligned with Zephyr Scale Server's update schema.
132
- // Only include fields that exist to avoid accidental nulling; required fields must be present.
133
226
  const payload = {};
134
- // Required base fields (schema requires these on Server/Data Center). If missing, throw.
135
227
  const requiredFields = [
136
228
  ['projectKey', testCaseData.projectKey],
137
229
  ['name', testCaseData.name],
@@ -144,66 +236,94 @@ export class ZephyrToolHandlers {
144
236
  }
145
237
  payload[field] = value;
146
238
  }
147
- // Optional name update
148
- if (typeof name === 'string' && name.trim().length > 0) {
239
+ if (typeof name === 'string' && name.trim().length > 0)
149
240
  payload.name = name;
150
- }
151
- // Optional simple scalar/string fields
152
- const optionalScalarFields = [
153
- 'objective',
154
- 'precondition',
155
- 'folder',
156
- 'component',
157
- 'owner',
158
- 'estimatedTime'
159
- ];
160
- for (const field of optionalScalarFields) {
241
+ for (const field of ['objective', 'precondition', 'folder', 'component', 'owner', 'estimatedTime']) {
161
242
  if (testCaseData[field] !== undefined)
162
243
  payload[field] = testCaseData[field];
163
244
  }
164
- // Arrays / objects
165
245
  if (Array.isArray(testCaseData.labels))
166
246
  payload.labels = testCaseData.labels;
167
247
  if (testCaseData.customFields)
168
248
  payload.customFields = testCaseData.customFields;
169
249
  if (testCaseData.parameters)
170
250
  payload.parameters = testCaseData.parameters;
171
- // issueLinks preferred; map deprecated issueKey if present and issueLinks absent
172
251
  if (Array.isArray(testCaseData.issueLinks)) {
173
252
  payload.issueLinks = testCaseData.issueLinks;
174
253
  }
175
254
  else if (testCaseData.issueKey) {
176
255
  payload.issueLinks = [testCaseData.issueKey];
177
256
  }
178
- // Build testScript. Force type to BDD when performing a BDD update.
179
- payload.testScript = {
180
- type: 'BDD',
181
- text: finalText
182
- };
183
- // PUT update
257
+ payload.testScript = { type: 'BDD', text: finalText };
184
258
  const updateResponse = await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`, payload);
185
- if (updateResponse.status === 200) {
259
+ if (updateResponse.status !== 200) {
260
+ throw new Error(`Failed to update ${test_case_key}: ${updateResponse.status}`);
261
+ }
262
+ return {
263
+ content: [{
264
+ type: 'text',
265
+ text: `✅ Updated ${test_case_key} with BDD content successfully\nPayload summary: ${JSON.stringify({ textLength: finalText.length, projectKey: payload.projectKey, name: payload.name, preservedLabels: payload.labels?.length || 0 }, null, 2)}`,
266
+ }],
267
+ };
268
+ }
269
+ catch (error) {
270
+ if (error instanceof McpError)
271
+ throw error;
272
+ throw new McpError(ErrorCode.InternalError, `Failed to update test case BDD: ${this.formatError(error)}`);
273
+ }
274
+ }
275
+ async createFolder(args) {
276
+ if (this.jiraConfig.type === 'cloud') {
277
+ return this.createFolderCloud(args);
278
+ }
279
+ return this.createFolderDC(args);
280
+ }
281
+ async createFolderCloud(args) {
282
+ const { project_key, name: folderPath, folder_type = 'TEST_CASE' } = args;
283
+ // Cloud v2 uses folderType (not type) and parentId integer
284
+ // Map TEST_RUN → TEST_CYCLE for Cloud v2
285
+ const cloudFolderType = folder_type === 'TEST_RUN' ? 'TEST_CYCLE' : folder_type;
286
+ const segments = folderPath.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
287
+ if (segments.length === 0) {
288
+ throw new McpError(ErrorCode.InvalidParams, 'folder name/path cannot be empty');
289
+ }
290
+ const leafName = segments[segments.length - 1];
291
+ let parentId = null;
292
+ // Resolve parent if nested path
293
+ if (segments.length > 1) {
294
+ const parentPath = '/' + segments.slice(0, -1).join('/');
295
+ parentId = await resolveFolderIdByPath(this.axiosInstance, project_key, parentPath, cloudFolderType);
296
+ if (parentId === null) {
297
+ throw new McpError(ErrorCode.InternalError, `Parent folder not found for path: ${parentPath}. Create parent folders first.`);
298
+ }
299
+ }
300
+ const payload = {
301
+ projectKey: project_key,
302
+ name: leafName,
303
+ folderType: cloudFolderType,
304
+ };
305
+ if (parentId !== null)
306
+ payload.parentId = parentId;
307
+ try {
308
+ const response = await this.axiosInstance.post(this.jiraConfig.apiEndpoints.folder, payload);
309
+ if (response.status === 201 || response.status === 200) {
186
310
  return {
187
- content: [
188
- {
311
+ content: [{
189
312
  type: 'text',
190
- text: `✅ Updated ${test_case_key} with BDD content successfully\nPayload summary: ${JSON.stringify({ textLength: finalText.length, projectKey: payload.projectKey, name: payload.name, preservedLabels: payload.labels?.length || 0 }, null, 2)}`,
191
- },
192
- ],
313
+ text: `✅ Folder created successfully: ${leafName} (ID: ${response.data.id || 'N/A'})\n${JSON.stringify(response.data, null, 2)}`,
314
+ }],
193
315
  };
194
316
  }
195
- throw new Error(`Failed to update ${test_case_key}: ${updateResponse.status}`);
317
+ throw new Error(`Unexpected status code: ${response.status}`);
196
318
  }
197
319
  catch (error) {
198
- throw new McpError(ErrorCode.InternalError, `Failed to update test case BDD: ${error instanceof Error ? error.message : String(error)}`);
320
+ if (error instanceof McpError)
321
+ throw error;
322
+ throw new McpError(ErrorCode.InternalError, `Failed to create folder: ${this.formatError(error)}`);
199
323
  }
200
324
  }
201
- async createFolder(args) {
325
+ async createFolderDC(args) {
202
326
  const { project_key, name, folder_type = 'TEST_CASE' } = args;
203
- // According to Zephyr Scale API documentation:
204
- // - projectKey: Project key (required)
205
- // - name: Full folder path including parent folders (e.g., "/folder/subfolder")
206
- // - type: Folder type (TEST_CASE, TEST_PLAN, or TEST_RUN)
207
327
  const payload = {
208
328
  projectKey: project_key,
209
329
  name: name,
@@ -213,35 +333,51 @@ export class ZephyrToolHandlers {
213
333
  const response = await this.axiosInstance.post(this.jiraConfig.apiEndpoints.folder, payload);
214
334
  if (response.status === 201 || response.status === 200) {
215
335
  return {
216
- content: [
217
- {
336
+ content: [{
218
337
  type: 'text',
219
338
  text: `✅ Folder created successfully: ${response.data.name || name} (ID: ${response.data.id || 'N/A'})\n${JSON.stringify(response.data, null, 2)}`,
220
- },
221
- ],
339
+ }],
222
340
  };
223
341
  }
224
- else {
225
- throw new Error(`Unexpected status code: ${response.status}`);
226
- }
342
+ throw new Error(`Unexpected status code: ${response.status}`);
227
343
  }
228
344
  catch (error) {
229
- let errorMessage = 'Unknown error';
230
- if (error instanceof Error && 'response' in error) {
231
- const axiosError = error;
232
- errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}, Payload sent: ${JSON.stringify(payload)}`;
233
- }
234
- else if (error instanceof Error) {
235
- errorMessage = error.message;
236
- }
237
- else {
238
- errorMessage = String(error);
239
- }
240
- throw new McpError(ErrorCode.InternalError, `Failed to create folder: ${errorMessage}`);
345
+ if (error instanceof McpError)
346
+ throw error;
347
+ throw new McpError(ErrorCode.InternalError, `Failed to create folder: ${this.formatError(error)}`);
241
348
  }
242
349
  }
243
350
  async getTestRunCases(args) {
244
351
  const { test_run_key } = args;
352
+ if (this.jiraConfig.type === 'cloud') {
353
+ try {
354
+ // Cloud: test cases are retrieved via executions associated with the cycle
355
+ const response = await this.axiosInstance.get('/testexecutions', {
356
+ params: { testCycle: test_run_key, maxResults: 1000 },
357
+ });
358
+ const executions = Array.isArray(response.data)
359
+ ? response.data
360
+ : response.data?.values ?? [];
361
+ // Deduplicate by test case key
362
+ const seen = new Set();
363
+ const testCaseKeys = [];
364
+ for (const exec of executions) {
365
+ const key = exec.testCase?.key
366
+ ?? exec.testCase?.self?.match(/testcases\/([^/]+)/)?.[1];
367
+ if (key && !seen.has(key)) {
368
+ seen.add(key);
369
+ testCaseKeys.push(key);
370
+ }
371
+ }
372
+ return {
373
+ content: [{ type: 'text', text: JSON.stringify(testCaseKeys, null, 2) }],
374
+ };
375
+ }
376
+ catch (error) {
377
+ throw new McpError(ErrorCode.InternalError, `Failed to get test run cases: ${this.formatError(error)}`);
378
+ }
379
+ }
380
+ // Data Center: items[] in the run response
245
381
  try {
246
382
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
247
383
  const testCases = response.data.items?.map((item) => item.testCaseKey) || [];
@@ -250,61 +386,104 @@ export class ZephyrToolHandlers {
250
386
  };
251
387
  }
252
388
  catch (error) {
253
- throw new McpError(ErrorCode.InternalError, `Failed to get test run cases: ${error instanceof Error ? error.message : String(error)}`);
389
+ throw new McpError(ErrorCode.InternalError, `Failed to get test run cases: ${this.formatError(error)}`);
254
390
  }
255
391
  }
256
392
  async deleteTestCase(args) {
393
+ if (this.jiraConfig.type === 'cloud') {
394
+ throw new McpError(ErrorCode.InvalidRequest, 'delete_test_case is not supported by the Zephyr Scale Cloud v2 API.');
395
+ }
257
396
  const { test_case_key } = args;
258
397
  try {
259
398
  const response = await this.axiosInstance.delete(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
260
399
  if (response.status === 204) {
261
- return {
262
- content: [{ type: 'text', text: `Test case ${test_case_key} deleted successfully.` }],
263
- };
264
- }
265
- else {
266
- return {
267
- content: [{ type: 'text', text: `Failed to delete test case. Status: ${response.status}` }],
268
- isError: true,
269
- };
400
+ return { content: [{ type: 'text', text: `Test case ${test_case_key} deleted successfully.` }] };
270
401
  }
402
+ return {
403
+ content: [{ type: 'text', text: `Failed to delete test case. Status: ${response.status}` }],
404
+ isError: true,
405
+ };
271
406
  }
272
407
  catch (error) {
273
- throw new McpError(ErrorCode.InternalError, `Failed to delete test case: ${error instanceof Error ? error.message : String(error)}`);
408
+ throw new McpError(ErrorCode.InternalError, `Failed to delete test case: ${this.formatError(error)}`);
274
409
  }
275
410
  }
276
411
  async deleteTestRun(args) {
412
+ if (this.jiraConfig.type === 'cloud') {
413
+ throw new McpError(ErrorCode.InvalidRequest, 'delete_test_run is not supported by the Zephyr Scale Cloud v2 API.');
414
+ }
277
415
  const { test_run_key } = args;
278
416
  try {
279
417
  const response = await this.axiosInstance.delete(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
280
418
  if (response.status === 204) {
281
- return {
282
- content: [{ type: 'text', text: `Test run ${test_run_key} deleted successfully.` }],
283
- };
284
- }
285
- else {
286
- return {
287
- content: [{ type: 'text', text: `Failed to delete test run. Status: ${response.status}` }],
288
- isError: true,
289
- };
419
+ return { content: [{ type: 'text', text: `Test run ${test_run_key} deleted successfully.` }] };
290
420
  }
421
+ return {
422
+ content: [{ type: 'text', text: `Failed to delete test run. Status: ${response.status}` }],
423
+ isError: true,
424
+ };
291
425
  }
292
426
  catch (error) {
293
- throw new McpError(ErrorCode.InternalError, `Failed to delete test run: ${error instanceof Error ? error.message : String(error)}`);
427
+ throw new McpError(ErrorCode.InternalError, `Failed to delete test run: ${this.formatError(error)}`);
294
428
  }
295
429
  }
296
430
  async createTestRun(args) {
431
+ if (this.jiraConfig.type === 'cloud') {
432
+ return this.createTestRunCloud(args);
433
+ }
434
+ return this.createTestRunDC(args);
435
+ }
436
+ async createTestRunCloud(args) {
437
+ const { project_key, name, test_case_keys, folder, planned_start_date, planned_end_date, description, owner, environment, custom_fields, } = args;
438
+ // Cloud v2 TestCycleInput: projectKey, name, description, plannedStartDate,
439
+ // plannedEndDate, statusName, folderId, ownerId, customFields
440
+ const payload = { projectKey: project_key, name };
441
+ if (description)
442
+ payload.description = description;
443
+ if (planned_start_date)
444
+ payload.plannedStartDate = planned_start_date;
445
+ if (planned_end_date)
446
+ payload.plannedEndDate = planned_end_date;
447
+ if (custom_fields)
448
+ payload.customFields = custom_fields;
449
+ // Resolve folder path → folderId
450
+ if (folder) {
451
+ const folderId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder, 'TEST_CYCLE');
452
+ if (folderId !== null)
453
+ payload.folderId = folderId;
454
+ }
455
+ // Note: environment and owner (by account ID) not mapped here — Cloud requires IDs
456
+ try {
457
+ const response = await this.axiosInstance.post(this.jiraConfig.apiEndpoints.testrun, payload);
458
+ if (response.status !== 201)
459
+ throw new Error(`Unexpected status code: ${response.status}`);
460
+ const cycleKey = response.data.key || 'Unknown';
461
+ // Step 2: add test cases to the cycle
462
+ if (test_case_keys && test_case_keys.length > 0) {
463
+ await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testrun}/${cycleKey}/testcases`, { items: test_case_keys.map((k) => ({ testCaseKey: k })) });
464
+ }
465
+ return {
466
+ content: [{
467
+ type: 'text',
468
+ text: `✅ Test run (cycle) created successfully: ${cycleKey}\n${JSON.stringify({
469
+ key: cycleKey,
470
+ name,
471
+ testCaseCount: test_case_keys?.length || 0,
472
+ }, null, 2)}`,
473
+ }],
474
+ };
475
+ }
476
+ catch (error) {
477
+ if (error instanceof McpError)
478
+ throw error;
479
+ throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
480
+ }
481
+ }
482
+ async createTestRunDC(args) {
297
483
  const { project_key, name, test_case_keys, test_plan_key, folder, planned_start_date, planned_end_date, description, owner, environment, issue_key, issue_links, custom_fields } = args;
298
- // Build the basic payload
299
- const payload = {
300
- projectKey: project_key,
301
- name: name,
302
- };
303
- // Add optional fields
484
+ const payload = { projectKey: project_key, name };
304
485
  if (test_case_keys && test_case_keys.length > 0) {
305
- payload.items = test_case_keys.map((testCaseKey) => ({
306
- testCaseKey: testCaseKey
307
- }));
486
+ payload.items = test_case_keys.map((testCaseKey) => ({ testCaseKey }));
308
487
  }
309
488
  if (folder)
310
489
  payload.folder = folder;
@@ -328,144 +507,129 @@ export class ZephyrToolHandlers {
328
507
  payload.testPlanKey = test_plan_key;
329
508
  try {
330
509
  const response = await this.axiosInstance.post(this.jiraConfig.apiEndpoints.testrun, payload);
331
- if (response.status === 201) {
332
- const testRunKey = response.data.key || 'Unknown';
333
- return {
334
- content: [
335
- {
336
- type: 'text',
337
- text: `✅ Test run created successfully: ${testRunKey}\n${JSON.stringify({
338
- key: testRunKey,
339
- name: name,
340
- testCaseCount: test_case_keys?.length || 0,
341
- environment: environment || 'Not specified'
342
- }, null, 2)}`,
343
- },
344
- ],
345
- };
346
- }
347
- else {
510
+ if (response.status !== 201)
348
511
  throw new Error(`Unexpected status code: ${response.status}`);
349
- }
512
+ const testRunKey = response.data.key || 'Unknown';
513
+ return {
514
+ content: [{
515
+ type: 'text',
516
+ text: `✅ Test run created successfully: ${testRunKey}\n${JSON.stringify({
517
+ key: testRunKey, name, testCaseCount: test_case_keys?.length || 0,
518
+ environment: environment || 'Not specified'
519
+ }, null, 2)}`,
520
+ }],
521
+ };
350
522
  }
351
523
  catch (error) {
352
- let errorMessage = 'Unknown error';
353
- if (error instanceof Error && 'response' in error) {
354
- const axiosError = error;
355
- errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
356
- }
357
- else if (error instanceof Error) {
358
- errorMessage = error.message;
359
- }
360
- else {
361
- errorMessage = String(error);
362
- }
363
- throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${errorMessage}`);
524
+ throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
364
525
  }
365
526
  }
366
527
  async getTestRun(args) {
367
528
  const { test_run_key } = args;
529
+ // Both Cloud (/testcycles/{key}) and DC (/rest/atm/1.0/testrun/{key}) handled
530
+ // via apiEndpoints.testrun which now correctly maps to /testcycles for Cloud
368
531
  try {
369
532
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
370
533
  return {
371
- content: [
372
- {
373
- type: 'text',
374
- text: JSON.stringify(response.data, null, 2),
375
- },
376
- ],
534
+ content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }],
377
535
  };
378
536
  }
379
537
  catch (error) {
380
- let errorMessage = 'Unknown error';
381
- if (error instanceof Error && 'response' in error) {
382
- const axiosError = error;
383
- if (axiosError.response?.status === 404) {
384
- errorMessage = `Test run ${test_run_key} not found`;
385
- }
386
- else {
387
- errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
388
- }
389
- }
390
- else if (error instanceof Error) {
391
- errorMessage = error.message;
392
- }
393
- else {
394
- errorMessage = String(error);
395
- }
396
- throw new McpError(ErrorCode.InternalError, `Failed to get test run: ${errorMessage}`);
538
+ throw new McpError(ErrorCode.InternalError, `Failed to get test run: ${this.formatError(error)}`);
397
539
  }
398
540
  }
399
541
  async getTestExecution(args) {
400
542
  const { execution_id, test_run_keys } = args;
401
- // Require users to specify test runs to search - fail immediately if not provided
543
+ if (this.jiraConfig.type === 'cloud') {
544
+ // Cloud v2: direct fetch by ID or key (e.g. PROJ-E123)
545
+ try {
546
+ const response = await this.axiosInstance.get(`/testexecutions/${execution_id}`);
547
+ return {
548
+ content: [{
549
+ type: 'text',
550
+ text: `✅ Test execution ${execution_id} found:\n${JSON.stringify(response.data, null, 2)}`,
551
+ }],
552
+ };
553
+ }
554
+ catch (error) {
555
+ throw new McpError(ErrorCode.InternalError, `Failed to get test execution: ${this.formatError(error)}`);
556
+ }
557
+ }
558
+ // Data Center: iterate test runs searching testresults
402
559
  if (!test_run_keys || !Array.isArray(test_run_keys) || test_run_keys.length === 0) {
403
- throw new McpError(ErrorCode.InvalidParams, 'test_run_keys is required. Please provide an array of test run keys to search in (e.g., ["PROJ-C152", "PROJ-C161"]). Use get_test_run_cases to find test runs if needed.');
560
+ throw new McpError(ErrorCode.InvalidParams, 'test_run_keys is required for Data Center. Provide an array of test run keys to search in.');
404
561
  }
405
562
  try {
406
- const testRunsToTry = test_run_keys;
407
563
  const searchResults = [];
408
- for (const testRunKey of testRunsToTry) {
564
+ for (const testRunKey of test_run_keys) {
409
565
  try {
410
566
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${testRunKey}/testresults`);
411
567
  if (response.status === 200 && response.data) {
412
- // Look for the specific execution_id in the results
413
568
  const results = Array.isArray(response.data) ? response.data : [response.data];
414
- const matchingExecution = results.find((result) => result.id && result.id.toString() === execution_id);
415
- if (matchingExecution) {
569
+ const match = results.find((r) => r.id && r.id.toString() === execution_id);
570
+ if (match) {
416
571
  return {
417
- content: [
418
- {
572
+ content: [{
419
573
  type: 'text',
420
- text: `✅ Test execution ${execution_id} found in ${testRunKey}:\n${JSON.stringify(matchingExecution, null, 2)}`,
421
- },
422
- ],
574
+ text: `✅ Test execution ${execution_id} found in ${testRunKey}:\n${JSON.stringify(match, null, 2)}`,
575
+ }],
423
576
  };
424
577
  }
425
- // Store search info for debugging
426
- searchResults.push({
427
- testRunKey,
428
- executionCount: results.length,
429
- executionIds: results.map((r) => r.id).slice(0, 5) // Show first 5 IDs
430
- });
578
+ searchResults.push({ testRunKey, executionCount: results.length, executionIds: results.map((r) => r.id).slice(0, 5) });
431
579
  }
432
580
  }
433
581
  catch (runError) {
434
- // Store error info for debugging
435
- searchResults.push({
436
- testRunKey,
437
- error: runError instanceof Error ? runError.message : String(runError)
438
- });
439
- continue;
582
+ searchResults.push({ testRunKey, error: runError instanceof Error ? runError.message : String(runError) });
440
583
  }
441
584
  }
442
- // If not found, provide helpful debugging info
443
- throw new Error(`Test execution ${execution_id} not found in any of the ${testRunsToTry.length} test runs searched. Search results: ${JSON.stringify(searchResults, null, 2)}`);
585
+ throw new Error(`Test execution ${execution_id} not found in any of the ${test_run_keys.length} test runs. Search results: ${JSON.stringify(searchResults, null, 2)}`);
444
586
  }
445
587
  catch (error) {
446
- let errorMessage = 'Unknown error';
447
- if (error instanceof Error) {
448
- errorMessage = error.message;
449
- }
450
- else {
451
- errorMessage = String(error);
452
- }
453
- throw new McpError(ErrorCode.InternalError, `Failed to get test execution: ${errorMessage}`);
588
+ if (error instanceof McpError)
589
+ throw error;
590
+ throw new McpError(ErrorCode.InternalError, `Failed to get test execution: ${this.formatError(error)}`);
454
591
  }
455
592
  }
456
593
  async searchTestCasesByFolder(args) {
457
594
  const { project_key, folder_path, max_results = 100 } = args;
458
- // Build JQL-style query for Zephyr Scale API
459
- // Escape double quotes in folder path
460
- const escapedFolderPath = folder_path.replace(/"/g, '\\"');
461
- const query = `projectKey = "${project_key}" AND folder = "${escapedFolderPath}"`;
462
- const params = {
463
- query: query,
464
- maxResults: max_results,
465
- };
595
+ if (this.jiraConfig.type === 'cloud') {
596
+ try {
597
+ // Cloud v2: GET /testcases?projectKey=X&folderId=Y
598
+ const folderId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder_path, 'TEST_CASE');
599
+ if (folderId === null) {
600
+ return {
601
+ content: [{
602
+ type: 'text',
603
+ text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}. No test cases returned.`,
604
+ }],
605
+ };
606
+ }
607
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
608
+ params: { projectKey: project_key, folderId, maxResults: max_results },
609
+ });
610
+ const testCases = Array.isArray(response.data)
611
+ ? response.data
612
+ : response.data?.values ?? [];
613
+ return {
614
+ content: [{
615
+ type: 'text',
616
+ text: `✅ Found ${testCases.length} test cases in folder "${folder_path}" (folderId: ${folderId}):\n${JSON.stringify({
617
+ folder: folder_path, folderId, testCaseKeys: testCases.map((tc) => tc.key), totalCount: testCases.length,
618
+ }, null, 2)}`,
619
+ }],
620
+ };
621
+ }
622
+ catch (error) {
623
+ throw new McpError(ErrorCode.InternalError, `Failed to search test cases by folder: ${this.formatError(error)}`);
624
+ }
625
+ }
626
+ // Data Center: query-based search
466
627
  try {
467
- const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.search, { params });
468
- // Handle different response structures
628
+ const escapedFolderPath = folder_path.replace(/"/g, '\\"');
629
+ const query = `projectKey = "${project_key}" AND folder = "${escapedFolderPath}"`;
630
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.search, {
631
+ params: { query, maxResults: max_results },
632
+ });
469
633
  let testCases = [];
470
634
  if (Array.isArray(response.data)) {
471
635
  testCases = response.data;
@@ -477,58 +641,66 @@ export class ZephyrToolHandlers {
477
641
  testCases = response.data.results;
478
642
  }
479
643
  return {
480
- content: [
481
- {
644
+ content: [{
482
645
  type: 'text',
483
646
  text: `✅ Found ${testCases.length} test cases in folder "${folder_path}":\n${JSON.stringify({
484
- folder: folder_path,
485
- query: query,
486
- testCaseKeys: testCases.map((tc) => tc.key),
487
- totalCount: testCases.length
647
+ folder: folder_path, query, testCaseKeys: testCases.map((tc) => tc.key), totalCount: testCases.length,
488
648
  }, null, 2)}`,
489
- },
490
- ],
649
+ }],
491
650
  };
492
651
  }
493
652
  catch (error) {
494
- let errorMessage = 'Unknown error';
495
- if (error instanceof Error && 'response' in error) {
496
- const axiosError = error;
497
- if (axiosError.response?.status === 404) {
498
- errorMessage = `Folder "${folder_path}" not found or no test cases found`;
499
- }
500
- else {
501
- errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
502
- }
503
- }
504
- else if (error instanceof Error) {
505
- errorMessage = error.message;
506
- }
507
- else {
508
- errorMessage = String(error);
509
- }
510
- throw new McpError(ErrorCode.InternalError, `Failed to search test cases by folder: ${errorMessage}`);
653
+ throw new McpError(ErrorCode.InternalError, `Failed to search test cases by folder: ${this.formatError(error)}`);
511
654
  }
512
655
  }
513
656
  async searchTestRuns(args) {
514
657
  const { project_key, folder, max_results = 200, fields } = args;
515
- // Build query string per Zephyr Scale API docs
516
- const queryParts = [];
517
- if (project_key)
518
- queryParts.push(`projectKey = "${project_key}"`);
519
- if (folder)
520
- queryParts.push(`folder = "${folder}"`);
521
- if (queryParts.length === 0) {
658
+ if (!project_key && !folder) {
522
659
  throw new McpError(ErrorCode.InvalidParams, 'At least one of project_key or folder must be provided.');
523
660
  }
524
- const query = queryParts.join(' AND ');
525
- const params = { query, maxResults: max_results };
526
- if (fields)
527
- params.fields = fields;
528
- const searchEndpoint = this.jiraConfig.type === 'cloud'
529
- ? '/testruns/search'
530
- : '/rest/atm/1.0/testrun/search';
661
+ if (this.jiraConfig.type === 'cloud') {
662
+ try {
663
+ // Cloud v2: GET /testcycles?projectKey=X&folderId=Y
664
+ const params = { maxResults: max_results };
665
+ if (project_key)
666
+ params.projectKey = project_key;
667
+ if (folder && project_key) {
668
+ const folderId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder, 'TEST_CYCLE');
669
+ if (folderId !== null)
670
+ params.folderId = folderId;
671
+ }
672
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testrun, { params });
673
+ const testRuns = Array.isArray(response.data)
674
+ ? response.data
675
+ : response.data?.values ?? [];
676
+ return {
677
+ content: [{
678
+ type: 'text',
679
+ text: `✅ Found ${testRuns.length} test run(s):\n${JSON.stringify({
680
+ totalCount: testRuns.length,
681
+ testRuns: testRuns.map((tr) => ({
682
+ key: tr.key, name: tr.name, status: tr.status?.name, folder: tr.folder?.name,
683
+ })),
684
+ }, null, 2)}`,
685
+ }],
686
+ };
687
+ }
688
+ catch (error) {
689
+ throw new McpError(ErrorCode.InternalError, `Failed to search test runs: ${this.formatError(error)}`);
690
+ }
691
+ }
692
+ // Data Center: query-based search
531
693
  try {
694
+ const queryParts = [];
695
+ if (project_key)
696
+ queryParts.push(`projectKey = "${project_key}"`);
697
+ if (folder)
698
+ queryParts.push(`folder = "${folder}"`);
699
+ const query = queryParts.join(' AND ');
700
+ const searchEndpoint = '/rest/atm/1.0/testrun/search';
701
+ const params = { query, maxResults: max_results };
702
+ if (fields)
703
+ params.fields = fields;
532
704
  const response = await this.axiosInstance.get(searchEndpoint, { params });
533
705
  let testRuns = [];
534
706
  if (Array.isArray(response.data)) {
@@ -541,50 +713,32 @@ export class ZephyrToolHandlers {
541
713
  testRuns = response.data.results;
542
714
  }
543
715
  return {
544
- content: [
545
- {
716
+ content: [{
546
717
  type: 'text',
547
718
  text: `✅ Found ${testRuns.length} test run(s) matching query "${query}":\n${JSON.stringify({
548
- query,
549
- totalCount: testRuns.length,
719
+ query, totalCount: testRuns.length,
550
720
  testRuns: testRuns.map((tr) => ({
551
- key: tr.key,
552
- name: tr.name,
553
- status: tr.status,
554
- folder: tr.folder,
555
- testCaseCount: tr.testCaseCount,
556
- issueKey: tr.issueKey,
557
- }))
721
+ key: tr.key, name: tr.name, status: tr.status, folder: tr.folder,
722
+ testCaseCount: tr.testCaseCount, issueKey: tr.issueKey,
723
+ })),
558
724
  }, null, 2)}`,
559
- },
560
- ],
725
+ }],
561
726
  };
562
727
  }
563
728
  catch (error) {
564
- let errorMessage = 'Unknown error';
565
- if (error instanceof Error && 'response' in error) {
566
- const axiosError = error;
567
- errorMessage = `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
568
- }
569
- else if (error instanceof Error) {
570
- errorMessage = error.message;
571
- }
572
- else {
573
- errorMessage = String(error);
574
- }
575
- throw new McpError(ErrorCode.InternalError, `Failed to search test runs: ${errorMessage}`);
729
+ throw new McpError(ErrorCode.InternalError, `Failed to search test runs: ${this.formatError(error)}`);
576
730
  }
577
731
  }
578
732
  async addTestCasesToRun(args) {
579
- const { test_run_key, test_case_keys } = args;
580
733
  if (this.jiraConfig.type === 'datacenter') {
581
734
  throw new McpError(ErrorCode.InvalidRequest, 'add_test_cases_to_run is only supported on Zephyr Scale Cloud. The Data Center API (v1) does not provide an endpoint to modify test runs after creation.');
582
735
  }
736
+ const { test_run_key, test_case_keys } = args;
583
737
  try {
584
738
  const payload = {
585
739
  items: test_case_keys.map(key => ({ testCaseKey: key }))
586
740
  };
587
- const response = await this.axiosInstance.post(`/testcycles/${test_run_key}/testcases`, payload);
741
+ const response = await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}/testcases`, payload);
588
742
  if (response.status === 200 || response.status === 201 || response.status === 204) {
589
743
  return {
590
744
  content: [{ type: 'text', text: `Added ${test_case_keys.length} test case(s) to test run ${test_run_key}.` }],
@@ -592,11 +746,18 @@ export class ZephyrToolHandlers {
592
746
  }
593
747
  }
594
748
  catch (error) {
595
- throw new McpError(ErrorCode.InternalError, `Failed to add test cases: ${error instanceof Error ? error.message : String(error)}`);
749
+ throw new McpError(ErrorCode.InternalError, `Failed to add test cases: ${this.formatError(error)}`);
596
750
  }
597
751
  return {
598
752
  content: [{ type: 'text', text: 'An unexpected error occurred.' }],
599
753
  isError: true,
600
754
  };
601
755
  }
756
+ formatError(error) {
757
+ if (error instanceof Error && 'response' in error) {
758
+ const axiosError = error;
759
+ return `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
760
+ }
761
+ return error instanceof Error ? error.message : String(error);
762
+ }
602
763
  }