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 @@ -14,11 +14,11 @@ Self-hosted MCP manager/gateway: one streamable-HTTP `/mcp` endpoint federating
- `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/presets.ts` — one-click upstream presets: builtin family configs (itglue/cwpsa/planner — full specs incl. BYOK headers, per-user mode, userConnect, personalCredentials) + 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/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)
- `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)
- `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
- `upstream/manager.ts` also pools **per-principal links** for `sessionMode:"per-user"` upstreams: spec clone with the caller's credential REFS layered over headers/env (still resolved via the secret store at connect — anti-passthrough intact); catalog discovery stays on the shared link; personal pool flushed on upstream upsert/remove. `requirePersonalCredentials` refuses the shared fallback
- `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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Point Claude (Code, Desktop, or any MCP client) at a single URL; the gateway con
- **Zero-config client connect (DCR)** — the gateway hosts its own OAuth authorization server facade (RFC 8414 metadata + RFC 7591 dynamic client registration + rotating refresh tokens), brokering user sign-in to your IdP; `claude mcp add <url>` connects with no pre-provisioned client id — URL only. Registered clients are managed (and revocable) from the admin UI
- **Roles & policy** — viewer/editor/admin (plus custom roles) gate tools by annotation-derived tiers (read/write/destructive), with per-upstream grants and per-tool allow/deny overrides; enforcement is re-checked at call time
- **Secrets stay server-side** — upstream API keys live in OpenBao (`bao:path#field` refs), Azure Key Vault (`kv:` refs), or env vars, injected at connect time; the inbound client token is never passed through to upstreams
- **Install from the UI** — one-click **presets** for the MSPStack family (IT Glue, ConnectWise PSA, Planner) 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
- **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
- **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

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.9.1",
"version": "0.10.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.9.1",
"version": "0.10.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
21 changes: 21 additions & 0 deletions packages/gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ 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),
/**
* Upstreams that want a freshly minted OAuth2 access token rather than a
* static credential (third-party MCP servers behind Entra/Easy Auth, e.g.
* CIPP). The gateway performs the client-credentials exchange itself at
* connect time, caches the token, and re-mints it when it nears expiry —
* the static parts (client id + a secret REF) are all that is stored.
*/
auth: z
.object({
kind: z.literal("oauth2-client-credentials"),
/** Token endpoint, e.g. https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token */
tokenUrl: z.string().min(1),
clientId: z.string().min(1),
/** `${VAR}` / `bao:` / `kv:` ref — resolved at connect, never stored resolved. */
clientSecret: z.string().min(1),
scope: z.string().min(1),
/** Header the token is injected into (default Authorization: Bearer …). */
header: z.string().min(1).default("Authorization"),
prefix: z.string().default("Bearer "),
})
.optional(),
/**
* Declared personal-credential fields for per-user upstreams — pure
* metadata consumed by /me to render a labeled guided setup form instead
Expand Down
27 changes: 25 additions & 2 deletions packages/gateway/src/domain/presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,33 @@ const planner = BUILTIN_PRESETS.find((p) => p.id === "planner")!;

describe("builtin presets", () => {
it("cover the family and validate against their own schema", () => {
expect(BUILTIN_PRESETS.map((p) => p.id).sort()).toEqual(["cwpsa", "itglue", "planner"]);
for (const p of BUILTIN_PRESETS) {
expect(BUILTIN_PRESETS.map((p) => p.id).sort()).toEqual(["cipp", "cwpsa", "itglue", "planner"]);
for (const p of BUILTIN_PRESETS.filter((p) => p.id !== "cipp")) {
expect(p.grants).toEqual({ viewer: "read", editor: "write" });
}
// CIPP is admin-only by default (its read-tier tools surface secrets).
expect(BUILTIN_PRESETS.find((p) => p.id === "cipp")!.grants).toEqual({
viewer: "none",
editor: "none",
});
});

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",
tenantId: "tenant-1",
clientId: "client-1",
secretRef: "kv:cipp-mcp-secret",
});
expect(spec.auth).toEqual({
kind: "oauth2-client-credentials",
tokenUrl: "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token",
clientId: "client-1",
clientSecret: "kv:cipp-mcp-secret", // a REF, resolved at connect
scope: "api://client-1/.default",
header: "Authorization",
prefix: "Bearer ",
});
});

it("summaries omit the spec template", () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/gateway/src/domain/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,44 @@ export const BUILTIN_PRESETS: Preset[] = [
},
grants: { viewer: "read", editor: "write" },
}),
presetSchema.parse({
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.",
params: [
{
key: "url",
label: "MCP endpoint",
placeholder: "https://<your-cipp>.azurewebsites.net/api/ExecMcp",
},
{ key: "tenantId", label: "Entra tenant id" },
{ key: "clientId", label: "CIPP API client id (MCP access enabled)" },
{
key: "secretRef",
label: "Client secret REFERENCE (not the value)",
placeholder: "kv:cipp-mcp-secret — write the value on the Secrets tab first",
},
],
spec: {
id: "cipp",
namespace: "cipp",
transport: "http",
url: "{{url}}",
headers: {},
auth: {
kind: "oauth2-client-credentials",
tokenUrl: "https://login.microsoftonline.com/{{tenantId}}/oauth2/v2.0/token",
clientId: "{{clientId}}",
clientSecret: "{{secretRef}}",
scope: "api://{{clientId}}/.default",
},
},
// Deliberately restrictive: CIPP's tools arrive annotated read-only even
// when they surface secrets, so viewer/editor get nothing until an admin
// widens it deliberately (admins keep their destructive default).
grants: { viewer: "none", editor: "none" },
}),
presetSchema.parse({
id: "planner",
title: "Microsoft Planner (mcp-planner)",
Expand Down
79 changes: 74 additions & 5 deletions packages/gateway/src/upstream/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { SERVER_NAME, SERVER_VERSION } from "../version.js";

const BACKOFF_INITIAL_MS = 1_000;
const BACKOFF_MAX_MS = 60_000;
/** Re-mint an OAuth2 upstream token this long before it actually expires. */
const TOKEN_SKEW_MS = 60_000;

