Skip to content
Open
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
139 changes: 139 additions & 0 deletions packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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";
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

afterEach(() => {
vi.restoreAllMocks();
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<readonly [string, unknown]> = [],
overrides: Partial<TestEnv> = {},
) {
const values = new Map<string, unknown>(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 { 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<ConnectOutcome>;
};
vi.spyOn(base, "beginConnect")
.mockResolvedValue({ kind: "redirect", url: "https://login.example.com" });
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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test is very light and doesn't really exercise the riskiest parts of this

Untested branches introduced by the diff: all four guards at portal.ts:328-329; both sides of the legacy previous === undefined branch; the rotation branch at 343-346; the #connectingRevisions refcount; allowsOAuthCallback (both outcomes, in either package); the portal's own allowsOAuthFallback (mcp-shared tests a hand-written fake instead); invalidateConnectionState() (no test invokes it at all); and — noted by agent 2 — portalAuthRequiresReconnect("token","token"), the single most common deployment configuration, asserted nowhere. A regression making it true bricks every token deployment silently.

});

it("establishes the current token revision and invalidates a legacy transport session once", async () => {
const { account, values } = setup([
["server", portalServer("token")],
["connectionGeneration", 2],
["mcpSessionId", "legacy-session"],
], {
MCP_PORTAL_AUTH: "token",
MCP_PORTAL_TOKEN: "configured-token",
});

await expect(account.getConnection(PORTAL_ENDPOINT)).resolves.toMatchObject({
authorization: "configured-token",
generation: 3,
sessionId: null,
});
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 setupResult = setup();
const { env } = setupResult;
const account = setupResult.account as unknown as {
allowsOAuthCallback(server: ConnectedServer): boolean;
allowsOAuthFallback(server: ConnectedServer): boolean;
};
const oauthServer = portalServer("oauth", env.MCP_PORTAL_URL);

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);
});

it("rejects and cleans an OAuth callback after the deployment repoints the portal", async () => {
const nonce = "n".repeat(64);
const { account, values } = setup([
["nonce", { value: nonce, expiresAt: Date.now() + 60_000, stage: "oauth" }],
["pendingAuth", { generation: 1 }],
["oauthVerifier", "verifier"],
["server", portalServer("oauth", "https://old.example.com/mcp")],
], { MCP_PORTAL_URL: "https://new.example.com/mcp" });
const fetch = vi.fn<typeof globalThis.fetch>();
vi.stubGlobal("fetch", fetch);

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();
});
});
9 changes: 7 additions & 2 deletions packages/gatekeeper-mcp-portal/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,20 @@ 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);
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);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "";
7 changes: 4 additions & 3 deletions packages/gatekeeper-mcp-portal/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,14 @@ 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,
): boolean {
return (connected === "token") !== (configured === "token");
return configured === "oauth" ? connected === "token" : connected !== configured;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc-comment above (lines 165-168) is now stale relative to this new logic. It says probing may legitimately move between none and OAuth (bidirectionally), but configured === "oauth" ? connected === "token" : connected !== configured only tolerates that drift when the portal is configured as oauth. When configured as none, a connected oauth state now requires a reconnect (connected !== configured) — the intended new hardening, but it contradicts the comment. Please update the doc to describe the asymmetric rule (an oauth-configured portal may prove public during probing, while an explicitly none-configured portal stays strict).

}

/**
Expand Down
85 changes: 83 additions & 2 deletions packages/gatekeeper-mcp-portal/src/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -240,6 +245,10 @@ export class GatekeeperVendor extends WorkerEntrypoint<Env> implements Gatekeepe
* real addition: a portal may be fronted by one instead of using OAuth.
*/
export class McpAccount extends McpAccountBase<Env> {
// 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<string, number>();

protected baseUrl(): string {
return getBaseUrl(this.env);
}
Expand All @@ -260,6 +269,78 @@ export class McpAccount extends McpAccountBase<Env> {
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<string> {
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<ConnectOutcome> {
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;
if (remaining === 0) this.#connectingRevisions.delete(revision);
else this.#connectingRevisions.set(revision, remaining);
}
}
}

override async getConnection(endpoint: string): Promise<McpConnection> {
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<string>("portalConfigRevision");
const reconnectingToCurrentRevision = this.#connectingRevisions.has(revision);
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 (previous !== undefined || server.auth === "token") this.invalidateConnectionState();
this.ctx.storage.kv.put("portalConfigRevision", revision);
}
return super.getConnection(endpoint);
}
}

// ---------------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions packages/gatekeeper-mcp-portal/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { fileURLToPath } from "node:url";
import capnwebValidate from "capnweb-validate/vite";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [capnwebValidate()],
test: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per sol

C1 — HIGH — vitest.config.ts:5-13 omits include, so the suite runs a stale shadow copy that asserts the opposite of this commit's fix
All three flagged this. Two independently produced runtime evidence. I confirmed it directly:
packages/gatekeeper-mcp-portal/vitest.config.ts → no include, no environment
packages/mcp-shared/vitest.config.ts (its source) → include: ["tests/*.test.ts"], environment: "node"

.wrangler/validate/tests/config.test.ts Aug 13 16:15 (pre-branch, collectible)
.wrangler/validate/src/config.ts:131 return (connected === "token") !== (configured === "token");
pnpm test:run reports 5 files / 67 tests where only 3 files exist; a clean checkout gives 3 / 38. The consequence is not merely noise — the run simultaneously asserts both answers for the same input:

  • new tests/config.test.ts:150: portalAuthRequiresReconnect("oauth","none") → true
  • stale .wrangler/.../config.test.ts:144: same call → false
    So the package's own green suite proves nothing about the function this commit exists to change, and a future revert would be confirmed by a passing test. Compounding it: .wrangler/** is deliberately excluded from the test task's fingerprint (scripts/vitest-task-vite-config.ts), so a cached run can replay green over a snapshot nothing invalidates, and pnpm clean (rm -rf dist src/generated) never removes it.

Fix is one line: include: ["tests/*.test.ts"] (plus environment: "node" for parity). Adding capnwebValidate() is correct and necessary — only the omissions are the problem.

include: ["__tests__/*.test.ts"],
environment: "node",
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)),
},
},
});
Loading
Loading