fix: key the in-flight clear guard on the OAuth storage key too (#2217)

Copilot: `runClear`'s double-click guard deduped by `server.id`, which is
the same identity mismatch this PR fixes, one layer up. Two catalog
entries against one URL share one credential blob, one grant and one
revocation, so an id-keyed guard let exactly the race it exists to
prevent through between them — concurrent store writes, concurrent RFC
7009 requests, and two contradictory teardown toasts.

The guard now keys on `oauthClearKey`, a pure helper that resolves the
OAuth storage key and falls back to the entry id for a config that has
none (stdio), where there is no shared state to collide over. The id
fallback keeps such entries distinct from each other rather than
collapsing them onto one key, and both forms are prefixed so a
user-supplied id can never impersonate a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-03 20:48:45 -04:00
commit b2bbfcb409

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { oauthClearKey } from "./oauthClearKey";
describe("oauthClearKey", () => {
// #2217 — the whole point: two entries against one URL share one OAuth blob,
// so they must share one clear identity even though their ids differ.
it("keys two entries with the same URL identically", () => {
const config = {
type: "streamable-http",
url: "https://mcp.example/mcp",
} as const;
expect(oauthClearKey(config, "a")).toBe(oauthClearKey(config, "b"));
});
it("separates entries pointing at different URLs", () => {
expect(
oauthClearKey({ type: "sse", url: "https://one.example/mcp" }, "a"),
).not.toBe(
oauthClearKey({ type: "sse", url: "https://two.example/mcp" }, "a"),
);
});
it("falls back to the entry id when there is no OAuth server URL", () => {
const stdio = { type: "stdio", command: "node" } as const;
expect(oauthClearKey(stdio, "a")).toBe("id:a");
expect(oauthClearKey(stdio, "a")).not.toBe(oauthClearKey(stdio, "b"));
});
// The prefix is what keeps a stdio entry named "url:…" from colliding with a
// URL-keyed one — an id is user-supplied and a URL is not a reserved shape.
it("cannot collide a URL key with an id key", () => {
expect(
oauthClearKey({ type: "stdio", command: "node" }, "url:https://x/mcp"),
).not.toBe(
oauthClearKey({ type: "sse", url: "https://x/mcp" }, "anything"),
);
});
});