@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.
- package/dist/api-client.js +59 -1
- package/dist/tools/config.js +55 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/tools/config.js
CHANGED
|
@@ -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
|
}
|