mcp-multi-jira 0.1.0 → 0.1.1
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 +56 -2
- package/dist/mcp/remote-session.js +106 -13
- package/dist/mcp/session-manager.js +72 -0
- package/dist/oauth/atlassian.js +83 -8
- 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
|
}
|
|
@@ -386,14 +435,18 @@ async function handleListAccounts() {
|
|
|
386
435
|
}
|
|
387
436
|
const storeKind = resolveTokenStoreFromConfig(config);
|
|
388
437
|
const tokenStore = await createTokenStore({ store: storeKind });
|
|
438
|
+
const scopes = resolveScopes();
|
|
439
|
+
const staticClientInfo = getStaticClientInfoFromEnv();
|
|
389
440
|
const statusMap = new Map();
|
|
390
441
|
for (const account of accounts) {
|
|
391
442
|
try {
|
|
392
|
-
const status = await
|
|
443
|
+
const status = await resolveAuthStatusForList({
|
|
393
444
|
alias: account.alias,
|
|
394
445
|
tokenStore,
|
|
395
446
|
storeKind,
|
|
396
447
|
allowPrompt: process.stdin.isTTY,
|
|
448
|
+
scopes,
|
|
449
|
+
staticClientInfo,
|
|
397
450
|
});
|
|
398
451
|
statusMap.set(account.alias, formatAuthStatus(status));
|
|
399
452
|
}
|
|
@@ -440,6 +493,7 @@ async function handleServe(options) {
|
|
|
440
493
|
return;
|
|
441
494
|
}
|
|
442
495
|
await manager.connectAll();
|
|
496
|
+
manager.startBackgroundRefresh();
|
|
443
497
|
await startLocalServer(manager, PACKAGE_VERSION);
|
|
444
498
|
}
|
|
445
499
|
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
|
@@ -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)
|
|
@@ -146,17 +157,54 @@ export class LocalOAuthProvider {
|
|
|
146
157
|
return this.codeVerifierValue;
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
function extractRedirectUriFromClientInfo(clientInfo) {
|
|
161
|
+
if (!clientInfo || typeof clientInfo !== "object") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const redirectUris = clientInfo.redirect_uris;
|
|
165
|
+
if (!Array.isArray(redirectUris)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const first = redirectUris[0];
|
|
169
|
+
if (typeof first !== "string" || first.length === 0) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
return first;
|
|
173
|
+
}
|
|
174
|
+
export async function startCallbackServer(expectedState, options) {
|
|
175
|
+
let redirectUri = options?.redirectUri;
|
|
176
|
+
if (!redirectUri) {
|
|
177
|
+
const port = await getPort({ port: 3334 });
|
|
178
|
+
redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
|
|
179
|
+
}
|
|
180
|
+
const redirectUrl = new URL(redirectUri);
|
|
181
|
+
if (redirectUrl.protocol !== "http:") {
|
|
182
|
+
throw new Error(`Invalid redirect URI protocol: ${redirectUri}`);
|
|
183
|
+
}
|
|
184
|
+
if (redirectUrl.pathname !== "/oauth/callback") {
|
|
185
|
+
throw new Error(`Invalid redirect URI path: ${redirectUri}`);
|
|
186
|
+
}
|
|
187
|
+
const port = Number(redirectUrl.port);
|
|
188
|
+
if (!port || Number.isNaN(port)) {
|
|
189
|
+
throw new Error(`Redirect URI must include an explicit port (e.g. http://127.0.0.1:3334/oauth/callback), got: ${redirectUri}`);
|
|
190
|
+
}
|
|
191
|
+
const hostname = redirectUrl.hostname;
|
|
152
192
|
const server = http.createServer();
|
|
153
193
|
let closed = false;
|
|
194
|
+
const sockets = new Set();
|
|
195
|
+
server.on("connection", (socket) => {
|
|
196
|
+
sockets.add(socket);
|
|
197
|
+
socket.on("close", () => sockets.delete(socket));
|
|
198
|
+
});
|
|
154
199
|
const close = () => new Promise((resolve) => {
|
|
155
200
|
if (closed) {
|
|
156
201
|
resolve();
|
|
157
202
|
return;
|
|
158
203
|
}
|
|
159
204
|
closed = true;
|
|
205
|
+
for (const socket of sockets) {
|
|
206
|
+
socket.destroy();
|
|
207
|
+
}
|
|
160
208
|
server.close(() => resolve());
|
|
161
209
|
});
|
|
162
210
|
const codePromise = new Promise((resolve, reject) => {
|
|
@@ -176,7 +224,10 @@ export async function startCallbackServer(expectedState) {
|
|
|
176
224
|
reject(new Error("Invalid OAuth response"));
|
|
177
225
|
return;
|
|
178
226
|
}
|
|
179
|
-
res.writeHead(200, {
|
|
227
|
+
res.writeHead(200, {
|
|
228
|
+
"content-type": "text/plain",
|
|
229
|
+
connection: "close",
|
|
230
|
+
});
|
|
180
231
|
res.end("Authentication complete. You can return to the CLI.");
|
|
181
232
|
resolve(code);
|
|
182
233
|
}
|
|
@@ -190,8 +241,21 @@ export async function startCallbackServer(expectedState) {
|
|
|
190
241
|
}
|
|
191
242
|
});
|
|
192
243
|
});
|
|
193
|
-
await new Promise((resolve) => {
|
|
194
|
-
|
|
244
|
+
await new Promise((resolve, reject) => {
|
|
245
|
+
const onError = (err) => {
|
|
246
|
+
reject(err);
|
|
247
|
+
};
|
|
248
|
+
server.once("error", onError);
|
|
249
|
+
server.listen(port, hostname, () => {
|
|
250
|
+
server.off("error", onError);
|
|
251
|
+
resolve();
|
|
252
|
+
});
|
|
253
|
+
}).catch((err) => {
|
|
254
|
+
const code = err.code;
|
|
255
|
+
if (code === "EADDRINUSE") {
|
|
256
|
+
throw new Error(`OAuth callback port ${port} is already in use (redirect URI: ${redirectUri}). Close the other process using it and retry.`);
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
195
259
|
});
|
|
196
260
|
return { redirectUri, codePromise, close };
|
|
197
261
|
}
|
|
@@ -203,9 +267,20 @@ export async function loginWithDynamicOAuth(options) {
|
|
|
203
267
|
allowRedirect: true,
|
|
204
268
|
staticClientInfo: options.staticClientInfo,
|
|
205
269
|
});
|
|
206
|
-
const
|
|
270
|
+
const redirectUriFromEnv = process.env.MCP_JIRA_REDIRECT_URI;
|
|
271
|
+
let redirectUri = redirectUriFromEnv;
|
|
272
|
+
if (!redirectUri) {
|
|
273
|
+
if (options.staticClientInfo) {
|
|
274
|
+
redirectUri = "http://127.0.0.1:3334/oauth/callback";
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
const clientInfo = await provider.clientInformation();
|
|
278
|
+
redirectUri = extractRedirectUriFromClientInfo(clientInfo);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const { redirectUri: callbackRedirectUri, codePromise, close, } = await startCallbackServer(provider.getState(), { redirectUri });
|
|
207
282
|
try {
|
|
208
|
-
provider.setRedirectUrl(
|
|
283
|
+
provider.setRedirectUrl(callbackRedirectUri);
|
|
209
284
|
const result = await auth(provider, {
|
|
210
285
|
serverUrl: MCP_SERVER_URL,
|
|
211
286
|
scope: options.scopes.join(" "),
|
|
@@ -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.1",
|
|
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
|
-
}
|