mcp-multi-jira 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +143 -0
- package/dist/agents/install.js +279 -0
- package/dist/cli.js +576 -0
- package/dist/config/paths.js +14 -0
- package/dist/config/store.js +54 -0
- package/dist/keytar-f6bnxfss.node +0 -0
- package/dist/mcp/mock.js +63 -0
- package/dist/mcp/remote-session.js +189 -0
- package/dist/mcp/remoteSession.js +163 -0
- package/dist/mcp/server.js +266 -0
- package/dist/mcp/session-manager.js +62 -0
- package/dist/mcp/sessionManager.js +62 -0
- package/dist/mcp/types.js +1 -0
- package/dist/oauth/atlassian.js +272 -0
- package/dist/oauth/client-info-store.js +29 -0
- package/dist/oauth/clientInfoStore.js +29 -0
- package/dist/security/token-store.js +214 -0
- package/dist/security/tokenStore.js +204 -0
- package/dist/types.js +1 -0
- package/dist/utils/fs.js +28 -0
- package/dist/utils/log.js +30 -0
- package/dist/version.js +4 -0
- package/package.json +60 -0
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { auth, discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, refreshAuthorization, selectResourceURL, } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
5
|
+
import getPort from "get-port";
|
|
6
|
+
import open from "open";
|
|
7
|
+
import { debug, info, warn } from "../utils/log.js";
|
|
8
|
+
import { readClientInfo, writeClientInfo } from "./client-info-store.js";
|
|
9
|
+
export const DEFAULT_SCOPES = [
|
|
10
|
+
"offline_access",
|
|
11
|
+
"read:jira-work",
|
|
12
|
+
"write:jira-work",
|
|
13
|
+
"read:jira-user",
|
|
14
|
+
];
|
|
15
|
+
export const MCP_SERVER_URL = process.env.MCP_JIRA_ENDPOINT ?? "https://mcp.atlassian.com/v1/mcp";
|
|
16
|
+
export const MCP_SSE_URL = process.env.MCP_JIRA_SSE_ENDPOINT ?? "https://mcp.atlassian.com/v1/sse";
|
|
17
|
+
export function getStaticClientInfoFromEnv(options) {
|
|
18
|
+
const clientId = options?.clientId ||
|
|
19
|
+
process.env.MCP_JIRA_CLIENT_ID ||
|
|
20
|
+
process.env.ATLASSIAN_CLIENT_ID;
|
|
21
|
+
const clientSecret = options?.clientSecret ||
|
|
22
|
+
process.env.MCP_JIRA_CLIENT_SECRET ||
|
|
23
|
+
process.env.ATLASSIAN_CLIENT_SECRET;
|
|
24
|
+
if (!clientId) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return { clientId, clientSecret };
|
|
28
|
+
}
|
|
29
|
+
function toTokenSet(tokens, fallbackScopes) {
|
|
30
|
+
const scopes = tokens.scope
|
|
31
|
+
? tokens.scope.split(" ").filter(Boolean)
|
|
32
|
+
: fallbackScopes;
|
|
33
|
+
const expiresIn = tokens.expires_in ?? 0;
|
|
34
|
+
return {
|
|
35
|
+
accessToken: tokens.access_token,
|
|
36
|
+
refreshToken: tokens.refresh_token,
|
|
37
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
38
|
+
scopes,
|
|
39
|
+
tokenType: tokens.token_type,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function toOAuthTokens(set) {
|
|
43
|
+
const expiresIn = Math.max(0, Math.floor((set.expiresAt - Date.now()) / 1000));
|
|
44
|
+
return {
|
|
45
|
+
access_token: set.accessToken,
|
|
46
|
+
refresh_token: set.refreshToken,
|
|
47
|
+
token_type: set.tokenType ?? "Bearer",
|
|
48
|
+
scope: set.scopes.join(" "),
|
|
49
|
+
expires_in: expiresIn,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export class LocalOAuthProvider {
|
|
53
|
+
alias;
|
|
54
|
+
tokenStore;
|
|
55
|
+
scopes;
|
|
56
|
+
allowRedirect;
|
|
57
|
+
staticClientInfo;
|
|
58
|
+
stateValue = crypto.randomUUID();
|
|
59
|
+
codeVerifierValue;
|
|
60
|
+
clientInfoCache;
|
|
61
|
+
redirectUrlValue;
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.alias = options.alias;
|
|
64
|
+
this.tokenStore = options.tokenStore;
|
|
65
|
+
this.scopes = options.scopes;
|
|
66
|
+
this.allowRedirect = options.allowRedirect;
|
|
67
|
+
this.staticClientInfo = options.staticClientInfo;
|
|
68
|
+
}
|
|
69
|
+
setRedirectUrl(url) {
|
|
70
|
+
this.redirectUrlValue = url;
|
|
71
|
+
}
|
|
72
|
+
getState() {
|
|
73
|
+
return this.stateValue;
|
|
74
|
+
}
|
|
75
|
+
get redirectUrl() {
|
|
76
|
+
return this.redirectUrlValue;
|
|
77
|
+
}
|
|
78
|
+
get clientMetadata() {
|
|
79
|
+
return {
|
|
80
|
+
redirect_uris: this.redirectUrlValue ? [this.redirectUrlValue] : [],
|
|
81
|
+
token_endpoint_auth_method: this.staticClientInfo?.clientSecret
|
|
82
|
+
? "client_secret_post"
|
|
83
|
+
: "none",
|
|
84
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
85
|
+
response_types: ["code"],
|
|
86
|
+
client_name: "mcp-jira",
|
|
87
|
+
client_uri: "https://github.com/",
|
|
88
|
+
scope: this.scopes.join(" "),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
state() {
|
|
92
|
+
return this.stateValue;
|
|
93
|
+
}
|
|
94
|
+
async clientInformation() {
|
|
95
|
+
if (this.staticClientInfo) {
|
|
96
|
+
return {
|
|
97
|
+
client_id: this.staticClientInfo.clientId,
|
|
98
|
+
client_secret: this.staticClientInfo.clientSecret,
|
|
99
|
+
token_endpoint_auth_method: this.staticClientInfo.clientSecret
|
|
100
|
+
? "client_secret_post"
|
|
101
|
+
: "none",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (this.clientInfoCache !== undefined) {
|
|
105
|
+
return this.clientInfoCache ?? undefined;
|
|
106
|
+
}
|
|
107
|
+
const stored = await readClientInfo(MCP_SERVER_URL);
|
|
108
|
+
this.clientInfoCache = stored;
|
|
109
|
+
return stored ?? undefined;
|
|
110
|
+
}
|
|
111
|
+
async saveClientInformation(clientInfo) {
|
|
112
|
+
this.clientInfoCache = clientInfo;
|
|
113
|
+
await writeClientInfo(MCP_SERVER_URL, clientInfo);
|
|
114
|
+
}
|
|
115
|
+
async tokens() {
|
|
116
|
+
const tokens = await this.tokenStore.get(this.alias);
|
|
117
|
+
if (!tokens) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
return toOAuthTokens(tokens);
|
|
121
|
+
}
|
|
122
|
+
async saveTokens(tokens) {
|
|
123
|
+
const set = toTokenSet(tokens, this.scopes);
|
|
124
|
+
await this.tokenStore.set(this.alias, set);
|
|
125
|
+
}
|
|
126
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
127
|
+
if (!this.allowRedirect) {
|
|
128
|
+
throw new Error("Authorization required. Run `mcp-multi-jira login <alias>` to reauthenticate.");
|
|
129
|
+
}
|
|
130
|
+
info("Open the following URL in your browser to authorize:");
|
|
131
|
+
info(authorizationUrl.toString());
|
|
132
|
+
try {
|
|
133
|
+
await open(authorizationUrl.toString());
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
warn(`Failed to open browser automatically: ${String(err)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
saveCodeVerifier(codeVerifier) {
|
|
140
|
+
this.codeVerifierValue = codeVerifier;
|
|
141
|
+
}
|
|
142
|
+
codeVerifier() {
|
|
143
|
+
if (!this.codeVerifierValue) {
|
|
144
|
+
throw new Error("Missing OAuth code verifier.");
|
|
145
|
+
}
|
|
146
|
+
return this.codeVerifierValue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function startCallbackServer(expectedState) {
|
|
150
|
+
const port = await getPort({ port: 3334 });
|
|
151
|
+
const redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
|
|
152
|
+
const server = http.createServer();
|
|
153
|
+
let closed = false;
|
|
154
|
+
const close = () => new Promise((resolve) => {
|
|
155
|
+
if (closed) {
|
|
156
|
+
resolve();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
closed = true;
|
|
160
|
+
server.close(() => resolve());
|
|
161
|
+
});
|
|
162
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
163
|
+
server.on("request", (req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
const url = new URL(req.url ?? "", redirectUri);
|
|
166
|
+
if (url.pathname !== "/oauth/callback") {
|
|
167
|
+
res.writeHead(404);
|
|
168
|
+
res.end("Not found");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const code = url.searchParams.get("code");
|
|
172
|
+
const state = url.searchParams.get("state");
|
|
173
|
+
if (!code || state !== expectedState) {
|
|
174
|
+
res.writeHead(400);
|
|
175
|
+
res.end("Invalid OAuth response.");
|
|
176
|
+
reject(new Error("Invalid OAuth response"));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
180
|
+
res.end("Authentication complete. You can return to the CLI.");
|
|
181
|
+
resolve(code);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
reject(err);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
close().catch(() => undefined);
|
|
189
|
+
}, 100);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
await new Promise((resolve) => {
|
|
194
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
195
|
+
});
|
|
196
|
+
return { redirectUri, codePromise, close };
|
|
197
|
+
}
|
|
198
|
+
export async function loginWithDynamicOAuth(options) {
|
|
199
|
+
const provider = new LocalOAuthProvider({
|
|
200
|
+
alias: options.alias,
|
|
201
|
+
tokenStore: options.tokenStore,
|
|
202
|
+
scopes: options.scopes,
|
|
203
|
+
allowRedirect: true,
|
|
204
|
+
staticClientInfo: options.staticClientInfo,
|
|
205
|
+
});
|
|
206
|
+
const { redirectUri, codePromise, close } = await startCallbackServer(provider.getState());
|
|
207
|
+
try {
|
|
208
|
+
provider.setRedirectUrl(redirectUri);
|
|
209
|
+
const result = await auth(provider, {
|
|
210
|
+
serverUrl: MCP_SERVER_URL,
|
|
211
|
+
scope: options.scopes.join(" "),
|
|
212
|
+
});
|
|
213
|
+
if (result !== "REDIRECT") {
|
|
214
|
+
await close();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const code = await codePromise;
|
|
218
|
+
await auth(provider, {
|
|
219
|
+
serverUrl: MCP_SERVER_URL,
|
|
220
|
+
authorizationCode: code,
|
|
221
|
+
scope: options.scopes.join(" "),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
await close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export async function refreshTokensIfNeeded(options) {
|
|
229
|
+
const existing = await options.tokenStore.get(options.alias);
|
|
230
|
+
if (!existing) {
|
|
231
|
+
throw new Error("Missing stored tokens.");
|
|
232
|
+
}
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
if (existing.expiresAt > now + 5 * 60 * 1000) {
|
|
235
|
+
return existing;
|
|
236
|
+
}
|
|
237
|
+
if (!existing.refreshToken) {
|
|
238
|
+
throw new Error("No refresh token available. Please login again.");
|
|
239
|
+
}
|
|
240
|
+
const provider = new LocalOAuthProvider({
|
|
241
|
+
alias: options.alias,
|
|
242
|
+
tokenStore: options.tokenStore,
|
|
243
|
+
scopes: options.scopes,
|
|
244
|
+
allowRedirect: false,
|
|
245
|
+
staticClientInfo: options.staticClientInfo,
|
|
246
|
+
});
|
|
247
|
+
const clientInfo = await provider.clientInformation();
|
|
248
|
+
if (!clientInfo) {
|
|
249
|
+
throw new Error("Missing OAuth client registration. Please login again.");
|
|
250
|
+
}
|
|
251
|
+
let resourceMetadata;
|
|
252
|
+
try {
|
|
253
|
+
resourceMetadata =
|
|
254
|
+
await discoverOAuthProtectedResourceMetadata(MCP_SERVER_URL);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
debug(`Protected resource metadata lookup failed: ${String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
const authServerUrl = resourceMetadata?.authorization_servers?.[0] ??
|
|
260
|
+
new URL("/", MCP_SERVER_URL);
|
|
261
|
+
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
|
|
262
|
+
const resource = await selectResourceURL(MCP_SERVER_URL, {}, resourceMetadata);
|
|
263
|
+
const refreshed = await refreshAuthorization(authServerUrl, {
|
|
264
|
+
metadata,
|
|
265
|
+
clientInformation: clientInfo,
|
|
266
|
+
refreshToken: existing.refreshToken,
|
|
267
|
+
resource,
|
|
268
|
+
});
|
|
269
|
+
const updated = toTokenSet(refreshed, options.scopes);
|
|
270
|
+
await options.tokenStore.set(options.alias, updated);
|
|
271
|
+
return updated;
|
|
272
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
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 PQueue from "p-queue";
|
|
6
|
+
import { plainTokenFilePath, tokenFilePath } from "../config/paths.js";
|
|
7
|
+
import { atomicWrite, ensureDir } from "../utils/fs.js";
|
|
8
|
+
const SERVICE_NAME = "mcp-jira";
|
|
9
|
+
const TOKEN_ENV = "MCP_JIRA_TOKEN_PASSWORD";
|
|
10
|
+
let cachedPassword = null;
|
|
11
|
+
const fileStoreQueue = new PQueue({ concurrency: 1 });
|
|
12
|
+
async function getMasterPassword(intent) {
|
|
13
|
+
if (cachedPassword !== null) {
|
|
14
|
+
return cachedPassword;
|
|
15
|
+
}
|
|
16
|
+
if (process.env[TOKEN_ENV] !== undefined) {
|
|
17
|
+
cachedPassword = process.env[TOKEN_ENV];
|
|
18
|
+
return cachedPassword;
|
|
19
|
+
}
|
|
20
|
+
if (!process.stdin.isTTY) {
|
|
21
|
+
throw new Error("Encrypted token store requires a password. Set MCP_JIRA_TOKEN_PASSWORD to run non-interactively.");
|
|
22
|
+
}
|
|
23
|
+
cachedPassword = await promptPassword({
|
|
24
|
+
message: intent === "read"
|
|
25
|
+
? "Enter master password to unlock Jira tokens"
|
|
26
|
+
: "Create a master password to encrypt Jira tokens",
|
|
27
|
+
mask: "*",
|
|
28
|
+
});
|
|
29
|
+
return cachedPassword;
|
|
30
|
+
}
|
|
31
|
+
async function loadEncryptedFile(password) {
|
|
32
|
+
const filePath = tokenFilePath();
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
35
|
+
const payload = JSON.parse(raw);
|
|
36
|
+
if (!payload.ciphertext) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
const salt = Buffer.from(payload.salt, "base64");
|
|
40
|
+
const iv = Buffer.from(payload.iv, "base64");
|
|
41
|
+
const tag = Buffer.from(payload.tag, "base64");
|
|
42
|
+
const ciphertext = Buffer.from(payload.ciphertext, "base64");
|
|
43
|
+
const key = crypto.scryptSync(password, salt, 32);
|
|
44
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
45
|
+
decipher.setAuthTag(tag);
|
|
46
|
+
const decrypted = Buffer.concat([
|
|
47
|
+
decipher.update(ciphertext),
|
|
48
|
+
decipher.final(),
|
|
49
|
+
]).toString("utf8");
|
|
50
|
+
return JSON.parse(decrypted);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.code === "ENOENT") {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function saveEncryptedFile(password, tokens) {
|
|
60
|
+
const salt = crypto.randomBytes(16);
|
|
61
|
+
const iv = crypto.randomBytes(12);
|
|
62
|
+
const key = crypto.scryptSync(password, salt, 32);
|
|
63
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
64
|
+
const plaintext = JSON.stringify(tokens);
|
|
65
|
+
const ciphertext = Buffer.concat([
|
|
66
|
+
cipher.update(plaintext, "utf8"),
|
|
67
|
+
cipher.final(),
|
|
68
|
+
]);
|
|
69
|
+
const tag = cipher.getAuthTag();
|
|
70
|
+
const payload = {
|
|
71
|
+
version: 1,
|
|
72
|
+
salt: salt.toString("base64"),
|
|
73
|
+
iv: iv.toString("base64"),
|
|
74
|
+
tag: tag.toString("base64"),
|
|
75
|
+
ciphertext: ciphertext.toString("base64"),
|
|
76
|
+
};
|
|
77
|
+
await ensureDir(path.dirname(tokenFilePath()));
|
|
78
|
+
await atomicWrite(tokenFilePath(), JSON.stringify(payload, null, 2));
|
|
79
|
+
}
|
|
80
|
+
async function loadPlainFile() {
|
|
81
|
+
const filePath = plainTokenFilePath();
|
|
82
|
+
try {
|
|
83
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
84
|
+
return JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err.code === "ENOENT") {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function savePlainFile(tokens) {
|
|
94
|
+
await ensureDir(path.dirname(plainTokenFilePath()));
|
|
95
|
+
await atomicWrite(plainTokenFilePath(), JSON.stringify(tokens, null, 2));
|
|
96
|
+
}
|
|
97
|
+
class EncryptedFileTokenStore {
|
|
98
|
+
async get(alias) {
|
|
99
|
+
const password = await getMasterPassword("read");
|
|
100
|
+
const tokens = await loadEncryptedFile(password);
|
|
101
|
+
return tokens[alias] ?? null;
|
|
102
|
+
}
|
|
103
|
+
async set(alias, tokens) {
|
|
104
|
+
await fileStoreQueue.add(async () => {
|
|
105
|
+
const password = await getMasterPassword("write");
|
|
106
|
+
const existing = await loadEncryptedFile(password);
|
|
107
|
+
existing[alias] = tokens;
|
|
108
|
+
await saveEncryptedFile(password, existing);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async remove(alias) {
|
|
112
|
+
await fileStoreQueue.add(async () => {
|
|
113
|
+
const password = await getMasterPassword("read");
|
|
114
|
+
const existing = await loadEncryptedFile(password);
|
|
115
|
+
if (existing[alias]) {
|
|
116
|
+
delete existing[alias];
|
|
117
|
+
await saveEncryptedFile(password, existing);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
class PlaintextTokenStore {
|
|
123
|
+
async get(alias) {
|
|
124
|
+
const tokens = await loadPlainFile();
|
|
125
|
+
return tokens[alias] ?? null;
|
|
126
|
+
}
|
|
127
|
+
async set(alias, tokens) {
|
|
128
|
+
await fileStoreQueue.add(async () => {
|
|
129
|
+
const existing = await loadPlainFile();
|
|
130
|
+
existing[alias] = tokens;
|
|
131
|
+
await savePlainFile(existing);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async remove(alias) {
|
|
135
|
+
await fileStoreQueue.add(async () => {
|
|
136
|
+
const existing = await loadPlainFile();
|
|
137
|
+
if (existing[alias]) {
|
|
138
|
+
delete existing[alias];
|
|
139
|
+
await savePlainFile(existing);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
class KeytarTokenStore {
|
|
145
|
+
keytar;
|
|
146
|
+
constructor(keytar) {
|
|
147
|
+
this.keytar = keytar;
|
|
148
|
+
}
|
|
149
|
+
async get(alias) {
|
|
150
|
+
const raw = await this.keytar.getPassword(SERVICE_NAME, `tokens:${alias}`);
|
|
151
|
+
if (!raw) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return JSON.parse(raw);
|
|
155
|
+
}
|
|
156
|
+
async set(alias, tokens) {
|
|
157
|
+
await this.keytar.setPassword(SERVICE_NAME, `tokens:${alias}`, JSON.stringify(tokens));
|
|
158
|
+
}
|
|
159
|
+
async remove(alias) {
|
|
160
|
+
await this.keytar.deletePassword(SERVICE_NAME, `tokens:${alias}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function loadKeytar() {
|
|
164
|
+
try {
|
|
165
|
+
const mod = await import("keytar");
|
|
166
|
+
return mod.default ?? mod;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export async function getAuthStatusForAlias(options) {
|
|
173
|
+
const allowPrompt = options.allowPrompt ?? false;
|
|
174
|
+
if (options.storeKind === "encrypted" &&
|
|
175
|
+
!allowPrompt &&
|
|
176
|
+
process.env[TOKEN_ENV] === undefined) {
|
|
177
|
+
return {
|
|
178
|
+
status: "locked",
|
|
179
|
+
reason: "Encrypted token store is locked. Set MCP_JIRA_TOKEN_PASSWORD or login interactively.",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const tokens = await options.tokenStore.get(options.alias);
|
|
183
|
+
if (!tokens) {
|
|
184
|
+
return {
|
|
185
|
+
status: "missing",
|
|
186
|
+
reason: "No tokens found. Run login to authenticate this account.",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (tokens.expiresAt < Date.now() && !tokens.refreshToken) {
|
|
190
|
+
return {
|
|
191
|
+
status: "expired",
|
|
192
|
+
reason: "Token expired and no refresh token available. Run login again.",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { status: "ok" };
|
|
196
|
+
}
|
|
197
|
+
export async function createTokenStore(options) {
|
|
198
|
+
const useKeychain = options?.useKeychain;
|
|
199
|
+
let store = options?.store ?? "encrypted";
|
|
200
|
+
if (useKeychain) {
|
|
201
|
+
store = "keychain";
|
|
202
|
+
}
|
|
203
|
+
if (store === "keychain") {
|
|
204
|
+
const keytar = await loadKeytar();
|
|
205
|
+
if (!keytar) {
|
|
206
|
+
throw new Error("Keychain usage requested but keytar could not be loaded. Reinstall dependencies or switch token storage to plain/encrypted.");
|
|
207
|
+
}
|
|
208
|
+
return new KeytarTokenStore(keytar);
|
|
209
|
+
}
|
|
210
|
+
if (store === "plain") {
|
|
211
|
+
return new PlaintextTokenStore();
|
|
212
|
+
}
|
|
213
|
+
return new EncryptedFileTokenStore();
|
|
214
|
+
}
|