diff --git a/CLAUDE.md b/CLAUDE.md index 293dfdf..6cc7734 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Self-hosted MCP manager/gateway: one streamable-HTTP `/mcp` endpoint federating - `index.ts` — CLI entry: refuses to start with no auth configured; boots DB → secrets → OIDC → upstreams → HTTP - `config.ts` — flags/env + `mspstack.config.json` (`ConfigError`); parses `MCP_TOKENS_` lists (duplicate labels are a `ConfigError` — labels are /me identities), OIDC (`OIDC_ISSUER`/`ENTRA_TENANT_ID` + required `OIDC_AUDIENCE`), `BAO_*` - `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/catalog.ts` — namespacing (`${namespace}_${tool}`, no double-prefix), routing map (no string-splitting), annotation-derived tiers (port of mcp-itglue `tierOf`), `derivedGroupOf(tool)` — the category for the group switches: `_meta.group`/`_meta.toolset` first (how the family servers tag their toolsets), else a bracketed description prefix (`[Identity > …]`, CIPP); an explicit `group_label` always wins - `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 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. @@ -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). 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/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. `POST /api/catalog/refresh` re-reads every upstream's tools on demand (discovery also re-runs automatically on `tools/list_changed` and after a reconnect). `/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 805852a..755aaad 100644 --- a/README.md +++ b/README.md @@ -14,7 +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 +- **Manageable at scale** — the admin catalog nests tools under server → category, and every level has one-click switches for the whole set or just its read / write / destructive tools; the same switches appear on `/me`. Categories come from the server itself (`_meta.group` on the tool, or a `[Category > …]` description prefix) and can be relabelled per tool. Long lists page in on demand, tool lists can be re-read from the servers at any time, and any upstream can be set to opt-in (`userDefault: "off"`, also a control in the add form) so users pick what they need instead of receiving hundreds of tools - **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 8f1e5fe..8b6158a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mcp-gateway-monorepo", "private": true, - "version": "0.11.0", + "version": "0.12.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 9e24690..252c364 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -1,6 +1,6 @@ { "name": "@mspstack/mcp-gateway", - "version": "0.11.0", + "version": "0.12.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/public/admin.html b/packages/gateway/public/admin.html index 580f459..4a19e43 100644 --- a/packages/gateway/public/admin.html +++ b/packages/gateway/public/admin.html @@ -1,732 +1,815 @@ - - - - - -MSPStack Gateway - - - -
-

MSPStack Gateway

- -
- -
- -
-
-

Admin sign in

- -

Paste an admin bearer token (a static MCP_TOKENS_ADMIN token or an OIDC access token for this gateway) — break-glass for when interactive login is unavailable.

-
- - -
-

-
-
- - -
- - - - + + + + + +MSPStack Gateway + + + +
+

MSPStack Gateway

+ +
+ +
+ +
+
+

Admin sign in

+ +

Paste an admin bearer token (a static MCP_TOKENS_ADMIN token or an OIDC access token for this gateway) — break-glass for when interactive login is unavailable.

+
+ + +
+

+
+
+ + +
+ + + + diff --git a/packages/gateway/public/me.html b/packages/gateway/public/me.html index fae7684..afa7748 100644 --- a/packages/gateway/public/me.html +++ b/packages/gateway/public/me.html @@ -155,6 +155,9 @@

Sign in

const tierPill = t => `${esc(t)}`; // ── My access ── +/** Which categories are expanded, and how many rows each one shows. */ +const meUi = { openGroup: new Set(), shown: new Map(), PAGE: 25 }; + async function renderAccess() { const el = $("#access"); let data; @@ -193,24 +196,79 @@

title="${allOn ? "Hide" : "Show"} all ${tier} tools of ${esc(s.upstreamId)} in my sessions">${allOn ? "✓ " : ""}${tier} ${n}`; }).join(" ")}

- - ${s.tools.map(t => ` - - - - `).join("")} -
${esc(t.exposedName)}${tierPill(t.tier)}
+ ${(() => { + // Categories (server-provided or admin-labelled) so a 200-tool + // server is navigable: switch a whole group, or a tier inside it. + const cats = new Map(); + for (const t of s.tools) { + const c = cats.get(t.group ?? "") ?? { name: t.group ?? "", tools: [] }; + c.tools.push(t); + cats.set(t.group ?? "", c); + } + const list = [...cats.values()].sort((a, b) => (a.name || "￿").localeCompare(b.name || "￿")); + const single = list.length === 1; + return list.map(c => { + const key = `${s.upstreamId} ${c.name}`; + const open = single || meUi.openGroup.has(key); + const on = c.tools.filter(t => t.enabled).length; + const limit = meUi.shown.get(key) ?? meUi.PAGE; + const rows = c.tools.slice(0, limit); + const counts = c.tools.reduce((a, t) => ((a[t.tier] = (a[t.tier] || 0) + 1), a), {}); + return ` + ${single ? "" : `
+ + + ${esc(c.name || "(ungrouped)")} + ${on} of ${c.tools.length} + + ${["read", "write", "destructive"].map(tier => { + const n = counts[tier] || 0; + if (!n) return ""; + const allOn = c.tools.filter(t => t.tier === tier).every(t => t.enabled); + return ``; + }).join(" ")} +
`} + ${open ? ` + ${rows.map(t => ` + + + + `).join("")} +
${esc(t.exposedName)}${tierPill(t.tier)}
+ ${c.tools.length > rows.length + ? `` + : ""}` : ""}`; + }).join(""); + })()} `; }).join("")} `; el.onclick = async (ev) => { + const openGroup = ev.target.closest("[data-open-group]"); + if (openGroup) { + const key = openGroup.dataset.openGroup; + meUi.openGroup.has(key) ? meUi.openGroup.delete(key) : meUi.openGroup.add(key); + renderAccess(); + return; + } + const more = ev.target.closest("[data-more]"); + if (more) { + meUi.shown.set(more.dataset.more, (meUi.shown.get(more.dataset.more) ?? meUi.PAGE) + meUi.PAGE); + renderAccess(); + return; + } const b = ev.target.closest("[data-bulk-u]"); if (!b) return; + const body = { upstreamId: b.dataset.bulkU, enabled: b.dataset.bulkEn === "1" }; + if (b.dataset.bulkTier) body.tier = b.dataset.bulkTier; + if (b.dataset.bulkGroup !== undefined) body.group = b.dataset.bulkGroup; + const what = [b.dataset.bulkTier, b.dataset.bulkGroup].filter(Boolean).join(" in ") || "tool"; 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"}`); + const r = await api("/prefs", { method: "PUT", body: JSON.stringify(body) }); + toast(`${body.enabled ? "Enabled" : "Hidden"} ${r.changed} ${what}${r.changed === 1 ? "" : "s"}`); renderAccess(); } catch (e) { toast(e.message, true); } }; diff --git a/packages/gateway/src/domain/catalog.test.ts b/packages/gateway/src/domain/catalog.test.ts index 432a694..75b4ba3 100644 --- a/packages/gateway/src/domain/catalog.test.ts +++ b/packages/gateway/src/domain/catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { buildCatalog, exposedNameFor, exposedTools, tierOf } from "./catalog.js"; +import { buildCatalog, derivedGroupOf, exposedNameFor, exposedTools, tierOf } from "./catalog.js"; const tool = (name: string, annotations?: Tool["annotations"]): Tool => ({ name, @@ -22,6 +22,28 @@ describe("tierOf", () => { }); }); +describe("derivedGroupOf", () => { + it("prefers _meta.group / _meta.toolset (the family servers' own tagging)", () => { + expect(derivedGroupOf({ ...tool("cw_search_tickets"), _meta: { group: "tickets" } })).toBe("tickets"); + expect(derivedGroupOf({ ...tool("cw_list_my_time"), _meta: { toolset: "time" } })).toBe("time"); + // _meta wins over a description prefix + expect( + derivedGroupOf({ ...tool("x"), description: "[Ignored] doc", _meta: { group: "finance" } }) + ).toBe("finance"); + }); + + it("falls back to a bracketed description prefix (CIPP) and its first segment", () => { + expect(derivedGroupOf({ ...tool("ListUsers"), description: "[Identity > Administration > Users] list" })).toBe("Identity"); + expect(derivedGroupOf({ ...tool("ListStandards"), description: "[Tenant > Standards] list" })).toBe("Tenant"); + }); + + it("returns null when there is nothing to derive", () => { + expect(derivedGroupOf(tool("plain"))).toBeNull(); + expect(derivedGroupOf({ ...tool("plain"), description: "no brackets here" })).toBeNull(); + expect(derivedGroupOf({ ...tool("plain"), _meta: { group: " " } })).toBeNull(); + }); +}); + describe("exposedNameFor", () => { it("prefixes the namespace", () => { expect(exposedNameFor("demo", "echo")).toBe("demo_echo"); diff --git a/packages/gateway/src/domain/catalog.ts b/packages/gateway/src/domain/catalog.ts index 6c4edf6..be0c7aa 100644 --- a/packages/gateway/src/domain/catalog.ts +++ b/packages/gateway/src/domain/catalog.ts @@ -28,12 +28,21 @@ export function exposedNameFor(namespace: string, toolName: string): string { } /** - * 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. + * The category a tool belongs to, for the group switches in both UIs. + * Two sources, in order: + * 1. `_meta.group` (or `_meta.toolset`) — the clean, spec-sanctioned way; the + * MSPStack family servers tag their toolsets this way, and it costs no + * description tokens. + * 2. a bracketed prefix in the description — CIPP writes + * "[Identity > Administration > Users] …"; its first segment is the group. + * An explicit `group_label` in tool_settings always wins over both. */ export function derivedGroupOf(tool: Tool): string | null { + const meta = tool._meta as Record | undefined; + for (const key of ["group", "toolset"]) { + const value = meta?.[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } const match = /^\s*\[([^\]>]+?)(?:\s*>[^\]]*)?\]/.exec(tool.description ?? ""); return match ? match[1]!.trim() : null; } diff --git a/packages/gateway/src/domain/presets.test.ts b/packages/gateway/src/domain/presets.test.ts index a02924e..d51aa96 100644 --- a/packages/gateway/src/domain/presets.test.ts +++ b/packages/gateway/src/domain/presets.test.ts @@ -24,14 +24,14 @@ 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 + // Every tool loads; visibility is managed by the group switches, and + // opt-in keeps a 231-tool server from swamping anyone by accident. if (spec.transport === "http") { - expect(spec.url).toBe("https://cipp.example.net/api/ExecMcp?tags=Identity,Endpoint"); + expect(spec.url).toBe("https://cipp.example.net/api/ExecMcp"); } expect(spec.userDefault).toBe("off"); expect(spec.auth).toEqual({ diff --git a/packages/gateway/src/domain/presets.ts b/packages/gateway/src/domain/presets.ts index 37a48fb..90526b0 100644 --- a/packages/gateway/src/domain/presets.ts +++ b/packages/gateway/src/domain/presets.ts @@ -125,18 +125,13 @@ 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. 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.", + "M365 multi-tenant management. The gateway mints its own access token from the CIPP API client credentials, so nothing expires. All tools load; pick the categories you want with the group switches (Identity, Endpoint, Email-Exchange, Tenant, Security, …). 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)" }, { @@ -149,7 +144,7 @@ export const BUILTIN_PRESETS: Preset[] = [ id: "cipp", namespace: "cipp", transport: "http", - url: "{{url}}?tags={{tags}}", + url: "{{url}}", headers: {}, // 200+ tools would swamp every session; users pick what they need. userDefault: "off", diff --git a/packages/gateway/src/http/admin-api.ts b/packages/gateway/src/http/admin-api.ts index b86d4b8..fdd12e6 100644 Binary files a/packages/gateway/src/http/admin-api.ts and b/packages/gateway/src/http/admin-api.ts differ diff --git a/packages/gateway/src/http/me-api.ts b/packages/gateway/src/http/me-api.ts index 14069dc..dc9f94f 100644 --- a/packages/gateway/src/http/me-api.ts +++ b/packages/gateway/src/http/me-api.ts @@ -18,6 +18,7 @@ import { Router, type Request, type Response } from "express"; import { z } from "zod"; import { prefsIdentity, principalSlug, type Principal } from "../auth/principal.js"; +import { derivedGroupOf } from "../domain/catalog.js"; import type { AppDeps, AuthOutcome } from "./app.js"; interface MeDeps { @@ -69,13 +70,20 @@ export function createMeRouter(deps: AppDeps, me: MeDeps): Router { // `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>(); + const byUpstream = new Map< + string, + Array<{ name: string; exposedName: string; tier: string; group: string; enabled: boolean }> + >(); for (const entry of policy.visibleEntries(principal.roleId, manager.catalogEntries())) { const list = byUpstream.get(entry.upstreamId) ?? []; + const setting = repo.toolSetting(entry.upstreamId, entry.upstreamToolName); list.push({ name: entry.upstreamToolName, exposedName: entry.exposedName, - tier: entry.tier, + // The effective tier is what the switches act on, overrides included. + tier: setting?.tierOverride ?? entry.tier, + /** Category for the group switches — explicit label, else derived. */ + group: setting?.groupLabel ?? derivedGroupOf(entry.tool) ?? "", enabled: policy.allowsFor(principal, entry), }); byUpstream.set(entry.upstreamId, list);