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 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 getAuthStatusForAlias({
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 = (async () => {
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?.refreshToken) {
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
- await this.refreshTokens();
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
  }
@@ -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
- const port = await getPort({ port: 3334 });
151
- const redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
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, { "content-type": "text/plain" });
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
- server.listen(port, "127.0.0.1", () => resolve());
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 { redirectUri, codePromise, close } = await startCallbackServer(provider.getState());
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(redirectUri);
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.0",
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
- }