@loopops/mcp-server 2.0.2 → 2.0.3
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/api-client.js +95 -41
- package/dist/token-lock.d.ts +27 -0
- package/dist/token-lock.js +96 -0
- package/package.json +1 -1
- package/dist/db.d.ts +0 -3
- package/dist/db.js +0 -8
- package/dist/roles.d.ts +0 -1
- package/dist/roles.js +0 -33
package/dist/api-client.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { homedir, platform } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { withRefreshLock } from "./token-lock.js";
|
|
26
27
|
const apiUrl = process.env.API_URL;
|
|
27
28
|
const oktaIssuer = process.env.OKTA_ISSUER;
|
|
28
29
|
const oktaClientId = process.env.OKTA_CLIENT_ID;
|
|
@@ -178,29 +179,59 @@ function claudeDesktopConfigPath() {
|
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* We update whichever files have the loop-operations stanza. Missing
|
|
188
|
-
* files are skipped silently. This mirrors @loopops/mcp-cli's
|
|
189
|
-
* `updateRefreshToken` helper — keep the two in sync.
|
|
190
|
-
*
|
|
191
|
-
* Best-effort: if all writes fail, we still keep the new token
|
|
192
|
-
* in-memory so the current subprocess keeps working. The next cold
|
|
193
|
-
* spawn is the one that breaks.
|
|
182
|
+
* Every config path that might hold the loop-operations MCP stanza.
|
|
183
|
+
* Priority order matches `@loopops/mcp-cli`'s `getClaudeConfig`:
|
|
184
|
+
* 1. ~/.claude/settings.json (Claude Code)
|
|
185
|
+
* 2. ~/.mcp.json (CLI canonical, also legacy Claude Desktop path)
|
|
186
|
+
* 3. OS-specific Claude Desktop config
|
|
194
187
|
*/
|
|
195
|
-
function
|
|
188
|
+
function tokenConfigPaths() {
|
|
196
189
|
const paths = [
|
|
197
|
-
join(homedir(), ".mcp.json"),
|
|
198
190
|
join(homedir(), ".claude", "settings.json"),
|
|
191
|
+
join(homedir(), ".mcp.json"),
|
|
199
192
|
];
|
|
200
193
|
const desktop = claudeDesktopConfigPath();
|
|
201
194
|
if (desktop)
|
|
202
195
|
paths.push(desktop);
|
|
203
|
-
|
|
196
|
+
return paths;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Read the current refresh token from disk, trying each config path
|
|
200
|
+
* in priority order. Returns the first token we find, or null if none
|
|
201
|
+
* of the files have the loop-operations stanza.
|
|
202
|
+
*
|
|
203
|
+
* Callers MUST hold the refresh lock before reading — otherwise another
|
|
204
|
+
* process may rotate between read and use.
|
|
205
|
+
*/
|
|
206
|
+
function readRefreshTokenFromDisk() {
|
|
207
|
+
for (const path of tokenConfigPaths()) {
|
|
208
|
+
try {
|
|
209
|
+
if (!existsSync(path))
|
|
210
|
+
continue;
|
|
211
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
212
|
+
const token = data.mcpServers?.[SERVER_NAME]?.env?.OKTA_REFRESH_TOKEN;
|
|
213
|
+
if (typeof token === "string" && token.length > 0)
|
|
214
|
+
return token;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Try the next path.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Persist a rotated refresh token across every config location we know
|
|
224
|
+
* users store MCP settings in. We update whichever files have the
|
|
225
|
+
* loop-operations stanza. Missing files are skipped silently. This
|
|
226
|
+
* mirrors @loopops/mcp-cli's `updateRefreshToken` helper — keep the
|
|
227
|
+
* two in sync.
|
|
228
|
+
*
|
|
229
|
+
* Best-effort: if all writes fail, we still keep the new token
|
|
230
|
+
* in-memory so the current subprocess keeps working. The next cold
|
|
231
|
+
* spawn is the one that breaks.
|
|
232
|
+
*/
|
|
233
|
+
function persistRotatedRefreshToken(newToken) {
|
|
234
|
+
for (const path of tokenConfigPaths()) {
|
|
204
235
|
try {
|
|
205
236
|
if (!existsSync(path))
|
|
206
237
|
continue;
|
|
@@ -228,34 +259,57 @@ function persistRotatedRefreshToken(newToken) {
|
|
|
228
259
|
/**
|
|
229
260
|
* Mint a fresh access token via Okta's /token endpoint. Updates the
|
|
230
261
|
* in-memory cache AND (if Okta rotated the refresh token) persists the
|
|
231
|
-
* new refresh token to
|
|
262
|
+
* new refresh token to every MCP config path.
|
|
263
|
+
*
|
|
264
|
+
* Serialised across processes with a lockfile. Our Okta policy rotates
|
|
265
|
+
* the refresh token on every use, so two processes hitting /token in
|
|
266
|
+
* parallel would race: one would succeed with the current token, the
|
|
267
|
+
* other would see invalid_grant (or bank a token that the first
|
|
268
|
+
* rotation has already superseded). The lock ensures only one /token
|
|
269
|
+
* call is in flight per machine; losers wait, then re-read the
|
|
270
|
+
* freshly-rotated token from disk and proceed.
|
|
232
271
|
*/
|
|
233
272
|
async function refreshAccessTokenOnce() {
|
|
234
|
-
if (!oktaIssuer || !oktaClientId
|
|
235
|
-
throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID
|
|
236
|
-
}
|
|
237
|
-
const response = await doFetch(`${oktaIssuer}/v1/token`, {
|
|
238
|
-
method: "POST",
|
|
239
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
240
|
-
body: new URLSearchParams({
|
|
241
|
-
grant_type: "refresh_token",
|
|
242
|
-
client_id: oktaClientId,
|
|
243
|
-
refresh_token: refreshToken,
|
|
244
|
-
}),
|
|
245
|
-
}, DEFAULT_TIMEOUT_MS);
|
|
246
|
-
if (!response.ok) {
|
|
247
|
-
const body = await response.text().catch(() => "<unreadable>");
|
|
248
|
-
throw new OktaRefreshError(`Okta refresh failed (HTTP ${response.status}). Your refresh token may be revoked or idle-expired. Re-run \`npx @loopops/mcp-cli login\`. Detail: ${body.slice(0, 300)}`);
|
|
273
|
+
if (!oktaIssuer || !oktaClientId) {
|
|
274
|
+
throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID. Re-run `npx @loopops/mcp-cli login`.");
|
|
249
275
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
276
|
+
return await withRefreshLock(async () => {
|
|
277
|
+
// Re-read from disk inside the lock. Our in-memory `refreshToken`
|
|
278
|
+
// may be stale because another process already rotated it and
|
|
279
|
+
// wrote the new value.
|
|
280
|
+
const diskToken = readRefreshTokenFromDisk();
|
|
281
|
+
const currentToken = diskToken ?? refreshToken;
|
|
282
|
+
if (!currentToken) {
|
|
283
|
+
throw new OktaRefreshError("Missing OKTA_REFRESH_TOKEN. Re-run `npx @loopops/mcp-cli login`.");
|
|
284
|
+
}
|
|
285
|
+
if (diskToken && diskToken !== refreshToken) {
|
|
286
|
+
// Pick up a newer token another process already rotated for us;
|
|
287
|
+
// sync in-memory to match disk.
|
|
288
|
+
refreshToken = diskToken;
|
|
289
|
+
}
|
|
290
|
+
const response = await doFetch(`${oktaIssuer}/v1/token`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
293
|
+
body: new URLSearchParams({
|
|
294
|
+
grant_type: "refresh_token",
|
|
295
|
+
client_id: oktaClientId,
|
|
296
|
+
refresh_token: currentToken,
|
|
297
|
+
}),
|
|
298
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
const body = await response.text().catch(() => "<unreadable>");
|
|
301
|
+
throw new OktaRefreshError(`Okta refresh failed (HTTP ${response.status}). Your refresh token may be revoked or idle-expired. Re-run \`npx @loopops/mcp-cli login\`. Detail: ${body.slice(0, 300)}`);
|
|
302
|
+
}
|
|
303
|
+
const tokens = (await response.json());
|
|
304
|
+
cachedAccessToken = tokens.access_token;
|
|
305
|
+
cachedAccessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
|
|
306
|
+
// Handle rotation (our Okta policy rotates on every use).
|
|
307
|
+
if (tokens.refresh_token && tokens.refresh_token !== currentToken) {
|
|
308
|
+
refreshToken = tokens.refresh_token;
|
|
309
|
+
persistRotatedRefreshToken(tokens.refresh_token);
|
|
310
|
+
}
|
|
311
|
+
return cachedAccessToken;
|
|
312
|
+
});
|
|
259
313
|
}
|
|
260
314
|
async function acquireAccessToken(forceRefresh = false) {
|
|
261
315
|
if (!forceRefresh &&
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process exclusive lock for Okta refresh-token rotation.
|
|
3
|
+
*
|
|
4
|
+
* Our Okta policy rotates the refresh token on every use. When multiple
|
|
5
|
+
* processes hold the same refresh token (Claude Desktop + Claude Code +
|
|
6
|
+
* any other MCP host), each one independently calling Okta's /token
|
|
7
|
+
* endpoint races the rotation — one wins, the others get invalid_grant
|
|
8
|
+
* (or worse, bank a rotated token that's already been superseded).
|
|
9
|
+
*
|
|
10
|
+
* This lock serialises rotation across all processes on the machine, so
|
|
11
|
+
* only one /token call is in flight at a time. Every holder must:
|
|
12
|
+
* 1. Acquire the lock.
|
|
13
|
+
* 2. Re-read the refresh token from DISK (not memory — another
|
|
14
|
+
* process may have just rotated it).
|
|
15
|
+
* 3. Call Okta.
|
|
16
|
+
* 4. Persist the new token before releasing.
|
|
17
|
+
*
|
|
18
|
+
* Implementation: atomic O_EXCL lockfile creation. Cross-platform
|
|
19
|
+
* (POSIX + Windows NTFS both honour it). If the holder crashes, the
|
|
20
|
+
* lockfile persists — we treat it as stale after LOCK_STALE_MS and
|
|
21
|
+
* reclaim it.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: `@loopops/mcp-cli` ships a mirror of this file. They are
|
|
24
|
+
* separate published packages installed independently via npx. Keep
|
|
25
|
+
* both copies in sync.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withRefreshLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process exclusive lock for Okta refresh-token rotation.
|
|
3
|
+
*
|
|
4
|
+
* Our Okta policy rotates the refresh token on every use. When multiple
|
|
5
|
+
* processes hold the same refresh token (Claude Desktop + Claude Code +
|
|
6
|
+
* any other MCP host), each one independently calling Okta's /token
|
|
7
|
+
* endpoint races the rotation — one wins, the others get invalid_grant
|
|
8
|
+
* (or worse, bank a rotated token that's already been superseded).
|
|
9
|
+
*
|
|
10
|
+
* This lock serialises rotation across all processes on the machine, so
|
|
11
|
+
* only one /token call is in flight at a time. Every holder must:
|
|
12
|
+
* 1. Acquire the lock.
|
|
13
|
+
* 2. Re-read the refresh token from DISK (not memory — another
|
|
14
|
+
* process may have just rotated it).
|
|
15
|
+
* 3. Call Okta.
|
|
16
|
+
* 4. Persist the new token before releasing.
|
|
17
|
+
*
|
|
18
|
+
* Implementation: atomic O_EXCL lockfile creation. Cross-platform
|
|
19
|
+
* (POSIX + Windows NTFS both honour it). If the holder crashes, the
|
|
20
|
+
* lockfile persists — we treat it as stale after LOCK_STALE_MS and
|
|
21
|
+
* reclaim it.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: `@loopops/mcp-cli` ships a mirror of this file. They are
|
|
24
|
+
* separate published packages installed independently via npx. Keep
|
|
25
|
+
* both copies in sync.
|
|
26
|
+
*/
|
|
27
|
+
import { closeSync, openSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
const LOCK_PATH = join(homedir(), ".loopops-refresh.lock");
|
|
31
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
32
|
+
const LOCK_STALE_MS = 30_000;
|
|
33
|
+
const POLL_INTERVAL_MS = 50;
|
|
34
|
+
export async function withRefreshLock(fn) {
|
|
35
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
36
|
+
let fd;
|
|
37
|
+
while (true) {
|
|
38
|
+
try {
|
|
39
|
+
fd = openSync(LOCK_PATH, "wx");
|
|
40
|
+
try {
|
|
41
|
+
writeSync(fd, `${process.pid}\n`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Writing PID is best-effort diagnostics; ignore failure.
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const code = err.code;
|
|
50
|
+
if (code !== "EEXIST")
|
|
51
|
+
throw err;
|
|
52
|
+
// Lock exists. Check if the holder crashed (stale lock).
|
|
53
|
+
try {
|
|
54
|
+
const age = Date.now() - statSync(LOCK_PATH).mtimeMs;
|
|
55
|
+
if (age > LOCK_STALE_MS) {
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(LOCK_PATH);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Raced another reclaimer; loop and retry.
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Lockfile vanished between EEXIST and stat; loop and retry.
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (Date.now() >= deadline) {
|
|
70
|
+
throw new Error(`Timed out after ${LOCK_TIMEOUT_MS}ms waiting for refresh lock ` +
|
|
71
|
+
`at ${LOCK_PATH}. Another process may be holding it. If this ` +
|
|
72
|
+
`persists, remove the file manually.`);
|
|
73
|
+
}
|
|
74
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return await fn();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (fd !== undefined) {
|
|
82
|
+
try {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// best-effort
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(LOCK_PATH);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// best-effort
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
package/package.json
CHANGED
package/dist/db.d.ts
DELETED
package/dist/db.js
DELETED
package/dist/roles.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function loadSkillsForRole(roleName: string): Promise<Set<string>>;
|
package/dist/roles.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { resolve, dirname } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { parse } from "yaml";
|
|
5
|
-
function resolveSkills(roles, roleName, visited = new Set()) {
|
|
6
|
-
if (visited.has(roleName))
|
|
7
|
-
return [];
|
|
8
|
-
visited.add(roleName);
|
|
9
|
-
const role = roles[roleName];
|
|
10
|
-
if (!role)
|
|
11
|
-
return [];
|
|
12
|
-
const inherited = role.inherits
|
|
13
|
-
? resolveSkills(roles, role.inherits, visited)
|
|
14
|
-
: [];
|
|
15
|
-
return [...new Set([...inherited, ...role.skills])];
|
|
16
|
-
}
|
|
17
|
-
export async function loadSkillsForRole(roleName) {
|
|
18
|
-
// Look for skills.yaml relative to the project root
|
|
19
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const configPath = resolve(__dirname, "../../../config/mcp/skills.yaml");
|
|
21
|
-
// Try project-relative path first, then fall back to cwd-based path
|
|
22
|
-
let raw;
|
|
23
|
-
try {
|
|
24
|
-
raw = await readFile(configPath, "utf-8");
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
const cwdPath = resolve(process.cwd(), "config/mcp/skills.yaml");
|
|
28
|
-
raw = await readFile(cwdPath, "utf-8");
|
|
29
|
-
}
|
|
30
|
-
const config = parse(raw);
|
|
31
|
-
const skills = resolveSkills(config.roles, roleName);
|
|
32
|
-
return new Set(skills);
|
|
33
|
-
}
|