zephyr-scale-mcp-server 0.3.2 → 0.4.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.
@@ -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,111 @@ 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 via test executions (Cloud v2 has no /testcycles/{key}/testcases)
462
+ if (test_case_keys && test_case_keys.length > 0) {
463
+ for (const testCaseKey of test_case_keys) {
464
+ await this.axiosInstance.post('/testexecutions', {
465
+ projectKey: project_key,
466
+ testCaseKey,
467
+ testCycleKey: cycleKey,
468
+ statusName: 'Not Executed',
469
+ });
470
+ }
471
+ }
472
+ return {
473
+ content: [{
474
+ type: 'text',
475
+ text: `✅ Test run (cycle) created successfully: ${cycleKey}\n${JSON.stringify({
476
+ key: cycleKey,
477
+ name,
478
+ testCaseCount: test_case_keys?.length || 0,
479
+ }, null, 2)}`,
480
+ }],
481
+ };
482
+ }
483
+ catch (error) {
484
+ if (error instanceof McpError)
485
+ throw error;
486
+ throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
487
+ }
488
+ }
489
+ async createTestRunDC(args) {
297
490
  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
491
+ const payload = { projectKey: project_key, name };
304
492
  if (test_case_keys && test_case_keys.length > 0) {
305
- payload.items = test_case_keys.map((testCaseKey) => ({
306
- testCaseKey: testCaseKey
307
- }));
493
+ payload.items = test_case_keys.map((testCaseKey) => ({ testCaseKey }));
308
494
  }
309
495
  if (folder)
310
496
  payload.folder = folder;
@@ -328,144 +514,129 @@ export class ZephyrToolHandlers {
328
514
  payload.testPlanKey = test_plan_key;
329
515
  try {
330
516
  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 {
517
+ if (response.status !== 201)
348
518
  throw new Error(`Unexpected status code: ${response.status}`);
349
- }
519
+ const testRunKey = response.data.key || 'Unknown';
520
+ return {
521
+ content: [{
522
+ type: 'text',
523
+ text: `✅ Test run created successfully: ${testRunKey}\n${JSON.stringify({
524
+ key: testRunKey, name, testCaseCount: test_case_keys?.length || 0,
525
+ environment: environment || 'Not specified'
526
+ }, null, 2)}`,
527
+ }],
528
+ };
350
529
  }
351
530
  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}`);
531
+ throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
364
532
  }
365
533
  }
366
534
  async getTestRun(args) {
367
535
  const { test_run_key } = args;
536
+ // Both Cloud (/testcycles/{key}) and DC (/rest/atm/1.0/testrun/{key}) handled
537
+ // via apiEndpoints.testrun which now correctly maps to /testcycles for Cloud
368
538
  try {
369
539
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
370
540
  return {
371
- content: [
372
- {
373
- type: 'text',
374
- text: JSON.stringify(response.data, null, 2),
375
- },
376
- ],
541
+ content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }],
377
542
  };
378
543
  }
379
544
  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}`);
545
+ throw new McpError(ErrorCode.InternalError, `Failed to get test run: ${this.formatError(error)}`);
397
546
  }
398
547
  }
399
548
  async getTestExecution(args) {
400
549
  const { execution_id, test_run_keys } = args;
401
- // Require users to specify test runs to search - fail immediately if not provided
550
+ if (this.jiraConfig.type === 'cloud') {
551
+ // Cloud v2: direct fetch by ID or key (e.g. PROJ-E123)
552
+ try {
553
+ const response = await this.axiosInstance.get(`/testexecutions/${execution_id}`);
554
+ return {
555
+ content: [{
556
+ type: 'text',
557
+ text: `✅ Test execution ${execution_id} found:\n${JSON.stringify(response.data, null, 2)}`,
558
+ }],
559
+ };
560
+ }
561
+ catch (error) {
562
+ throw new McpError(ErrorCode.InternalError, `Failed to get test execution: ${this.formatError(error)}`);
563
+ }
564
+ }
565
+ // Data Center: iterate test runs searching testresults
402
566
  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.');
567
+ throw new McpError(ErrorCode.InvalidParams, 'test_run_keys is required for Data Center. Provide an array of test run keys to search in.');
404
568
  }
