diff --git a/CLAUDE.md b/CLAUDE.md index d1ea4e0..782fcb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_` 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) diff --git a/README.md b/README.md index 89b020a..eceb412 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/package.json b/package.json index 4a6a804..625f821 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/packages/gateway/package.json b/packages/gateway/package.json index f0de784..f67aded 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -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", diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index 2b4822f..d5471a8 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -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//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 diff --git a/packages/gateway/src/domain/presets.test.ts b/packages/gateway/src/domain/presets.test.ts index 12277a0..7fc6b67 100644 --- a/packages/gateway/src/domain/presets.test.ts +++ b/packages/gateway/src/domain/presets.test.ts @@ -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", () => { diff --git a/packages/gateway/src/domain/presets.ts b/packages/gateway/src/domain/presets.ts index 685f21b..a56908a 100644 --- a/packages/gateway/src/domain/presets.ts +++ b/packages/gateway/src/domain/presets.ts @@ -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://.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)", diff --git a/packages/gateway/src/upstream/connection.ts b/packages/gateway/src/upstream/connection.ts index 094197a..bd64b08 100644 --- a/packages/gateway/src/upstream/connection.ts +++ b/packages/gateway/src/upstream/connection.ts @@ -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 @@ -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. */ @@ -94,6 +98,12 @@ export class UpstreamConnection { /** Connect (or join an in-flight connect). Serialized so reconnects never race. */ async connect(): Promise { + // 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) { @@ -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): Promise> { + 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 { + const client = this.client; + this.client = null; + if (!client) return; + client.onclose = undefined; + await client.close().catch(() => undefined); + } + private async doConnect(): Promise { const context = `upstream "${this.spec.id}"`; try { @@ -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 }, diff --git a/packages/gateway/src/upstream/oauth2-auth.test.ts b/packages/gateway/src/upstream/oauth2-auth.test.ts new file mode 100644 index 0000000..f9fed32 --- /dev/null +++ b/packages/gateway/src/upstream/oauth2-auth.test.ts @@ -0,0 +1,181 @@ +/** + * UpstreamConnection with an `auth` block: the gateway mints its own OAuth2 + * access token (client credentials) for upstreams that expect a finished + * bearer rather than a static credential — third-party MCP servers behind + * Entra/Easy Auth, e.g. CIPP. Covers the mint, the secret-store lookup for + * the client secret, and the re-mint when the token nears expiry. + */ + +import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { UpstreamConnection } from "./connection.js"; +import { MemorySecretStore } from "../secrets/memory.js"; +import type { UpstreamSpec } from "../config.js"; + +/** + * One HTTP server playing both roles: the Entra-ish token endpoint + * (POST /token) and a bearer-protected MCP upstream (POST /mcp). + */ +class FakeTokenAndUpstream { + private readonly sessions = new Map(); + private http!: HttpServer; + /** Tokens handed out, newest last. */ + readonly issued: string[] = []; + /** Bodies posted to the token endpoint (to assert the grant shape). */ + readonly tokenRequests: URLSearchParams[] = []; + /** expires_in for the NEXT token — lets a test make one go stale instantly. */ + nextExpiresIn = 3600; + rejectedRequests = 0; + + async start(): Promise { + this.http = createServer((req, res) => { + this.handle(req, res).catch((err) => res.writeHead(500).end(String(err))); + }); + await new Promise((resolve) => this.http.listen(0, resolve)); + return `http://localhost:${(this.http.address() as AddressInfo).port}`; + } + + async stop(): Promise { + await new Promise((resolve) => this.http.close(() => resolve())); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString("utf8"); + + if (req.url?.startsWith("/token")) { + this.tokenRequests.push(new URLSearchParams(raw)); + const token = `at-${this.issued.length + 1}`; + this.issued.push(token); + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ access_token: token, expires_in: this.nextExpiresIn, token_type: "Bearer" }) + ); + return; + } + + // MCP endpoint: only the CURRENT token is accepted (expired ones 401). + const auth = req.headers.authorization; + if (auth !== `Bearer ${this.issued.at(-1)}`) { + this.rejectedRequests += 1; + res.writeHead(401).end("unauthorized"); + return; + } + + const body: unknown = raw ? JSON.parse(raw) : undefined; + const sessionId = req.headers["mcp-session-id"] as string | undefined; + const existing = sessionId ? this.sessions.get(sessionId) : undefined; + if (existing) { + await existing.handleRequest(req, res, body); + return; + } + if (req.method === "POST" && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => this.sessions.set(id, transport), + }); + const mcp = new McpServer({ name: "fake-cipp", version: "0.0.0" }); + mcp.tool("ping", async () => ({ content: [{ type: "text" as const, text: "pong" }] })); + await mcp.connect(transport); + await transport.handleRequest(req, res, body); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }).end( + JSON.stringify({ error: "Unknown or expired MCP session. Re-initialize." }) + ); + } +} + +const spec = (base: string): UpstreamSpec => + ({ + id: "cipp", + namespace: "cipp", + transport: "http", + url: `${base}/mcp`, + headers: { "x-static": "kept" }, + enabled: true, + auth: { + kind: "oauth2-client-credentials", + tokenUrl: `${base}/token`, + clientId: "client-1", + clientSecret: "bao:cipp#secret", // resolved through the secret store + scope: "api://client-1/.default", + header: "Authorization", + prefix: "Bearer ", + }, + }) as UpstreamSpec; + +describe("UpstreamConnection — minted OAuth2 upstream tokens", () => { + const upstream = new FakeTokenAndUpstream(); + let base: string; + let store: MemorySecretStore; + + beforeAll(async () => { + base = await upstream.start(); + store = new MemorySecretStore(); + await store.put("cipp", "secret", "s3cr3t"); + }); + + afterAll(async () => { + await upstream.stop(); + }); + + it("mints a token from the client-credentials grant and calls with it", async () => { + const connection = new UpstreamConnection(spec(base), store); + try { + await connection.connect(); + expect(await connection.listTools()).toHaveLength(1); + expect((await connection.callTool("ping", {})).content).toEqual([{ type: "text", text: "pong" }]); + + const body = upstream.tokenRequests.at(-1)!; + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("client_id")).toBe("client-1"); + expect(body.get("scope")).toBe("api://client-1/.default"); + // the secret came from the store, not from the spec literal + expect(body.get("client_secret")).toBe("s3cr3t"); + expect(upstream.rejectedRequests).toBe(0); + } finally { + await connection.close(); + } + }); + + it("re-mints when the token is near expiry, so calls never 401", async () => { + upstream.nextExpiresIn = 30; // < the 60s skew ⇒ stale immediately + const connection = new UpstreamConnection(spec(base), store); + try { + await connection.connect(); + const firstToken = upstream.issued.at(-1); + const mintsAfterConnect = upstream.tokenRequests.length; + + // Next call notices the stale token, rebuilds the connection with a + // fresh one, and still succeeds (the fake rejects superseded tokens). + await connection.connect(); + expect(upstream.tokenRequests.length).toBe(mintsAfterConnect + 1); + expect(upstream.issued.at(-1)).not.toBe(firstToken); + expect((await connection.callTool("ping", {})).content).toEqual([{ type: "text", text: "pong" }]); + } finally { + upstream.nextExpiresIn = 3600; + await connection.close(); + } + }); + + it("surfaces token-endpoint failures as connect errors (no silent unauthenticated connect)", async () => { + const broken = { + ...spec(base), + auth: { ...(spec(base) as { auth: Record }).auth, tokenUrl: `${base}/nowhere` }, + } as UpstreamSpec; + const connection = new UpstreamConnection(broken, store); + try { + // /nowhere is not the token route → 401 from the MCP branch → mint fails. + await expect(connection.connect()).rejects.toThrow(/token request failed/); + expect(connection.connected).toBe(false); + } finally { + await connection.close(); + } + }); +});