@loopops/mcp-server 2.0.2 → 2.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.
@@ -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
- * Persist a rotated refresh token across every config location we know
182
- * users store MCP settings in:
183
- * - ~/.mcp.json (CLI canonical, also legacy Claude Desktop path)
184
- * - ~/.claude/settings.json (Claude Code)
185
- * - OS-specific Claude Desktop config (macOS/Windows/Linux)
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 persistRotatedRefreshToken(newToken) {
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
- for (const path of paths) {
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 ~/.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.
232
271
  */
233
272
  async function refreshAccessTokenOnce() {
234
- if (!oktaIssuer || !oktaClientId || !refreshToken) {
235
- throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID / OKTA_REFRESH_TOKEN. Re-run `npx @loopops/mcp-cli login`.");
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
- const tokens = (await response.json());
251
- cachedAccessToken = tokens.access_token;
252
- cachedAccessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
253
- // Handle rotation (our Okta policy rotates on every use).
254
- if (tokens.refresh_token && tokens.refresh_token !== refreshToken) {
255
- refreshToken = tokens.refresh_token;
256
- persistRotatedRefreshToken(tokens.refresh_token);
257
- }
258
- 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
+ });
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
+ }
@@ -107,6 +107,50 @@ export function registerConfigTools(server, allowed) {
107
107
  branch,
108
108
  })));
109
109
  }
110
+ if (allowed.has("compare_scenarios")) {
111
+ server.tool("compare_scenarios", "Run the Plan capacity model for two scenarios and render a side-by-side comparison per measure. For each (territory × measure) cell, shows A vs B capacity, target, gap, and the delta (B−A). Use when evaluating a stretch / conservative / hiring-delay scenario against `base` before deciding whether to promote. No writes — neither scenario's `active` status changes.", {
112
+ scenarioA: z
113
+ .string()
114
+ .describe("First scenario id (e.g. 'base'). Must exist in config/plan/scenarios/ and be complete."),
115
+ scenarioB: z
116
+ .string()
117
+ .describe("Second scenario id to compare against A (e.g. 'stretch'). Must exist and be complete."),
118
+ planYear: z
119
+ .number()
120
+ .int()
121
+ .optional()
122
+ .describe("Override the configured plan year for both runs. Defaults to capacity_config.planning.plan_year."),
123
+ measureIds: z
124
+ .array(z.string())
125
+ .optional()
126
+ .describe("Filter both reports to a subset of measures. Default: all target_settable measures."),
127
+ branch: z
128
+ .string()
129
+ .optional()
130
+ .describe("Git branch to read configs from. Default: main."),
131
+ }, safeTool(async ({ scenarioA, scenarioB, planYear, measureIds, branch }) => trpcMutation("mcp.compareScenarios", {
132
+ scenarioA,
133
+ scenarioB,
134
+ planYear,
135
+ measureIds,
136
+ branch,
137
+ })));
138
+ }
139
+ if (allowed.has("promote_scenario")) {
140
+ server.tool("promote_scenario", "Promote a scenario to active by editing `active_scenario` in config/plan/capacity_config.yaml. Fails hard if the scenario doesn't resolve (missing or dangling components) — won't promote a broken plan. Dry-run by default — returns the diff; pass dryRun:false to commit the change.", {
141
+ scenarioId: z
142
+ .string()
143
+ .describe("Scenario id to promote (e.g. 'stretch'). Must exist in config/plan/scenarios/ and be complete."),
144
+ dryRun: z
145
+ .boolean()
146
+ .default(true)
147
+ .describe("When true (default), return the diff without writing. Pass dryRun:false to commit."),
148
+ branch: z
149
+ .string()
150
+ .optional()
151
+ .describe("Git branch to read from and write back to. Default: main. Writing to main = live promotion."),
152
+ }, safeTool(async ({ scenarioId, dryRun, branch }) => trpcMutation("mcp.promoteScenario", { scenarioId, dryRun, branch })));
153
+ }
110
154
  if (allowed.has("preview_routing")) {
111
155
  server.tool("preview_routing", "Simulate Route loop resolution for a hypothetical lead. Walks config/route/rules.yaml, dispatches to territory/pool/queue/user/target_account handler, and returns the resolved owner + audit trail. Useful for testing rule changes before merging. Requires at least billing geography or email.", {
112
156
  email: z.string().optional().describe("Lead email — also extracts the domain for target_account lookup."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopops/mcp-server",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
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
- }