405
569
  try {
406
- const testRunsToTry = test_run_keys;
407
570
  const searchResults = [];
408
- for (const testRunKey of testRunsToTry) {
571
+ for (const testRunKey of test_run_keys) {
409
572
  try {
410
573
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${testRunKey}/testresults`);
411
574
  if (response.status === 200 && response.data) {
412
- // Look for the specific execution_id in the results
413
575
  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) {
576
+ const match = results.find((r) => r.id && r.id.toString() === execution_id);
577
+ if (match) {
416
578
  return {
417
- content: [
418
- {
579
+ content: [{
419
580
  type: 'text',
420
- text: `✅ Test execution ${execution_id} found in ${testRunKey}:\n${JSON.stringify(matchingExecution, null, 2)}`,
421
- },
422
- ],
581
+ text: `✅ Test execution ${execution_id} found in ${testRunKey}:\n${JSON.stringify(match, null, 2)}`,
582
+ }],
423
583
  };
424
584
  }
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
- });
585
+ searchResults.push({ testRunKey, executionCount: results.length, executionIds: results.map((r) => r.id).slice(0, 5) });
431
586
  }
432
587
  }
433
588
  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;
589
+ searchResults.push({ testRunKey, error: runError instanceof Error ? runError.message : String(runError) });
440
590
  }
441
591
  }
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)}`);
592
+ 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
593
  }
445
594
  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}`);
595
+ if (error instanceof McpError)
596
+ throw error;
597
+ throw new McpError(ErrorCode.InternalError, `Failed to get test execution: ${this.formatError(error)}`);
454
598
  }
455
599
  }
456
600
  async searchTestCasesByFolder(args) {
457
601
  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
- };
602
+ if (this.jiraConfig.type === 'cloud') {
603
+ try {
604
+ // Cloud v2: GET /testcases?projectKey=X&folderId=Y
605
+ const folderId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder_path, 'TEST_CASE');
606
+ if (folderId === null) {
607
+ return {
608
+ content: [{
609
+ type: 'text',
610
+ text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}. No test cases returned.`,
611
+ }],
612
+ };
613
+ }
614
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
615
+ params: { projectKey: project_key, folderId, maxResults: max_results },
616
+ });
617
+ const testCases = Array.isArray(response.data)
618
+ ? response.data
619
+ : response.data?.values ?? [];
620
+ return {
621
+ content: [{
622
+ type: 'text',
623
+ text: `✅ Found ${testCases.length} test cases in folder "${folder_path}" (folderId: ${folderId}):\n${JSON.stringify({
624
+ folder: folder_path, folderId, testCaseKeys: testCases.map((tc) => tc.key), totalCount: testCases.length,
625
+ }, null, 2)}`,
626
+ }],
627
+ };
628
+ }
629
+ catch (error) {
630
+ throw new McpError(ErrorCode.InternalError, `Failed to search test cases by folder: ${this.formatError(error)}`);
631
+ }
632
+ }
633
+ // Data Center: query-based search
466
634
  try {
467
- const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.search, { params });
468
- // Handle different response structures
635
+ const escapedFolderPath = folder_path.replace(/"/g, '\\"');
636
+ const query = `projectKey = "${project_key}" AND folder = "${escapedFolderPath}"`;
637
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.search, {
638
+ params: { query, maxResults: max_results },
639
+ });
469
640
  let testCases = [];
470
641
  if (Array.isArray(response.data)) {
471
642
  testCases = response.data;
@@ -477,58 +648,66 @@ export class ZephyrToolHandlers {
477
648
  testCases = response.data.results;
478
649
  }
479
650
  return {
480
- content: [
481
- {
651
+ content: [{
482
652
  type: 'text',
483
653
  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
654
+ folder: folder_path, query, testCaseKeys: testCases.map((tc) => tc.key), totalCount: testCases.length,
488
655
  }, null, 2)}`,
489
- },
490
- ],
656
+ }],
491
657
  };
492
658
  }
493
659
  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}`);
660
+ throw new McpError(ErrorCode.InternalError, `Failed to search test cases by folder: ${this.formatError(error)}`);
511
661
  }
512
662
  }
