From 05bc4a00032ce9fb2fd9dfc935f6845920ef07e9 Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Wed, 29 Jul 2026 13:11:01 -0400 Subject: [PATCH 1/4] api: bulk tool toggles by tier or group, in one transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One click on a tier switch would otherwise be one request per tool — 225 of them for cipp's read tier, non-atomically. New PATCH /api/catalog/:upstreamId {enabled, tier?, group?} (no tier/group = the whole server) and the same shape on PUT /api/me/prefs, both backed by transactional repo helpers returning the row count. Targets are resolved from the live catalog — and on /me from the CALLER'S envelope — so a stale UI can never touch a tool the user was never allowed to see; matching uses the EFFECTIVE tier, so an override moves a tool between switches exactly as the UI shows it. bulkSetUserPrefs also has the opt-in mode the upcoming off-by-default upstreams need. Co-Authored-By: Claude Fable 5 --- packages/gateway/src/db/repo.test.ts | 28 ++++++++++ packages/gateway/src/db/repo.ts | 64 +++++++++++++++++++++++ packages/gateway/src/http/admin-api.ts | Bin 16282 -> 17621 bytes packages/gateway/src/http/app.test.ts | 44 ++++++++++++++++ packages/gateway/src/http/me-api.test.ts | 27 ++++++++++ packages/gateway/src/http/me-api.ts | 26 ++++++++- 6 files changed, 188 insertions(+), 1 deletion(-) diff --git a/packages/gateway/src/db/repo.test.ts b/packages/gateway/src/db/repo.test.ts index 8b94935..4f88f1d 100644 --- a/packages/gateway/src/db/repo.test.ts +++ b/packages/gateway/src/db/repo.test.ts @@ -63,6 +63,34 @@ describe("Repo", () => { expect(setting.tierOverride).toBe("read"); // preserved }); + it("bulkSetToolEnabled writes the whole set and reports the count", () => { + const repo = fresh(); + const names = ["a", "b", "c"]; + expect(repo.bulkSetToolEnabled("up", names, false)).toBe(3); + for (const n of names) expect(repo.toolSetting("up", n)?.enabled).toBe(false); + // re-enabling flips them back, and an existing tier override survives + repo.upsertToolSetting({ upstreamId: "up", toolName: "a", tierOverride: "destructive" }); + expect(repo.bulkSetToolEnabled("up", names, true)).toBe(3); + expect(repo.toolSetting("up", "a")).toMatchObject({ enabled: true, tierOverride: "destructive" }); + expect(repo.bulkSetToolEnabled("up", [], false)).toBe(0); + }); + + it("bulkSetUserPrefs denies, clears, and opts in", () => { + const repo = fresh(); + const who = "oidc:https://idp|u1"; + expect(repo.bulkSetUserPrefs(who, "up", ["a", "b"], false)).toBe(2); + expect(repo.userPrefFor(who, "up", "a")).toBe(false); + + // enable (default): rows are DELETED — narrowing removed, never widened + repo.bulkSetUserPrefs(who, "up", ["a", "b"], true); + expect(repo.userPrefFor(who, "up", "a")).toBeNull(); + + // opt-in form (off-by-default upstreams): explicit enabled rows + repo.bulkSetUserPrefs(who, "up", ["a"], true, true); + expect(repo.userPrefFor(who, "up", "a")).toBe(true); + expect(repo.listUserPrefs(who).map((p) => p.toolName)).toEqual(["a"]); + }); + it("upserts users on login and keeps known fields", () => { const repo = fresh(); const user = repo.upsertUserOnLogin({ iss: "https://idp", sub: "u1", email: "a@b.c" }); diff --git a/packages/gateway/src/db/repo.ts b/packages/gateway/src/db/repo.ts index 74c2e66..406e423 100644 --- a/packages/gateway/src/db/repo.ts +++ b/packages/gateway/src/db/repo.ts @@ -321,6 +321,70 @@ export class Repo { .run(setting.upstreamId, setting.toolName, enabled ? 1 : 0, tierOverride, groupLabel); } + /** + * Flip `enabled` for a whole set of tools in one transaction — the admin + * bulk switches (a tier or a group of one upstream, or the entire upstream). + * Callers resolve the tool names from the live catalog, so this never has to + * guess what exists; returns how many rows it wrote. + */ + bulkSetToolEnabled(upstreamId: string, toolNames: string[], enabled: boolean): number { + if (toolNames.length === 0) return 0; + const upsert = this.db.prepare( + `INSERT INTO tool_settings (upstream_id, tool_name, enabled) + VALUES (?, ?, ?) + ON CONFLICT(upstream_id, tool_name) DO UPDATE SET enabled = excluded.enabled` + ); + this.db.exec("BEGIN"); + try { + for (const toolName of toolNames) upsert.run(upstreamId, toolName, enabled ? 1 : 0); + this.db.exec("COMMIT"); + } catch (err) { + this.db.exec("ROLLBACK"); + throw err; + } + return toolNames.length; + } + + /** + * Personal narrowing for a whole set of tools in one transaction. + * `enabled=false` writes deny rows; `enabled=true` DELETES them (or, for + * off-by-default upstreams, writes explicit opt-in rows) — the caller + * decides which via `optIn`, and the envelope is checked before we get here. + */ + bulkSetUserPrefs( + principal: string, + upstreamId: string, + toolNames: string[], + enabled: boolean, + optIn = false + ): number { + if (toolNames.length === 0) return 0; + const deny = this.db.prepare( + `INSERT INTO user_prefs (principal, upstream_id, tool_name, enabled) VALUES (?, ?, ?, 0) + ON CONFLICT (principal, upstream_id, tool_name) DO UPDATE SET enabled = 0` + ); + const allow = this.db.prepare( + `INSERT INTO user_prefs (principal, upstream_id, tool_name, enabled) VALUES (?, ?, ?, 1) + ON CONFLICT (principal, upstream_id, tool_name) DO UPDATE SET enabled = 1` + ); + const clear = this.db.prepare( + "DELETE FROM user_prefs WHERE principal = ? AND upstream_id = ? AND tool_name = ?" + ); + this.db.exec("BEGIN"); + try { + for (const toolName of toolNames) { + if (!enabled) deny.run(principal, upstreamId, toolName); + else if (optIn) allow.run(principal, upstreamId, toolName); + else clear.run(principal, upstreamId, toolName); + } + this.db.exec("COMMIT"); + } catch (err) { + this.db.exec("ROLLBACK"); + throw err; + } + return toolNames.length; + } + // ── users ── upsertUserOnLogin(user: { diff --git a/packages/gateway/src/http/admin-api.ts b/packages/gateway/src/http/admin-api.ts index 087d27788024348b79db348c719635d5162cf446..23ae0be5242f9c9a1393ab43bd1f66a7bb8b8d45 100644 GIT binary patch delta 986 zcmZ{j&5G1O6onC7s0#-{9R!h^9_>k>sS&|VH?dL4K*RWhGcAaS)+9GSOi~rAs%IQw zXt&~S;>w+GAovn)UHAgNfp6eVQk`xPvuM({Zk>DbovJVMUmwqWGfs8dztx|W+9>43 zBmy5wu6W@pd^>>Na-rvqs-K&8svpkf2ZIaHJtjjng!*?kOtIihmL`*e=dQQfmMA$Z zhwz@!piCsyPH#Bh+Ja}OWqycYQUo5y7#fVgNSSMhl`NY2&g6LjAvZji$q+bbdPA5_ zs3t(*Au4N!^g{XygP_qEnxa9;BT|yeYMPWAh@{vwfbZs+Hq34frO;FpWDYekc*z(m zxZnvYW=99@qU*h4ah4lYu8SV{_W;?F68OFk%fH^p>Sr;yR-NT25|e^|mRfIDfBM&| zPj@d>FV?TD&_3ybORh5mDc2xm>)(4*y7%jzMi`}ZxPV4!cwxD z>26zcm;MGT=J%-MF)y(53_w61aTBH>MUzCZy&XK { }); }); +describe("bulk catalog toggle", () => { + const bulk = (body: unknown, upstream = "fake") => + fetch(`${base}/api/catalog/${upstream}`, { + method: "PATCH", + headers: { Authorization: "Bearer tok-admin", "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + it("switches a whole tier, a whole server, and 404s an unknown upstream", async () => { + // fake exposes read_thing (read, annotated) + write_thing (no annotation → write) + const readOff = await bulk({ enabled: false, tier: "read" }); + expect(readOff.status).toBe(200); + expect((await readOff.json()) as { changed: number }).toEqual({ ok: true, changed: 1 }); + expect(repo.toolSetting("fake", "read_thing")?.enabled).toBe(false); + expect(repo.toolSetting("fake", "write_thing")?.enabled ?? true).toBe(true); // untouched + + // whole server (no tier) — both tools + const allOn = await bulk({ enabled: true }); + expect((await allOn.json()) as { changed: number }).toMatchObject({ changed: 2 }); + expect(repo.toolSetting("fake", "read_thing")?.enabled).toBe(true); + + expect((await bulk({ enabled: false }, "nope")).status).toBe(404); + // non-admins can't + const asViewer = await fetch(`${base}/api/catalog/fake`, { + method: "PATCH", + headers: { Authorization: "Bearer tok-viewer", "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + expect(asViewer.status).toBe(403); + }); + + it("matches the EFFECTIVE tier, so an override moves a tool between switches", async () => { + repo.upsertToolSetting({ upstreamId: "fake", toolName: "read_thing", tierOverride: "destructive" }); + try { + // read switch now matches nothing; destructive matches the overridden tool + expect((await (await bulk({ enabled: false, tier: "read" })).json()) as { changed: number }).toMatchObject({ changed: 0 }); + expect((await (await bulk({ enabled: false, tier: "destructive" })).json()) as { changed: number }).toMatchObject({ changed: 1 }); + expect(repo.toolSetting("fake", "read_thing")?.enabled).toBe(false); + } finally { + repo.upsertToolSetting({ upstreamId: "fake", toolName: "read_thing", tierOverride: null, enabled: true }); + } + }); +}); + describe("preset install endpoint", () => { const preset = { id: "fam", diff --git a/packages/gateway/src/http/me-api.test.ts b/packages/gateway/src/http/me-api.test.ts index 4455438..9c5c92d 100644 --- a/packages/gateway/src/http/me-api.test.ts +++ b/packages/gateway/src/http/me-api.test.ts @@ -195,6 +195,33 @@ describe("/api/me", () => { await me("PUT", "/prefs", "tok-editor", { upstreamId: "fake", toolName: "write_thing", enabled: true }); }); + it("bulk prefs narrow a whole tier in one call and stay inside the envelope", async () => { + // editor sees read_thing (read) + write_thing (write) + const hideWrites = await me("PUT", "/prefs", "tok-editor", { + upstreamId: "fake", + tier: "write", + enabled: false, + }); + expect(hideWrites.status).toBe(200); + expect(hideWrites.json).toMatchObject({ ok: true, changed: 1 }); + + const after = await me("GET", "/access", "tok-editor"); + const tools = (after.json.servers as Array<{ tools: Array<{ name: string; enabled: boolean }> }>)[0]!.tools; + expect(tools.find((t) => t.name === "write_thing")!.enabled).toBe(false); + expect(tools.find((t) => t.name === "read_thing")!.enabled).toBe(true); + + // a viewer's write switch touches NOTHING — write_thing is outside their envelope + const asViewer = await me("PUT", "/prefs", "tok-viewer", { + upstreamId: "fake", + tier: "write", + enabled: false, + }); + expect(asViewer.json).toMatchObject({ changed: 0 }); + + // restore + await me("PUT", "/prefs", "tok-editor", { upstreamId: "fake", tier: "write", enabled: true }); + }); + it("rejects prefs for tools outside the envelope (no widening, no junk)", async () => { // viewer never sees write_thing — targeting it is a 404, not a stored row. const denied = await me("PUT", "/prefs", "tok-viewer", { diff --git a/packages/gateway/src/http/me-api.ts b/packages/gateway/src/http/me-api.ts index 2a4ac44..5a4483c 100644 --- a/packages/gateway/src/http/me-api.ts +++ b/packages/gateway/src/http/me-api.ts @@ -109,6 +109,9 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { .object({ upstreamId: z.string().min(1), toolName: z.string().default(""), + /** Bulk forms: a whole tier and/or group of the upstream at once. */ + tier: z.enum(["read", "write", "destructive"]).optional(), + group: z.string().optional(), enabled: z.boolean(), }) .parse(req.body); @@ -126,9 +129,30 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { return; } + // Bulk: resolve the tools from the caller's OWN envelope, so one click + // can never touch something they were never allowed to see. + if (body.tier || body.group !== undefined) { + const targets = envelope.filter((e) => { + if (e.upstreamId !== body.upstreamId) return false; + const setting = repo.toolSetting(e.upstreamId, e.upstreamToolName); + if (body.tier && (setting?.tierOverride ?? e.tier) !== body.tier) return false; + if (body.group !== undefined && (setting?.groupLabel ?? "") !== body.group) return false; + return true; + }); + const changed = repo.bulkSetUserPrefs( + prefsIdentity(principal), + body.upstreamId, + targets.map((e) => e.upstreamToolName), + body.enabled + ); + me.onPolicyChanged(); + res.json({ ok: true, changed }); + return; + } + repo.setUserPref(prefsIdentity(principal), body.upstreamId, body.toolName, body.enabled); me.onPolicyChanged(); - res.json({ ok: true }); + res.json({ ok: true, changed: 1 }); }) ); From 116f02e8a6ff4ad5937d6fe8722d73344aced03f Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Wed, 29 Jul 2026 13:18:27 -0400 Subject: [PATCH 2/4] admin: group the tool catalog by server, with tier bulk switches and store-aware secret hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tools tab was a flat list of every tool — unusable once CIPP added 231 of them, and (unlike /me) with no way to act on one server's set. Now each upstream is a collapsible group whose header carries a server on/off button, per-tier bulk switches with counts, and an "N of M enabled" readout; bodies render at most 50 rows behind a name filter. Bulk clicks go through the new single-request endpoint. Tools also gain a derived group: servers that prefix descriptions with a category path ("[Identity > Administration > Users]" — CIPP) now surface its first segment as the group-label placeholder, and bulk-by-group matches it, so a category can be switched even where no label was ever typed. Secrets-tab hints now follow the configured store, which /api/secrets/health reports as "scheme": Key Vault explains the flat - secret and kv: refs, OpenBao keeps the KV-v2 path#field wording, and with no store at all it says so and points at ${VAR} env references instead of implying one. Co-Authored-By: Claude Fable 5 --- packages/gateway/public/admin.html | 131 +++++++++++++++++++++---- packages/gateway/src/domain/catalog.ts | 11 +++ packages/gateway/src/http/admin-api.ts | Bin 17621 -> 18211 bytes 3 files changed, 123 insertions(+), 19 deletions(-) diff --git a/packages/gateway/public/admin.html b/packages/gateway/public/admin.html index 8ae5ff2..580f459 100644 --- a/packages/gateway/public/admin.html +++ b/packages/gateway/public/admin.html @@ -381,24 +381,104 @@

${esc(p.title)}

} // ── Tools ── +/** Tools tab state: which upstream groups are expanded, and the name filter. */ +const toolsUi = { open: new Set(), filter: "", ROW_CAP: 50 }; + async function renderTools() { const el = $("#tab-tools"); const tools = await api("/catalog"); - el.innerHTML = `

Tool catalog (${tools.length})

- - - ${tools.map(t => ` - - - - - - - `).join("")} -
OnToolUpstreamTierOverrideGroup
${esc(t.exposedName)}${esc(t.upstreamId)}${tierPill(t.derivedTier)}
`; + const enabledCount = tools.filter(t => t.enabled).length; + const groupOf = (t) => t.groupLabel || t.derivedGroup || ""; + + // Group by upstream, then count per effective tier for the bulk switches. + const byUpstream = new Map(); + for (const t of tools) { + const g = byUpstream.get(t.upstreamId) ?? { id: t.upstreamId, tools: [], tiers: { read: 0, write: 0, destructive: 0 }, on: 0 }; + g.tools.push(t); + g.tiers[t.effectiveTier] = (g.tiers[t.effectiveTier] || 0) + 1; + if (t.enabled) g.on += 1; + byUpstream.set(t.upstreamId, g); + } + + const tierBtn = (id, tier, n, allOn) => n === 0 ? "" : + ``; + + el.innerHTML = `
+

Tool catalog ${enabledCount} of ${tools.length} enabled · ${byUpstream.size} servers

+

Bulk switches apply to every tool of that tier in the group — for all roles. + Per-role limits live on the Roles tab. Tier shown is the override when set, else the annotation-derived value.

+
+ +
+ ${[...byUpstream.values()].map(g => { + const open = toolsUi.open.has(g.id); + const matching = toolsUi.filter + ? g.tools.filter(t => t.exposedName.toLowerCase().includes(toolsUi.filter.toLowerCase())) + : g.tools; + const shown = matching.slice(0, toolsUi.ROW_CAP); + return `
+
+ + + ${esc(g.id)} + ${g.on} of ${g.tools.length} enabled + + ${["read", "write", "destructive"].map(tier => { + const n = g.tiers[tier] || 0; + const allOn = n > 0 && g.tools.filter(t => t.effectiveTier === tier).every(t => t.enabled); + return tierBtn(g.id, tier, n, allOn); + }).join(" ")} +
+ ${open ? `
+ + + ${shown.map(t => ` + + + + + + `).join("")} +
OnToolTierOverrideGroup
${esc(t.exposedName)}${tierPill(t.effectiveTier)}${t.tierOverride ? ` override, was ${esc(t.derivedTier)}` : ""}
+ ${matching.length > shown.length ? `

…${matching.length - shown.length} more — narrow the filter, or use the switches above

` : ""} + ${matching.length === 0 ? '

no tools match the filter

' : ""} +
` : ""} +
`; + }).join("")} +
`; + + $("#tool-filter").oninput = (ev) => { + toolsUi.filter = ev.target.value; + const el2 = ev.target; + clearTimeout(el2._t); + el2._t = setTimeout(() => renderTools().then(() => $("#tool-filter").focus()), 250); + }; + + el.onclick = async (ev) => { + const openBtn = ev.target.closest("[data-open]"); + if (openBtn) { + const id = openBtn.dataset.open; + toolsUi.open.has(id) ? toolsUi.open.delete(id) : toolsUi.open.add(id); + renderTools(); + return; + } + const bulk = ev.target.closest("[data-bulk-u]"); + if (!bulk) return; + const body = { enabled: bulk.dataset.bulkEn === "1" }; + if (bulk.dataset.bulkTier) body.tier = bulk.dataset.bulkTier; + try { + const r = await api(`/catalog/${encodeURIComponent(bulk.dataset.bulkU)}`, { method: "PATCH", body: JSON.stringify(body) }); + toast(`${body.enabled ? "Enabled" : "Disabled"} ${r.changed} tool${r.changed === 1 ? "" : "s"}`); + renderTools(); + } catch (e) { toast(e.message, true); } + }; + el.onchange = async (ev) => { const i = ev.target; if (!i.dataset.k) return; const patch = {}; @@ -594,11 +674,24 @@

${esc(p.title)}

el.innerHTML = `

Secret store

${dot(health.ok)}${esc(health.detail)}

-

Values are written straight to OpenBao and never stored or displayed by the gateway. - Reference them in upstream headers/env as bao:<path>#<field>.

+

${!health.scheme + ? `No secret store is configured, so nothing can be written here. Set KEY_VAULT_URI + (Azure Key Vault, kv: refs) or BAO_ADDR (OpenBao, bao: refs). + Without a store, upstream headers/env can still reference this process's environment as + \${VAR} — resolved server-side at connect time.` + : health.scheme === "kv" + ? `Values are written straight to Azure Key Vault and never stored or displayed by the gateway. + Key Vault has no nested fields, so the gateway stores one secret named + <name>-<field> — letters, digits and dashes only. + Reference it in upstream headers/env as kv:<name>-<field>, + e.g. kv:cipp-mcp-secret.` + : `Values are written straight to OpenBao and never stored or displayed by the gateway. + KV v2 keeps several fields under one path, so + upstreams/itglue can hold both token and region. + Reference them in upstream headers/env as bao:<path>#<field>.`}

- - + +
diff --git a/packages/gateway/src/domain/catalog.ts b/packages/gateway/src/domain/catalog.ts index 16cfbd8..6c4edf6 100644 --- a/packages/gateway/src/domain/catalog.ts +++ b/packages/gateway/src/domain/catalog.ts @@ -27,6 +27,17 @@ export function exposedNameFor(namespace: string, toolName: string): string { return toolName.startsWith(`${namespace}_`) ? toolName : `${namespace}_${toolName}`; } +/** + * Servers that expose hundreds of tools tend to carry a category path in the + * description — CIPP writes "[Identity > Administration > Users] …". Its first + * segment makes a natural group for the admin UI's bulk switches, so derive it + * when present. An explicit `group_label` in tool_settings always wins. + */ +export function derivedGroupOf(tool: Tool): string | null { + const match = /^\s*\[([^\]>]+?)(?:\s*>[^\]]*)?\]/.exec(tool.description ?? ""); + return match ? match[1]!.trim() : null; +} + export interface CatalogEntry { upstreamId: string; namespace: string; diff --git a/packages/gateway/src/http/admin-api.ts b/packages/gateway/src/http/admin-api.ts index 23ae0be5242f9c9a1393ab43bd1f66a7bb8b8d45..b86d4b882fbb397feb3e64ebfaea786a6b32c4b9 100644 GIT binary patch delta 662 zcmZ8ePiqrF6sN_LcrPl1UpEwYV|Nxjrqmz}XigTyiz0M(_iZv{G81MdiAxC;PkQm- zegg$h9>iSz8vO!(5@)lz4bJJ!oA>+o=KAB_*H3#t9_@!Y8L_1NsnLu1^8#xWM$Zs1 zmgIWIg-SB+xYXlKcmGT0D(Urb#vP5dDVs?*A>?E;Bj!$Mh45^2ejZy_N<#2@l#_D8 zm3WNb@4meWB8Faa8#`xLzweoL-Gm-&O6Ihd(Q%F1{KD+kRg&O|0==C}kqHOMQ<7K- zWlx~J9kUXaP;e=c@@xvH(U{y)!{^!BZ(VpgtC23`G)~KSTrUDEZKf85Hn3!tWUQy) z4vKvJ5M%vU)i!Y>){=<=VS53p_~`zfoqPEC@Zi<|A)A003=jkn4o^>!>bzuICoML@ zWm^M=+`_0jq@|O#zuvq*y6!ylfi7$|p&4BuuS3`F2`@(vtOpjD2<5!<>9Vi*jN(F& z%(1k7Te?5zZqnU$B36Cx-0fJ(yx0q;4YfMJ0GD8lg_RoKi=tyRa(%z8cA7OktZ!h| ThhcbdGprW=i~c=VKZo5v)C9}< delta 128 zcmZ47$9T1qaf1=l=1iu^a+6nUaBl8ZjAl{HOjFQE%1^1(OE1bVEl^OjwN)t1OG!=3 z%u7vCP*YRTn7m(6Tu4bt6D);LIr)OB#AajlGjg0osl|F(#rb&}lV!NHCm(TBom`+N VDv(-IT9l^%G_xeN$eOE`3jog?DwF^K From d62cc6ad3dc3851e1112b92506181e1674bdf18a Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Wed, 29 Jul 2026 13:24:53 -0400 Subject: [PATCH 3/4] me: opt-in upstreams (userDefault) and tier switches for personal narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 231-tool server floods every session it appears in, so upstreams gain userDefault: "on" | "off". With "off" the server is listed on /me but contributes nothing until the user opts in — per tool, per tier, or server-wide — and the role envelope stays the ceiling, so an opt-in can never reach a tool the role cannot see. On-by-default upstreams behave exactly as before. /me grows the same per-tier bulk switches as the admin catalog (one request per click via the bulk prefs endpoint), shows "N of M active", and labels opt-in servers so the empty state reads as a choice rather than a bug. GET /api/me/access now derives each tool's `enabled` from PolicyService.allowsFor — the same function the MCP boundary calls — instead of recomputing the pref logic locally, which is what let the page disagree with reality for opt-in upstreams during development. Co-Authored-By: Claude Fable 5 --- packages/gateway/public/me.html | 37 +++++++++++-- packages/gateway/src/config.ts | 9 +++ .../src/domain/policy-narrowing.test.ts | 55 +++++++++++++++++++ packages/gateway/src/domain/policy.ts | 13 ++++- packages/gateway/src/http/me-api.ts | 30 ++++++++-- 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/packages/gateway/public/me.html b/packages/gateway/public/me.html index 21a2d17..fae7684 100644 --- a/packages/gateway/public/me.html +++ b/packages/gateway/public/me.html @@ -22,7 +22,7 @@ border-radius: var(--radius); padding: 16px; margin-bottom: 16px; } h2 { font-size: 14px; margin: 0 0 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } - h3 { font-size: 13px; margin: 14px 0 8px; } + h3 { font-size: 13px; margin: 14px 0 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); } th { color: var(--muted); font-weight: 500; font-size: 12px; } @@ -169,14 +169,29 @@

Sign in

return; } + const tierCounts = (tools) => tools.reduce((a, t) => ((a[t.tier] = (a[t.tier] || 0) + 1), a), {}); + el.innerHTML = `

My access

-

Turn servers or individual tools off for your own sessions. You can only narrow what your role already grants — turning something on just removes your personal off-switch.

- ${data.servers.map(s => ` +

Turn servers, whole tiers, or individual tools off for your own sessions. You can only narrow what your role already grants — turning something on just removes your personal off-switch.

+ ${data.servers.map(s => { + const counts = tierCounts(s.tools); + const optIn = s.userDefault === "off"; + const activeCount = s.tools.filter(t => t.enabled).length; + return `

${esc(s.upstreamId)} - (${s.tools.length} tool${s.tools.length === 1 ? "" : "s"}) + (${activeCount} of ${s.tools.length} active) + ${optIn ? '· opt-in server: choose what you need' : ""} + + ${["read", "write", "destructive"].map(tier => { + const n = counts[tier] || 0; + if (!n) return ""; + const allOn = s.tools.filter(t => t.tier === tier).every(t => t.enabled); + return ``; + }).join(" ")}

${s.tools.map(t => ` @@ -185,9 +200,21 @@

`).join("")}
${tierPill(t.tier)}
-
`).join("")} + `; + }).join("")}
`; + el.onclick = async (ev) => { + const b = ev.target.closest("[data-bulk-u]"); + if (!b) return; + try { + const r = await api("/prefs", { method: "PUT", body: JSON.stringify({ + upstreamId: b.dataset.bulkU, tier: b.dataset.bulkTier, enabled: b.dataset.bulkEn === "1" }) }); + toast(`${b.dataset.bulkEn === "1" ? "Enabled" : "Hidden"} ${r.changed} ${b.dataset.bulkTier} tool${r.changed === 1 ? "" : "s"}`); + renderAccess(); + } catch (e) { toast(e.message, true); } + }; + el.onchange = async (ev) => { const i = ev.target; if (i.dataset.u === undefined) return; diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index d5471a8..890470a 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -62,6 +62,15 @@ const upstreamBase = { sessionMode: z.enum(["shared", "per-user"]).default("shared"), /** per-user only: refuse shared-credential fallback for callers without personal creds. */ requirePersonalCredentials: z.boolean().default(false), + /** + * Whether this upstream's tools are active for a user out of the box. + * "on" (default) = today's behavior: everything inside the role envelope is + * live until the user narrows it. "off" = opt-in — the server shows up on + * /me but contributes nothing until the user enables what they want, which + * keeps a 200-tool server from flooding every session. The envelope is still + * the ceiling: opting in can never reach a tool the role cannot see. + */ + userDefault: z.enum(["on", "off"]).default("on"), /** * Upstreams that want a freshly minted OAuth2 access token rather than a * static credential (third-party MCP servers behind Entra/Easy Auth, e.g. diff --git a/packages/gateway/src/domain/policy-narrowing.test.ts b/packages/gateway/src/domain/policy-narrowing.test.ts index a9d67b6..5f89692 100644 --- a/packages/gateway/src/domain/policy-narrowing.test.ts +++ b/packages/gateway/src/domain/policy-narrowing.test.ts @@ -32,6 +32,61 @@ function setup() { return { repo, policy, editor }; } +describe("off-by-default upstreams (userDefault: off)", () => { + const optInSpec = { + id: "up1", + namespace: "up1", + transport: "http" as const, + url: "http://unused/mcp", + headers: {}, + enabled: true, + userDefault: "off" as const, + }; + + it("contributes nothing until the user opts in, per tool or server-wide", () => { + const { repo, policy, editor } = setup(); + repo.upsertUpstream(optInSpec, "api"); + const write = entry("update_doc", "write"); + const read = entry("get_doc", "read"); + + // inside the envelope, but inactive without an opt-in + expect(policy.allowsFor(editor, write)).toBe(false); + expect(policy.allowsFor(editor, read)).toBe(false); + + // per-tool opt-in + repo.bulkSetUserPrefs(prefsIdentity(editor), "up1", ["update_doc"], true, true); + expect(policy.allowsFor(editor, write)).toBe(true); + expect(policy.allowsFor(editor, read)).toBe(false); + + // server-wide opt-in covers the rest + repo.bulkSetUserPrefs(prefsIdentity(editor), "up1", [""], true, true); + expect(policy.allowsFor(editor, read)).toBe(true); + }); + + it("an opt-in can never widen past the role envelope", () => { + const { repo, policy, editor } = setup(); + repo.upsertUpstream(optInSpec, "api"); + // editor's ceiling is write — opting into a destructive tool changes nothing + repo.bulkSetUserPrefs(prefsIdentity(editor), "up1", ["delete_doc", ""], true, true); + expect(policy.allowsFor(editor, entry("delete_doc", "destructive"))).toBe(false); + }); + + it("a per-tool deny still wins over a server-wide opt-in", () => { + const { repo, policy, editor } = setup(); + repo.upsertUpstream(optInSpec, "api"); + repo.bulkSetUserPrefs(prefsIdentity(editor), "up1", [""], true, true); + repo.bulkSetUserPrefs(prefsIdentity(editor), "up1", ["update_doc"], false); + expect(policy.allowsFor(editor, entry("update_doc", "write"))).toBe(false); + expect(policy.allowsFor(editor, entry("get_doc", "read"))).toBe(true); + }); + + it("upstreams without the flag keep the on-by-default behavior", () => { + const { repo, policy, editor } = setup(); + repo.upsertUpstream({ ...optInSpec, userDefault: "on" }, "api"); + expect(policy.allowsFor(editor, entry("update_doc", "write"))).toBe(true); + }); +}); + describe("personal narrowing (allowsFor = envelope ∧ prefs)", () => { it("defaults to the envelope when no prefs exist", () => { const { policy, editor } = setup(); diff --git a/packages/gateway/src/domain/policy.ts b/packages/gateway/src/domain/policy.ts index 8260a15..ce29cb8 100644 --- a/packages/gateway/src/domain/policy.ts +++ b/packages/gateway/src/domain/policy.ts @@ -74,8 +74,17 @@ export class PolicyService { allowsFor(principal: Principal, entry: CatalogEntry): boolean { if (!this.allows(principal.roleId, entry)) return false; const who = prefsIdentity(principal); - if (this.repo.userPrefFor(who, entry.upstreamId, "") === false) return false; - if (this.repo.userPrefFor(who, entry.upstreamId, entry.upstreamToolName) === false) return false; + const serverPref = this.repo.userPrefFor(who, entry.upstreamId, ""); + const toolPref = this.repo.userPrefFor(who, entry.upstreamId, entry.upstreamToolName); + // Off-by-default upstreams invert the personal layer: nothing is live until + // the user opts in (server-wide or per tool). The envelope check above is + // still the ceiling, so an opt-in can never widen beyond the role. + if (this.repo.getUpstream(entry.upstreamId)?.spec.userDefault === "off") { + if (serverPref === false || toolPref === false) return false; + return serverPref === true || toolPref === true; + } + if (serverPref === false) return false; + if (toolPref === false) return false; return true; } diff --git a/packages/gateway/src/http/me-api.ts b/packages/gateway/src/http/me-api.ts index 5a4483c..14069dc 100644 --- a/packages/gateway/src/http/me-api.ts +++ b/packages/gateway/src/http/me-api.ts @@ -62,11 +62,13 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { const principal = req.principal!; const who = prefsIdentity(principal); const prefs = repo.listUserPrefs(who); - const serverOff = new Set(prefs.filter((p) => !p.enabled && p.toolName === "").map((p) => p.upstreamId)); - const toolOff = new Set(prefs.filter((p) => !p.enabled && p.toolName !== "").map((p) => `${p.upstreamId} ${p.toolName}`)); + const serverPref = new Map(prefs.filter((p) => p.toolName === "").map((p) => [p.upstreamId, p.enabled])); // Only entries inside the admin envelope are listed at all — personal // narrowing is shown on top of them; envelope-denied tools stay invisible. + // `enabled` comes from PolicyService.allowsFor — the SAME function the + // MCP boundary uses — so the page can never disagree with reality + // (including the inverted opt-in rule of userDefault:"off" upstreams). const byUpstream = new Map>(); for (const entry of policy.visibleEntries(principal.roleId, manager.catalogEntries())) { const list = byUpstream.get(entry.upstreamId) ?? []; @@ -74,7 +76,7 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { name: entry.upstreamToolName, exposedName: entry.exposedName, tier: entry.tier, - enabled: !serverOff.has(entry.upstreamId) && !toolOff.has(`${entry.upstreamId} ${entry.upstreamToolName}`), + enabled: policy.allowsFor(principal, entry), }); byUpstream.set(entry.upstreamId, list); } @@ -82,15 +84,20 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { principal: { label: principal.label, role: principal.roleName }, servers: [...byUpstream.entries()].map(([upstreamId, tools]) => { const spec = repo.getUpstream(upstreamId)?.spec; + const optIn = spec?.userDefault === "off"; return { upstreamId, - enabled: !serverOff.has(upstreamId), + // Server switch state: opt-in servers are "on" once an explicit + // server-wide opt-in exists; normal ones until a deny appears. + enabled: optIn ? serverPref.get(upstreamId) === true : serverPref.get(upstreamId) !== false, // One-click Connect offer (metadata only — the flow itself lives // at /me/connect/:upstreamId and needs the cookie session). connect: connectAvailable && spec?.userConnect ? { label: spec.userConnect.label, tokenField: spec.userConnect.tokenField } : null, requiresPersonalCredentials: spec?.requirePersonalCredentials ?? false, + /** "off" = opt-in server: nothing is live until the user enables it. */ + userDefault: spec?.userDefault ?? "on", // Declared personal-credential fields → /me renders a labeled // guided form instead of raw name/value inputs. credentialFields: spec?.personalCredentials ?? [], @@ -139,18 +146,29 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { if (body.group !== undefined && (setting?.groupLabel ?? "") !== body.group) return false; return true; }); + // Opt-in upstreams need explicit enabled rows; for normal ones + // "enable" just deletes the personal deny. + const optIn = repo.getUpstream(body.upstreamId)?.spec.userDefault === "off"; const changed = repo.bulkSetUserPrefs( prefsIdentity(principal), body.upstreamId, targets.map((e) => e.upstreamToolName), - body.enabled + body.enabled, + optIn ); me.onPolicyChanged(); res.json({ ok: true, changed }); return; } - repo.setUserPref(prefsIdentity(principal), body.upstreamId, body.toolName, body.enabled); + // Single tool (or the whole server via toolName ""), same opt-in rule. + repo.bulkSetUserPrefs( + prefsIdentity(principal), + body.upstreamId, + [body.toolName], + body.enabled, + repo.getUpstream(body.upstreamId)?.spec.userDefault === "off" + ); me.onPolicyChanged(); res.json({ ok: true, changed: 1 }); }) From 54481ce37959d88b22d507411ba5983f0be4ae96 Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Wed, 29 Jul 2026 13:26:24 -0400 Subject: [PATCH 4/4] Release v0.11.0 Tool-selection ergonomics: bulk tier/group toggles on both the admin catalog and /me, server-grouped admin catalog, store-aware secret hints, opt-in upstreams (userDefault), and a CIPP preset that scopes itself with tags. --- CLAUDE.md | 4 ++-- README.md | 1 + package.json | 2 +- packages/gateway/package.json | 2 +- packages/gateway/src/domain/presets.test.ts | 6 ++++++ packages/gateway/src/domain/presets.ts | 11 +++++++++-- 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 782fcb6..293dfdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Self-hosted MCP manager/gateway: one streamable-HTTP `/mcp` endpoint federating - `db/` — `node:sqlite` schema (roles/upstreams/grants/tool_overrides/tool_settings/users/group_mappings, seeded viewer/editor/admin) + typed `Repo` - `domain/catalog.ts` — namespacing (`${namespace}_${tool}`, no double-prefix), routing map (no string-splitting), annotation-derived tiers (port of mcp-itglue `tierOf`) - `domain/presets.ts` — one-click upstream presets: builtin family configs (itglue/cwpsa/planner/cipp — full specs incl. BYOK headers, per-user mode, userConnect, personalCredentials, the `auth` mint block) + optional `mspstack.presets.json` (file overrides builtin ids); `{{param}}` templating rendered server-side and validated via `parseUpstreamSpec`; recommended grants by role NAME resolved at install (`GET /api/presets`, `POST /api/presets/:id/install` with `dryRun`). Spec's `personalCredentials` metadata drives the /me guided credential forms (`credentialFields` in `/api/me/access`) -- `domain/policy.ts` — `PolicyService`: toolEnabled ∧ (override(allow) ∨ (tier ≤ maxTier ∧ ¬deny)); maxTier = per-upstream grant ?? role default. Same function gates tools/list AND tools/call. `allowsFor(principal, entry)` = envelope ∧ personal prefs (deny-only rows in `user_prefs`; "enable" deletes the row — narrowing can never widen) +- `domain/policy.ts` — `PolicyService`: toolEnabled ∧ (override(allow) ∨ (tier ≤ maxTier ∧ ¬deny)); maxTier = per-upstream grant ?? role default. Same function gates tools/list AND tools/call. `allowsFor(principal, entry)` = envelope ∧ personal prefs (deny rows in `user_prefs`; "enable" deletes the row — narrowing can never widen). Spec `userDefault: "off"` inverts the personal layer for that upstream: nothing is live until an explicit opt-in row exists (per tool or server-wide `''`), still capped by the envelope — for servers with hundreds of tools. `/api/me/access` derives its `enabled` flags from `allowsFor` so the page can't disagree with the boundary - `auth/` — `static-tokens.ts` (timing-safe bearer match), `oidc.ts` (jose JWKS resource-server verifier for inbound *access* tokens), `login.ts` (interactive login: openid-client cookie+PKCE confidential-client flow consuming an *id-token*; signed identity-only session cookie, HMAC + freshness; `safeReturnTo`), `authz-server.ts` (OAuth AS facade: RFC 8414 metadata, RFC 7591 DCR for public clients, single-use hashed 60s codes + PKCE S256, HS256 gateway JWTs keyed by `GATEWAY_JWT_SECRET` (default derived from `SESSION_SECRET`), rotating refresh tokens — 30d sliding, family-revoked on replay, client-bound consume that can't burn a live token — register rate limit; clients managed via `/api/oauth-clients` + Users tab), `prm.ts` (RFC 9728 doc + WWW-Authenticate; lists the gateway itself as AS when login is configured, else the raw IdP), `directory.ts` (app-only Graph search of Entra users/groups via the login app's own creds — powers the admin UI group-mapping typeahead at `/api/directory/search`; null for non-Entra issuers → UI degrades to paste-an-id), `principal.ts` (session binding key). Four inbound auth paths in `createAuthResolver`: static token, gateway-issued JWT (routed by unverified `iss == PUBLIC_URL`, then fully verified), OIDC bearer, and the cookie session — the cookie/JWT carry only identity and the role is re-resolved every request (persisted at callback via `setUserRole`), so a session id never carries privilege. `loginUpsert()` is shared by the bearer + callback paths so they can't drift. `/oauth/authorize` brokers user auth to Entra by piggybacking the interactive login: the pending request rides in the signed transient cookie and `/auth/callback` mints the code. - `secrets/` — `SecretStore` interface (scheme-tagged: `bao` | `kv`), `openbao.ts` (KV v2, AppRole or token, 5-min cache), `keyvault.ts` (Azure Key Vault, `DefaultAzureCredential`, lazy SDK import, same 5-min cache; `put(path, field)` writes `path-field`), `memory.ts` (tests). Refs: `bao:path#field` / `kv:secret-name`; env refs: `${VAR}` — all resolved only at upstream connect time. One store at a time (`BAO_ADDR` xor `KEY_VAULT_URI`) - `upstream/connection.ts` — one pooled SDK `Client` per upstream; header/env injection; backoff reconnect (1s→60s) + `onRecovered`; retry-once on dropped transport AND on server-side session expiry (upstream 404 "unknown session" → transparent re-initialize + retry, per MCP spec). Optional spec `auth` block (`oauth2-client-credentials`): the gateway mints the upstream's bearer itself (secret via `${VAR}`/`bao:`/`kv:` ref), caches it, and rebuilds the connection when it nears expiry (60s skew) — for third-party servers that want a finished token, e.g. CIPP behind Easy Auth. Neither secret nor token is ever logged @@ -23,7 +23,7 @@ Self-hosted MCP manager/gateway: one streamable-HTTP `/mcp` endpoint federating - `upstream/manager.ts` — policy-free catalog owner; hot `upsertUpstream`/`removeUpstream`; `summaries()` for the UI - `mcp/gateway-server.ts` — low-level SDK `Server` per session, closes over the Principal; unknown and forbidden tools get the same error (no existence oracle) - `http/app.ts` — `/mcp` (origin check → resolveAuth → principal-bound sessions), PRM endpoints, per-session fingerprint-diffed `list_changed`, mounts `/api` + `/admin` -- `http/admin-api.ts` — admin-only JSON API (upstream CRUD, preflight, registry search, catalog toggles, roles/grants/overrides, users, mappings, secret writes) +- `http/admin-api.ts` — admin-only JSON API (upstream CRUD, preflight, registry search, catalog toggles, roles/grants/overrides, users, mappings, secret writes). Bulk toggles: `PATCH /api/catalog/:upstreamId` `{enabled, tier?, group?}` (one transaction, targets resolved from the live catalog, matching on the EFFECTIVE tier and on `groupLabel ?? derivedGroupOf(tool)`); `PUT /api/me/prefs` takes the same `tier?`/`group?` shape scoped to the caller's envelope. `/api/secrets/health` also reports `scheme` so the UI can show `kv:` vs `bao:` hints - `http/me-api.ts` — `/api/me/*` for ANY principal (mounted before `/api`), static-token principals included: effective access (envelope ∧ prefs), narrow-only prefs (404 outside the envelope), personal credential registration → secret store under `gw-user---`, SQLite keeps only refs. Consumed by per-principal sessions later (`sessionMode`). One-click delegated Connect: upstream spec's `userConnect` block + `auth/user-connect.ts` (Entra auth-code+PKCE against a PUBLIC client) + `/me/connect/:upstreamId|callback` routes in app.ts — the refresh token lands in the secret store as the signed-in user's personal credential, no scripts/copy-paste. `/access` emits `connect: null` unless the whole flow is mounted (login + userConnect + secret store) so the UI never renders dead Connect buttons - `public/admin.html` — dependency-free single-file admin UI ("Sign in with Microsoft" cookie flow + token paste as break-glass), served at `/admin` - `public/me.html` — dependency-free user page ("My MCP Access") served at `/me`: my servers/tools (narrow-only), my personal upstream credentials (ref only), connect snippet. Same sign-in panel as admin.html: cookie session ("Sign in with Microsoft", shown when `/health` reports `login: true`) + bearer-token paste (sessionStorage `mspstack-me-token`) — so /me works on token-only deployments. With login configured, session-less HTML GETs of `/` and `/me` still redirect to `/auth/login` (silent SSO, unchanged UX); `?signin=token` bypasses the gate (break-glass). Without login, `/` redirects to `/me`, the panel shows only the token box, and the Connect snippets include the `Authorization: Bearer` header (no OAuth facade → URL-alone connect can't work; opt-in checkbox embeds the signed-in token). `/api/*` stays server-side-gated (the real boundary) diff --git a/README.md b/README.md index eceb412..805852a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Point Claude (Code, Desktop, or any MCP client) at a single URL; the gateway con - **Minted upstream tokens** — for servers that expect a short-lived bearer instead of a static key (CIPP and friends behind Entra/Easy Auth), an upstream's `auth` block holds only a client id plus a secret reference: the gateway runs the client-credentials exchange itself, caches the token, and refreshes it before expiry - **Install from the UI** — one-click **presets** for the MSPStack family (IT Glue, ConnectWise PSA, Planner) and CIPP that fill BYOK headers, per-user session mode, Connect wiring, and apply recommended role grants (extend with your own via `mspstack.presets.json`); or add any MCP server by URL, npm package (npx), or Docker image; search the official MCP registry; preflight-test before saving; crashed stdio servers restart with backoff - **Guided user setup** — upstreams declare their personal-credential fields, so `/me` renders labeled forms (not raw header names), plus ready-to-copy connect snippets: Claude Code CLI (user-scope by default) and JSON config for Desktop/Cursor/VS Code +- **Manageable at scale** — the admin catalog groups tools by server with one-click switches for a whole tier (or category, derived from the server's own description prefixes); big servers can ship `userDefault: "off"` so users opt into the tools they need instead of receiving hundreds - **Admin UI** at `/admin` — status, server management, tool toggles, role matrix, users & group mappings (with live Entra group search when the login app holds the directory-read Graph roles), OAuth client management, secret writes ## Quick start diff --git a/package.json b/package.json index 625f821..8f1e5fe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mcp-gateway-monorepo", "private": true, - "version": "0.10.0", + "version": "0.11.0", "description": "MSPStack Gateway — self-hosted MCP manager: one endpoint federating many MCP servers with OAuth, roles, secret storage, and tool toggles", "type": "module", "workspaces": [ diff --git a/packages/gateway/package.json b/packages/gateway/package.json index f67aded..9e24690 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -1,6 +1,6 @@ { "name": "@mspstack/mcp-gateway", - "version": "0.10.0", + "version": "0.11.0", "description": "Self-hosted MCP gateway: one streamable-HTTP endpoint federating many MCP servers with namespaced tools, per-tool toggles, roles, and secure upstream credential injection", "type": "module", "main": "dist/index.js", diff --git a/packages/gateway/src/domain/presets.test.ts b/packages/gateway/src/domain/presets.test.ts index 7fc6b67..a02924e 100644 --- a/packages/gateway/src/domain/presets.test.ts +++ b/packages/gateway/src/domain/presets.test.ts @@ -24,10 +24,16 @@ describe("builtin presets", () => { it("cipp renders an oauth2 client-credentials auth block with a secret ref", () => { const spec = renderPreset(BUILTIN_PRESETS.find((p) => p.id === "cipp")!, { url: "https://cipp.example.net/api/ExecMcp", + tags: "Identity,Endpoint", tenantId: "tenant-1", clientId: "client-1", secretRef: "kv:cipp-mcp-secret", }); + // scoped URL + opt-in, so a 231-tool server can't swamp anyone by accident + if (spec.transport === "http") { + expect(spec.url).toBe("https://cipp.example.net/api/ExecMcp?tags=Identity,Endpoint"); + } + expect(spec.userDefault).toBe("off"); expect(spec.auth).toEqual({ kind: "oauth2-client-credentials", tokenUrl: "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token", diff --git a/packages/gateway/src/domain/presets.ts b/packages/gateway/src/domain/presets.ts index a56908a..37a48fb 100644 --- a/packages/gateway/src/domain/presets.ts +++ b/packages/gateway/src/domain/presets.ts @@ -125,13 +125,18 @@ export const BUILTIN_PRESETS: Preset[] = [ id: "cipp", title: "CIPP (CyberDrain Improved Partner Portal)", description: - "M365 multi-tenant management. The gateway mints its own access token from the CIPP API client credentials, so nothing expires. Ships restrictive grants: admin only — CIPP exposes hundreds of read tools including LAPS passwords and BitLocker keys.", + "M365 multi-tenant management. The gateway mints its own access token from the CIPP API client credentials, so nothing expires. Scope the tool set with tags — Identity 26, Endpoint 23, Email-Exchange 39, Tenant 38, Security 16, CIPP 33, Tools 5 (all 231 at once is slow to discover). Ships restrictive defaults: admin-only grants and opt-in for users, because CIPP marks even LAPS passwords and BitLocker keys as read-only tools.", params: [ { key: "url", label: "MCP endpoint", placeholder: "https://.azurewebsites.net/api/ExecMcp", }, + { + key: "tags", + label: "Tool tags (comma-separated)", + placeholder: "Identity,Endpoint,Email-Exchange", + }, { key: "tenantId", label: "Entra tenant id" }, { key: "clientId", label: "CIPP API client id (MCP access enabled)" }, { @@ -144,8 +149,10 @@ export const BUILTIN_PRESETS: Preset[] = [ id: "cipp", namespace: "cipp", transport: "http", - url: "{{url}}", + url: "{{url}}?tags={{tags}}", headers: {}, + // 200+ tools would swamp every session; users pick what they need. + userDefault: "off", auth: { kind: "oauth2-client-credentials", tokenUrl: "https://login.microsoftonline.com/{{tenantId}}/oauth2/v2.0/token",