@loopops/mcp-server 2.0.0 → 2.0.2

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,7 +20,7 @@
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
26
  const apiUrl = process.env.API_URL;
@@ -153,34 +153,77 @@ function isRetryable(err) {
153
153
  return false;
154
154
  }
155
155
  /**
156
- * Persist a rotated refresh token back to ~/.mcp.json. Best-effort — if
157
- * the write fails, we still keep the new token in-memory so the current
158
- * subprocess keeps working. Claude Desktop's next spawn will need the
159
- * disk value though.
156
+ * OS-specific location Claude Desktop reads its config from. We have
157
+ * to write here in addition to ~/.mcp.json, because Claude Desktop
158
+ * does NOT read ~/.mcp.json on macOS (it reads Application Support
159
+ * instead). If we only update ~/.mcp.json + ~/.claude/settings.json,
160
+ * Claude Desktop falls behind after every rotation and breaks with
161
+ * "invalid_grant" on its next cold start.
162
+ */
163
+ function claudeDesktopConfigPath() {
164
+ const home = homedir();
165
+ switch (platform()) {
166
+ case "darwin":
167
+ return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
168
+ case "win32": {
169
+ const appdata = process.env.APPDATA;
170
+ return appdata
171
+ ? join(appdata, "Claude", "claude_desktop_config.json")
172
+ : null;
173
+ }
174
+ case "linux":
175
+ return join(home, ".config", "Claude", "claude_desktop_config.json");
176
+ default:
177
+ return null;
178
+ }
179
+ }
180
+ /**
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.
160
194
  */
161
195
  function persistRotatedRefreshToken(newToken) {
162
- try {
163
- const path = join(homedir(), ".mcp.json");
164
- if (!existsSync(path))
165
- return;
166
- const raw = readFileSync(path, "utf-8");
167
- const data = JSON.parse(raw);
168
- const stanza = data.mcpServers?.[SERVER_NAME];
169
- if (!stanza?.env)
170
- return;
171
- stanza.env.OKTA_REFRESH_TOKEN = newToken;
172
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
196
+ const paths = [
197
+ join(homedir(), ".mcp.json"),
198
+ join(homedir(), ".claude", "settings.json"),
199
+ ];
200
+ const desktop = claudeDesktopConfigPath();
201
+ if (desktop)
202
+ paths.push(desktop);
203
+ for (const path of paths) {
173
204
  try {
174
- chmodSync(path, 0o600);
205
+ if (!existsSync(path))
206
+ continue;
207
+ const raw = readFileSync(path, "utf-8");
208
+ const data = JSON.parse(raw);
209
+ const stanza = data.mcpServers?.[SERVER_NAME];
210
+ if (!stanza?.env)
211
+ continue;
212
+ stanza.env.OKTA_REFRESH_TOKEN = newToken;
213
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
214
+ try {
215
+ chmodSync(path, 0o600);
216
+ }
217
+ catch {
218
+ // ok on platforms where this is a no-op
219
+ }
175
220
  }
176
- catch {
177
- // ok on platforms where this is a no-op
221
+ catch (err) {
222
+ // Log to stderr (visible in Claude Desktop/Code logs) but don't
223
+ // fail the request.
224
+ console.error(`[MCP] Could not persist rotated refresh token to ${path}:`, err instanceof Error ? err.message : String(err));
178
225
  }
179
226
  }
180
- catch (err) {
181
- // Log to stderr (visible in Claude Desktop logs) but don't fail the request.
182
- console.error("[MCP] Could not persist rotated refresh token to ~/.mcp.json:", err instanceof Error ? err.message : String(err));
183
- }
184
227
  }
185
228
  /**
186
229
  * Mint a fresh access token via Okta's /token endpoint. Updates the
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.0",
3
+ "version": "2.0.2",
4
4
  "description": "Loop Operations MCP Server — AI skills for RevOps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",