From e68befc3f39942a509f7df6390ec46212f526443 Mon Sep 17 00:00:00 2001 From: Dan Carter Date: Thu, 13 Aug 2026 19:26:10 -0400 Subject: [PATCH 1/6] Harden MCP portal authentication changes --- .../__tests__/account-revision.test.ts | 54 +++++++++++ .../__tests__/config.test.ts | 8 +- packages/gatekeeper-mcp-portal/src/config.ts | 2 +- packages/gatekeeper-mcp-portal/src/portal.ts | 90 ++++++++++++++++++- .../gatekeeper-mcp-portal/vitest.config.ts | 13 +++ .../__tests__/account-endpoint.test.ts | 26 ++++++ packages/mcp-shared/src/account.ts | 47 ++++++++-- 7 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts create mode 100644 packages/gatekeeper-mcp-portal/vitest.config.ts diff --git a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts new file mode 100644 index 000000000..23c1298a8 --- /dev/null +++ b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + McpAccountBase, + type ConnectedServer, + type ConnectOutcome, +} from "@gadgets/mcp-shared/account"; +import { McpAccount } from "../src/portal.js"; + +afterEach(() => vi.restoreAllMocks()); + +describe("McpAccount configuration revision", () => { + it("records the current revision before handing an OAuth attempt to its callback", async () => { + const values = new Map([["portalConfigRevision", "old"]]); + const ctx = { + id: { toString: () => "account" }, + storage: { + kv: { + get: (key: string) => values.get(key), + put: (key: string, value: unknown) => values.set(key, value), + delete: (key: string) => values.delete(key), + }, + }, + exports: {}, + }; + const env = { + MCP_PORTAL_URL: "https://portal.example.com/mcp", + MCP_PORTAL_AUTH: "oauth", + }; + const server: ConnectedServer = { + endpoint: env.MCP_PORTAL_URL, + serverId: "portal", + serverName: "Portal", + provenance: "deployment", + auth: "oauth", + }; + const base = McpAccountBase.prototype as unknown as { + beginConnect(nonce: string, target: ConnectedServer | null): Promise; + }; + vi.spyOn(base, "beginConnect") + .mockResolvedValue({ kind: "redirect", url: "https://login.example.com" }); + const account = new McpAccount(ctx as never, env as never); + const internals = account as unknown as { + awaitingSelection(nonce: string): boolean; + server(): ConnectedServer | undefined; + }; + vi.spyOn(internals, "awaitingSelection").mockReturnValue(true); + vi.spyOn(internals, "server").mockReturnValue(server); + + await expect(account.beginConnect("nonce", server)).resolves.toMatchObject({ kind: "redirect" }); + + expect(values.get("portalConfigRevision")).not.toBe("old"); + }); +}); diff --git a/packages/gatekeeper-mcp-portal/__tests__/config.test.ts b/packages/gatekeeper-mcp-portal/__tests__/config.test.ts index caa5dea59..c2744299e 100644 --- a/packages/gatekeeper-mcp-portal/__tests__/config.test.ts +++ b/packages/gatekeeper-mcp-portal/__tests__/config.test.ts @@ -142,9 +142,13 @@ describe("portalAuthRequiresReconnect", () => { expect(portalAuthRequiresReconnect("token", "oauth")).toBe(true); }); - it("allows none and oauth to differ after probing the endpoint", () => { + it("allows an oauth-configured portal to prove public during probing", () => { expect(portalAuthRequiresReconnect("none", "oauth")).toBe(false); - expect(portalAuthRequiresReconnect("oauth", "none")).toBe(false); + }); + + it("keeps explicitly unauthenticated mode strict", () => { + expect(portalAuthRequiresReconnect("oauth", "none")).toBe(true); + expect(portalAuthRequiresReconnect("none", "none")).toBe(false); }); }); diff --git a/packages/gatekeeper-mcp-portal/src/config.ts b/packages/gatekeeper-mcp-portal/src/config.ts index 2f5164fca..280a90e28 100644 --- a/packages/gatekeeper-mcp-portal/src/config.ts +++ b/packages/gatekeeper-mcp-portal/src/config.ts @@ -187,7 +187,7 @@ export function portalServer(config: PortalConfig): ConnectedServer { export function portalAuthRequiresReconnect( connected: ServerAuthKind, configured: ServerAuthKind, ): boolean { - return (connected === "token") !== (configured === "token"); + return configured === "oauth" ? connected === "token" : connected !== configured; } /** diff --git a/packages/gatekeeper-mcp-portal/src/portal.ts b/packages/gatekeeper-mcp-portal/src/portal.ts index ea7374b7a..a464baa52 100644 --- a/packages/gatekeeper-mcp-portal/src/portal.ts +++ b/packages/gatekeeper-mcp-portal/src/portal.ts @@ -25,13 +25,17 @@ import { } from "@gadgets/workshop-shared/gatekeeper"; import { isValidToolName } from "@gadgets/mcp-shared/client"; import { MAX_TOOLS_PER_SERVER, type ServerTrust } from "@gadgets/mcp-shared/tools"; -import { bindingNameFragment, hostOf } from "@gadgets/mcp-shared/util"; +import { bindingNameFragment, hexEncode, hostOf } from "@gadgets/mcp-shared/util"; import type { McpLog, McpLogFields } from "@gadgets/mcp-shared/log"; import { generateSessionTypes, sessionTypeName } from "@gadgets/mcp-shared/schema-to-ts"; import { McpAccountBase, type ConnectedServer, type ConnectOutcome } from "@gadgets/mcp-shared/account"; import { generateNonce } from "@gadgets/mcp-shared/connect-nonce"; -import { withClient, type ConnectionAccount } from "@gadgets/mcp-shared/connection"; +import { + withClient, + type ConnectionAccount, + type McpConnection, +} from "@gadgets/mcp-shared/connection"; import { McpSessionBase } from "@gadgets/mcp-shared/session"; import { McpFacetBase } from "@gadgets/mcp-shared/facet"; import { @@ -73,6 +77,7 @@ import { requirePortalServerScope, isPortalToolGrantable, toolGrantOptions, + type PortalConfig, } from "./config.js"; import type { ConfiguratorUIOption } from "@gadgets/configurator-ui"; import { MCP_BASE_TYPES } from "@gadgets/mcp-shared/base-types"; @@ -240,6 +245,10 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe * real addition: a portal may be fronted by one instead of using OAuth. */ export class McpAccount extends McpAccountBase { + // Counts in-flight attempts per revision. Separate Durable Object requests can overlap at the + // digest and portal probe, so one losing or older attempt must not clear a newer attempt's guard. + #connectingRevisions = new Map(); + protected baseUrl(): string { return getBaseUrl(this.env); } @@ -260,6 +269,83 @@ export class McpAccount extends McpAccountBase { protected override staticToken(server: ConnectedServer): string | null { return portalTokenFor(this.env, server.endpoint); } + + protected override allowsOAuthFallback(server: ConnectedServer): boolean { + return server.auth !== "none"; + } + + protected override allowsOAuthCallback(server: ConnectedServer): boolean { + const config = readPortalConfig(this.env); + return config?.auth === "oauth" && sameEndpoint(config.endpoint, server.endpoint); + } + + async #configurationRevision(config: PortalConfig): Promise { + const token = config.auth === "token" ? this.env.MCP_PORTAL_TOKEN ?? "" : ""; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${config.auth}\u0000${token}`), + ); + return hexEncode(new Uint8Array(digest)); + } + + override async beginConnect( + initiationNonce: string, + target: ConnectedServer | null, + ): Promise { + const config = readPortalConfig(this.env); + const revision = config && this.awaitingSelection(initiationNonce) + ? await this.#configurationRevision(config) + : undefined; + if (revision !== undefined) { + this.#connectingRevisions.set( + revision, (this.#connectingRevisions.get(revision) ?? 0) + 1); + } + try { + const outcome = await super.beginConnect(initiationNonce, target); + if (outcome.kind !== "invalid") { + const current = readPortalConfig(this.env); + const server = this.server(); + if (current && server && sameEndpoint(current.endpoint, server.endpoint)) { + this.ctx.storage.kv.put( + "portalConfigRevision", + await this.#configurationRevision(current), + ); + } + } + return outcome; + } finally { + if (revision !== undefined) { + const remaining = (this.#connectingRevisions.get(revision) ?? 1) - 1; + if (remaining === 0) this.#connectingRevisions.delete(revision); + else this.#connectingRevisions.set(revision, remaining); + } + } + } + + override async getConnection(endpoint: string): Promise { + const config = readPortalConfig(this.env); + const server = this.server(); + if (!config || !server || !sameEndpoint(config.endpoint, endpoint) + || portalAuthRequiresReconnect(server.auth, config.auth)) { + throw new Error("This deployment's MCP portal configuration changed. Reconnect the account."); + } + const revision = await this.#configurationRevision(config); + const previous = this.ctx.storage.kv.get("portalConfigRevision"); + const reconnectingToCurrentRevision = this.#connectingRevisions.has(revision); + if (previous === undefined) { + // Existing token accounts predate the revision marker. Their cached session may have been + // minted under a different configured token, so invalidate it once before establishing the + // current revision as the baseline. New connects record the revision in `beginConnect()`. + if (!reconnectingToCurrentRevision) { + if (server.auth === "token") this.invalidateConnectionState(); + this.ctx.storage.kv.put("portalConfigRevision", revision); + } + } else if (previous !== revision && !reconnectingToCurrentRevision) { + this.invalidateConnectionState(); + this.ctx.storage.kv.put("portalConfigRevision", revision); + } + return super.getConnection(endpoint); + } } // --------------------------------------------------------------------------- diff --git a/packages/gatekeeper-mcp-portal/vitest.config.ts b/packages/gatekeeper-mcp-portal/vitest.config.ts new file mode 100644 index 000000000..48495a37b --- /dev/null +++ b/packages/gatekeeper-mcp-portal/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; +import capnwebValidate from "capnweb-validate/vite"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [capnwebValidate()], + test: { + alias: { + "cloudflare:workers": fileURLToPath( + new URL("../mcp-shared/__tests__/stubs/cloudflare-workers.ts", import.meta.url)), + }, + }, +}); diff --git a/packages/mcp-shared/__tests__/account-endpoint.test.ts b/packages/mcp-shared/__tests__/account-endpoint.test.ts index 91ec6560e..1818473e4 100644 --- a/packages/mcp-shared/__tests__/account-endpoint.test.ts +++ b/packages/mcp-shared/__tests__/account-endpoint.test.ts @@ -178,6 +178,32 @@ describe("connect initiation nonce", () => { await expect(repoint).rejects.toThrow("stop test probe"); }); + it("keeps an observed-public account usable while an OAuth-configured reconnect probes", async () => { + const context = fakeContext(); + const connected = { ...server("https://portal.example/mcp"), auth: "none" as const }; + context.storage.kv.put("server", connected); + const account = new InterleavingAccount(context as never, {}); + const credentialsExpired = vi.fn(async () => undefined); + const nonce = "1".repeat(64); + await account.setCallback({ credentialsExpired } as never, nonce); + + const reconnect = account.beginConnect(nonce, { + ...connected, + auth: "oauth", + }); + + await expect(account.getConnection(connected.endpoint)) + .resolves.toMatchObject({ authorization: null }); + expect(credentialsExpired).not.toHaveBeenCalled(); + expect(context.storage.kv.get("server")).toEqual(connected); + + account.failProbe(); + await expect(reconnect).rejects.toThrow("stop test probe"); + await expect(account.getConnection(connected.endpoint)) + .resolves.toMatchObject({ authorization: null }); + expect(context.storage.kv.get("server")).toEqual(connected); + }); + it("ignores a transport session written by an operation from before repoint", async () => { const context = fakeContext(); const old = { ...server("https://old.example/mcp"), auth: "none" as const }; diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index 937396eb4..d58481b5f 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -180,6 +180,16 @@ export abstract class McpAccountBase return null; } + /** Whether an unauthenticated target may follow a 401 into the standard OAuth flow. */ + protected allowsOAuthFallback(_server: ConnectedServer): boolean { + return true; + } + + /** Whether current connector configuration still permits this pending OAuth callback. */ + protected allowsOAuthCallback(_server: ConnectedServer): boolean { + return true; + } + /** Relaxes host and scheme checks for local development against an MCP server on localhost. */ protected fetchOptions(): FetchOptions { return fetchOptions(this.env); @@ -203,6 +213,13 @@ export abstract class McpAccountBase return generation; } + /** Invalidates credentials captured by facets and clears their transport session. */ + protected invalidateConnectionState(): void { + this.advanceConnectionGeneration(); + this.ctx.storage.kv.delete("mcpSessionId"); + this.ctx.storage.kv.put("expiredNotified", false); + } + private isCurrentConnection(server: ConnectedServer, generation: number): boolean { const current = this.server(); return this.connectionGeneration() === generation && current !== undefined && @@ -310,7 +327,12 @@ export abstract class McpAccountBase const generation = this.advanceConnectionGeneration(); if (existing) this.ctx.storage.kv.delete("mcpSessionId"); const endpointChanged = existing !== undefined && existing.endpoint !== server.endpoint; - if (endpointChanged) { + const credentialAuthorityChanged = existing !== undefined && existing.auth !== server.auth && + (existing.auth === "token" || server.auth === "token" || + (existing.auth === "oauth" && server.auth === "none")); + // `none` -> `oauth` is only configuration's guess until the probe challenges us. Publishing the + // guess here would make a previously public account look credentialed while it has no tokens. + if (endpointChanged || credentialAuthorityChanged) { this.ctx.storage.kv.put("server", server); for (const key of [ "tokens", "oauthClient", "oauthDiscovery", "oauthVerifier", "pendingAuth", @@ -318,10 +340,12 @@ export abstract class McpAccountBase this.ctx.storage.kv.delete(key); } this.ctx.storage.kv.put("expiredNotified", false); - this.log().info("portal repointed", { - event: "connect.repointed", - serverHost: hostOf(server.endpoint), - }); + if (endpointChanged) { + this.log().info("portal repointed", { + event: "connect.repointed", + serverHost: hostOf(server.endpoint), + }); + } } const log = this.log().with({ @@ -374,6 +398,13 @@ export abstract class McpAccountBase `The MCP server "${server.serverName}" rejected this deployment's configured token.`, { cause: err }); } + if (!this.allowsOAuthFallback(server)) { + this.restoreSelection(initiationNonce); + throw new Error( + `The MCP server "${server.serverName}" requires authorization, but this connection is ` + + "configured for unauthenticated access.", + { cause: err }); + } // The endpoint answered with an authorization challenge, so OAuth is now the observed auth // mode even if deployment configuration optimistically called the portal public. Persist that // mode because `getAuthorization()` uses it to decide whether to read the tokens the callback @@ -577,6 +608,12 @@ export abstract class McpAccountBase const pending = this.ctx.storage.kv.get("pendingAuth"); if (!pending) return false; const server = this.requireServer(); + if (!this.allowsOAuthCallback(server)) { + this.ctx.storage.kv.delete("nonce"); + this.ctx.storage.kv.delete("pendingAuth"); + this.ctx.storage.kv.delete("oauthVerifier"); + return false; + } // Single-use: consumed before the exchange, so a replayed callback cannot reach the token endpoint. this.ctx.storage.kv.delete("nonce"); this.ctx.storage.kv.delete("pendingAuth"); From 8624ef7c8be5650ccdf7806865d266f5b15655d6 Mon Sep 17 00:00:00 2001 From: Dan Carter Date: Tue, 18 Aug 2026 23:42:55 -0400 Subject: [PATCH 2/6] Preserve OAuth state on refused reconnects --- .../__tests__/account-endpoint.test.ts | 69 +++++++++++++++++++ packages/mcp-shared/src/account.ts | 23 ++++--- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/packages/mcp-shared/__tests__/account-endpoint.test.ts b/packages/mcp-shared/__tests__/account-endpoint.test.ts index 1818473e4..7d530bdb5 100644 --- a/packages/mcp-shared/__tests__/account-endpoint.test.ts +++ b/packages/mcp-shared/__tests__/account-endpoint.test.ts @@ -104,6 +104,14 @@ class AuthChallengeAccount extends McpAccountBase { } } +class StrictUnauthenticatedAccount extends AuthChallengeAccount { + protected override allowsOAuthFallback(): boolean { return false; } + + isWaiting(nonce: string): boolean { + return this.awaitingSelection(nonce); + } +} + class OAuthFlowAccount extends McpAccountBase { protected baseUrl(): string { return "https://gatekeeper.example"; } protected log(): never { return testLog as never; } @@ -204,6 +212,67 @@ describe("connect initiation nonce", () => { expect(context.storage.kv.get("server")).toEqual(connected); }); + it("preserves OAuth state when a same-endpoint token reconnect has no configured token", async () => { + const context = fakeContext(); + const connected = { ...server("https://portal.example/mcp"), auth: "oauth" as const }; + const tokens = { + access_token: "access", + token_type: "Bearer", + refresh_token: "refresh", + issuer: "https://auth.example", + expiresAt: Date.now() + 60_000, + }; + const oauthClient = { client_id: "client", issuer: "https://auth.example" }; + const oauthDiscovery = { + authorizationServerUrl: "https://auth.example", + authorizationServerMetadata: { + issuer: "https://auth.example", + authorization_endpoint: "https://auth.example/authorize", + token_endpoint: "https://auth.example/token", + }, + }; + context.storage.kv.put("server", connected); + context.storage.kv.put("tokens", tokens); + context.storage.kv.put("oauthClient", oauthClient); + context.storage.kv.put("oauthDiscovery", oauthDiscovery); + const account = new UnconfiguredTokenAccount(context as never, {}); + const nonce = "2".repeat(64); + await account.prepareReconnect(nonce); + + await expect(account.beginConnect(nonce, { + ...connected, + auth: "token", + provenance: "deployment", + })).rejects.toThrow(/No preissued token is configured/); + + expect(context.storage.kv.get("server")).toEqual(connected); + expect(context.storage.kv.get("tokens")).toEqual(tokens); + expect(context.storage.kv.get("oauthClient")).toEqual(oauthClient); + expect(context.storage.kv.get("oauthDiscovery")).toEqual(oauthDiscovery); + expect(account.isWaiting(nonce)).toBe(true); + }); + + it("preserves OAuth state when a same-endpoint unauthenticated reconnect is refused", async () => { + const context = fakeContext(); + const connected = { ...server("https://portal.example/mcp"), auth: "oauth" as const }; + const tokens = { access_token: "access", token_type: "Bearer" }; + context.storage.kv.put("server", connected); + context.storage.kv.put("tokens", tokens); + const account = new StrictUnauthenticatedAccount(context as never, {}); + const nonce = "3".repeat(64); + await account.prepareReconnect(nonce); + + await expect(account.beginConnect(nonce, { + ...connected, + auth: "none", + provenance: "deployment", + })).rejects.toThrow(/configured for unauthenticated access/); + + expect(context.storage.kv.get("server")).toEqual(connected); + expect(context.storage.kv.get("tokens")).toEqual(tokens); + expect(account.isWaiting(nonce)).toBe(true); + }); + it("ignores a transport session written by an operation from before repoint", async () => { const context = fakeContext(); const old = { ...server("https://old.example/mcp"), auth: "none" as const }; diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index d58481b5f..4e5728082 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -330,22 +330,23 @@ export abstract class McpAccountBase const credentialAuthorityChanged = existing !== undefined && existing.auth !== server.auth && (existing.auth === "token" || server.auth === "token" || (existing.auth === "oauth" && server.auth === "none")); - // `none` -> `oauth` is only configuration's guess until the probe challenges us. Publishing the - // guess here would make a previously public account look credentialed while it has no tokens. - if (endpointChanged || credentialAuthorityChanged) { - this.ctx.storage.kv.put("server", server); + const clearCredentials = () => { for (const key of [ "tokens", "oauthClient", "oauthDiscovery", "oauthVerifier", "pendingAuth", ]) { this.ctx.storage.kv.delete(key); } this.ctx.storage.kv.put("expiredNotified", false); - if (endpointChanged) { - this.log().info("portal repointed", { - event: "connect.repointed", - serverHost: hostOf(server.endpoint), - }); - } + }; + // `none` -> `oauth` is only configuration's guess until the probe challenges us. Publishing the + // guess here would make a previously public account look credentialed while it has no tokens. + if (endpointChanged) { + this.ctx.storage.kv.put("server", server); + clearCredentials(); + this.log().info("portal repointed", { + event: "connect.repointed", + serverHost: hostOf(server.endpoint), + }); } const log = this.log().with({ @@ -380,6 +381,7 @@ export abstract class McpAccountBase // token from every later request. Only an endpoint that answered with no credential at all is. const connected: ConnectedServer = server.auth === "token" ? server : { ...server, auth: "none" }; + if (credentialAuthorityChanged && !endpointChanged) clearCredentials(); this.ctx.storage.kv.put("server", connected); await this.complete(connected, info, generation); log.info("connected without authorization", { event: "connect.completed" }); @@ -410,6 +412,7 @@ export abstract class McpAccountBase // mode because `getAuthorization()` uses it to decide whether to read the tokens the callback // stores. const oauthServer: ConnectedServer = { ...server, auth: "oauth" }; + if (credentialAuthorityChanged && !endpointChanged) clearCredentials(); this.ctx.storage.kv.put("server", oauthServer); try { return await this.beginOAuth(oauthServer, err.resourceMetadataUrl, generation); From 79bf391544d99848b98724c85efccf4c40a3c671 Mon Sep 17 00:00:00 2001 From: Dan Carter Date: Wed, 19 Aug 2026 10:17:30 -0400 Subject: [PATCH 3/6] Guard overlapping portal reconnects --- .../__tests__/account-revision.test.ts | 78 +++++++++++++++++++ .../__tests__/config.test.ts | 1 + packages/gatekeeper-mcp-portal/src/config.ts | 5 +- .../gatekeeper-mcp-portal/vitest.config.ts | 2 + .../__tests__/account-endpoint.test.ts | 30 +++++++ packages/mcp-shared/src/account.ts | 3 + 6 files changed, 117 insertions(+), 2 deletions(-) diff --git a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts index 23c1298a8..adb2386bd 100644 --- a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts +++ b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts @@ -51,4 +51,82 @@ describe("McpAccount configuration revision", () => { expect(values.get("portalConfigRevision")).not.toBe("old"); }); + + it("establishes the current token revision and invalidates a legacy transport session once", async () => { + const values = new Map([ + ["server", { + endpoint: "https://portal.example.com/mcp", + serverId: "portal", + serverName: "Portal", + provenance: "deployment", + auth: "token", + } satisfies ConnectedServer], + ["connectionGeneration", 2], + ["mcpSessionId", "legacy-session"], + ]); + const ctx = { + id: { toString: () => "account" }, + storage: { + kv: { + get: (key: string) => values.get(key), + put: (key: string, value: unknown) => values.set(key, value), + delete: (key: string) => values.delete(key), + }, + }, + exports: {}, + }; + const account = new McpAccount(ctx as never, { + MCP_PORTAL_URL: "https://portal.example.com/mcp", + MCP_PORTAL_AUTH: "token", + MCP_PORTAL_TOKEN: "configured-token", + } as never); + + await expect(account.getConnection("https://portal.example.com/mcp")).resolves.toMatchObject({ + authorization: "configured-token", + generation: 3, + sessionId: null, + }); + await expect(account.getConnection("https://portal.example.com/mcp")).resolves.toMatchObject({ + generation: 3, + }); + expect(values.get("portalConfigRevision")).toEqual(expect.any(String)); + }); + + it("allows only OAuth callbacks and fallbacks still permitted by live portal configuration", () => { + const values = new Map(); + const env = { + MCP_PORTAL_URL: "https://portal.example.com/mcp", + MCP_PORTAL_AUTH: "oauth", + }; + const account = new McpAccount({ + id: { toString: () => "account" }, + storage: { + kv: { + get: (key: string) => values.get(key), + put: (key: string, value: unknown) => values.set(key, value), + delete: (key: string) => values.delete(key), + }, + }, + exports: {}, + } as never, env as never) as unknown as { + allowsOAuthCallback(server: ConnectedServer): boolean; + allowsOAuthFallback(server: ConnectedServer): boolean; + }; + const oauthServer: ConnectedServer = { + endpoint: env.MCP_PORTAL_URL, + serverId: "portal", + serverName: "Portal", + provenance: "deployment", + auth: "oauth", + }; + + expect(account.allowsOAuthCallback(oauthServer)).toBe(true); + expect(account.allowsOAuthFallback(oauthServer)).toBe(true); + expect(account.allowsOAuthFallback({ ...oauthServer, auth: "none" })).toBe(false); + env.MCP_PORTAL_AUTH = "none"; + expect(account.allowsOAuthCallback(oauthServer)).toBe(false); + env.MCP_PORTAL_AUTH = "oauth"; + env.MCP_PORTAL_URL = "https://replacement.example.com/mcp"; + expect(account.allowsOAuthCallback(oauthServer)).toBe(false); + }); }); diff --git a/packages/gatekeeper-mcp-portal/__tests__/config.test.ts b/packages/gatekeeper-mcp-portal/__tests__/config.test.ts index c2744299e..476f9db04 100644 --- a/packages/gatekeeper-mcp-portal/__tests__/config.test.ts +++ b/packages/gatekeeper-mcp-portal/__tests__/config.test.ts @@ -136,6 +136,7 @@ describe("portalServer", () => { describe("portalAuthRequiresReconnect", () => { it("requires reconnecting when token authority changes", () => { + expect(portalAuthRequiresReconnect("token", "token")).toBe(false); expect(portalAuthRequiresReconnect("none", "token")).toBe(true); expect(portalAuthRequiresReconnect("oauth", "token")).toBe(true); expect(portalAuthRequiresReconnect("token", "none")).toBe(true); diff --git a/packages/gatekeeper-mcp-portal/src/config.ts b/packages/gatekeeper-mcp-portal/src/config.ts index 280a90e28..5bc667fb2 100644 --- a/packages/gatekeeper-mcp-portal/src/config.ts +++ b/packages/gatekeeper-mcp-portal/src/config.ts @@ -181,8 +181,9 @@ export function portalServer(config: PortalConfig): ConnectedServer { } /** - * Probing may legitimately move between none and OAuth. A preissued token is deployment authority, - * so entering or leaving that mode requires the account to reconnect against current configuration. + * An OAuth-configured portal may prove public during probing, but explicitly unauthenticated and + * preissued-token configurations are strict. Entering or leaving either strict mode requires the + * account to reconnect against current configuration. */ export function portalAuthRequiresReconnect( connected: ServerAuthKind, configured: ServerAuthKind, diff --git a/packages/gatekeeper-mcp-portal/vitest.config.ts b/packages/gatekeeper-mcp-portal/vitest.config.ts index 48495a37b..bd7729ec7 100644 --- a/packages/gatekeeper-mcp-portal/vitest.config.ts +++ b/packages/gatekeeper-mcp-portal/vitest.config.ts @@ -5,6 +5,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [capnwebValidate()], test: { + include: ["__tests__/*.test.ts"], + environment: "node", alias: { "cloudflare:workers": fileURLToPath( new URL("../mcp-shared/__tests__/stubs/cloudflare-workers.ts", import.meta.url)), diff --git a/packages/mcp-shared/__tests__/account-endpoint.test.ts b/packages/mcp-shared/__tests__/account-endpoint.test.ts index 7d530bdb5..0287e73af 100644 --- a/packages/mcp-shared/__tests__/account-endpoint.test.ts +++ b/packages/mcp-shared/__tests__/account-endpoint.test.ts @@ -38,6 +38,10 @@ class InterleavingAccount extends McpAccountBase { this.#rejectProbe?.(new Error("stop test probe")); } + challengeProbe(): void { + this.#rejectProbe?.(new McpAuthRequiredError("authorization required", null)); + } + isWaiting(nonce: string): boolean { return this.awaitingSelection(nonce); } @@ -273,6 +277,32 @@ describe("connect initiation nonce", () => { expect(account.isWaiting(nonce)).toBe(true); }); + it("does not let a superseded OAuth fallback clear a newer attempt's state", async () => { + const context = fakeContext(); + const connected = { + ...server("https://portal.example/mcp"), + auth: "token" as const, + provenance: "deployment" as const, + }; + context.storage.kv.put("server", connected); + const account = new InterleavingAccount(context as never, {}); + const firstNonce = "4".repeat(64); + await account.prepareReconnect(firstNonce); + const first = account.beginConnect(firstNonce, { ...connected, auth: "oauth" }); + + const secondNonce = "5".repeat(64); + await account.prepareReconnect(secondNonce); + const pendingAuth = { generation: 3 }; + context.storage.kv.put("pendingAuth", pendingAuth); + context.storage.kv.put("oauthVerifier", "new-verifier"); + account.challengeProbe(); + + await expect(first).rejects.toThrow(/replaced by a newer/); + expect(context.storage.kv.get("server")).toEqual(connected); + expect(context.storage.kv.get("pendingAuth")).toEqual(pendingAuth); + expect(context.storage.kv.get("oauthVerifier")).toBe("new-verifier"); + }); + it("ignores a transport session written by an operation from before repoint", async () => { const context = fakeContext(); const old = { ...server("https://old.example/mcp"), auth: "none" as const }; diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index 4e5728082..a0f2ce44e 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -407,6 +407,9 @@ export abstract class McpAccountBase "configured for unauthenticated access.", { cause: err }); } + if (generation !== this.connectionGeneration()) { + throw new Error("This connection attempt was replaced by a newer one.", { cause: err }); + } // The endpoint answered with an authorization challenge, so OAuth is now the observed auth // mode even if deployment configuration optimistically called the portal public. Persist that // mode because `getAuthorization()` uses it to decide whether to read the tokens the callback From 024b51b741a25dcbfb64b78c48f06176b0788927 Mon Sep 17 00:00:00 2001 From: Dan Carter Date: Wed, 19 Aug 2026 10:29:33 -0400 Subject: [PATCH 4/6] Cover stale portal OAuth callbacks --- .../__tests__/account-revision.test.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts index adb2386bd..8d51c06ef 100644 --- a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts +++ b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts @@ -7,7 +7,10 @@ import { } from "@gadgets/mcp-shared/account"; import { McpAccount } from "../src/portal.js"; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("McpAccount configuration revision", () => { it("records the current revision before handing an OAuth attempt to its callback", async () => { @@ -129,4 +132,43 @@ describe("McpAccount configuration revision", () => { env.MCP_PORTAL_URL = "https://replacement.example.com/mcp"; expect(account.allowsOAuthCallback(oauthServer)).toBe(false); }); + + it("rejects and cleans an OAuth callback after the deployment repoints the portal", async () => { + const nonce = "n".repeat(64); + const values = new Map([ + ["nonce", { value: nonce, expiresAt: Date.now() + 60_000, stage: "oauth" }], + ["pendingAuth", { generation: 1 }], + ["oauthVerifier", "verifier"], + ["server", { + endpoint: "https://old.example.com/mcp", + serverId: "portal", + serverName: "Portal", + provenance: "deployment", + auth: "oauth", + } satisfies ConnectedServer], + ]); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const account = new McpAccount({ + id: { toString: () => "account" }, + storage: { + kv: { + get: (key: string) => values.get(key), + put: (key: string, value: unknown) => values.set(key, value), + delete: (key: string) => values.delete(key), + }, + }, + exports: {}, + } as never, { + MCP_PORTAL_URL: "https://new.example.com/mcp", + MCP_PORTAL_AUTH: "oauth", + } as never); + + await expect(account.acceptAuthCode("code", nonce)).resolves.toBe(false); + + expect(values.has("nonce")).toBe(false); + expect(values.has("pendingAuth")).toBe(false); + expect(values.has("oauthVerifier")).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); }); From 80b655860244ec1a43c7a207ae29f5bfe5077152 Mon Sep 17 00:00:00 2001 From: Dan Carter Date: Wed, 19 Aug 2026 12:56:19 -0400 Subject: [PATCH 5/6] Simplify portal auth state handling --- .../__tests__/account-revision.test.ts | 157 +++++++----------- packages/gatekeeper-mcp-portal/src/portal.ts | 11 +- .../__tests__/account-endpoint.test.ts | 20 +-- packages/mcp-shared/src/account.ts | 9 +- 4 files changed, 76 insertions(+), 121 deletions(-) diff --git a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts index 8d51c06ef..090f57237 100644 --- a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts +++ b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts @@ -12,37 +12,59 @@ afterEach(() => { vi.unstubAllGlobals(); }); +const PORTAL_ENDPOINT = "https://portal.example.com/mcp"; + +type TestEnv = { + MCP_PORTAL_URL: string; + MCP_PORTAL_AUTH: string; + MCP_PORTAL_TOKEN?: string; +}; + +function portalServer( + auth: ConnectedServer["auth"], endpoint = PORTAL_ENDPOINT, +): ConnectedServer { + return { + endpoint, + serverId: "portal", + serverName: "Portal", + provenance: "deployment", + auth, + }; +} + +function setup( + entries: Iterable = [], + overrides: Partial = {}, +) { + const values = new Map(entries); + const env: TestEnv = { + MCP_PORTAL_URL: PORTAL_ENDPOINT, + MCP_PORTAL_AUTH: "oauth", + ...overrides, + }; + const account = new McpAccount({ + id: { toString: () => "account" }, + storage: { + kv: { + get: (key: string) => values.get(key), + put: (key: string, value: unknown) => values.set(key, value), + delete: (key: string) => values.delete(key), + }, + }, + exports: {}, + } as never, env as never); + return { account, env, values }; +} + describe("McpAccount configuration revision", () => { it("records the current revision before handing an OAuth attempt to its callback", async () => { - const values = new Map([["portalConfigRevision", "old"]]); - const ctx = { - id: { toString: () => "account" }, - storage: { - kv: { - get: (key: string) => values.get(key), - put: (key: string, value: unknown) => values.set(key, value), - delete: (key: string) => values.delete(key), - }, - }, - exports: {}, - }; - const env = { - MCP_PORTAL_URL: "https://portal.example.com/mcp", - MCP_PORTAL_AUTH: "oauth", - }; - const server: ConnectedServer = { - endpoint: env.MCP_PORTAL_URL, - serverId: "portal", - serverName: "Portal", - provenance: "deployment", - auth: "oauth", - }; + const { account, env, values } = setup([["portalConfigRevision", "old"]]); + const server = portalServer("oauth", env.MCP_PORTAL_URL); const base = McpAccountBase.prototype as unknown as { beginConnect(nonce: string, target: ConnectedServer | null): Promise; }; vi.spyOn(base, "beginConnect") .mockResolvedValue({ kind: "redirect", url: "https://login.example.com" }); - const account = new McpAccount(ctx as never, env as never); const internals = account as unknown as { awaitingSelection(nonce: string): boolean; server(): ConnectedServer | undefined; @@ -50,78 +72,41 @@ describe("McpAccount configuration revision", () => { vi.spyOn(internals, "awaitingSelection").mockReturnValue(true); vi.spyOn(internals, "server").mockReturnValue(server); - await expect(account.beginConnect("nonce", server)).resolves.toMatchObject({ kind: "redirect" }); + await expect(account.beginConnect("nonce", server)) + .resolves.toMatchObject({ kind: "redirect" }); expect(values.get("portalConfigRevision")).not.toBe("old"); }); it("establishes the current token revision and invalidates a legacy transport session once", async () => { - const values = new Map([ - ["server", { - endpoint: "https://portal.example.com/mcp", - serverId: "portal", - serverName: "Portal", - provenance: "deployment", - auth: "token", - } satisfies ConnectedServer], + const { account, values } = setup([ + ["server", portalServer("token")], ["connectionGeneration", 2], ["mcpSessionId", "legacy-session"], - ]); - const ctx = { - id: { toString: () => "account" }, - storage: { - kv: { - get: (key: string) => values.get(key), - put: (key: string, value: unknown) => values.set(key, value), - delete: (key: string) => values.delete(key), - }, - }, - exports: {}, - }; - const account = new McpAccount(ctx as never, { - MCP_PORTAL_URL: "https://portal.example.com/mcp", + ], { MCP_PORTAL_AUTH: "token", MCP_PORTAL_TOKEN: "configured-token", - } as never); + }); - await expect(account.getConnection("https://portal.example.com/mcp")).resolves.toMatchObject({ + await expect(account.getConnection(PORTAL_ENDPOINT)).resolves.toMatchObject({ authorization: "configured-token", generation: 3, sessionId: null, }); - await expect(account.getConnection("https://portal.example.com/mcp")).resolves.toMatchObject({ + await expect(account.getConnection(PORTAL_ENDPOINT)).resolves.toMatchObject({ generation: 3, }); expect(values.get("portalConfigRevision")).toEqual(expect.any(String)); }); it("allows only OAuth callbacks and fallbacks still permitted by live portal configuration", () => { - const values = new Map(); - const env = { - MCP_PORTAL_URL: "https://portal.example.com/mcp", - MCP_PORTAL_AUTH: "oauth", - }; - const account = new McpAccount({ - id: { toString: () => "account" }, - storage: { - kv: { - get: (key: string) => values.get(key), - put: (key: string, value: unknown) => values.set(key, value), - delete: (key: string) => values.delete(key), - }, - }, - exports: {}, - } as never, env as never) as unknown as { + const setupResult = setup(); + const { env } = setupResult; + const account = setupResult.account as unknown as { allowsOAuthCallback(server: ConnectedServer): boolean; allowsOAuthFallback(server: ConnectedServer): boolean; }; - const oauthServer: ConnectedServer = { - endpoint: env.MCP_PORTAL_URL, - serverId: "portal", - serverName: "Portal", - provenance: "deployment", - auth: "oauth", - }; + const oauthServer = portalServer("oauth", env.MCP_PORTAL_URL); expect(account.allowsOAuthCallback(oauthServer)).toBe(true); expect(account.allowsOAuthFallback(oauthServer)).toBe(true); @@ -135,34 +120,14 @@ describe("McpAccount configuration revision", () => { it("rejects and cleans an OAuth callback after the deployment repoints the portal", async () => { const nonce = "n".repeat(64); - const values = new Map([ + const { account, values } = setup([ ["nonce", { value: nonce, expiresAt: Date.now() + 60_000, stage: "oauth" }], ["pendingAuth", { generation: 1 }], ["oauthVerifier", "verifier"], - ["server", { - endpoint: "https://old.example.com/mcp", - serverId: "portal", - serverName: "Portal", - provenance: "deployment", - auth: "oauth", - } satisfies ConnectedServer], - ]); + ["server", portalServer("oauth", "https://old.example.com/mcp")], + ], { MCP_PORTAL_URL: "https://new.example.com/mcp" }); const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); - const account = new McpAccount({ - id: { toString: () => "account" }, - storage: { - kv: { - get: (key: string) => values.get(key), - put: (key: string, value: unknown) => values.set(key, value), - delete: (key: string) => values.delete(key), - }, - }, - exports: {}, - } as never, { - MCP_PORTAL_URL: "https://new.example.com/mcp", - MCP_PORTAL_AUTH: "oauth", - } as never); await expect(account.acceptAuthCode("code", nonce)).resolves.toBe(false); diff --git a/packages/gatekeeper-mcp-portal/src/portal.ts b/packages/gatekeeper-mcp-portal/src/portal.ts index a464baa52..eca0e63ed 100644 --- a/packages/gatekeeper-mcp-portal/src/portal.ts +++ b/packages/gatekeeper-mcp-portal/src/portal.ts @@ -315,7 +315,7 @@ export class McpAccount extends McpAccountBase { return outcome; } finally { if (revision !== undefined) { - const remaining = (this.#connectingRevisions.get(revision) ?? 1) - 1; + const remaining = this.#connectingRevisions.get(revision)! - 1; if (remaining === 0) this.#connectingRevisions.delete(revision); else this.#connectingRevisions.set(revision, remaining); } @@ -332,16 +332,11 @@ export class McpAccount extends McpAccountBase { const revision = await this.#configurationRevision(config); const previous = this.ctx.storage.kv.get("portalConfigRevision"); const reconnectingToCurrentRevision = this.#connectingRevisions.has(revision); - if (previous === undefined) { + if (!reconnectingToCurrentRevision && previous !== revision) { // Existing token accounts predate the revision marker. Their cached session may have been // minted under a different configured token, so invalidate it once before establishing the // current revision as the baseline. New connects record the revision in `beginConnect()`. - if (!reconnectingToCurrentRevision) { - if (server.auth === "token") this.invalidateConnectionState(); - this.ctx.storage.kv.put("portalConfigRevision", revision); - } - } else if (previous !== revision && !reconnectingToCurrentRevision) { - this.invalidateConnectionState(); + if (previous !== undefined || server.auth === "token") this.invalidateConnectionState(); this.ctx.storage.kv.put("portalConfigRevision", revision); } return super.getConnection(endpoint); diff --git a/packages/mcp-shared/__tests__/account-endpoint.test.ts b/packages/mcp-shared/__tests__/account-endpoint.test.ts index 0287e73af..248c304b3 100644 --- a/packages/mcp-shared/__tests__/account-endpoint.test.ts +++ b/packages/mcp-shared/__tests__/account-endpoint.test.ts @@ -34,12 +34,8 @@ class InterleavingAccount extends McpAccountBase { return await new Promise((_resolve, reject) => { this.#rejectProbe = reject; }); } - failProbe(): void { - this.#rejectProbe?.(new Error("stop test probe")); - } - - challengeProbe(): void { - this.#rejectProbe?.(new McpAuthRequiredError("authorization required", null)); + rejectProbe(reason = new Error("stop test probe")): void { + this.#rejectProbe?.(reason); } isWaiting(nonce: string): boolean { @@ -150,7 +146,7 @@ describe("connect initiation nonce", () => { await expect(account.beginConnect(nonce, server("https://a.example/mcp"))) .resolves.toEqual({ kind: "invalid" }); - account.failProbe(); + account.rejectProbe(); await expect(first).rejects.toThrow("stop test probe"); // The request still owns the claim, so a transient failure reopens the already-rendered form. expect(account.isWaiting(nonce)).toBe(true); @@ -186,7 +182,7 @@ describe("connect initiation nonce", () => { await expect(account.getConnection("https://old.example/mcp")) .rejects.toThrow(/account is now connected to new\.example/); - account.failProbe(); + account.rejectProbe(); await expect(repoint).rejects.toThrow("stop test probe"); }); @@ -209,7 +205,7 @@ describe("connect initiation nonce", () => { expect(credentialsExpired).not.toHaveBeenCalled(); expect(context.storage.kv.get("server")).toEqual(connected); - account.failProbe(); + account.rejectProbe(); await expect(reconnect).rejects.toThrow("stop test probe"); await expect(account.getConnection(connected.endpoint)) .resolves.toMatchObject({ authorization: null }); @@ -295,7 +291,7 @@ describe("connect initiation nonce", () => { const pendingAuth = { generation: 3 }; context.storage.kv.put("pendingAuth", pendingAuth); context.storage.kv.put("oauthVerifier", "new-verifier"); - account.challengeProbe(); + account.rejectProbe(new McpAuthRequiredError("authorization required", null)); await expect(first).rejects.toThrow(/replaced by a newer/); expect(context.storage.kv.get("server")).toEqual(connected); @@ -319,7 +315,7 @@ describe("connect initiation nonce", () => { old.endpoint, connection.generation, connection.sessionId, "old-session"); expect(context.storage.kv.get("mcpSessionId")).toBeUndefined(); - account.failProbe(); + account.rejectProbe(); await expect(repoint).rejects.toThrow("stop test probe"); }); @@ -382,7 +378,7 @@ describe("connect initiation nonce", () => { await expect(refreshing).rejects.toThrow(/previous MCP connection|connection changed/); expect(context.storage.kv.get("tokens")).toBeUndefined(); - account.failProbe(); + account.rejectProbe(); await expect(repoint).rejects.toThrow("stop test probe"); }); diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index a0f2ce44e..02c07b9c8 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -327,9 +327,8 @@ export abstract class McpAccountBase const generation = this.advanceConnectionGeneration(); if (existing) this.ctx.storage.kv.delete("mcpSessionId"); const endpointChanged = existing !== undefined && existing.endpoint !== server.endpoint; - const credentialAuthorityChanged = existing !== undefined && existing.auth !== server.auth && - (existing.auth === "token" || server.auth === "token" || - (existing.auth === "oauth" && server.auth === "none")); + const clearCredentialsOnCommit = existing !== undefined && !endpointChanged && + existing.auth !== server.auth && !(existing.auth === "none" && server.auth === "oauth"); const clearCredentials = () => { for (const key of [ "tokens", "oauthClient", "oauthDiscovery", "oauthVerifier", "pendingAuth", @@ -381,7 +380,7 @@ export abstract class McpAccountBase // token from every later request. Only an endpoint that answered with no credential at all is. const connected: ConnectedServer = server.auth === "token" ? server : { ...server, auth: "none" }; - if (credentialAuthorityChanged && !endpointChanged) clearCredentials(); + if (clearCredentialsOnCommit) clearCredentials(); this.ctx.storage.kv.put("server", connected); await this.complete(connected, info, generation); log.info("connected without authorization", { event: "connect.completed" }); @@ -415,7 +414,7 @@ export abstract class McpAccountBase // mode because `getAuthorization()` uses it to decide whether to read the tokens the callback // stores. const oauthServer: ConnectedServer = { ...server, auth: "oauth" }; - if (credentialAuthorityChanged && !endpointChanged) clearCredentials(); + if (clearCredentialsOnCommit) clearCredentials(); this.ctx.storage.kv.put("server", oauthServer); try { return await this.beginOAuth(oauthServer, err.resourceMetadataUrl, generation); From ca83ee2269990b008946d01ce3af1ecaaaf0b47c Mon Sep 17 00:00:00 2001 From: Dan Carter Date: Thu, 20 Aug 2026 11:51:57 -0400 Subject: [PATCH 6/6] Make portal account tests self-contained --- .../gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts | 1 + packages/gatekeeper-mcp-portal/vitest.config.ts | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts diff --git a/packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts b/packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts new file mode 100644 index 000000000..9cf3c27e7 --- /dev/null +++ b/packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts @@ -0,0 +1 @@ +export default ""; diff --git a/packages/gatekeeper-mcp-portal/vitest.config.ts b/packages/gatekeeper-mcp-portal/vitest.config.ts index bd7729ec7..6ae845503 100644 --- a/packages/gatekeeper-mcp-portal/vitest.config.ts +++ b/packages/gatekeeper-mcp-portal/vitest.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ alias: { "cloudflare:workers": fileURLToPath( new URL("../mcp-shared/__tests__/stubs/cloudflare-workers.ts", import.meta.url)), + "./generated/server-configurator-ui.txt": fileURLToPath( + new URL("./__tests__/stubs/configurator-html.ts", import.meta.url)), }, }, });