Merge pull request #2238 from modelcontextprotocol/v2/fix/2217-oauth-clear-shared-url

fix: key the OAuth clear's active-session check on the storage key
This commit is contained in:
Cliff Hall 2026-09-04 09:06:00 -04:00 • committed by GitHub
commit e915d4f038
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 558 additions and 18 deletions

View file

@ -151,12 +151,18 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
clearOAuthTokens = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no_endpoint" });
// #2217: the clear path resolves the session's OAuth key from the config
// the client was BUILT with, not the (mutable) catalog entry — so the fake
// has to carry the constructor's config the way the real client does.
transportConfig: unknown = undefined;
getTransportConfig = vi.fn(() => this.transportConfig);
}
const instances: FakeInspectorClient[] = [];
return {
...actual,
InspectorClient: vi.fn(function () {
InspectorClient: vi.fn(function (transportConfig: unknown) {
const client = new FakeInspectorClient();
client.transportConfig = transportConfig;
instances.push(client);
return client;
}),
@ -244,7 +250,13 @@ vi.mock("@inspector/core/mcp/state/stderrLogState.js", () => ({
}),
}));
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
vi.mock("@inspector/core/mcp/remote/index.js", async (importOriginal) => ({
// Partial: `getWebProxiedFetch` (the OAuth clear's revocation fetch, #2144)
// reaches for `createRemoteFetch` from this module, and a bare stub would
// throw a missing-export error before the clear under test ever ran.
...(await importOriginal<
typeof import("@inspector/core/mcp/remote/index.js")
>()),
RemoteInspectorClientStorage: vi.fn(function () {
return { saveSession: vi.fn() };
}),
@ -601,6 +613,11 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
<button onClick={() => props.servers.onServerSettings("A")}>
open-settings
</button>
{/* A second settings target, so a test can drive a clear against an
entry other than the active one (#2217). */}
<button onClick={() => props.servers.onServerSettings("B")}>
open-settings-b
</button>
{/* The real server grid (and its Add / Edit controls) lives inside this
mocked view, so the config modal is only reachable through these
callbacks — and the highlight batch only observable through this prop. */}
@ -4214,3 +4231,123 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
}
});
});
// #2217 — persisted OAuth state is keyed by server URL and `serverList`
// enforces no URL uniqueness, so two catalog entries can share one credential
// blob, one grant and one revocation. `runClear`'s in-flight guard used to
// dedupe by catalog id, which cannot see that: clearing both entries while the
// first revocation was still out ran concurrent store writes, concurrent RFC
// 7009 requests and two contradictory teardown toasts — the exact race the
// guard exists to prevent (Copilot, on this PR).
describe("App dedupes concurrent OAuth clears that share a storage key (#2217)", () => {
const SHARED_URL = "https://shared.example/mcp";
const sharedEntry = (id: string, name: string): ServerEntry => ({
id,
name,
config: { type: "streamable-http", url: SHARED_URL },
connection: { status: "disconnected" },
});
let previousUseServers: typeof useServers | undefined;
beforeEach(() => {
vi.clearAllMocks();
clientInstances.length = 0;
previousUseServers = vi.mocked(useServers).getMockImplementation();
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
vi.mocked(useServers).mockReturnValue({
servers: [sharedEntry("A", "PlotRocket"), sharedEntry("B", "Same URL")],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
});
afterEach(() => {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
});
/** Open the settings modal for `which` and press its OAuth-section clear. */
async function clearFromSettings(
user: ReturnType<typeof userEvent.setup>,
which: "open-settings" | "open-settings-b",
): Promise<void> {
await user.click(screen.getByText(which));
// The control lives in an accordion section. The modal stays mounted
// across a target switch, so the section may already be open from a
// previous call — toggling it again would close it.
const section = await screen.findByRole("button", {
name: "OAuth Settings",
});
if (section.getAttribute("aria-expanded") !== "true") {
await user.click(section);
}
await user.click(
await screen.findByRole("button", { name: "Clear stored OAuth state" }),
);
}
// Three full modal interaction sequences against the whole App tree, so this
// one runs long enough to trip the 5s default when the suite is under load.
it(
"suppresses a second clear for another entry with the same URL, and allows one after it settles",
{ timeout: 20000 },
async () => {
// `delay: null` drops userEvent's inter-event waits, which dominate here.
const user = userEvent.setup({ delay: null });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// The instances are typed `EventTarget`; an intersection names the
// test-only spy without erasing that (Copilot).
const client = clientInstances[0] as EventTarget & {
clearOAuthTokens: ReturnType<typeof vi.fn>;
};
// Hold the first clear open, as a pending RFC 7009 request would.
let settle: (v: { status: string; reason: string }) => void = () => {};
client.clearOAuthTokens.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
await clearFromSettings(user, "open-settings");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1),
);
// Entry B: a different catalog id, the same OAuth storage key. Both clears
// route through the live client precisely because they share that key, so
// an id-keyed guard would let this second one straight through.
await clearFromSettings(user, "open-settings-b");
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1);
// Suppression is for the duration of the in-flight clear only — the key
// must be released when it settles, or the control is dead for the rest of
// the session.
await act(async () => {
settle({ status: "skipped", reason: "no_endpoint" });
await Promise.resolve();
});
client.clearOAuthTokens.mockResolvedValue({
status: "skipped",
reason: "no_endpoint",
});
await clearFromSettings(user, "open-settings-b");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2),
);
},
);
});

