Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ROLE>` 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.
Expand All @@ -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-<principalSlug>-<upstreamId>-<field>`, 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion packages/gateway/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading