@loopops/mcp-server 2.0.1 → 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 +121 -38
- package/dist/token-lock.d.ts +27 -0
- package/dist/token-lock.js +96 -0
- package/dist/tools/eng.js +2 -20
- 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
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* updater helper, and also keep the latest rotated token in memory
|
|
21
21
|
* in case Claude Desktop doesn't restart before next use.
|
|
22
22
|
*/
|
|
23
|
-
import { homedir } from "node:os";
|
|
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;
|
|
@@ -153,25 +154,84 @@ function isRetryable(err) {
|
|
|
153
154
|
return false;
|
|
154
155
|
}
|
|
155
156
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
157
|
+
* OS-specific location Claude Desktop reads its config from. We have
|
|
158
|
+
* to write here in addition to ~/.mcp.json, because Claude Desktop
|
|
159
|
+
* does NOT read ~/.mcp.json on macOS (it reads Application Support
|
|
160
|
+
* instead). If we only update ~/.mcp.json + ~/.claude/settings.json,
|
|
161
|
+
* Claude Desktop falls behind after every rotation and breaks with
|
|
162
|
+
* "invalid_grant" on its next cold start.
|
|
163
|
+
*/
|
|
164
|
+
function claudeDesktopConfigPath() {
|
|
165
|
+
const home = homedir();
|
|
166
|
+
switch (platform()) {
|
|
167
|
+
case "darwin":
|
|
168
|
+
return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
169
|
+
case "win32": {
|
|
170
|
+
const appdata = process.env.APPDATA;
|
|
171
|
+
return appdata
|
|
172
|
+
? join(appdata, "Claude", "claude_desktop_config.json")
|
|
173
|
+
: null;
|
|
174
|
+
}
|
|
175
|
+
case "linux":
|
|
176
|
+
return join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
177
|
+
default:
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
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
|
|
187
|
+
*/
|
|
188
|
+
function tokenConfigPaths() {
|
|
189
|
+
const paths = [
|
|
190
|
+
join(homedir(), ".claude", "settings.json"),
|
|
191
|
+
join(homedir(), ".mcp.json"),
|
|
192
|
+
];
|
|
193
|
+
const desktop = claudeDesktopConfigPath();
|
|
194
|
+
if (desktop)
|
|
195
|
+
paths.push(desktop);
|
|
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.
|
|
160
202
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
|
|
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.
|
|
164
228
|
*
|
|
165
229
|
* Best-effort: if all writes fail, we still keep the new token
|
|
166
230
|
* in-memory so the current subprocess keeps working. The next cold
|
|
167
231
|
* spawn is the one that breaks.
|
|
168
232
|
*/
|
|
169
233
|
function persistRotatedRefreshToken(newToken) {
|
|
170
|
-
const
|
|
171
|
-
join(homedir(), ".mcp.json"),
|
|
172
|
-
join(homedir(), ".claude", "settings.json"),
|
|
173
|
-
];
|
|
174
|
-
for (const path of paths) {
|
|
234
|
+
for (const path of tokenConfigPaths()) {
|
|
175
235
|
try {
|
|
176
236
|
if (!existsSync(path))
|
|
177
237
|
continue;
|
|
@@ -199,34 +259,57 @@ function persistRotatedRefreshToken(newToken) {
|
|
|
199
259
|
/**
|
|
200
260
|
* Mint a fresh access token via Okta's /token endpoint. Updates the
|
|
201
261
|
* in-memory cache AND (if Okta rotated the refresh token) persists the
|
|
202
|
-
* 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.
|
|
203
271
|
*/
|
|
204
272
|
async function refreshAccessTokenOnce() {
|
|
205
|
-
if (!oktaIssuer || !oktaClientId
|
|
206
|
-
throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID
|
|
207
|
-
}
|
|
208
|
-
const response = await doFetch(`${oktaIssuer}/v1/token`, {
|
|
209
|
-
method: "POST",
|
|
210
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
211
|
-
body: new URLSearchParams({
|
|
212
|
-
grant_type: "refresh_token",
|
|
213
|
-
client_id: oktaClientId,
|
|
214
|
-
refresh_token: refreshToken,
|
|
215
|
-
}),
|
|
216
|
-
}, DEFAULT_TIMEOUT_MS);
|
|
217
|
-
if (!response.ok) {
|
|
218
|
-
const body = await response.text().catch(() => "<unreadable>");
|
|
219
|
-
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`.");
|
|
220
275
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
});
|
|
230
313
|
}
|
|
231
314
|
async function acquireAccessToken(forceRefresh = false) {
|
|
232
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/dist/tools/eng.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { trpcMutation, trpcQuery } from "../api-client.js";
|
|
2
|
+
import { trpcQuery } from "../api-client.js";
|
|
4
3
|
import { safeTool } from "./_helpers.js";
|
|
5
4
|
import { rangeSchema } from "./_schemas.js";
|
|
6
5
|
export function registerEngTools(server, allowed) {
|
|
@@ -11,25 +10,8 @@ export function registerEngTools(server, allowed) {
|
|
|
11
10
|
.describe("Time window. Short ranges (1h, 6h, 24h, 7d) are most useful. Default: 24h."),
|
|
12
11
|
}, safeTool(async ({ range }) => trpcQuery("mcp.systemDiagnostics", { range })));
|
|
13
12
|
}
|
|
14
|
-
if (allowed.has("revoke_api_key")) {
|
|
15
|
-
server.tool("revoke_api_key", "Immediately revoke every active MCP API key for a user. The user's loop_sk_* tokens in Claude Desktop stop working on the next tRPC call (401). Use for urgent access cutoff (suspected compromise, termination not yet processed in Okta). Routine offboarding should just deactivate the user in Okta — the 5-min reconciler picks that up.", {
|
|
16
|
-
email: z
|
|
17
|
-
.string()
|
|
18
|
-
.email()
|
|
19
|
-
.describe("Email of the Loop user to revoke."),
|
|
20
|
-
reason: z
|
|
21
|
-
.string()
|
|
22
|
-
.min(8)
|
|
23
|
-
.describe("Why you're revoking. Required — recorded in audit_log."),
|
|
24
|
-
}, safeTool(async ({ email, reason }) => trpcMutation("mcp.revokeApiKey", { email, reason })));
|
|
25
|
-
}
|
|
26
13
|
if (allowed.has("access_review")) {
|
|
27
|
-
server.tool("access_review", "Produce a user-access snapshot for SOC/security review. Lists every Loop user with role, status, SF linkage,
|
|
28
|
-
includeRevoked: z
|
|
29
|
-
.boolean()
|
|
30
|
-
.optional()
|
|
31
|
-
.describe("If true, also include users whose API keys have been revoked (for full historical picture). Default: false (active access only)."),
|
|
32
|
-
}, safeTool(async ({ includeRevoked }) => trpcQuery("mcp.accessReview", { includeRevoked })));
|
|
14
|
+
server.tool("access_review", "Produce a user-access snapshot for SOC/security review. Lists every active Loop user with role, status, SF linkage, and last-active date. The review itself is audited. Intended for quarterly access reviews. For token-level detail (issued/rotated/revoked), query the Okta system log directly.", {}, safeTool(async () => trpcQuery("mcp.accessReview")));
|
|
33
15
|
}
|
|
34
16
|
if (allowed.has("sync_local")) {
|
|
35
17
|
server.tool("sync_local", "Pull the latest changes from the remote repo into your local working tree. Runs `git pull --ff-only` in the repo at $LOOP_REPO_PATH. Use this after update_config / deploy_config writes commits directly to the remote branch so your local clone catches up.", {}, safeTool(async () => {
|
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
|
-
}
|