View file

@ -115,6 +115,7 @@ import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { getAuthToken } from "./lib/authToken";
import { messagesToLogEntries } from "./lib/protocolReplay";
import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
import { resolveOAuthClearIdentity } from "./utils/oauthClearKey";
import {
bodyDroppedToastId,
CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
@ -1350,10 +1351,10 @@ function App() {
const settingsModalIsStdio = settingsModalServerType === "stdio";
/**
* Servers whose clear is in flight (#2144). Keyed by id, not a single flag:
* the callback explicitly supports clearing a server other than the active
* one, so a global lock would silently drop B's click while A's revocation
* was still out. See `runClear`.
* Servers whose clear is in flight (#2144). Keyed per server, not a single
* flag: the callback explicitly supports clearing a server other than the
* active one, so a global lock would silently drop B's click while A's
* revocation was still out. See `runClear`.
*/
const clearOAuthInFlightRef = useRef<Set<string>>(new Set());
@ -1375,11 +1376,27 @@ function App() {
// — and with revocation taking up to five seconds, that means concurrent
// RFC 7009 requests, concurrent store writes, and two contradictory
// toasts. Keyed by server so a *different* server's clear is unaffected.
if (clearOAuthInFlightRef.current.has(server.id)) return;
clearOAuthInFlightRef.current.add(server.id);
//
// "Different server" is the OAuth storage key, not the catalog id
// (#2217, Copilot): two entries against one URL share one blob, one
// grant and one revocation, so an id-keyed guard lets exactly the race
// above through between them. And for anything touching the live
// session the key is the *client's*, not the entry's — an entry edited
// while connected reads a URL the session never authorized against, so
// an entry-keyed lock would name an operation nobody is performing.
// `resolveOAuthClearIdentity` is the same call the clear itself makes,
// so the two cannot disagree.
const { inFlightKey } = resolveOAuthClearIdentity({
server,
activeServerId,
activeClientConfig: inspectorClient?.getTransportConfig(),
activeEntryConfig: activeServer?.config,
});
if (clearOAuthInFlightRef.current.has(inFlightKey)) return;
clearOAuthInFlightRef.current.add(inFlightKey);
clearServerOAuthAndDisconnect(server)
.finally(() => {
clearOAuthInFlightRef.current.delete(server.id);
clearOAuthInFlightRef.current.delete(inFlightKey);
})
.catch((err: unknown) => {
notifications.show({
@ -1392,7 +1409,12 @@ function App() {
});
});
},
[clearServerOAuthAndDisconnect],
[
clearServerOAuthAndDisconnect,
activeServerId,
inspectorClient,
activeServer,
],
);
const handleClearConnectionOAuth = useCallback(() => {

View file

@ -112,6 +112,12 @@ const entry = (
...over,
});
/** A catalog entry pointing at a different URL, so a different OAuth blob. */
const otherUrlEntry = (id: string): ServerEntry =>
entry(id, {
config: { type: "streamable-http", url: "https://other.example/mcp" },
});
const challenge = (
reason: AuthChallenge["reason"] = "unauthorized",
): AuthChallenge => ({ reason });
@ -151,6 +157,13 @@ function fakeClient(over: Partial<Record<string, unknown>> = {}) {
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "failed" }),
disconnect: vi.fn().mockResolvedValue(undefined),
resumeAfterOAuth: vi.fn().mockResolvedValue(undefined),
// #2217: the clear path resolves the session's OAuth key from the config
// the client was built with, since a catalog entry can be edited while
// connected without rebuilding it. Defaults to the shared fixture URL.
getTransportConfig: vi.fn(() => ({
type: "streamable-http" as const,
url: "https://mcp.example/mcp",
})),
...over,
});
}
@ -1801,7 +1814,8 @@ describe("useOAuthRecovery", () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("b"));
// A genuinely unrelated entry: its own URL, so its own OAuth blob.
await h.api().clearServerOAuthAndDisconnect(otherUrlEntry("b"));
});
expect(client.disconnect).not.toHaveBeenCalled();
expect(
@ -1809,6 +1823,96 @@ describe("useOAuthRecovery", () => {
).toBeDefined();
});
// #2217 — OAuth state is keyed by URL, so two catalog entries against the
// same URL share one blob. Clearing the inactive one destroys the active
// session's tokens (and revokes its grant), which an id-only check cannot
// see; the session was left connected on dead credentials with no notice.
it("disconnects the active session when clearing an entry that shares its URL", async () => {
const client = fakeClient();
const h = harness({
servers: [entry("a"), entry("b")],
activeServerId: "a",
client,
});
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("b"));
});
expect(client.disconnect).toHaveBeenCalled();
// The live client owns the clear, so in-memory flow state goes too.
expect(clearServerOAuthStateMock).toHaveBeenCalledWith(
expect.objectContaining({
isActiveConnection: true,
inspectorClient: client,
}),
);
expect(
toastWith(
"authorizes against the same URL, so its stored tokens went too",
),
).toBeDefined();
});
// Copilot on this PR: a card can be edited while connected and the catalog
// write does not rebuild the client, so the active *entry* can read a URL
// the live session never authorized against. Reading the entry would miss
// an entry still sitting on the client's real URL.
it("compares against the live client's URL, not the edited catalog entry's", async () => {
const client = fakeClient({
// Still authorized against the shared URL...
getTransportConfig: vi.fn(() => ({
type: "streamable-http" as const,
url: "https://mcp.example/mcp",
})),
});
const h = harness({
// ...while A's catalog entry has since been edited elsewhere.
servers: [otherUrlEntry("a"), entry("b")],
activeServerId: "a",
client,
});
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("b"));
});
expect(client.disconnect).toHaveBeenCalled();
});
// The same-URL branch still snapshots the session it acted on, so a switch
// during the in-flight clear must not drag the cleanup onto the new one.
it("does not disconnect a session switched to during a shared-URL clear", async () => {
let settle: (r: { cleared: boolean }) => void = () => {};
clearServerOAuthStateMock.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
const client = fakeClient();
const h = harness({
servers: [entry("a"), entry("b"), otherUrlEntry("c")],
activeServerId: "a",
client,
});
let done: Promise<void>;
await act(async () => {
done = h.api().clearServerOAuthAndDisconnect(entry("b"));
await Promise.resolve();
});
h.rerender({
servers: [entry("a"), entry("b"), otherUrlEntry("c")],
activeServerId: "c",
client,
});
await act(async () => {
settle({ cleared: true });
await done;
});
expect(client.disconnect).not.toHaveBeenCalled();
});
// #2144 — this is the web client's production wiring for revocation.
// Without asserting the arguments, removing the per-server opt-out or
// handing it the page-origin fetch would leave every test green.

