@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.
@@ -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
- * Persist a rotated refresh token across every config location we know
157
- * users store MCP settings in:
158
- * - ~/.mcp.json (Claude Desktop)
159
- * - ~/.claude/settings.json (Claude Code)
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
- * We update whichever files have the loop-operations stanza. Missing
162
- * files are skipped silently. This mirrors @loopops/mcp-cli's
163
- * `updateRefreshToken` helper — keep the two in sync.
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 paths = [
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 ~/.mcp.json.
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 || !refreshToken) {
206
- throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID / OKTA_REFRESH_TOKEN. Re-run `npx @loopops/mcp-cli login`.");
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
- const tokens = (await response.json());
222
- cachedAccessToken = tokens.access_token;
223
- cachedAccessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
224
- // Handle rotation (our Okta policy rotates on every use).
225
- if (tokens.refresh_token && tokens.refresh_token !== refreshToken) {
226
- refreshToken = tokens.refresh_token;
227
- persistRotatedRefreshToken(tokens.refresh_token);
228
- }
229
- return cachedAccessToken;
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 { z } from "zod";
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, active/revoked key counts, and last-active date. The review itself is audited auditors can see who produced it and when. Intended for quarterly access reviews.", {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopops/mcp-server",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Loop Operations MCP Server — AI skills for RevOps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/dist/db.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import pg from "pg";
2
- export type Db = pg.Pool;
3
- export declare function createDb(connectionString: string): Db;
package/dist/db.js DELETED
@@ -1,8 +0,0 @@
1
- import pg from "pg";
2
- export function createDb(connectionString) {
3
- return new pg.Pool({
4
- connectionString,
5
- max: 3,
6
- idleTimeoutMillis: 30000,
7
- });
8
- }
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
- }