@jira-deploy/mcp 1.0.16 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,12 @@ npm install -g @jira-deploy/mcp
29
29
  npx -y @jira-deploy/mcp
30
30
  ```
31
31
 
32
+ 如果要使用 JSON config,可透過 `--config` 明確傳入:
33
+
34
+ ```bash
35
+ npx -y @jira-deploy/mcp --config ~/.ares/config.json
36
+ ```
37
+
32
38
  MCP app 維持走 npm / npx 發布與安裝,不提供 standalone binary 安裝。
33
39
 
34
40
  ### 本地開發 / 從 monorepo 啟動
@@ -53,23 +59,17 @@ cp .env.example .env
53
59
  "servers": {
54
60
  "jira-deploy": {
55
61
  "command": "npx",
56
- "args": ["-y", "@jira-deploy/mcp"],
62
+ "args": ["-y", "@jira-deploy/mcp", "--config", "/path/to/config.json"],
57
63
  "env": {
58
64
  "JIRA_BASE_URL": "https://your-org.atlassian.net",
59
- "JIRA_API_TOKEN": "your_jira_api_token",
60
- "BITBUCKET_URL": "https://bitbucket.example.com",
61
- "BITBUCKET_API_TOKEN": "your_bitbucket_api_token",
62
- "CONF_BASE_URL": "https://confluence.example.com",
63
- "CONF_TOKEN": "your_confluence_token",
64
- "JIRA_DEPLOY_CONFIG_PATH": "/path/to/jira-deploy-config.json"
65
+ "JIRA_API_TOKEN": "your_jira_api_token"
65
66
  }
66
67
  }
67
68
  }
68
69
  }
69
70
  ```
70
71
 
71
- 建議把 Jira 的機密設定放在系統環境變數或 `.env` 檔,
72
- 不要 commit 到 git(`.env` 已在 .gitignore)。
72
+ 建議把 Jira、Bitbucket、Confluence、Jabber 的機密設定放在 JSON config、系統環境變數或 `.env` 檔,不要 commit 到 git(`.env` 已在 .gitignore)。`--config` 的值會優先於 shell env;未使用 `--config` 時,MCP 仍可讀取既有 env。
73
73
 
74
74
  ## 使用者端使用方式
75
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jira-deploy/mcp",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "MCP Server for Jira deploy ticket workflow",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -27,7 +27,7 @@
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.0.0",
29
29
  "dotenv": "^16.3.0",
30
- "@jira-deploy/core": "1.0.16"
30
+ "@jira-deploy/core": "1.0.17"
31
31
  },