/**
* The upstream forgot our streamable-HTTP session while the local transport
Expand Down Expand Up @@ -69,6 +71,8 @@ export class UpstreamConnection {
private closed = false;
private backoffMs = BACKOFF_INITIAL_MS;
private reconnectTimer: NodeJS.Timeout | null = null;
/** When the current connection's minted OAuth2 token goes stale (0 = n/a). */
private tokenExpiresAt = 0;
private status: UpstreamStatus = { connected: false, lastError: null, reconnectAttempts: 0 };

/** Fires when the upstream announces its tool list changed. */
Expand All @@ -94,6 +98,12 @@ export class UpstreamConnection {

/** Connect (or join an in-flight connect). Serialized so reconnects never race. */
async connect(): Promise<void> {
// A minted OAuth2 token is fixed for the transport's lifetime, so a stale
// one means the whole connection must be rebuilt with a fresh token.
if (this.client && this.tokenExpiresAt && Date.now() >= this.tokenExpiresAt) {
console.error(`[upstream:${this.spec.id}] access token expiring — reconnecting with a fresh one`);
await this.dropClient();
}
if (this.client) return;
if (this.closed) throw new Error(`upstream "${this.spec.id}" is closed`);
if (!this.connecting) {
Expand All @@ -104,6 +114,63 @@ export class UpstreamConnection {
return this.connecting;
}

/**
* Client-credentials exchange for upstreams that want a minted token rather
* than a static credential. The secret itself comes from the store/env like
* any other injected value; only the resulting header is added, and neither
* the secret nor the token is ever logged.
*/
private async withMintedToken(headers: Record<string, string>): Promise<Record<string, string>> {
const auth = this.spec.auth;
this.tokenExpiresAt = 0;
if (!auth) return headers;

const clientSecret = await resolveInjectionValue(
auth.clientSecret,
this.env,
this.secretStore,
`upstream "${this.spec.id}".auth.clientSecret`
);
const response = await fetch(auth.tokenUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: auth.clientId,
client_secret: clientSecret,
scope: auth.scope,
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as { error?: string; error_description?: string };
// error_description can be long but carries no secret — trim it.
detail = ` ${body.error ?? ""}${body.error_description ? `: ${body.error_description.slice(0, 200)}` : ""}`;
} catch {
// non-JSON error body
}
throw new Error(`token request failed (${response.status})${detail}`);
}
const token = (await response.json()) as { access_token?: string; expires_in?: number };
if (!token.access_token) throw new Error("token response carried no access_token");
this.tokenExpiresAt = Date.now() + (token.expires_in ?? 3600) * 1000 - TOKEN_SKEW_MS;
console.error(
`[upstream:${this.spec.id}] minted access token via client credentials (expires in ${token.expires_in ?? 3600}s)`
);
return { ...headers, [auth.header]: `${auth.prefix}${token.access_token}` };
}

/** Tear down the live client without triggering the reconnect supervisor. */
private async dropClient(): Promise<void> {
const client = this.client;
this.client = null;
if (!client) return;
client.onclose = undefined;
await client.close().catch(() => undefined);
}

private async doConnect(): Promise<void> {
const context = `upstream "${this.spec.id}"`;
try {
Expand All @@ -117,11 +184,13 @@ export class UpstreamConnection {
this.secretStore,
`${context}.url`
);
const headers = await resolveInjectionRecord(
this.spec.headers,
this.env,
this.secretStore,
`${context}.headers`
const headers = await this.withMintedToken(
await resolveInjectionRecord(
this.spec.headers,
this.env,
this.secretStore,
`${context}.headers`
)
);
transport = new StreamableHTTPClientTransport(new URL(url), {
requestInit: { headers },
Expand Down
Loading
Loading