513
663
  async searchTestRuns(args) {
514
664
  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) {
665
+ if (!project_key && !folder) {
522
666
  throw new McpError(ErrorCode.InvalidParams, 'At least one of project_key or folder must be provided.');
523
667
  }
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';
668
+ if (this.jiraConfig.type === 'cloud') {
669
+ try {
670
+ // Cloud v2: GET /testcycles?projectKey=X&folderId=Y
671
+ const params = { maxResults: max_results };
672
+ if (project_key)
673
+ params.projectKey = project_key;
674
+ if (folder && project_key) {
675
+ const folderId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder, 'TEST_CYCLE');
676
+ if (folderId !== null)
677
+ params.folderId = folderId;
678
+ }
679
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testrun, { params });
680
+ const testRuns = Array.isArray(response.data)
681
+ ? response.data
682
+ : response.data?.values ?? [];
683
+ return {
684
+ content: [{
685
+ type: 'text',
686
+ text: `✅ Found ${testRuns.length} test run(s):\n${JSON.stringify({
687
+ totalCount: testRuns.length,
688
+ testRuns: testRuns.map((tr) => ({
689
+ key: tr.key, name: tr.name, status: tr.status?.name, folder: tr.folder?.name,
690
+ })),
691
+ }, null, 2)}`,
692
+ }],
693
+ };
694
+ }
695
+ catch (error) {
696
+ throw new McpError(ErrorCode.InternalError, `Failed to search test runs: ${this.formatError(error)}`);
697
+ }
698
+ }
699
+ // Data Center: query-based search
531
700
  try {
701
+ const queryParts = [];
702
+ if (project_key)
703
+ queryParts.push(`projectKey = "${project_key}"`);
704
+ if (folder)
705
+ queryParts.push(`folder = "${folder}"`);
706
+ const query = queryParts.join(' AND ');
707
+ const searchEndpoint = '/rest/atm/1.0/testrun/search';
708
+ const params = { query, maxResults: max_results };
709
+ if (fields)
710
+ params.fields = fields;
532
711
  const response = await this.axiosInstance.get(searchEndpoint, { params });
533
712
  let testRuns = [];
534
713
  if (Array.isArray(response.data)) {
@@ -541,62 +720,51 @@ export class ZephyrToolHandlers {
541
720
  testRuns = response.data.results;
542
721
  }
543
722
  return {
544
- content: [
545
- {
723
+ content: [{
546
724
  type: 'text',
547
725
  text: `✅ Found ${testRuns.length} test run(s) matching query "${query}":\n${JSON.stringify({
548
- query,
549
- totalCount: testRuns.length,
726
+ query, totalCount: testRuns.length,
550
727
  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
- }))
728
+ key: tr.key, name: tr.name, status: tr.status, folder: tr.folder,
729
+ testCaseCount: tr.testCaseCount, issueKey: tr.issueKey,
730
+ })),
558
731
  }, null, 2)}`,
559
- },
560
- ],
732
+ }],
561
733
  };
562
734
  }
563
735
  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}`);
736
+ throw new McpError(ErrorCode.InternalError, `Failed to search test runs: ${this.formatError(error)}`);
576
737
  }
577
738
  }
578
739
  async addTestCasesToRun(args) {
579
- const { test_run_key, test_case_keys } = args;
580
740
  if (this.jiraConfig.type === 'datacenter') {
581
741
  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
742
  }
743
+ const { test_run_key, test_case_keys } = args;
744
+ // Derive project key from the test run key (e.g. PROJ-R123 → PROJ)
745
+ const project_key = test_run_key.split('-')[0];
583
746
  try {
584
- const payload = {
585
- items: test_case_keys.map(key => ({ testCaseKey: key }))
586
- };
587
- const response = await this.axiosInstance.post(`/testcycles/${test_run_key}/testcases`, payload);
588
- if (response.status === 200 || response.status === 201 || response.status === 204) {
589
- return {
590
- content: [{ type: 'text', text: `Added ${test_case_keys.length} test case(s) to test run ${test_run_key}.` }],
591
- };
747
+ for (const testCaseKey of test_case_keys) {
748
+ await this.axiosInstance.post('/testexecutions', {
749
+ projectKey: project_key,
750
+ testCaseKey,
751
+ testCycleKey: test_run_key,
752
+ statusName: 'Not Executed',
753
+ });
592
754
  }
755
+ return {
756
+ content: [{ type: 'text', text: `Added ${test_case_keys.length} test case(s) to test run ${test_run_key}.` }],
757
+ };
593
758
  }
594
759
  catch (error) {
595
- throw new McpError(ErrorCode.InternalError, `Failed to add test cases: ${error instanceof Error ? error.message : String(error)}`);
760
+ throw new McpError(ErrorCode.InternalError, `Failed to add test cases: ${this.formatError(error)}`);
596
761
  }
597
- return {
598
- content: [{ type: 'text', text: 'An unexpected error occurred.' }],
599
- isError: true,
600
- };
762
+ }
763
+ formatError(error) {
764
+ if (error instanceof Error && 'response' in error) {
765
+ const axiosError = error;
766
+ return `Status: ${axiosError.response?.status}, Data: ${JSON.stringify(axiosError.response?.data)}`;
767
+ }
768
+ return error instanceof Error ? error.message : String(error);
601
769
  }
602
770
  }