@loopops/mcp-server 2.7.0 → 2.9.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.
@@ -256,6 +256,63 @@ function persistRotatedRefreshToken(newToken) {
256
256
  }
257
257
  }
258
258
  }
259
+ /**
260
+ * Build a useful error message when Okta rejects our refresh token. The
261
+ * generic answer ("token may be revoked or idle-expired") is the last
262
+ * resort — first we check whether settings.json is pointing at the wrong
263
+ * tenant or client, which is a much more common cause and one the user
264
+ * cannot diagnose from the bare 400.
265
+ *
266
+ * The Loop API exposes the tenant it actually authenticates against at
267
+ * `GET /api/mcp/tenant-info` (added 2026-04-29 alongside the
268
+ * trial-2194356 → integrator-4680962 migration). If that endpoint
269
+ * disagrees with our env vars, we report exactly which key is wrong and
270
+ * the one-shot fix command. Older webapp deployments will 404 the
271
+ * endpoint — we silently fall through to the generic message.
272
+ */
273
+ async function diagnoseRefreshFailure(status, body) {
274
+ const head = `Okta refresh failed (HTTP ${status}). Detail: ${body.slice(0, 300)}`;
275
+ const generic = "Your refresh token may be revoked or idle-expired. Re-run " +
276
+ "`npx @loopops/mcp-cli@latest login` (the `@latest` pin bypasses the " +
277
+ "npx cache, which can serve a stale cli with old tenant defaults for " +
278
+ "up to 24h after a publish).";
279
+ if (!apiUrl)
280
+ return `${head}\n\n${generic}`;
281
+ try {
282
+ const apiBase = new URL(apiUrl).origin;
283
+ const tenantInfoUrl = `${apiBase}/api/mcp/tenant-info`;
284
+ const probe = await doFetch(tenantInfoUrl, { method: "GET", headers: { accept: "application/json" } }, 5_000);
285
+ if (!probe.ok)
286
+ return `${head}\n\n${generic}`;
287
+ const live = (await probe.json());
288
+ const issuerMismatch = !!live.issuer && live.issuer !== oktaIssuer;
289
+ const clientMismatch = !!live.clientId && live.clientId !== oktaClientId;
290
+ if (!issuerMismatch && !clientMismatch)
291
+ return `${head}\n\n${generic}`;
292
+ const lines = [
293
+ head,
294
+ "",
295
+ "DIAGNOSIS: your MCP config points at a tenant the Loop API does not recognise.",
296
+ ];
297
+ if (issuerMismatch) {
298
+ lines.push(` OKTA_ISSUER on disk: ${oktaIssuer}`);
299
+ lines.push(` OKTA_ISSUER expected: ${live.issuer}`);
300
+ }
301
+ if (clientMismatch) {
302
+ lines.push(` OKTA_CLIENT_ID on disk: ${oktaClientId}`);
303
+ lines.push(` OKTA_CLIENT_ID expected: ${live.clientId}`);
304
+ }
305
+ lines.push("");
306
+ lines.push("Fix: `npx @loopops/mcp-cli@latest login`. The `@latest` pin is " +
307
+ "important — without it, npx may serve a cached older cli that " +
308
+ "writes the same wrong defaults right back to disk.");
309
+ return lines.join("\n");
310
+ }
311
+ catch {
312
+ // Network blip, DNS failure, or older webapp without the endpoint.
313
+ return `${head}\n\n${generic}`;
314
+ }
315
+ }
259
316
  /**
260
317
  * Mint a fresh access token via Okta's /token endpoint. Updates the
261
318
  * in-memory cache AND (if Okta rotated the refresh token) persists the
@@ -298,7 +355,8 @@ async function refreshAccessTokenOnce() {
298
355
  }, DEFAULT_TIMEOUT_MS);
299
356
  if (!response.ok) {
300
357
  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)}`);
358
+ const diagnosis = await diagnoseRefreshFailure(response.status, body);
359
+ throw new OktaRefreshError(diagnosis);
302
360
  }
303
361
  const tokens = (await response.json());
304
362
  cachedAccessToken = tokens.access_token;
@@ -343,4 +343,59 @@ export function registerConfigTools(server, allowed) {
343
343
  branch: z.string().optional().describe("Branch to read YAML from. Default: main."),
344
344
  }, safeTool(async ({ mode, dryRun, useAgent, branch }) => trpcMutation("mcp.assignTerritories", { mode, dryRun, useAgent, branch })));
345
345
  }
346
+ if (allowed.has("show_govern_config")) {
347
+ server.tool("show_govern_config", [
348
+ "Read a YAML file under `config/govern/`. Most-common use: viewing",
349
+ "`schedules` to see what crons exist + their enabled/disabled state.",
350
+ "Other files: `actions`, `agent`, `audit_config`, `compliance_rules`,",
351
+ "`health_monitoring`.",
352
+ ].join(" "), {
353
+ file: z
354
+ .enum([
355
+ "schedules",
356
+ "actions",
357
+ "agent",
358
+ "audit_config",
359
+ "compliance_rules",
360
+ "health_monitoring",
361
+ ])
362
+ .describe("Which file under config/govern/ to read. `schedules` is the cron table."),
363
+ }, safeTool(async (input) => trpcQuery("mcp.showGovernConfig", input)));
364
+ }
365
+ if (allowed.has("update_govern_config")) {
366
+ server.tool("update_govern_config", [
367
+ "Edit a Company Operations governance YAML config (config/govern/*.yaml)",
368
+ "by committing the new content to the repo via the GitHub API.",
369
+ "Most-common use: flipping a cron entry's `enabled: true/false` in",
370
+ "`schedules.yaml`. Workflow: read with show_govern_config, edit, send",
371
+ "back. Commit goes to target branch (default `main`); Vercel auto-deploys.",
372
+ "Schedule changes take effect at the next scheduler tick. Adding or",
373
+ "removing a schedule (not just toggling) also requires a matching",
374
+ "vercel.json edit.",
375
+ ].join(" "), {
376
+ file: z
377
+ .enum([
378
+ "schedules",
379
+ "actions",
380
+ "agent",
381
+ "audit_config",
382
+ "compliance_rules",
383
+ "health_monitoring",
384
+ ])
385
+ .describe("Which file under config/govern/ to update. `schedules` is the cron table."),
386
+ content: z
387
+ .string()
388
+ .min(1)
389
+ .describe("Full new YAML content. The tool replaces the file wholesale — read with show_govern_config first, edit, send back. Don't drop unrelated entries."),
390
+ message: z
391
+ .string()
392
+ .min(3)
393
+ .max(72)
394
+ .describe("Git commit message. Convention: `govern: <what changed and why>` (e.g. 'govern: disable cg_sync_clickhouse cron temporarily')."),
395
+ branch: z
396
+ .string()
397
+ .optional()
398
+ .describe("Target branch. Default: main. Pass a feature branch for review-before-merge."),
399
+ }, safeTool(async (input) => trpcMutation("mcp.updateGovernConfig", input)));
400
+ }
346
401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopops/mcp-server",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Loop Operations MCP Server — AI skills for RevOps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",