32
32
  "scripts": {
33
33
  "start": "node src/index.js",
package/src/env.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {config} from 'dotenv';
2
+ import {readFileSync} from 'node:fs';
2
3
 
3
4
  export function loadMcpEnv({
4
5
  packageEnvPath,
@@ -9,3 +10,105 @@ export function loadMcpEnv({
9
10
  configImpl({path: packageEnvPath});
10
11
  }
11
12
  }
13
+
14
+ const CONFIG_ENV_MAP = [
15
+ ['jira.baseUrl', 'JIRA_BASE_URL'],
16
+ ['jira.apiToken', 'JIRA_API_TOKEN'],
17
+ ['jira.deployConfigPath', 'JIRA_DEPLOY_CONFIG_PATH'],
18
+ ['jira.projectKey', 'JIRA_PROJECT_KEY'],
19
+ ['jira.releaseProjectKey', 'JIRA_RELEASE_PROJECT_KEY'],
20
+ ['bitbucket.url', 'BITBUCKET_URL'],
21
+ ['bitbucket.baseUrl', 'BITBUCKET_BASE_URL'],
22
+ ['bitbucket.apiToken', 'BITBUCKET_API_TOKEN'],
23
+ ['confluence.baseUrl', 'CONF_BASE_URL'],
24
+ ['confluence.token', 'CONF_TOKEN'],
25
+ ['poll.intervalMs', 'POLL_INTERVAL_MS'],
26
+ ['poll.timeoutMs', 'POLL_TIMEOUT_MS'],
27
+ ['jabber.notifyScript', 'JABBER_NOTIFY_SCRIPT'],
28
+ ['jabber.server', 'JABBER_SERVER'],
29
+ ['jabber.user', 'JABBER_USER'],
30
+ ['jabber.domain', 'JABBER_DOMAIN'],
31
+ ['jabber.keychainService', 'JABBER_KEYCHAIN_SERVICE'],
32
+ ['jabber.keychainAccount', 'JABBER_KEYCHAIN_ACCOUNT'],
33
+ ['jabber.to', 'JABBER_TO'],
34
+ ['jabber.room', 'JABBER_ROOM'],
35
+ ['jabber.port', 'JABBER_PORT'],
36
+ ['jabber.resource', 'JABBER_RESOURCE'],
37
+ ['jabber.nick', 'JABBER_NICK'],
38
+ ];
39
+
40
+ function isPlainObject(value) {
41
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+
44
+ function getPathValue(object, path) {
45
+ return path.split('.').reduce((current, segment) => (
46
+ isPlainObject(current) && Object.hasOwn(current, segment) ? current[segment] : undefined
47
+ ), object);
48
+ }
49
+
50
+ function hasConfigValue(value) {
51
+ return value !== undefined && value !== null && value !== '';
52
+ }
53
+
54
+ function stringifyConfigValue(value) {
55
+ if (typeof value === 'boolean') {
56
+ return value ? 'true' : 'false';
57
+ }
58
+ return String(value);
59
+ }
60
+
61
+ export function parseMcpArgs(args = process.argv.slice(2)) {
62
+ const remainingArgs = [];
63
+ let configPath = null;
64
+ for (let index = 0; index < args.length; index += 1) {
65
+ const arg = args[index];
66
+ if (arg === '--config') {
67
+ configPath = args[index + 1] ?? null;
68
+ index += 1;
69
+ continue;
70
+ }
71
+ remainingArgs.push(arg);
72
+ }
73
+ return {configPath, remainingArgs};
74
+ }
75
+
76
+ export function mcpJsonConfigToCoreEnv(configValue) {
77
+ const nextEnv = {};
78
+ if (!isPlainObject(configValue)) {
79
+ return nextEnv;
80
+ }
81
+
82
+ for (const [configPath, envKey] of CONFIG_ENV_MAP) {
83
+ const value = getPathValue(configValue, configPath);
84
+ if (hasConfigValue(value)) {
85
+ nextEnv[envKey] = stringifyConfigValue(value);
86
+ }
87
+ }
88
+
89
+ if (typeof configValue.dryRun === 'boolean') {
90
+ nextEnv.DRY_RUN = stringifyConfigValue(configValue.dryRun);
91
+ }
92
+
93
+ if (isPlainObject(configValue.env)) {
94
+ for (const [key, value] of Object.entries(configValue.env)) {
95
+ if (hasConfigValue(value)) {
96
+ nextEnv[key] = stringifyConfigValue(value);
97
+ }
98
+ }
99
+ }
100
+
101
+ return nextEnv;
102
+ }
103
+
104
+ export function loadMcpRuntimeConfig({
105
+ configPath,
106
+ readConfig = (path) => JSON.parse(readFileSync(path, 'utf8')),
107
+ } = {}) {
108
+ if (!configPath) {
109
+ return {};
110
+ }
111
+ return {
112
+ env: mcpJsonConfigToCoreEnv(readConfig(configPath)),
113
+ };
114
+ }
package/src/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {fileURLToPath} from 'url';
3
3
  import {dirname, join} from 'path';
4
- import {loadMcpEnv} from './env.js';
4
+ import {loadMcpEnv, loadMcpRuntimeConfig, parseMcpArgs} from './env.js';
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  loadMcpEnv({packageEnvPath: join(__dirname, '..', '.env')});
8
+ const {configPath} = parseMcpArgs();
9
+ const {configureRuntimeConfig} = await import('@jira-deploy/core/runtime-config');
10
+ configureRuntimeConfig(loadMcpRuntimeConfig({configPath}));
8
11
 
9
12
  const {StdioServerTransport} = await import('@modelcontextprotocol/sdk/server/stdio.js');
10
13
  const {createMcpServer} = await import('./server.js');
@@ -1,7 +1,10 @@
1
1
  import http from 'http';
2
2
  import https from 'https';
3
+ import {getRuntimeConfigValue} from '@jira-deploy/core/runtime-config';
3
4
 
4
- const DRY_RUN = process.env.DRY_RUN === 'true';
5
+ function isDryRun() {
6
+ return getRuntimeConfigValue('DRY_RUN') === 'true';
7
+ }
5
8
 
6
9
  function ok(data) {
7
10
  return {content: [{type: 'text', text: JSON.stringify(data, null, 2)}]};
@@ -12,9 +15,9 @@ function text(data) {
12
15
  }
13
16
 
14
17
  function requiredEnv(names, label) {
15
- const values = Object.fromEntries(names.map((name) => [name, process.env[name]]));
18
+ const values = Object.fromEntries(names.map((name) => [name, getRuntimeConfigValue(name)]));
16
19
  const missing = names.filter((name) => !values[name]);
17
- if (missing.length > 0 && !DRY_RUN) {
20
+ if (missing.length > 0 && !isDryRun()) {
18
21
  throw new Error(`${label} requires env vars: ${missing.join(', ')}`);
19
22
  }
20
23
  return values;
@@ -109,18 +112,18 @@ function requestText(baseUrl, path, {token, timeoutMs = 15000} = {}) {
109
112
  }
110
113
 
111
114
  function jiraConfig() {
112
- const baseUrl = process.env.JIRA_BASE_URL;
113
- const token = process.env.JIRA_API_TOKEN ?? process.env.JIRA_TOKEN;
114
- if (!DRY_RUN && (!baseUrl || !token)) {
115
+ const baseUrl = getRuntimeConfigValue('JIRA_BASE_URL');
116
+ const token = getRuntimeConfigValue('JIRA_API_TOKEN') ?? getRuntimeConfigValue('JIRA_TOKEN');
117
+ if (!isDryRun() && (!baseUrl || !token)) {
115
118
  throw new Error('Jira utility tools require JIRA_BASE_URL and JIRA_API_TOKEN');
116
119
  }
117
120
  return {baseUrl, token};
118
121
  }
119
122
 
120
123
  function bitbucketConfig() {
121
- const baseUrl = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
122
- const token = process.env.BITBUCKET_API_TOKEN ?? process.env.BITBUCKET_TOKEN;
123
- if (!DRY_RUN && (!baseUrl || !token)) {
124
+ const baseUrl = getRuntimeConfigValue('BITBUCKET_URL') ?? getRuntimeConfigValue('BITBUCKET_BASE_URL');
125
+ const token = getRuntimeConfigValue('BITBUCKET_API_TOKEN') ?? getRuntimeConfigValue('BITBUCKET_TOKEN');
126
+ if (!isDryRun() && (!baseUrl || !token)) {
124
127
  throw new Error('Bitbucket utility tools require BITBUCKET_URL and BITBUCKET_API_TOKEN');
125
128
  }
126
129
  return {baseUrl, token};
@@ -128,7 +131,7 @@ function bitbucketConfig() {
128
131
 
129
132
  function confluenceConfig() {
130
133
  requiredEnv(['CONF_BASE_URL', 'CONF_TOKEN'], 'Confluence utility tools');
131
- return {baseUrl: process.env.CONF_BASE_URL, token: process.env.CONF_TOKEN};
134
+ return {baseUrl: getRuntimeConfigValue('CONF_BASE_URL'), token: getRuntimeConfigValue('CONF_TOKEN')};
132
135
  }
133
136
 
134
137
  function stripHtml(html) {
@@ -459,7 +462,7 @@ export async function executeUtilityTool(name, args = {}) {
459
462
  }
460
463
 
461
464
  async function jiraGetIssue({issueKey}) {
462
- if (DRY_RUN) {
465
+ if (isDryRun()) {
463
466
  return ok({key: issueKey, fields: {summary: '[DRY] Mock issue', status: {name: 'To Do'}}});
464
467
  }
465
468
  const {baseUrl, token} = jiraConfig();
@@ -496,7 +499,7 @@ async function jiraGetIssue({issueKey}) {
496
499
  }
497
500
 
498
501
  async function jiraSearchIssues({jql, maxResults = 20}) {
499
- if (DRY_RUN) return ok({total: 0, issues: []});
502
+ if (isDryRun()) return ok({total: 0, issues: []});
500
503
  const {baseUrl, token} = jiraConfig();
501
504
  const data = await requestJson(
502
505
  baseUrl,
@@ -515,7 +518,7 @@ async function jiraSearchIssues({jql, maxResults = 20}) {
515
518
  }
516
519
 
517
520
  async function bitbucketListPrs({projectKey, repoSlug, state = 'OPEN', limit = 25}) {
518
- if (DRY_RUN) return ok({total: 0, prs: []});
521
+ if (isDryRun()) return ok({total: 0, prs: []});
519
522
  const {baseUrl, token} = bitbucketConfig();
520
523
  const data = await requestJson(
521
524
  baseUrl,
@@ -535,7 +538,7 @@ async function bitbucketListPrs({projectKey, repoSlug, state = 'OPEN', limit = 2
535
538
  }
536
539
 
537
540
  async function bitbucketGetPr({projectKey, repoSlug, prId}) {
538
- if (DRY_RUN) return ok({id: prId, title: '[DRY] Mock PR', state: 'OPEN'});
541
+ if (isDryRun()) return ok({id: prId, title: '[DRY] Mock PR', state: 'OPEN'});
539
542
  const {baseUrl, token} = bitbucketConfig();
540
543
  const [pr, activities] = await Promise.all([
541
544
  requestJson(baseUrl, `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`, {token}),
@@ -568,7 +571,7 @@ async function bitbucketGetPr({projectKey, repoSlug, prId}) {
568
571
  }
569
572
 
570
573
  async function bitbucketGetPrChanges({projectKey, repoSlug, prId}) {
571
- if (DRY_RUN) return ok({total: 0, files: []});
574
+ if (isDryRun()) return ok({total: 0, files: []});
572
575
  const {baseUrl, token} = bitbucketConfig();
573
576
  const data = await requestJson(
574
577
  baseUrl,
@@ -591,7 +594,7 @@ async function bitbucketGetPrDiff({
591
594
  contextLines = 3,
592
595
  excludePatterns = ['__snapshots__', 'messages_en.json', 'messages_ko.json'],
593
596
  }) {
594
- if (DRY_RUN) return ok({diffs: []});
597
+ if (isDryRun()) return ok({diffs: []});
595
598
  const {baseUrl, token} = bitbucketConfig();
596
599
  const pathParam = path ? `&path=${encodeURIComponent(path)}` : '';
597
600
  const data = await requestJson(
@@ -634,7 +637,7 @@ async function bitbucketGetPrDiff({
634
637
  }
635
638
 
636
639
  async function bitbucketGetPrComments({projectKey, repoSlug, prId}) {
637
- if (DRY_RUN) return ok({total: 0, comments: []});
640
+ if (isDryRun()) return ok({total: 0, comments: []});
638
641
  const {baseUrl, token} = bitbucketConfig();
639
642
  const data = await requestJson(
640
643
  baseUrl,
@@ -662,7 +665,7 @@ async function bitbucketGetPrComments({projectKey, repoSlug, prId}) {
662
665
 
663
666
  async function bitbucketAddPrComment({projectKey, repoSlug, prId, text: commentText, parentId}) {
664
667
  const {baseUrl, token} = bitbucketConfig();
665
- if (DRY_RUN) return ok({success: true, dryRun: true, text: commentText});
668
+ if (isDryRun()) return ok({success: true, dryRun: true, text: commentText});
666
669
  const result = await requestJson(
667
670
  baseUrl,
668
671
  `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/comments`,
@@ -682,7 +685,7 @@ async function bitbucketAddPrInlineComment({
682
685
  fileType = 'TO',
683
686
  }) {
684
687
  const {baseUrl, token} = bitbucketConfig();
685
- if (DRY_RUN) return ok({success: true, dryRun: true, text: commentText, filePath, line});
688
+ if (isDryRun()) return ok({success: true, dryRun: true, text: commentText, filePath, line});
686
689
  const result = await requestJson(
687
690
  baseUrl,
688
691
  `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/comments`,
@@ -699,7 +702,7 @@ async function bitbucketAddPrInlineComment({
699
702
  }
700
703
 
701
704
  async function bitbucketGetFile({projectKey, repoSlug, filePath, branch = 'master'}) {
702
- if (DRY_RUN) return text('[DRY] Mock file content');
705
+ if (isDryRun()) return text('[DRY] Mock file content');
703
706
  const {baseUrl, token} = bitbucketConfig();
704
707
  const encodedPath = filePath.split('/').map(encodeURIComponent).join('/');
705
708
  const content = await requestText(
@@ -711,7 +714,7 @@ async function bitbucketGetFile({projectKey, repoSlug, filePath, branch = 'maste
711
714
  }
712
715
 
713
716
  async function confluenceSearch({query, limit = 10}) {
714
- if (DRY_RUN) return ok({total: 0, results: []});
717
+ if (isDryRun()) return ok({total: 0, results: []});
715
718
  const {baseUrl, token} = confluenceConfig();
716
719
  const data = await requestJson(
717
720
  baseUrl,
@@ -732,7 +735,7 @@ async function confluenceSearch({query, limit = 10}) {
732
735
  }
733
736
 
734
737
  async function confluenceGetPage({pageId}) {
735
- if (DRY_RUN) return ok({id: pageId, title: '[DRY] Mock page', body: 'dry'});
738
+ if (isDryRun()) return ok({id: pageId, title: '[DRY] Mock page', body: 'dry'});
736
739
  const {baseUrl, token} = confluenceConfig();
737
740
  const data = await requestJson(
738
741
  baseUrl,
@@ -755,7 +758,7 @@ async function confluenceGetPage({pageId}) {
755
758
  }
756
759
 
757
760
  async function confluenceGetChildren({pageId, limit = 25}) {
758
- if (DRY_RUN) return ok({total: 0, children: []});
761
+ if (isDryRun()) return ok({total: 0, children: []});
759
762
  const {baseUrl, token} = confluenceConfig();
760
763
  const data = await requestJson(
761
764
  baseUrl,
@@ -772,7 +775,7 @@ async function confluenceGetChildren({pageId, limit = 25}) {
772
775
  }
773
776
 
774
777
  async function confluenceListSpaces({limit = 20}) {
775
- if (DRY_RUN) return ok({total: 0, spaces: []});
778
+ if (isDryRun()) return ok({total: 0, spaces: []});
776
779
  const {baseUrl, token} = confluenceConfig();
777
780
  const data = await requestJson(baseUrl, `/rest/api/space?limit=${limit}&expand=description.plain`, {
778
781
  token,
@@ -787,7 +790,7 @@ async function confluenceListSpaces({limit = 20}) {
787
790
  }
788
791
 
789
792
  async function confluenceGetPageStorage({pageId}) {
790
- if (DRY_RUN) return ok({id: pageId, title: '[DRY] Mock page', version: 1, body: '<p>dry</p>'});
793
+ if (isDryRun()) return ok({id: pageId, title: '[DRY] Mock page', version: 1, body: '<p>dry</p>'});
791
794
  const {baseUrl, token} = confluenceConfig();
792
795
  const data = await requestJson(baseUrl, `/rest/api/content/${pageId}?expand=body.storage,version,title`, {
793
796
  token,
@@ -802,7 +805,7 @@ async function confluenceGetPageStorage({pageId}) {
802
805
 
803
806
  async function confluenceCreatePage({spaceKey, parentId, title, body}) {
804
807
  const {baseUrl, token} = confluenceConfig();
805
- if (DRY_RUN) return ok({id: 'DRY', title, url: `${baseUrl}/pages/viewpage.action?pageId=DRY`});
808
+ if (isDryRun()) return ok({id: 'DRY', title, url: `${baseUrl}/pages/viewpage.action?pageId=DRY`});
806
809
  const data = await requestJson(baseUrl, '/rest/api/content', {
807
810
  method: 'POST',
808
811
  token,
@@ -820,7 +823,7 @@ async function confluenceCreatePage({spaceKey, parentId, title, body}) {
820
823
 
821
824
  async function confluenceUpdatePage({pageId, title, body, minorEdit = false}) {
822
825
  const {baseUrl, token} = confluenceConfig();
823
- if (DRY_RUN) return ok({id: pageId, title: title || '[DRY] Mock page', version: 2, dryRun: true});
826
+ if (isDryRun()) return ok({id: pageId, title: title || '[DRY] Mock page', version: 2, dryRun: true});
824
827
  const current = await requestJson(baseUrl, `/rest/api/content/${pageId}?expand=version,title`, {token});
825
828
  const nextVersion = (current.version?.number || 0) + 1;
826
829
  const resolvedTitle = title || current.title;
@@ -844,7 +847,7 @@ async function confluenceUpdatePage({pageId, title, body, minorEdit = false}) {
844
847
  }
845
848
 
846
849
  async function confluenceListVersions({pageId, limit = 25, start = 0}) {
847
- if (DRY_RUN) return ok({total: 0, start, limit, versions: []});
850
+ if (isDryRun()) return ok({total: 0, start, limit, versions: []});
848
851
  const {baseUrl, token} = confluenceConfig();
849
852
  const data = await requestJson(
850
853
  baseUrl,
@@ -862,7 +865,7 @@ async function confluenceListVersions({pageId, limit = 25, start = 0}) {
862
865
  }
863
866
 
864
867
  async function confluenceGetPageVersion({pageId, version}) {
865
- if (DRY_RUN) return ok({id: pageId, title: '[DRY] Mock page', version, body: 'dry'});
868
+ if (isDryRun()) return ok({id: pageId, title: '[DRY] Mock page', version, body: 'dry'});
866
869
  const {baseUrl, token} = confluenceConfig();
867
870
  const data = await requestJson(
868
871
  baseUrl,
@@ -881,7 +884,7 @@ async function confluenceGetPageVersion({pageId, version}) {
881
884
  }
882
885
 
883
886
  async function confluenceGetCalendarEvents({subCalendarId, date}) {
884
- if (DRY_RUN) {
887
+ if (isDryRun()) {
885
888
  return ok({
886
889
  date,
887
890
  total: 1,