zephyr-scale-mcp-server 0.5.1 → 0.6.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/build/index.js CHANGED
@@ -55,6 +55,8 @@ class ZephyrServer {
55
55
  return await this.toolHandlers.getTestRunCases(args);
56
56
  case 'delete_test_case':
57
57
  return await this.toolHandlers.deleteTestCase(args);
58
+ case 'update_test_run':
59
+ return await this.toolHandlers.updateTestRun(args);
58
60
  case 'delete_test_run':
59
61
  return await this.toolHandlers.deleteTestRun(args);
60
62
  case 'create_test_run':
@@ -1,6 +1,6 @@
1
1
  import axios from 'axios';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
- import { convertToGherkin, resolveFolderIdByPath } from './utils.js';
3
+ import { convertToGherkin, resolveFolderIdByPath, getAccountIdFromApiKey } from './utils.js';
4
4
  export class ZephyrToolHandlers {
5
5
  axiosInstance;
6
6
  jiraConfig;
@@ -61,9 +61,10 @@ export class ZephyrToolHandlers {
61
61
  payload.labels = labels;
62
62
  if (custom_fields)
63
63
  payload.customFields = custom_fields;
64
- // Cloud v2 uses ownerId (Jira Account ID) and componentId (integer)
65
- if (owner_id)
66
- payload.ownerId = owner_id;
64
+ // Default ownerId to the account ID embedded in the Zephyr API key JWT
65
+ const resolvedOwner = owner_id ?? getAccountIdFromApiKey();
66
+ if (resolvedOwner)
67
+ payload.ownerId = resolvedOwner;
67
68
  if (component_id)
68
69
  payload.componentId = component_id;
69
70
  // Resolve folder path → folderId
@@ -500,6 +501,69 @@ export class ZephyrToolHandlers {
500
501
  throw new McpError(ErrorCode.InternalError, `Failed to get test run cases: ${this.formatError(error)}`);
501
502
  }
502
503
  }
504
+ async updateTestRun(args) {
505
+ const { test_run_key, owner, name, description, planned_start_date, planned_end_date, status_id } = args;
506
+ if (this.jiraConfig.type !== 'cloud') {
507
+ throw new McpError(ErrorCode.InvalidRequest, 'update_test_run is only supported on Zephyr Scale Cloud.');
508
+ }
509
+ try {
510
+ // Fetch current cycle to preserve required fields
511
+ const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
512
+ const current = getResponse.data;
513
+ const payload = {
514
+ id: current.id,
515
+ key: test_run_key,
516
+ name: name ?? current.name,
517
+ project: current.project,
518
+ status: status_id ? { id: status_id } : current.status,
519
+ };
520
+ if (description !== undefined)
521
+ payload.description = description;
522
+ else if (current.description)
523
+ payload.description = current.description;
524
+ if (planned_start_date !== undefined)
525
+ payload.plannedStartDate = planned_start_date;
526
+ else if (current.plannedStartDate)
527
+ payload.plannedStartDate = current.plannedStartDate;
528
+ if (planned_end_date !== undefined)
529
+ payload.plannedEndDate = planned_end_date;
530
+ else if (current.plannedEndDate)
531
+ payload.plannedEndDate = current.plannedEndDate;
532
+ if (owner !== undefined)
533
+ payload.owner = { accountId: owner };
534
+ else if (current.owner)
535
+ payload.owner = current.owner;
536
+ if (current.jiraProjectVersion)
537
+ payload.jiraProjectVersion = current.jiraProjectVersion;
538
+ if (current.folder)
539
+ payload.folder = current.folder;
540
+ if (current.customFields && Object.keys(current.customFields).length > 0) {
541
+ payload.customFields = current.customFields;
542
+ }
543
+ await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`, payload);
544
+ // Resolve status name for the response
545
+ const projectKey = test_run_key.replace(/-R\d+$/, '');
546
+ const statusName = payload.status?.id
547
+ ? await this.resolveStatusName(payload.status.id)
548
+ : null;
549
+ return {
550
+ content: [{
551
+ type: 'text',
552
+ text: `✅ Updated test cycle ${test_run_key} successfully.\n${JSON.stringify({
553
+ key: test_run_key,
554
+ name: payload.name,
555
+ owner: payload.owner ?? null,
556
+ status: statusName ? { id: payload.status?.id, name: statusName } : payload.status,
557
+ }, null, 2)}`,
558
+ }],
559
+ };
560
+ }
561
+ catch (error) {
562
+ if (error instanceof McpError)
563
+ throw error;
564
+ throw new McpError(ErrorCode.InternalError, `Failed to update test run: ${this.formatError(error)}`);
565
+ }
566
+ }
503
567
  async deleteTestCase(args) {
504
568
  if (this.jiraConfig.type === 'cloud') {
505
569
  throw new McpError(ErrorCode.InvalidRequest, 'delete_test_case is not supported by the Zephyr Scale Cloud v2 API.');
@@ -558,9 +622,10 @@ export class ZephyrToolHandlers {
558
622
  payload.plannedEndDate = planned_end_date;
559
623
  if (custom_fields)
560
624
  payload.customFields = custom_fields;
561
- // Cloud v2 TestCycleInput supports ownerId (Jira Account ID)
562
- if (owner)
563
- payload.ownerId = owner;
625
+ // Default ownerId to the account ID embedded in the Zephyr API key JWT
626
+ const resolvedOwner = owner ?? getAccountIdFromApiKey();
627
+ if (resolvedOwner)
628
+ payload.ownerId = resolvedOwner;
564
629
  // Link to a Jira project version/release (integer ID)
565
630
  if (jira_project_version)
566
631
  payload.jiraProjectVersion = jira_project_version;
@@ -678,14 +743,28 @@ export class ZephyrToolHandlers {
678
743
  throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
679
744
  }
680
745
  }
746
+ async resolveStatusName(statusId) {
747
+ try {
748
+ const response = await this.axiosInstance.get(`/statuses/${statusId}`);
749
+ return response.data?.name ?? null;
750
+ }
751
+ catch {
752
+ return null;
753
+ }
754
+ }
681
755
  async getTestRun(args) {
682
756
  const { test_run_key } = args;
683
- // Both Cloud (/testcycles/{key}) and DC (/rest/atm/1.0/testrun/{key}) handled
684
- // via apiEndpoints.testrun which now correctly maps to /testcycles for Cloud
685
757
  try {
686
758
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
759
+ const data = response.data;
760
+ // Resolve status name — extract project key from the cycle key (e.g. DDCN-R377 → DDCN)
761
+ if (data?.status?.id) {
762
+ const statusName = await this.resolveStatusName(data.status.id);
763
+ if (statusName)
764
+ data.status.name = statusName;
765
+ }
687
766
  return {
688
- content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }],
767
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
689
768
  };
690
769
  }
691
770
  catch (error) {
@@ -402,6 +402,44 @@ export const toolSchemas = [
402
402
  required: ['test_cycle_key', 'project_key'],
403
403
  },
404
404
  },
405
+ {
406
+ name: 'update_test_run',
407
+ description: 'Update an existing test cycle — set owner, name, description, dates, or status. Unspecified fields are preserved. Cloud only.',
408
+ inputSchema: {
409
+ type: 'object',
410
+ properties: {
411
+ test_run_key: {
412
+ type: 'string',
413
+ description: 'Test cycle key to update (e.g., PROJ-R123)',
414
+ },
415
+ owner: {
416
+ type: 'string',
417
+ description: 'Jira Account ID of the new owner (e.g., "6269ee89494f17007056d8f0")',
418
+ },
419
+ name: {
420
+ type: 'string',
421
+ description: 'New name for the test cycle (optional)',
422
+ },
423
+ description: {
424
+ type: 'string',
425
+ description: 'New description (optional)',
426
+ },
427
+ planned_start_date: {
428
+ type: 'string',
429
+ description: 'Planned start date in ISO format (optional)',
430
+ },
431
+ planned_end_date: {
432
+ type: 'string',
433
+ description: 'Planned end date in ISO format (optional)',
434
+ },
435
+ status_id: {
436
+ type: 'number',
437
+ description: 'Numeric status ID to set (optional)',
438
+ },
439
+ },
440
+ required: ['test_run_key'],
441
+ },
442
+ },
405
443
  {
406
444
  name: 'delete_test_run',
407
445
  description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
package/build/utils.js CHANGED
@@ -83,6 +83,29 @@ export const priorityMapping = {
83
83
  'Medium': 'High',
84
84
  'Low': 'High'
85
85
  };
86
+ /**
87
+ * Decodes the Atlassian Account ID from the Zephyr JWT API key.
88
+ * The JWT payload contains context.user.accountId — no extra API call needed.
89
+ * Returns null if the token is missing or malformed.
90
+ */
91
+ export function getAccountIdFromApiKey(apiKey) {
92
+ try {
93
+ const token = apiKey ?? process.env.ZEPHYR_API_KEY;
94
+ if (!token)
95
+ return null;
96
+ const parts = token.split('.');
97
+ if (parts.length < 2)
98
+ return null;
99
+ // Base64url decode the payload (add padding as needed)
100
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
101
+ const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
102
+ const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
103
+ return decoded?.context?.user?.accountId ?? null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
86
109
  /**
87
110
  * Detects whether the Jira instance is Cloud or Data Center based on the base URL.
88
111
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zephyr-scale-mcp-server",
3
- "version": "0.5.1",
3
+ "version": "0.6.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
@@ -75,6 +75,8 @@ class ZephyrServer {
75
75
  return await this.toolHandlers.getTestRunCases(args);
76
76
  case 'delete_test_case':
77
77
  return await this.toolHandlers.deleteTestCase(args);
78
+ case 'update_test_run':
79
+ return await this.toolHandlers.updateTestRun(args);
78
80
  case 'delete_test_run':
79
81
  return await this.toolHandlers.deleteTestRun(args);
80
82
  case 'create_test_run':
@@ -13,7 +13,7 @@ import {
13
13
  ListExecutionsByCycleArgs,
14
14
  JiraConfig
15
15
  } from './types.js';
16
- import { convertToGherkin, resolveFolderIdByPath } from './utils.js';
16
+ import { convertToGherkin, resolveFolderIdByPath, getAccountIdFromApiKey } from './utils.js';
17
17
 
18
18
  export class ZephyrToolHandlers {
19
19
  constructor(
@@ -74,8 +74,9 @@ export class ZephyrToolHandlers {
74
74
  if (estimated_time) payload.estimatedTime = estimated_time;
75
75
  if (labels && labels.length > 0) payload.labels = labels;
76
76
  if (custom_fields) payload.customFields = custom_fields;
77
- // Cloud v2 uses ownerId (Jira Account ID) and componentId (integer)
78
- if (owner_id) payload.ownerId = owner_id;
77
+ // Default ownerId to the account ID embedded in the Zephyr API key JWT
78
+ const resolvedOwner = owner_id ?? getAccountIdFromApiKey();
79
+ if (resolvedOwner) payload.ownerId = resolvedOwner;
79
80
  if (component_id) payload.componentId = component_id;
80
81
 
81
82
  // Resolve folder path → folderId
@@ -544,6 +545,69 @@ export class ZephyrToolHandlers {
544
545
  }
545
546
  }
546
547
 
548
+ async updateTestRun(args: any) {
549
+ const { test_run_key, owner, name, description, planned_start_date, planned_end_date, status_id } = args;
550
+
551
+ if (this.jiraConfig.type !== 'cloud') {
552
+ throw new McpError(ErrorCode.InvalidRequest, 'update_test_run is only supported on Zephyr Scale Cloud.');
553
+ }
554
+
555
+ try {
556
+ // Fetch current cycle to preserve required fields
557
+ const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
558
+ const current = getResponse.data;
559
+
560
+ const payload: any = {
561
+ id: current.id,
562
+ key: test_run_key,
563
+ name: name ?? current.name,
564
+ project: current.project,
565
+ status: status_id ? { id: status_id } : current.status,
566
+ };
567
+
568
+ if (description !== undefined) payload.description = description;
569
+ else if (current.description) payload.description = current.description;
570
+
571
+ if (planned_start_date !== undefined) payload.plannedStartDate = planned_start_date;
572
+ else if (current.plannedStartDate) payload.plannedStartDate = current.plannedStartDate;
573
+
574
+ if (planned_end_date !== undefined) payload.plannedEndDate = planned_end_date;
575
+ else if (current.plannedEndDate) payload.plannedEndDate = current.plannedEndDate;
576
+
577
+ if (owner !== undefined) payload.owner = { accountId: owner };
578
+ else if (current.owner) payload.owner = current.owner;
579
+
580
+ if (current.jiraProjectVersion) payload.jiraProjectVersion = current.jiraProjectVersion;
581
+ if (current.folder) payload.folder = current.folder;
582
+ if (current.customFields && Object.keys(current.customFields).length > 0) {
583
+ payload.customFields = current.customFields;
584
+ }
585
+
586
+ await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`, payload);
587
+
588
+ // Resolve status name for the response
589
+ const projectKey = test_run_key.replace(/-R\d+$/, '');
590
+ const statusName = payload.status?.id
591
+ ? await this.resolveStatusName(payload.status.id)
592
+ : null;
593
+
594
+ return {
595
+ content: [{
596
+ type: 'text',
597
+ text: `✅ Updated test cycle ${test_run_key} successfully.\n${JSON.stringify({
598
+ key: test_run_key,
599
+ name: payload.name,
600
+ owner: payload.owner ?? null,
601
+ status: statusName ? { id: payload.status?.id, name: statusName } : payload.status,
602
+ }, null, 2)}`,
603
+ }],
604
+ };
605
+ } catch (error) {
606
+ if (error instanceof McpError) throw error;
607
+ throw new McpError(ErrorCode.InternalError, `Failed to update test run: ${this.formatError(error)}`);
608
+ }
609
+ }
610
+
547
611
  async deleteTestCase(args: any) {
548
612
  if (this.jiraConfig.type === 'cloud') {
549
613
  throw new McpError(
@@ -614,8 +678,9 @@ export class ZephyrToolHandlers {
614
678
  if (planned_start_date) payload.plannedStartDate = planned_start_date;
615
679
  if (planned_end_date) payload.plannedEndDate = planned_end_date;
616
680
  if (custom_fields) payload.customFields = custom_fields;
617
- // Cloud v2 TestCycleInput supports ownerId (Jira Account ID)
618
- if (owner) payload.ownerId = owner;
681
+ // Default ownerId to the account ID embedded in the Zephyr API key JWT
682
+ const resolvedOwner = owner ?? getAccountIdFromApiKey();
683
+ if (resolvedOwner) payload.ownerId = resolvedOwner;
619
684
  // Link to a Jira project version/release (integer ID)
620
685
  if (jira_project_version) payload.jiraProjectVersion = jira_project_version;
621
686
  if (folder) {
@@ -735,14 +800,29 @@ export class ZephyrToolHandlers {
735
800
  }
736
801
  }
737
802
 
803
+ private async resolveStatusName(statusId: number): Promise<string | null> {
804
+ try {
805
+ const response = await this.axiosInstance.get(`/statuses/${statusId}`);
806
+ return response.data?.name ?? null;
807
+ } catch {
808
+ return null;
809
+ }
810
+ }
811
+
738
812
  async getTestRun(args: any) {
739
813
  const { test_run_key } = args;
740
- // Both Cloud (/testcycles/{key}) and DC (/rest/atm/1.0/testrun/{key}) handled
741
- // via apiEndpoints.testrun which now correctly maps to /testcycles for Cloud
742
814
  try {
743
815
  const response = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testrun}/${test_run_key}`);
816
+ const data = response.data;
817
+
818
+ // Resolve status name — extract project key from the cycle key (e.g. DDCN-R377 → DDCN)
819
+ if (data?.status?.id) {
820
+ const statusName = await this.resolveStatusName(data.status.id);
821
+ if (statusName) data.status.name = statusName;
822
+ }
823
+
744
824
  return {
745
- content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }],
825
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
746
826
  };
747
827
  } catch (error) {
748
828
  throw new McpError(ErrorCode.InternalError, `Failed to get test run: ${this.formatError(error)}`);
@@ -402,6 +402,44 @@ export const toolSchemas = [
402
402
  required: ['test_cycle_key', 'project_key'],
403
403
  },
404
404
  },
405
+ {
406
+ name: 'update_test_run',
407
+ description: 'Update an existing test cycle — set owner, name, description, dates, or status. Unspecified fields are preserved. Cloud only.',
408
+ inputSchema: {
409
+ type: 'object',
410
+ properties: {
411
+ test_run_key: {
412
+ type: 'string',
413
+ description: 'Test cycle key to update (e.g., PROJ-R123)',
414
+ },
415
+ owner: {
416
+ type: 'string',
417
+ description: 'Jira Account ID of the new owner (e.g., "6269ee89494f17007056d8f0")',
418
+ },
419
+ name: {
420
+ type: 'string',
421
+ description: 'New name for the test cycle (optional)',
422
+ },
423
+ description: {
424
+ type: 'string',
425
+ description: 'New description (optional)',
426
+ },
427
+ planned_start_date: {
428
+ type: 'string',
429
+ description: 'Planned start date in ISO format (optional)',
430
+ },
431
+ planned_end_date: {
432
+ type: 'string',
433
+ description: 'Planned end date in ISO format (optional)',
434
+ },
435
+ status_id: {
436
+ type: 'number',
437
+ description: 'Numeric status ID to set (optional)',
438
+ },
439
+ },
440
+ required: ['test_run_key'],
441
+ },
442
+ },
405
443
  {
406
444
  name: 'delete_test_run',
407
445
  description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
package/src/utils.ts CHANGED
@@ -102,6 +102,27 @@ export const priorityMapping: { [key: string]: string } = {
102
102
  'Low': 'High'
103
103
  };
104
104
 
105
+ /**
106
+ * Decodes the Atlassian Account ID from the Zephyr JWT API key.
107
+ * The JWT payload contains context.user.accountId — no extra API call needed.
108
+ * Returns null if the token is missing or malformed.
109
+ */
110
+ export function getAccountIdFromApiKey(apiKey?: string): string | null {
111
+ try {
112
+ const token = apiKey ?? process.env.ZEPHYR_API_KEY;
113
+ if (!token) return null;
114
+ const parts = token.split('.');
115
+ if (parts.length < 2) return null;
116
+ // Base64url decode the payload (add padding as needed)
117
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
118
+ const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
119
+ const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
120
+ return decoded?.context?.user?.accountId ?? null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
105
126
  /**
106
127
  * Detects whether the Jira instance is Cloud or Data Center based on the base URL.
107
128
  */