View file

@ -35,6 +35,7 @@ import { oauthDetailsFromConnectionState } from "../components/groups/Connection
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
import { resolveOAuthClearIdentity } from "../utils/oauthClearKey";
import { getAuthToken } from "../lib/authToken";
import {
isBrowserTabVisible,
@ -1430,8 +1431,31 @@ export function useOAuthRecovery({
const clearServerOAuthAndDisconnect = useCallback(
async (server: ClearableServer) => {
const isActive = server.id === activeServerId;
const client = isActive ? inspectorClient : null;
// OAuth state is keyed by the server URL, but "is this the active
// connection" was keyed by catalog entry id — and `serverList` enforces
// no URL uniqueness, so two entries with different ids and the same URL
// are a supported (and useful) state: separate names, headers or
// settings against one server. Clearing the inactive one deletes the
// *shared* blob and, with #2144 in, revokes the active session's grant —
// while an id-only check takes the inactive branch and leaves that
// session connected on credentials that no longer exist, silently
// (#2217). Comparing the storage keys is what sees it; the id check
// cannot, by construction.
//
// The live client's own config is what the comparison uses, because a
// card can be edited while connected and the catalog write does not
// rebuild the client (Copilot). `resolveOAuthClearIdentity` owns the
// rule; `App`'s in-flight guard reads the same answer from it.
const { isActive, sharesActiveOAuthKey, affectsActiveSession } =
resolveOAuthClearIdentity({
server,
activeServerId,
activeClientConfig: inspectorClient?.getTransportConfig(),
activeEntryConfig: sessionRef.current.servers.find(
(s) => s.id === activeServerId,
)?.config,
});
const client = affectsActiveSession ? inspectorClient : null;
// The RFC 7009 leg is a bounded network request (#2144), so this callback
// can stay suspended for seconds — long enough for the user to close the
// modal and switch servers. `isActive` and `inspectorClient` were
@ -1446,15 +1470,19 @@ export function useOAuthRecovery({
// disconnect/reconnect to the SAME server builds a replacement
// `InspectorClient`, so an id-only check passes again and the old clear
// would run its session-wide cleanup against the new session.
//
// The id compared is the *active* one snapshotted alongside `isActive`,
// not the cleared entry's — for a shared-key clear those differ, and the
// session being protected is the active one.
const stillTargetsActiveSession = (): boolean =>
isActive &&
sessionRef.current.activeServerId === server.id &&
affectsActiveSession &&
sessionRef.current.activeServerId === activeServerId &&
sessionRef.current.inspectorClient === client;
const { cleared, revocation } = await clearServerOAuthState({
config: server.config,
inspectorClient: client,
isActiveConnection: isActive,
isActiveConnection: affectsActiveSession,
oauthStorage: webOAuthStorage,
revoke: server.settings?.oauthRevokeOnClear !== false,
fetchFn: getWebProxiedFetch(getAuthToken()),
@ -1481,7 +1509,7 @@ export function useOAuthRecovery({
finalizeExplicitDisconnect();
}
}
} else if (!isActive || stillTargetsActiveSession()) {
} else if (!affectsActiveSession || stillTargetsActiveSession()) {
// No client to disconnect — either this is a stored-only clear, or the
// active session has none yet (it is being built or torn down). Either
// way the resume snapshot is stale and must go; skipping it would leave
@ -1495,7 +1523,14 @@ export function useOAuthRecovery({
title: "OAuth state cleared",
message: isActive
? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
: sharesActiveOAuthKey
? // States the credential impact, which is certain, rather than
// the disconnect, which is not: the stale-session guard skips it
// after a switch, and `disconnect()` can reject (that failure
// gets its own toast above). Either way the shared state is gone,
// so the session must reconnect (Copilot).
`Stored OAuth state was removed for "${server.name}". The active session authorizes against the same URL, so its stored tokens went too — reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
color: "blue",
});
},

View file

@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { oauthClearKey, resolveOAuthClearIdentity } 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"),
);
});
});
describe("resolveOAuthClearIdentity", () => {
const http = (url: string) => ({ type: "streamable-http", url }) as const;
const X = http("https://x.example/mcp");
const Y = http("https://y.example/mcp");
const resolve = (
over: Partial<Parameters<typeof resolveOAuthClearIdentity>[0]> = {},
) =>
resolveOAuthClearIdentity({
server: { id: "A", config: X },
activeServerId: "A",
activeClientConfig: X,
activeEntryConfig: X,
...over,
});
it("treats the active entry as affecting the session", () => {
const id = resolve();
expect(id.isActive).toBe(true);
expect(id.sharesActiveOAuthKey).toBe(false);
expect(id.affectsActiveSession).toBe(true);
});
it("treats an unrelated entry as affecting nothing", () => {
const id = resolve({ server: { id: "B", config: Y } });
expect(id.affectsActiveSession).toBe(false);
expect(id.inFlightKey).toBe("url:https://y.example/mcp");
});
// The #2217 case: distinct ids, one blob.
it("treats a different entry with the session's URL as affecting the session", () => {
const id = resolve({ server: { id: "B", config: X } });
expect(id.isActive).toBe(false);
expect(id.sharesActiveOAuthKey).toBe(true);
expect(id.affectsActiveSession).toBe(true);
});
// Copilot: a card can be edited while connected, and the catalog write does
// not rebuild the client — so the entry reads Y while the live session is
// still authorized against X. Reading the entry would miss entry B at X.
it("follows the live client's URL, not the edited catalog entry's", () => {
const edited = resolve({
server: { id: "B", config: X },
activeClientConfig: X,
activeEntryConfig: Y,
});
expect(edited.sharesActiveOAuthKey).toBe(true);
// And an entry at the entry's *new* URL is not the session's credentials.
const notShared = resolve({
server: { id: "B", config: Y },
activeClientConfig: X,
activeEntryConfig: Y,
});
expect(notShared.sharesActiveOAuthKey).toBe(false);
});
// The lock has to name the storage operation actually performed. Clearing
// the edited active entry routes through the live client, which still acts
// on X — so an entry-keyed lock (`url:Y`) would not collide with a
// concurrent clear of entry B at X, which performs the very same operation.
it("locks an active-session clear on the client's key, not the entry's", () => {
expect(
resolve({
server: { id: "A", config: Y },
activeClientConfig: X,
activeEntryConfig: Y,
}).inFlightKey,
).toBe("url:https://x.example/mcp");
expect(
resolve({
server: { id: "B", config: X },
activeClientConfig: X,
activeEntryConfig: Y,
}).inFlightKey,
).toBe("url:https://x.example/mcp");
});
it("falls back to the active entry's config when there is no live client", () => {
const id = resolve({
server: { id: "B", config: X },
activeClientConfig: undefined,
activeEntryConfig: X,
});
expect(id.sharesActiveOAuthKey).toBe(true);
expect(id.inFlightKey).toBe("url:https://x.example/mcp");
});
it("handles no active server at all", () => {
const id = resolve({
server: { id: "B", config: X },
activeServerId: undefined,
activeClientConfig: undefined,
activeEntryConfig: undefined,
});
expect(id.isActive).toBe(false);
expect(id.affectsActiveSession).toBe(false);
expect(id.inFlightKey).toBe("url:https://x.example/mcp");
});
// A stdio active session has no OAuth key, so an active clear against it
// falls back to the entry id rather than collapsing onto a shared URL key.
it("falls back to the entry id when the session has no OAuth URL", () => {
const stdio = { type: "stdio", command: "node" } as const;
expect(
resolve({
server: { id: "A", config: stdio },
activeClientConfig: stdio,
activeEntryConfig: stdio,
}).inFlightKey,
).toBe("id:A");
});
});

View file

@ -0,0 +1,91 @@
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
/**
* The identity a *clear* of stored OAuth state acts on, for a config on its
* own.
*
* Persisted OAuth state is keyed by the server URL, and `core/mcp/serverList`
* enforces no URL uniqueness — so two catalog entries with distinct ids can
* point at one URL and therefore share one credential blob, one live grant and
* one revocation. Anything that dedupes, locks or compares *clears* has to key
* on that shared identity rather than on the entry id, which cannot see it
* (#2217).
*
* A config with no OAuth server URL (stdio, and anything else
* `getOAuthServerUrl` declines) has no shared blob to collide over, so it falls
* back to the entry id — which keeps every such entry distinct from every
* other, rather than collapsing them all onto one shared key.
*/
export function oauthClearKey(config: MCPServerConfig, id: string): string {
const url = getOAuthServerUrl(config);
// Prefixed so a URL-keyed entry can never collide with an id-keyed one.
return url !== undefined ? `url:${url}` : `id:${id}`;
}
/** What a clear is being asked to do, relative to the live session. */
export interface OAuthClearIdentityInput {
/** The catalog entry whose OAuth state the user asked to clear. */
server: { id: string; config: MCPServerConfig };
activeServerId: string | undefined;
/**
* The config the **live** `InspectorClient` was built with
* (`getTransportConfig()`), when there is one.
*
* This — not the catalog entry — is what the session's credentials are keyed
* under. A card can be edited while connected and the catalog write does not
* rebuild the client, so after A connects to X and is edited to Y, the live
* client still authorizes against X while the entry reads Y (Copilot).
*/
activeClientConfig: MCPServerConfig | undefined;
/** The active entry's catalog config, used only when there is no client. */
activeEntryConfig: MCPServerConfig | undefined;
}
export interface OAuthClearIdentity {
/** The cleared entry *is* the active one. */
isActive: boolean;
/** A different entry, whose OAuth state the live session is using. */
sharesActiveOAuthKey: boolean;
/** Either of the above: the active session's credentials are being destroyed. */
affectsActiveSession: boolean;
/**
* The identity to lock an in-flight clear on. Names the storage operation
* that will actually be performed — for anything touching the live session
* that is the client's key, which is not necessarily the entry's own.
*/
inFlightKey: string;
}
/**
* Resolve who a clear affects and what it locks, from one place.
*
* Both consumers need the same answer and would otherwise each re-derive it:
* `useOAuthRecovery` to decide whether to route through the live client and
* disconnect, and `App`'s `runClear` to dedupe concurrent clears. Two copies of
* this rule that disagree is exactly the class of bug #2217 is.
*/
export function resolveOAuthClearIdentity({
server,
activeServerId,
activeClientConfig,
activeEntryConfig,
}: OAuthClearIdentityInput): OAuthClearIdentity {
const isActive = activeServerId !== undefined && server.id === activeServerId;
// Client first: the entry is mutable, the built client is not.
const activeConfig = activeClientConfig ?? activeEntryConfig;
const activeKey = activeConfig ? getOAuthServerUrl(activeConfig) : undefined;
const clearedKey = getOAuthServerUrl(server.config);
const sharesActiveOAuthKey =
!isActive && clearedKey !== undefined && clearedKey === activeKey;
const affectsActiveSession = isActive || sharesActiveOAuthKey;
return {
isActive,
sharesActiveOAuthKey,
affectsActiveSession,
inFlightKey:
affectsActiveSession && activeKey !== undefined
? `url:${activeKey}`
: oauthClearKey(server.config, server.id),
};
}