mcp-multi-jira 0.1.0 → 0.1.2
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/dist/cli.js +60 -2
- package/dist/mcp/remote-session.js +106 -13
- package/dist/mcp/session-manager.js +72 -0
- package/dist/oauth/atlassian.js +96 -9
- package/dist/oauth/client-info-store.js +12 -0
- package/dist/security/token-store.js +6 -0
- package/package.json +6 -1
- package/dist/keytar-f6bnxfss.node +0 -0
- package/dist/mcp/mock.js +0 -63
- package/dist/mcp/remoteSession.js +0 -163
- package/dist/mcp/sessionManager.js +0 -62
- package/dist/oauth/clientInfoStore.js +0 -29
- package/dist/security/tokenStore.js +0 -204
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { loadConfig, removeAccount, setAccount, setTokenStore, } from "./config/
|
|
|
8
8
|
import { RemoteSession } from "./mcp/remote-session.js";
|
|
9
9
|
import { startLocalServer } from "./mcp/server.js";
|
|
10
10
|
import { SessionManager } from "./mcp/session-manager.js";
|
|
11
|
-
import { DEFAULT_SCOPES, getStaticClientInfoFromEnv, loginWithDynamicOAuth, } from "./oauth/atlassian.js";
|
|
11
|
+
import { DEFAULT_SCOPES, getStaticClientInfoFromEnv, isInvalidGrantError, loginWithDynamicOAuth, refreshTokensIfNeeded, } from "./oauth/atlassian.js";
|
|
12
12
|
import { createTokenStore, getAuthStatusForAlias, } from "./security/token-store.js";
|
|
13
13
|
import { info, setLogTarget, warn } from "./utils/log.js";
|
|
14
14
|
import { PACKAGE_VERSION } from "./version.js";
|
|
@@ -133,12 +133,61 @@ function formatAuthStatus(status) {
|
|
|
133
133
|
return "needs login";
|
|
134
134
|
case "expired":
|
|
135
135
|
return "expired";
|
|
136
|
+
case "invalid":
|
|
137
|
+
return "needs relogin";
|
|
136
138
|
case "locked":
|
|
137
139
|
return "locked";
|
|
138
140
|
default:
|
|
139
141
|
return "unknown";
|
|
140
142
|
}
|
|
141
143
|
}
|
|
144
|
+
function shouldVerifyRefresh(tokens) {
|
|
145
|
+
if (!tokens || tokens.refreshInvalid) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (!tokens.refreshToken) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return tokens.expiresAt < Date.now() + 5 * 60 * 1000;
|
|
152
|
+
}
|
|
153
|
+
async function resolveAuthStatusForList(options) {
|
|
154
|
+
const status = await getAuthStatusForAlias({
|
|
155
|
+
alias: options.alias,
|
|
156
|
+
tokenStore: options.tokenStore,
|
|
157
|
+
storeKind: options.storeKind,
|
|
158
|
+
allowPrompt: options.allowPrompt,
|
|
159
|
+
});
|
|
160
|
+
if (status.status !== "ok") {
|
|
161
|
+
return status;
|
|
162
|
+
}
|
|
163
|
+
const tokens = await options.tokenStore.get(options.alias);
|
|
164
|
+
if (!shouldVerifyRefresh(tokens)) {
|
|
165
|
+
return status;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await refreshTokensIfNeeded({
|
|
169
|
+
alias: options.alias,
|
|
170
|
+
tokenStore: options.tokenStore,
|
|
171
|
+
scopes: options.scopes,
|
|
172
|
+
staticClientInfo: options.staticClientInfo,
|
|
173
|
+
});
|
|
174
|
+
return status;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (tokens && isInvalidGrantError(err)) {
|
|
178
|
+
await options.tokenStore.set(options.alias, {
|
|
179
|
+
...tokens,
|
|
180
|
+
refreshInvalid: true,
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
status: "invalid",
|
|
184
|
+
reason: `Stored refresh token is invalid. Run \`mcp-multi-jira login ${options.alias}\` to reauthenticate this account.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
warn(`Failed to refresh tokens for ${options.alias}: ${String(err)}`);
|
|
188
|
+
return status;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
142
191
|
function isRecord(value) {
|
|
143
192
|
return Boolean(value) && typeof value === "object";
|
|
144
193
|
}
|
|
@@ -320,6 +369,7 @@ async function fetchUserEmail(session, toolNames) {
|
|
|
320
369
|
}
|
|
321
370
|
async function handleLogin(alias, options) {
|
|
322
371
|
const config = await loadConfig();
|
|
372
|
+
const existingAccount = Boolean(config.accounts[alias]);
|
|
323
373
|
if (config.accounts[alias]) {
|
|
324
374
|
const overwrite = await confirm({
|
|
325
375
|
message: `Account alias "${alias}" already exists. Re-authenticate and overwrite?`,
|
|
@@ -335,6 +385,9 @@ async function handleLogin(alias, options) {
|
|
|
335
385
|
const tokenStore = await createTokenStore({
|
|
336
386
|
store: tokenStoreKind,
|
|
337
387
|
});
|
|
388
|
+
if (existingAccount) {
|
|
389
|
+
await tokenStore.remove(alias);
|
|
390
|
+
}
|
|
338
391
|
const staticClientInfo = getStaticClientInfoFromEnv(options);
|
|
339
392
|
await loginWithDynamicOAuth({
|
|
340
393
|
alias,
|
|
@@ -386,14 +439,18 @@ async function handleListAccounts() {
|
|
|
386
439
|
}
|
|
387
440
|
const storeKind = resolveTokenStoreFromConfig(config);
|
|
388
441
|
const tokenStore = await createTokenStore({ store: storeKind });
|
|
442
|
+
const scopes = resolveScopes();
|
|
443
|
+
const staticClientInfo = getStaticClientInfoFromEnv();
|
|
389
444
|
const statusMap = new Map();
|
|
390
445
|
for (const account of accounts) {
|
|
391
446
|
try {
|
|
392
|
-
const status = await
|
|
447
|
+
const status = await resolveAuthStatusForList({
|
|
393
448
|
alias: account.alias,
|
|
394
449
|
tokenStore,
|
|
395
450
|
storeKind,
|
|
396
451
|
allowPrompt: process.stdin.isTTY,
|
|
452
|
+
scopes,
|
|
453
|
+
staticClientInfo,
|
|
397
454
|
});
|
|
398
455
|
statusMap.set(account.alias, formatAuthStatus(status));
|
|
399
456
|
}
|
|
@@ -440,6 +497,7 @@ async function handleServe(options) {
|
|
|
440
497
|
return;
|
|
441
498
|
}
|
|
442
499
|
await manager.connectAll();
|
|
500
|
+
manager.startBackgroundRefresh();
|
|
443
501
|
await startLocalServer(manager, PACKAGE_VERSION);
|
|
444
502
|
}
|
|
445
503
|
function warnTokenStoreOverride(config) {
|
|
@@ -2,7 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
4
|
import PQueue from "p-queue";
|
|
5
|
-
import { MCP_SERVER_URL, MCP_SSE_URL, refreshTokensIfNeeded, } from "../oauth/atlassian.js";
|
|
5
|
+
import { isInvalidGrantError, MCP_SERVER_URL, MCP_SSE_URL, refreshTokensIfNeeded, } from "../oauth/atlassian.js";
|
|
6
6
|
import { debug, warn } from "../utils/log.js";
|
|
7
7
|
import { PACKAGE_VERSION } from "../version.js";
|
|
8
8
|
export class RemoteSession {
|
|
@@ -45,20 +45,68 @@ export class RemoteSession {
|
|
|
45
45
|
}
|
|
46
46
|
await this.loadPromise;
|
|
47
47
|
}
|
|
48
|
+
async fetchRefreshedTokens() {
|
|
49
|
+
const alias = this.account.alias;
|
|
50
|
+
const refreshed = await refreshTokensIfNeeded({
|
|
51
|
+
alias,
|
|
52
|
+
tokenStore: this.tokenStore,
|
|
53
|
+
scopes: this.scopes,
|
|
54
|
+
staticClientInfo: this.staticClientInfo,
|
|
55
|
+
});
|
|
56
|
+
this.tokens = refreshed;
|
|
57
|
+
return refreshed;
|
|
58
|
+
}
|
|
59
|
+
async markRefreshTokenInvalid() {
|
|
60
|
+
if (!this.tokens || this.tokens.refreshInvalid) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const marked = { ...this.tokens, refreshInvalid: true };
|
|
64
|
+
await this.tokenStore.set(this.account.alias, marked);
|
|
65
|
+
this.tokens = marked;
|
|
66
|
+
}
|
|
67
|
+
async retryRefreshAfterReload(previousRefreshToken) {
|
|
68
|
+
const alias = this.account.alias;
|
|
69
|
+
const latest = await this.tokenStore.get(alias);
|
|
70
|
+
if (!latest?.refreshToken) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (latest.refreshToken === previousRefreshToken) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
this.tokens = latest;
|
|
77
|
+
try {
|
|
78
|
+
return await this.fetchRefreshedTokens();
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (!isInvalidGrantError(err)) {
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async refreshTokensInner() {
|
|
88
|
+
const alias = this.account.alias;
|
|
89
|
+
const currentRefreshToken = this.tokens?.refreshToken;
|
|
90
|
+
try {
|
|
91
|
+
return await this.fetchRefreshedTokens();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (!isInvalidGrantError(err)) {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
const refreshed = await this.retryRefreshAfterReload(currentRefreshToken);
|
|
98
|
+
if (refreshed) {
|
|
99
|
+
return refreshed;
|
|
100
|
+
}
|
|
101
|
+
await this.markRefreshTokenInvalid();
|
|
102
|
+
throw new Error(`Stored refresh token is invalid for account ${alias}. Run \`mcp-multi-jira login ${alias}\` again.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
48
105
|
refreshTokens() {
|
|
49
106
|
if (this.refreshPromise) {
|
|
50
107
|
return this.refreshPromise;
|
|
51
108
|
}
|
|
52
|
-
this.refreshPromise = (
|
|
53
|
-
const refreshed = await refreshTokensIfNeeded({
|
|
54
|
-
alias: this.account.alias,
|
|
55
|
-
tokenStore: this.tokenStore,
|
|
56
|
-
scopes: this.scopes,
|
|
57
|
-
staticClientInfo: this.staticClientInfo,
|
|
58
|
-
});
|
|
59
|
-
this.tokens = refreshed;
|
|
60
|
-
return refreshed;
|
|
61
|
-
})().finally(() => {
|
|
109
|
+
this.refreshPromise = this.refreshTokensInner().finally(() => {
|
|
62
110
|
this.refreshPromise = null;
|
|
63
111
|
});
|
|
64
112
|
return this.refreshPromise;
|
|
@@ -72,13 +120,32 @@ export class RemoteSession {
|
|
|
72
120
|
}
|
|
73
121
|
async ensureValidTokens() {
|
|
74
122
|
await this.ensureTokensLoaded();
|
|
123
|
+
if (!this.tokens) {
|
|
124
|
+
throw new Error("Missing tokens");
|
|
125
|
+
}
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
if (this.tokens.refreshInvalid) {
|
|
128
|
+
if (this.tokens.expiresAt > now) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`Tokens for ${this.account.alias} have expired and the stored refresh token is invalid. Run login again.`);
|
|
132
|
+
}
|
|
75
133
|
if (!this.tokenNeedsRefresh()) {
|
|
76
134
|
return;
|
|
77
135
|
}
|
|
78
|
-
if (!this.tokens
|
|
136
|
+
if (!this.tokens.refreshToken) {
|
|
79
137
|
throw new Error(`Tokens for ${this.account.alias} have expired and no refresh token is available.`);
|
|
80
138
|
}
|
|
81
|
-
|
|
139
|
+
if (this.tokens.expiresAt <= now) {
|
|
140
|
+
await this.refreshTokens();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await this.refreshTokens();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
warn(`[${this.account.alias}] Token refresh failed, continuing with existing access token: ${String(err)}`);
|
|
148
|
+
}
|
|
82
149
|
}
|
|
83
150
|
async connectStreamableHttp() {
|
|
84
151
|
if (!this.tokens) {
|
|
@@ -183,6 +250,32 @@ export class RemoteSession {
|
|
|
183
250
|
}
|
|
184
251
|
});
|
|
185
252
|
}
|
|
253
|
+
async refreshTokensInBackground() {
|
|
254
|
+
await this.ensureTokensLoaded();
|
|
255
|
+
if (!this.tokens) {
|
|
256
|
+
throw new Error("Missing tokens");
|
|
257
|
+
}
|
|
258
|
+
if (this.tokens.refreshInvalid) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (!this.tokenNeedsRefresh()) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!this.tokens.refreshToken) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
await this.refreshTokens();
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
if (this.tokens.expiresAt > now) {
|
|
273
|
+
warn(`[${this.account.alias}] Background token refresh failed, continuing with existing access token: ${String(err)}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
186
279
|
async close() {
|
|
187
280
|
await this.client.close();
|
|
188
281
|
}
|
|
@@ -2,6 +2,18 @@ import { loadConfig } from "../config/store.js";
|
|
|
2
2
|
import { getAuthStatusForAlias, } from "../security/token-store.js";
|
|
3
3
|
import { warn } from "../utils/log.js";
|
|
4
4
|
import { RemoteSession } from "./remote-session.js";
|
|
5
|
+
const DEFAULT_BACKGROUND_REFRESH_INTERVAL_MS = 60_000;
|
|
6
|
+
function resolveBackgroundRefreshIntervalMs() {
|
|
7
|
+
const raw = process.env.MCP_JIRA_BACKGROUND_REFRESH_INTERVAL_MS;
|
|
8
|
+
if (!raw) {
|
|
9
|
+
return DEFAULT_BACKGROUND_REFRESH_INTERVAL_MS;
|
|
10
|
+
}
|
|
11
|
+
const parsed = Number(raw);
|
|
12
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
return Math.floor(parsed);
|
|
16
|
+
}
|
|
5
17
|
export class SessionManager {
|
|
6
18
|
tokenStore;
|
|
7
19
|
scopes;
|
|
@@ -9,6 +21,9 @@ export class SessionManager {
|
|
|
9
21
|
tokenStoreKind;
|
|
10
22
|
sessions = new Map();
|
|
11
23
|
accounts = new Map();
|
|
24
|
+
backgroundRefreshTimer = null;
|
|
25
|
+
backgroundRefreshRunning = false;
|
|
26
|
+
backgroundRefreshStopped = false;
|
|
12
27
|
constructor(tokenStore, scopes, staticClientInfo, tokenStoreKind) {
|
|
13
28
|
this.tokenStore = tokenStore;
|
|
14
29
|
this.scopes = scopes;
|
|
@@ -56,7 +71,64 @@ export class SessionManager {
|
|
|
56
71
|
}
|
|
57
72
|
});
|
|
58
73
|
}
|
|
74
|
+
async refreshAllTokensOnce() {
|
|
75
|
+
for (const session of this.sessions.values()) {
|
|
76
|
+
try {
|
|
77
|
+
const status = await this.getAccountAuthStatus(session.account.alias, {
|
|
78
|
+
allowPrompt: false,
|
|
79
|
+
});
|
|
80
|
+
if (status.status !== "ok") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
await session.refreshTokensInBackground();
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
warn(`[${session.account.alias}] Background token refresh failed: ${String(err)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
startBackgroundRefresh(options) {
|
|
91
|
+
if (this.backgroundRefreshTimer) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const intervalMs = options?.intervalMs ?? resolveBackgroundRefreshIntervalMs();
|
|
95
|
+
if (intervalMs <= 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.backgroundRefreshStopped = false;
|
|
99
|
+
const schedule = (delay) => {
|
|
100
|
+
const timer = setTimeout(run, delay);
|
|
101
|
+
timer.unref?.();
|
|
102
|
+
this.backgroundRefreshTimer = timer;
|
|
103
|
+
};
|
|
104
|
+
const run = async () => {
|
|
105
|
+
if (this.backgroundRefreshStopped) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.backgroundRefreshRunning) {
|
|
109
|
+
schedule(intervalMs);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.backgroundRefreshRunning = true;
|
|
113
|
+
try {
|
|
114
|
+
await this.refreshAllTokensOnce();
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
this.backgroundRefreshRunning = false;
|
|
118
|
+
schedule(intervalMs);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
schedule(intervalMs);
|
|
122
|
+
}
|
|
123
|
+
stopBackgroundRefresh() {
|
|
124
|
+
this.backgroundRefreshStopped = true;
|
|
125
|
+
if (this.backgroundRefreshTimer) {
|
|
126
|
+
clearTimeout(this.backgroundRefreshTimer);
|
|
127
|
+
this.backgroundRefreshTimer = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
59
130
|
async closeAll() {
|
|
131
|
+
this.stopBackgroundRefresh();
|
|
60
132
|
await Promise.all(Array.from(this.sessions.values()).map((session) => session.close()));
|
|
61
133
|
}
|
|
62
134
|
}
|
package/dist/oauth/atlassian.js
CHANGED
|
@@ -5,7 +5,7 @@ import { auth, discoverAuthorizationServerMetadata, discoverOAuthProtectedResour
|
|
|
5
5
|
import getPort from "get-port";
|
|
6
6
|
import open from "open";
|
|
7
7
|
import { debug, info, warn } from "../utils/log.js";
|
|
8
|
-
import { readClientInfo, writeClientInfo } from "./client-info-store.js";
|
|
8
|
+
import { deleteClientInfo, readClientInfo, writeClientInfo, } from "./client-info-store.js";
|
|
9
9
|
export const DEFAULT_SCOPES = [
|
|
10
10
|
"offline_access",
|
|
11
11
|
"read:jira-work",
|
|
@@ -26,6 +26,17 @@ export function getStaticClientInfoFromEnv(options) {
|
|
|
26
26
|
}
|
|
27
27
|
return { clientId, clientSecret };
|
|
28
28
|
}
|
|
29
|
+
export function isInvalidGrantError(err) {
|
|
30
|
+
if (!err || typeof err !== "object") {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const name = err.name;
|
|
34
|
+
if (name === "InvalidGrantError") {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
const message = String(err).toLowerCase();
|
|
38
|
+
return (message.includes("invalidgranterror") || message.includes("invalid_grant"));
|
|
39
|
+
}
|
|
29
40
|
function toTokenSet(tokens, fallbackScopes) {
|
|
30
41
|
const scopes = tokens.scope
|
|
31
42
|
? tokens.scope.split(" ").filter(Boolean)
|
|
@@ -145,18 +156,67 @@ export class LocalOAuthProvider {
|
|
|
145
156
|
}
|
|
146
157
|
return this.codeVerifierValue;
|
|
147
158
|
}
|
|
159
|
+
async invalidateCredentials(scope) {
|
|
160
|
+
if (scope === "verifier" || scope === "all") {
|
|
161
|
+
this.codeVerifierValue = undefined;
|
|
162
|
+
}
|
|
163
|
+
if (scope === "tokens" || scope === "all") {
|
|
164
|
+
await this.tokenStore.remove(this.alias);
|
|
165
|
+
}
|
|
166
|
+
if ((scope === "client" || scope === "all") && !this.staticClientInfo) {
|
|
167
|
+
this.clientInfoCache = null;
|
|
168
|
+
await deleteClientInfo(MCP_SERVER_URL);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function extractRedirectUriFromClientInfo(clientInfo) {
|
|
173
|
+
if (!clientInfo || typeof clientInfo !== "object") {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const redirectUris = clientInfo.redirect_uris;
|
|
177
|
+
if (!Array.isArray(redirectUris)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const first = redirectUris[0];
|
|
181
|
+
if (typeof first !== "string" || first.length === 0) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
return first;
|
|
148
185
|
}
|
|
149
|
-
export async function startCallbackServer(expectedState) {
|
|
150
|
-
|
|
151
|
-
|
|
186
|
+
export async function startCallbackServer(expectedState, options) {
|
|
187
|
+
let redirectUri = options?.redirectUri;
|
|
188
|
+
if (!redirectUri) {
|
|
189
|
+
const port = await getPort({ port: 3334 });
|
|
190
|
+
redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
|
|
191
|
+
}
|
|
192
|
+
const redirectUrl = new URL(redirectUri);
|
|
193
|
+
if (redirectUrl.protocol !== "http:") {
|
|
194
|
+
throw new Error(`Invalid redirect URI protocol: ${redirectUri}`);
|
|
195
|
+
}
|
|
196
|
+
if (redirectUrl.pathname !== "/oauth/callback") {
|
|
197
|
+
throw new Error(`Invalid redirect URI path: ${redirectUri}`);
|
|
198
|
+
}
|
|
199
|
+
const port = Number(redirectUrl.port);
|
|
200
|
+
if (!port || Number.isNaN(port)) {
|
|
201
|
+
throw new Error(`Redirect URI must include an explicit port (e.g. http://127.0.0.1:3334/oauth/callback), got: ${redirectUri}`);
|
|
202
|
+
}
|
|
203
|
+
const hostname = redirectUrl.hostname;
|
|
152
204
|
const server = http.createServer();
|
|
153
205
|
let closed = false;
|
|
206
|
+
const sockets = new Set();
|
|
207
|
+
server.on("connection", (socket) => {
|
|
208
|
+
sockets.add(socket);
|
|
209
|
+
socket.on("close", () => sockets.delete(socket));
|
|
210
|
+
});
|
|
154
211
|
const close = () => new Promise((resolve) => {
|
|
155
212
|
if (closed) {
|
|
156
213
|
resolve();
|
|
157
214
|
return;
|
|
158
215
|
}
|
|
159
216
|
closed = true;
|
|
217
|
+
for (const socket of sockets) {
|
|
218
|
+
socket.destroy();
|
|
219
|
+
}
|
|
160
220
|
server.close(() => resolve());
|
|
161
221
|
});
|
|
162
222
|
const codePromise = new Promise((resolve, reject) => {
|
|
@@ -176,7 +236,10 @@ export async function startCallbackServer(expectedState) {
|
|
|
176
236
|
reject(new Error("Invalid OAuth response"));
|
|
177
237
|
return;
|
|
178
238
|
}
|
|
179
|
-
res.writeHead(200, {
|
|
239
|
+
res.writeHead(200, {
|
|
240
|
+
"content-type": "text/plain",
|
|
241
|
+
connection: "close",
|
|
242
|
+
});
|
|
180
243
|
res.end("Authentication complete. You can return to the CLI.");
|
|
181
244
|
resolve(code);
|
|
182
245
|
}
|
|
@@ -190,8 +253,21 @@ export async function startCallbackServer(expectedState) {
|
|
|
190
253
|
}
|
|
191
254
|
});
|
|
192
255
|
});
|
|
193
|
-
await new Promise((resolve) => {
|
|
194
|
-
|
|
256
|
+
await new Promise((resolve, reject) => {
|
|
257
|
+
const onError = (err) => {
|
|
258
|
+
reject(err);
|
|
259
|
+
};
|
|
260
|
+
server.once("error", onError);
|
|
261
|
+
server.listen(port, hostname, () => {
|
|
262
|
+
server.off("error", onError);
|
|
263
|
+
resolve();
|
|
264
|
+
});
|
|
265
|
+
}).catch((err) => {
|
|
266
|
+
const code = err.code;
|
|
267
|
+
if (code === "EADDRINUSE") {
|
|
268
|
+
throw new Error(`OAuth callback port ${port} is already in use (redirect URI: ${redirectUri}). Close the other process using it and retry.`);
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
195
271
|
});
|
|
196
272
|
return { redirectUri, codePromise, close };
|
|
197
273
|
}
|
|
@@ -203,9 +279,20 @@ export async function loginWithDynamicOAuth(options) {
|
|
|
203
279
|
allowRedirect: true,
|
|
204
280
|
staticClientInfo: options.staticClientInfo,
|
|
205
281
|
});
|
|
206
|
-
const
|
|
282
|
+
const redirectUriFromEnv = process.env.MCP_JIRA_REDIRECT_URI;
|
|
283
|
+
let redirectUri = redirectUriFromEnv;
|
|
284
|
+
if (!redirectUri) {
|
|
285
|
+
if (options.staticClientInfo) {
|
|
286
|
+
redirectUri = "http://127.0.0.1:3334/oauth/callback";
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const clientInfo = await provider.clientInformation();
|
|
290
|
+
redirectUri = extractRedirectUriFromClientInfo(clientInfo);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const { redirectUri: callbackRedirectUri, codePromise, close, } = await startCallbackServer(provider.getState(), { redirectUri });
|
|
207
294
|
try {
|
|
208
|
-
provider.setRedirectUrl(
|
|
295
|
+
provider.setRedirectUrl(callbackRedirectUri);
|
|
209
296
|
const result = await auth(provider, {
|
|
210
297
|
serverUrl: MCP_SERVER_URL,
|
|
211
298
|
scope: options.scopes.join(" "),
|
|
@@ -22,6 +22,18 @@ export async function readClientInfo(serverUrl) {
|
|
|
22
22
|
throw err;
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
export async function deleteClientInfo(serverUrl) {
|
|
26
|
+
const filePath = clientInfoPath(serverUrl);
|
|
27
|
+
try {
|
|
28
|
+
await fs.unlink(filePath);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === "ENOENT") {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
25
37
|
export async function writeClientInfo(serverUrl, info) {
|
|
26
38
|
const filePath = clientInfoPath(serverUrl);
|
|
27
39
|
await ensureDir(path.dirname(filePath));
|
|
@@ -186,6 +186,12 @@ export async function getAuthStatusForAlias(options) {
|
|
|
186
186
|
reason: "No tokens found. Run login to authenticate this account.",
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
+
if (tokens.refreshInvalid) {
|
|
190
|
+
return {
|
|
191
|
+
status: "invalid",
|
|
192
|
+
reason: `Stored refresh token is invalid. Run \`mcp-multi-jira login ${options.alias}\` to reauthenticate this account.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
189
195
|
if (tokens.expiresAt < Date.now() && !tokens.refreshToken) {
|
|
190
196
|
return {
|
|
191
197
|
status: "expired",
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-multi-jira",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Multi-account Jira MCP server",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/iipanda/mcp-multi-jira#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/iipanda/mcp-multi-jira.git"
|
|
10
|
+
},
|
|
6
11
|
"keywords": [
|
|
7
12
|
"mcp",
|
|
8
13
|
"model-context-protocol",
|
|
Binary file
|
package/dist/mcp/mock.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
const account = {
|
|
2
|
-
alias: "mock",
|
|
3
|
-
site: "mock://jira",
|
|
4
|
-
cloudId: "mock",
|
|
5
|
-
};
|
|
6
|
-
const tools = [
|
|
7
|
-
{
|
|
8
|
-
name: "mockEcho",
|
|
9
|
-
description: "Echoes arguments back as JSON.",
|
|
10
|
-
inputSchema: {
|
|
11
|
-
type: "object",
|
|
12
|
-
properties: {
|
|
13
|
-
cloudId: { type: "string" },
|
|
14
|
-
jql: { type: "string" },
|
|
15
|
-
},
|
|
16
|
-
required: ["cloudId", "jql"],
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
name: "mockSecondTool",
|
|
21
|
-
description: "Second tool for pass-through tests.",
|
|
22
|
-
inputSchema: {
|
|
23
|
-
type: "object",
|
|
24
|
-
properties: {
|
|
25
|
-
cloudId: { type: "string" },
|
|
26
|
-
query: { type: "string" },
|
|
27
|
-
},
|
|
28
|
-
required: ["cloudId", "query"],
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
];
|
|
32
|
-
const session = {
|
|
33
|
-
async listTools() {
|
|
34
|
-
return tools;
|
|
35
|
-
},
|
|
36
|
-
async callTool(_name, args) {
|
|
37
|
-
return {
|
|
38
|
-
content: [
|
|
39
|
-
{
|
|
40
|
-
type: "text",
|
|
41
|
-
text: JSON.stringify(args),
|
|
42
|
-
},
|
|
43
|
-
],
|
|
44
|
-
structuredContent: args,
|
|
45
|
-
};
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
export function createMockSessionManager() {
|
|
49
|
-
return {
|
|
50
|
-
listAccounts() {
|
|
51
|
-
return [account];
|
|
52
|
-
},
|
|
53
|
-
getSession(alias) {
|
|
54
|
-
if (alias === account.alias) {
|
|
55
|
-
return session;
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
58
|
-
},
|
|
59
|
-
async getAccountAuthStatus() {
|
|
60
|
-
return { status: "ok" };
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
3
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
-
import PQueue from "p-queue";
|
|
5
|
-
import { MCP_SERVER_URL, MCP_SSE_URL, refreshTokensIfNeeded, } from "../oauth/atlassian.js";
|
|
6
|
-
import { debug, warn } from "../utils/log.js";
|
|
7
|
-
export class RemoteSession {
|
|
8
|
-
account;
|
|
9
|
-
tokenStore;
|
|
10
|
-
scopes;
|
|
11
|
-
staticClientInfo;
|
|
12
|
-
client;
|
|
13
|
-
connected = false;
|
|
14
|
-
queue;
|
|
15
|
-
tokens = null;
|
|
16
|
-
constructor(account, tokenStore, scopes, staticClientInfo) {
|
|
17
|
-
this.account = account;
|
|
18
|
-
this.tokenStore = tokenStore;
|
|
19
|
-
this.scopes = scopes;
|
|
20
|
-
this.staticClientInfo = staticClientInfo;
|
|
21
|
-
this.client = this.createClient();
|
|
22
|
-
this.queue = new PQueue({ concurrency: 4 });
|
|
23
|
-
}
|
|
24
|
-
createClient() {
|
|
25
|
-
return new Client({ name: "mcp-jira", version: "0.1.0" });
|
|
26
|
-
}
|
|
27
|
-
async loadTokens() {
|
|
28
|
-
const stored = await this.tokenStore.get(this.account.alias);
|
|
29
|
-
if (!stored) {
|
|
30
|
-
throw new Error(`No tokens found for account ${this.account.alias}. Run login first.`);
|
|
31
|
-
}
|
|
32
|
-
this.tokens = stored;
|
|
33
|
-
}
|
|
34
|
-
tokenNeedsRefresh() {
|
|
35
|
-
if (!this.tokens) {
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
const now = Date.now();
|
|
39
|
-
return this.tokens.expiresAt < now + 5 * 60 * 1000;
|
|
40
|
-
}
|
|
41
|
-
async ensureValidTokens() {
|
|
42
|
-
if (!this.tokens) {
|
|
43
|
-
await this.loadTokens();
|
|
44
|
-
}
|
|
45
|
-
if (this.tokenNeedsRefresh()) {
|
|
46
|
-
if (!this.tokens?.refreshToken) {
|
|
47
|
-
throw new Error(`Tokens for ${this.account.alias} have expired and no refresh token is available.`);
|
|
48
|
-
}
|
|
49
|
-
const refreshed = await refreshTokensIfNeeded({
|
|
50
|
-
alias: this.account.alias,
|
|
51
|
-
tokenStore: this.tokenStore,
|
|
52
|
-
scopes: this.scopes,
|
|
53
|
-
staticClientInfo: this.staticClientInfo,
|
|
54
|
-
});
|
|
55
|
-
this.tokens = refreshed;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
async connectStreamableHttp() {
|
|
59
|
-
if (!this.tokens) {
|
|
60
|
-
throw new Error("Missing tokens");
|
|
61
|
-
}
|
|
62
|
-
const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL), {
|
|
63
|
-
requestInit: {
|
|
64
|
-
headers: {
|
|
65
|
-
authorization: `Bearer ${this.tokens.accessToken}`,
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
await this.client.connect(transport);
|
|
70
|
-
this.connected = true;
|
|
71
|
-
}
|
|
72
|
-
async connectSse() {
|
|
73
|
-
if (!this.tokens) {
|
|
74
|
-
throw new Error("Missing tokens");
|
|
75
|
-
}
|
|
76
|
-
const transport = new SSEClientTransport(new URL(MCP_SSE_URL), {
|
|
77
|
-
requestInit: {
|
|
78
|
-
headers: {
|
|
79
|
-
authorization: `Bearer ${this.tokens.accessToken}`,
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
eventSourceInit: {
|
|
83
|
-
headers: {
|
|
84
|
-
authorization: `Bearer ${this.tokens.accessToken}`,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
await this.client.connect(transport);
|
|
89
|
-
this.connected = true;
|
|
90
|
-
}
|
|
91
|
-
async connect() {
|
|
92
|
-
await this.ensureValidTokens();
|
|
93
|
-
if (this.connected) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
await this.connectStreamableHttp();
|
|
98
|
-
debug(`[${this.account.alias}] Connected via Streamable HTTP`);
|
|
99
|
-
}
|
|
100
|
-
catch (err) {
|
|
101
|
-
warn(`[${this.account.alias}] Streamable HTTP failed, falling back to SSE: ${String(err)}`);
|
|
102
|
-
await this.connectSse();
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
async refreshAndReconnect() {
|
|
106
|
-
await this.ensureValidTokens();
|
|
107
|
-
await this.client.close();
|
|
108
|
-
this.client = this.createClient();
|
|
109
|
-
this.connected = false;
|
|
110
|
-
await this.connect();
|
|
111
|
-
}
|
|
112
|
-
shouldRefreshOnError(err) {
|
|
113
|
-
if (!err || typeof err !== "object") {
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
const code = err.code;
|
|
117
|
-
if (code === 401 || code === 403) {
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
120
|
-
const message = String(err);
|
|
121
|
-
return (message.toLowerCase().includes("unauthorized") ||
|
|
122
|
-
message.toLowerCase().includes("forbidden"));
|
|
123
|
-
}
|
|
124
|
-
async listTools() {
|
|
125
|
-
if (!this.connected) {
|
|
126
|
-
await this.connect();
|
|
127
|
-
}
|
|
128
|
-
const tools = [];
|
|
129
|
-
let cursor;
|
|
130
|
-
do {
|
|
131
|
-
const result = await this.client.listTools({ cursor });
|
|
132
|
-
tools.push(...result.tools);
|
|
133
|
-
cursor = result.nextCursor;
|
|
134
|
-
} while (cursor);
|
|
135
|
-
return tools;
|
|
136
|
-
}
|
|
137
|
-
async callTool(name, args) {
|
|
138
|
-
if (!this.connected) {
|
|
139
|
-
await this.connect();
|
|
140
|
-
}
|
|
141
|
-
return this.queue.add(async () => {
|
|
142
|
-
try {
|
|
143
|
-
return await this.client.callTool({
|
|
144
|
-
name,
|
|
145
|
-
arguments: args,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
if (this.shouldRefreshOnError(err)) {
|
|
150
|
-
await this.refreshAndReconnect();
|
|
151
|
-
return this.client.callTool({
|
|
152
|
-
name,
|
|
153
|
-
arguments: args,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
throw err;
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
async close() {
|
|
161
|
-
await this.client.close();
|
|
162
|
-
}
|
|
163
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { loadConfig } from "../config/store.js";
|
|
2
|
-
import { getAuthStatusForAlias } from "../security/tokenStore.js";
|
|
3
|
-
import { RemoteSession } from "./remoteSession.js";
|
|
4
|
-
import { warn } from "../utils/log.js";
|
|
5
|
-
export class SessionManager {
|
|
6
|
-
tokenStore;
|
|
7
|
-
scopes;
|
|
8
|
-
staticClientInfo;
|
|
9
|
-
tokenStoreKind;
|
|
10
|
-
sessions = new Map();
|
|
11
|
-
accounts = new Map();
|
|
12
|
-
constructor(tokenStore, scopes, staticClientInfo, tokenStoreKind) {
|
|
13
|
-
this.tokenStore = tokenStore;
|
|
14
|
-
this.scopes = scopes;
|
|
15
|
-
this.staticClientInfo = staticClientInfo;
|
|
16
|
-
this.tokenStoreKind = tokenStoreKind;
|
|
17
|
-
}
|
|
18
|
-
async loadAll() {
|
|
19
|
-
const config = await loadConfig();
|
|
20
|
-
this.accounts = new Map(Object.values(config.accounts).map((account) => [account.alias, account]));
|
|
21
|
-
for (const account of this.accounts.values()) {
|
|
22
|
-
const session = new RemoteSession(account, this.tokenStore, this.scopes, this.staticClientInfo);
|
|
23
|
-
this.sessions.set(account.alias, session);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
listAccounts() {
|
|
27
|
-
return Array.from(this.accounts.values());
|
|
28
|
-
}
|
|
29
|
-
getSession(alias) {
|
|
30
|
-
return this.sessions.get(alias) ?? null;
|
|
31
|
-
}
|
|
32
|
-
async getAccountAuthStatus(alias, options) {
|
|
33
|
-
return getAuthStatusForAlias({
|
|
34
|
-
alias,
|
|
35
|
-
tokenStore: this.tokenStore,
|
|
36
|
-
storeKind: this.tokenStoreKind,
|
|
37
|
-
allowPrompt: options?.allowPrompt ?? false,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
async connectAll() {
|
|
41
|
-
const sessions = Array.from(this.sessions.values());
|
|
42
|
-
const results = await Promise.allSettled(sessions.map(async (session) => {
|
|
43
|
-
const status = await this.getAccountAuthStatus(session.account.alias, {
|
|
44
|
-
allowPrompt: false,
|
|
45
|
-
});
|
|
46
|
-
if (status.status !== "ok") {
|
|
47
|
-
warn(`[${session.account.alias}] Auth status ${status.status}. ${status.reason ?? "Run login."}`);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
await session.connect();
|
|
51
|
-
}));
|
|
52
|
-
results.forEach((result, index) => {
|
|
53
|
-
if (result.status === "rejected") {
|
|
54
|
-
const session = sessions[index];
|
|
55
|
-
warn(`[${session.account.alias}] Failed to connect: ${String(result.reason)}`);
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
async closeAll() {
|
|
60
|
-
await Promise.all(Array.from(this.sessions.values()).map((session) => session.close()));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { promises as fs } from "node:fs";
|
|
4
|
-
import { configDir } from "../config/paths.js";
|
|
5
|
-
import { atomicWrite, ensureDir } from "../utils/fs.js";
|
|
6
|
-
function hashServerUrl(serverUrl) {
|
|
7
|
-
return crypto.createHash("sha256").update(serverUrl).digest("hex");
|
|
8
|
-
}
|
|
9
|
-
function clientInfoPath(serverUrl) {
|
|
10
|
-
return path.join(configDir(), "oauth", hashServerUrl(serverUrl), "client_info.json");
|
|
11
|
-
}
|
|
12
|
-
export async function readClientInfo(serverUrl) {
|
|
13
|
-
const filePath = clientInfoPath(serverUrl);
|
|
14
|
-
try {
|
|
15
|
-
const raw = await fs.readFile(filePath, "utf8");
|
|
16
|
-
return JSON.parse(raw);
|
|
17
|
-
}
|
|
18
|
-
catch (err) {
|
|
19
|
-
if (err.code === "ENOENT") {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
throw err;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
export async function writeClientInfo(serverUrl, info) {
|
|
26
|
-
const filePath = clientInfoPath(serverUrl);
|
|
27
|
-
await ensureDir(path.dirname(filePath));
|
|
28
|
-
await atomicWrite(filePath, JSON.stringify(info, null, 2));
|
|
29
|
-
}
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { promises as fs } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { password as promptPassword } from "@inquirer/prompts";
|
|
5
|
-
import { plainTokenFilePath, tokenFilePath } from "../config/paths.js";
|
|
6
|
-
import { ensureDir, atomicWrite } from "../utils/fs.js";
|
|
7
|
-
const SERVICE_NAME = "mcp-jira";
|
|
8
|
-
const TOKEN_ENV = "MCP_JIRA_TOKEN_PASSWORD";
|
|
9
|
-
let cachedPassword = null;
|
|
10
|
-
async function getMasterPassword(intent) {
|
|
11
|
-
if (cachedPassword !== null) {
|
|
12
|
-
return cachedPassword;
|
|
13
|
-
}
|
|
14
|
-
if (process.env[TOKEN_ENV] !== undefined) {
|
|
15
|
-
cachedPassword = process.env[TOKEN_ENV];
|
|
16
|
-
return cachedPassword;
|
|
17
|
-
}
|
|
18
|
-
if (!process.stdin.isTTY) {
|
|
19
|
-
throw new Error("Encrypted token store requires a password. Set MCP_JIRA_TOKEN_PASSWORD to run non-interactively.");
|
|
20
|
-
}
|
|
21
|
-
cachedPassword = await promptPassword({
|
|
22
|
-
message: intent === "read"
|
|
23
|
-
? "Enter master password to unlock Jira tokens"
|
|
24
|
-
: "Create a master password to encrypt Jira tokens",
|
|
25
|
-
mask: "*",
|
|
26
|
-
});
|
|
27
|
-
return cachedPassword;
|
|
28
|
-
}
|
|
29
|
-
async function loadEncryptedFile(password) {
|
|
30
|
-
const filePath = tokenFilePath();
|
|
31
|
-
try {
|
|
32
|
-
const raw = await fs.readFile(filePath, "utf8");
|
|
33
|
-
const payload = JSON.parse(raw);
|
|
34
|
-
if (!payload.ciphertext) {
|
|
35
|
-
return {};
|
|
36
|
-
}
|
|
37
|
-
const salt = Buffer.from(payload.salt, "base64");
|
|
38
|
-
const iv = Buffer.from(payload.iv, "base64");
|
|
39
|
-
const tag = Buffer.from(payload.tag, "base64");
|
|
40
|
-
const ciphertext = Buffer.from(payload.ciphertext, "base64");
|
|
41
|
-
const key = crypto.scryptSync(password, salt, 32);
|
|
42
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
43
|
-
decipher.setAuthTag(tag);
|
|
44
|
-
const decrypted = Buffer.concat([
|
|
45
|
-
decipher.update(ciphertext),
|
|
46
|
-
decipher.final(),
|
|
47
|
-
]).toString("utf8");
|
|
48
|
-
return JSON.parse(decrypted);
|
|
49
|
-
}
|
|
50
|
-
catch (err) {
|
|
51
|
-
if (err.code === "ENOENT") {
|
|
52
|
-
return {};
|
|
53
|
-
}
|
|
54
|
-
throw err;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
async function saveEncryptedFile(password, tokens) {
|
|
58
|
-
const salt = crypto.randomBytes(16);
|
|
59
|
-
const iv = crypto.randomBytes(12);
|
|
60
|
-
const key = crypto.scryptSync(password, salt, 32);
|
|
61
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
62
|
-
const plaintext = JSON.stringify(tokens);
|
|
63
|
-
const ciphertext = Buffer.concat([
|
|
64
|
-
cipher.update(plaintext, "utf8"),
|
|
65
|
-
cipher.final(),
|
|
66
|
-
]);
|
|
67
|
-
const tag = cipher.getAuthTag();
|
|
68
|
-
const payload = {
|
|
69
|
-
version: 1,
|
|
70
|
-
salt: salt.toString("base64"),
|
|
71
|
-
iv: iv.toString("base64"),
|
|
72
|
-
tag: tag.toString("base64"),
|
|
73
|
-
ciphertext: ciphertext.toString("base64"),
|
|
74
|
-
};
|
|
75
|
-
await ensureDir(path.dirname(tokenFilePath()));
|
|
76
|
-
await atomicWrite(tokenFilePath(), JSON.stringify(payload, null, 2));
|
|
77
|
-
}
|
|
78
|
-
async function loadPlainFile() {
|
|
79
|
-
const filePath = plainTokenFilePath();
|
|
80
|
-
try {
|
|
81
|
-
const raw = await fs.readFile(filePath, "utf8");
|
|
82
|
-
return JSON.parse(raw);
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
if (err.code === "ENOENT") {
|
|
86
|
-
return {};
|
|
87
|
-
}
|
|
88
|
-
throw err;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
async function savePlainFile(tokens) {
|
|
92
|
-
await ensureDir(path.dirname(plainTokenFilePath()));
|
|
93
|
-
await atomicWrite(plainTokenFilePath(), JSON.stringify(tokens, null, 2));
|
|
94
|
-
}
|
|
95
|
-
class EncryptedFileTokenStore {
|
|
96
|
-
async get(alias) {
|
|
97
|
-
const password = await getMasterPassword("read");
|
|
98
|
-
const tokens = await loadEncryptedFile(password);
|
|
99
|
-
return tokens[alias] ?? null;
|
|
100
|
-
}
|
|
101
|
-
async set(alias, tokens) {
|
|
102
|
-
const password = await getMasterPassword("write");
|
|
103
|
-
const existing = await loadEncryptedFile(password);
|
|
104
|
-
existing[alias] = tokens;
|
|
105
|
-
await saveEncryptedFile(password, existing);
|
|
106
|
-
}
|
|
107
|
-
async remove(alias) {
|
|
108
|
-
const password = await getMasterPassword("read");
|
|
109
|
-
const existing = await loadEncryptedFile(password);
|
|
110
|
-
if (existing[alias]) {
|
|
111
|
-
delete existing[alias];
|
|
112
|
-
await saveEncryptedFile(password, existing);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
class PlaintextTokenStore {
|
|
117
|
-
async get(alias) {
|
|
118
|
-
const tokens = await loadPlainFile();
|
|
119
|
-
return tokens[alias] ?? null;
|
|
120
|
-
}
|
|
121
|
-
async set(alias, tokens) {
|
|
122
|
-
const existing = await loadPlainFile();
|
|
123
|
-
existing[alias] = tokens;
|
|
124
|
-
await savePlainFile(existing);
|
|
125
|
-
}
|
|
126
|
-
async remove(alias) {
|
|
127
|
-
const existing = await loadPlainFile();
|
|
128
|
-
if (existing[alias]) {
|
|
129
|
-
delete existing[alias];
|
|
130
|
-
await savePlainFile(existing);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
class KeytarTokenStore {
|
|
135
|
-
keytar;
|
|
136
|
-
constructor(keytar) {
|
|
137
|
-
this.keytar = keytar;
|
|
138
|
-
}
|
|
139
|
-
async get(alias) {
|
|
140
|
-
const raw = await this.keytar.getPassword(SERVICE_NAME, `tokens:${alias}`);
|
|
141
|
-
if (!raw) {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
return JSON.parse(raw);
|
|
145
|
-
}
|
|
146
|
-
async set(alias, tokens) {
|
|
147
|
-
await this.keytar.setPassword(SERVICE_NAME, `tokens:${alias}`, JSON.stringify(tokens));
|
|
148
|
-
}
|
|
149
|
-
async remove(alias) {
|
|
150
|
-
await this.keytar.deletePassword(SERVICE_NAME, `tokens:${alias}`);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
async function loadKeytar() {
|
|
154
|
-
try {
|
|
155
|
-
const mod = await import("keytar");
|
|
156
|
-
return mod.default ?? mod;
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
export async function getAuthStatusForAlias(options) {
|
|
163
|
-
const allowPrompt = options.allowPrompt ?? false;
|
|
164
|
-
if (options.storeKind === "encrypted" &&
|
|
165
|
-
!allowPrompt &&
|
|
166
|
-
process.env[TOKEN_ENV] === undefined) {
|
|
167
|
-
return {
|
|
168
|
-
status: "locked",
|
|
169
|
-
reason: "Encrypted token store is locked. Set MCP_JIRA_TOKEN_PASSWORD or login interactively.",
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const tokens = await options.tokenStore.get(options.alias);
|
|
173
|
-
if (!tokens) {
|
|
174
|
-
return {
|
|
175
|
-
status: "missing",
|
|
176
|
-
reason: "No tokens found. Run login to authenticate this account.",
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
if (tokens.expiresAt < Date.now() && !tokens.refreshToken) {
|
|
180
|
-
return {
|
|
181
|
-
status: "expired",
|
|
182
|
-
reason: "Token expired and no refresh token available. Run login again.",
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
return { status: "ok" };
|
|
186
|
-
}
|
|
187
|
-
export async function createTokenStore(options) {
|
|
188
|
-
const useKeychain = options?.useKeychain;
|
|
189
|
-
let store = options?.store ?? "encrypted";
|
|
190
|
-
if (useKeychain) {
|
|
191
|
-
store = "keychain";
|
|
192
|
-
}
|
|
193
|
-
if (store === "keychain") {
|
|
194
|
-
const keytar = await loadKeytar();
|
|
195
|
-
if (!keytar) {
|
|
196
|
-
throw new Error("Keychain usage requested but keytar could not be loaded. Reinstall dependencies or switch token storage to plain/encrypted.");
|
|
197
|
-
}
|
|
198
|
-
return new KeytarTokenStore(keytar);
|
|
199
|
-
}
|
|
200
|
-
if (store === "plain") {
|
|
201
|
-
return new PlaintextTokenStore();
|
|
202
|
-
}
|
|
203
|
-
return new EncryptedFileTokenStore();
|
|
204
|
-
}
|