Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
This commit is contained in:
commit
2eeab05d00
11 changed files with 8524 additions and 1598 deletions
|
|
@ -139,5 +139,26 @@ export {
|
|||
// Discovery
|
||||
export { discoverScopes } from "./discovery.js";
|
||||
|
||||
// RFC 7009 token revocation (#2144)
|
||||
export {
|
||||
DEFAULT_REVOCATION_TIMEOUT_MS,
|
||||
aggregateOutcomes,
|
||||
buildRevocationRequest,
|
||||
revocationAuthMethods,
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
revokeToken,
|
||||
selectRevocableToken,
|
||||
} from "./revocation.js";
|
||||
export type {
|
||||
ExecuteOAuthRevocationParams,
|
||||
OAuthRevocationPlan,
|
||||
PlanOAuthRevocationParams,
|
||||
RevocationRequestParams,
|
||||
RevokeTokenParams,
|
||||
TokenRevocationOutcome,
|
||||
TokenRevocationSkipReason,
|
||||
} from "./revocation.js";
|
||||
|
||||
// Logging (re-exported from core/logging)
|
||||
export { silentLogger } from "../logging/index.js";
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
OAuthClientInformationSchema,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import type { OAuthStorage } from "./storage.js";
|
||||
import type { OAuthStorage, RevocationSnapshot } from "./storage.js";
|
||||
import {
|
||||
type OAuthMemoryStore,
|
||||
type ServerOAuthState,
|
||||
|
|
@ -424,6 +424,29 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
await this.persist();
|
||||
}
|
||||
|
||||
async takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot> {
|
||||
await this.ensureLoaded();
|
||||
// Everything below is synchronous on purpose: read and clear against one
|
||||
// view of the in-memory state, so nothing can land between them. The
|
||||
// persist is the only await, and it happens after the mutation.
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const snapshot: RevocationSnapshot = {
|
||||
byIssuer: Object.fromEntries(
|
||||
Object.entries(state.byIssuer ?? {}).map(([issuer, slot]) => [
|
||||
issuer,
|
||||
{ tokens: slot?.tokens, clientInformation: slot?.clientInformation },
|
||||
]),
|
||||
),
|
||||
legacyTokens: state.tokens,
|
||||
legacyClientInformation: state.clientInformation,
|
||||
preregisteredClientInformation: state.preregisteredClientInformation,
|
||||
serverMetadata: state.serverMetadata,
|
||||
};
|
||||
this.memory.getState().clearServerState(serverUrl);
|
||||
await this.persist();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async clear(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory.getState().clearServerState(serverUrl);
|
||||
|
|
|
|||
743
core/auth/revocation.ts
Normal file
743
core/auth/revocation.ts
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
/**
|
||||
* RFC 7009 OAuth 2.0 Token Revocation (#2144).
|
||||
*
|
||||
* Clearing the Inspector's OAuth state used to delete the *local* copy of the
|
||||
* tokens and stop there: the access token, and the refresh token when one was
|
||||
* issued, stayed valid at the authorization server until they expired on their
|
||||
* own. From the AS's side nothing happened — the client just went quiet — so a
|
||||
* day of connect/disconnect iteration left it holding a pile of live grants for
|
||||
* sessions that had ended hours ago. RFC 7009 §1 describes exactly this case
|
||||
* (a client invalidating its tokens when the user logs out or walks away) and
|
||||
* this module is the request that closes it.
|
||||
*
|
||||
* Everything here is **best-effort**, and deliberately so: the local clear is
|
||||
* what the user asked for, and it must finish whether or not the AS cooperates.
|
||||
* An authorization server that advertises no `revocation_endpoint` is left
|
||||
* behaving exactly as before, a network error or a non-2xx is reported and
|
||||
* swallowed, and a short timeout keeps a slow AS from hanging the teardown.
|
||||
* Every path therefore returns a {@link TokenRevocationOutcome} rather than
|
||||
* throwing — a caller is telling us to forget these tokens, and there is no
|
||||
* failure here that should stop it.
|
||||
*/
|
||||
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { selectClientAuthMethod } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
OAuthClientInformationSchema,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import type { InspectorLogger } from "../logging/index.js";
|
||||
import type { OAuthStorage, RevocationSnapshot } from "./storage.js";
|
||||
|
||||
/**
|
||||
* How long a revocation request may take before it is abandoned.
|
||||
*
|
||||
* Short on purpose. This runs inside a disconnect the user has already
|
||||
* committed to, so an unreachable or wedged authorization server must not hold
|
||||
* the teardown open — five seconds is long enough for a real endpoint on a slow
|
||||
* link and short enough that a dead one is not felt as a hang.
|
||||
*
|
||||
* It is a budget for the **whole** call, not per request: a server with several
|
||||
* issuer-bound grants revokes them sequentially against one shared deadline, so
|
||||
* the bound the user feels is this number regardless of how many grants the
|
||||
* clear is about to delete.
|
||||
*/
|
||||
export const DEFAULT_REVOCATION_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Why a revocation request was not sent. */
|
||||
export type TokenRevocationSkipReason =
|
||||
/** The caller turned revocation off for this server. */
|
||||
| "disabled"
|
||||
/** The authorization server advertises no `revocation_endpoint`. */
|
||||
| "no_endpoint"
|
||||
/** Nothing is stored for this server, so there is no grant to revoke. */
|
||||
| "no_tokens"
|
||||
/** Authorization-server metadata could not be resolved at all. */
|
||||
| "no_metadata";
|
||||
|
||||
/**
|
||||
* What happened on the revocation leg. Never an exception: see the module
|
||||
* comment — the local clear runs regardless, so this is reported, not thrown.
|
||||
*/
|
||||
export type TokenRevocationOutcome =
|
||||
| {
|
||||
status: "revoked";
|
||||
/** Which token RFC 7009 §2.1 was asked about. */
|
||||
tokenTypeHint: "refresh_token" | "access_token";
|
||||
endpoint: string;
|
||||
}
|
||||
| { status: "skipped"; reason: TokenRevocationSkipReason }
|
||||
| { status: "failed"; detail: string; endpoint?: string };
|
||||
|
||||
/**
|
||||
* The token to name in the request, and the hint that describes it.
|
||||
*
|
||||
* A refresh token is preferred when one exists, and not merely as a
|
||||
* tie-breaker: RFC 7009 §2.1 says an AS asked to revoke a refresh token SHOULD
|
||||
* also invalidate the access tokens issued under the same grant, so the single
|
||||
* request covers both. Naming the access token instead would leave the refresh
|
||||
* token — the long-lived half, and the one a user cannot wait out — alive.
|
||||
*/
|
||||
export function selectRevocableToken(
|
||||
tokens: OAuthTokens | undefined,
|
||||
): { token: string; tokenTypeHint: "refresh_token" | "access_token" } | null {
|
||||
if (!tokens) return null;
|
||||
if (tokens.refresh_token) {
|
||||
return { token: tokens.refresh_token, tokenTypeHint: "refresh_token" };
|
||||
}
|
||||
if (tokens.access_token) {
|
||||
return { token: tokens.access_token, tokenTypeHint: "access_token" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-authentication methods the revocation endpoint advertises.
|
||||
*
|
||||
* RFC 8414 §2 gives `revocation_endpoint_auth_methods_supported` a default of
|
||||
* **`client_secret_basic`** when it is omitted — it does *not* inherit
|
||||
* `token_endpoint_auth_methods_supported`. Falling through to the token
|
||||
* endpoint's list would make metadata advertising only `client_secret_post`
|
||||
* there send POST credentials to a revocation endpoint that never advertised
|
||||
* that method, which a strict server rejects.
|
||||
*
|
||||
* The default is returned **literally** rather than as an empty list. An empty
|
||||
* list does not mean "apply the RFC default" to `selectClientAuthMethod`: it
|
||||
* means "the metadata said nothing", and the SDK then honors whatever
|
||||
* `token_endpoint_auth_method` the client's own registration declares. A DCR
|
||||
* client registered `client_secret_post` would therefore put its credentials
|
||||
* in the body of a request to an endpoint that promised only Basic, and the
|
||||
* revocation would fail. Naming the default is what actually enforces it.
|
||||
*/
|
||||
export function revocationAuthMethods(metadata: CachedMetadata): string[] {
|
||||
return (
|
||||
metadata.revocation_endpoint_auth_methods_supported ?? [
|
||||
"client_secret_basic",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export interface RevocationRequestParams {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
tokenTypeHint: "refresh_token" | "access_token";
|
||||
clientInformation?: OAuthClientInformation;
|
||||
supportedAuthMethods: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the RFC 7009 §2.1 request — an `application/x-www-form-urlencoded` POST
|
||||
* carrying `token` and `token_type_hint`, plus client authentication.
|
||||
*
|
||||
* Split out from {@link revokeToken} because *what is sent* is the part worth
|
||||
* asserting directly: a credential in the wrong place (basic vs. post) is a
|
||||
* silent 401 at a real AS and indistinguishable from "the server declined" in
|
||||
* an end-to-end test.
|
||||
*/
|
||||
export function buildRevocationRequest(params: RevocationRequestParams): {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
} {
|
||||
const body = new URLSearchParams({
|
||||
token: params.token,
|
||||
token_type_hint: params.tokenTypeHint,
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const client = params.clientInformation;
|
||||
if (client) {
|
||||
const method = selectClientAuthMethod(client, params.supportedAuthMethods);
|
||||
if (method === "client_secret_basic" && client.client_secret) {
|
||||
// RFC 6749 §2.3.1: each half is form-urlencoded *before* the colon joins
|
||||
// them. This deliberately differs from the SDK's `applyBasicAuth`, which
|
||||
// base64s the raw pair — matching that was the first instinct, on the
|
||||
// theory that a credential should be presented the same way here as at
|
||||
// the token endpoint. It does not hold up: a client authenticated with
|
||||
// `client_secret_post` never exercised the SDK's Basic path at all, so
|
||||
// there is no precedent to match, and the raw form is ambiguous for a
|
||||
// client id containing `:` and makes `btoa` throw outright on a
|
||||
// non-Latin-1 secret. Encoding is what the server decodes.
|
||||
const credentials = `${encodeURIComponent(client.client_id)}:${encodeURIComponent(client.client_secret)}`;
|
||||
headers.Authorization = `Basic ${base64Encode(credentials)}`;
|
||||
} else if (method === "client_secret_post" && client.client_secret) {
|
||||
body.set("client_id", client.client_id);
|
||||
body.set("client_secret", client.client_secret);
|
||||
} else {
|
||||
// Public client (or a confidential one with no secret on hand): RFC 7009
|
||||
// §2.1 still wants the client identified.
|
||||
body.set("client_id", client.client_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: params.endpoint,
|
||||
init: { method: "POST", headers, body: body.toString() },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 for the Basic credential, in both runtimes this code runs in.
|
||||
*
|
||||
* `btoa` is the browser's and is byte-oriented, so the percent-encoded
|
||||
* credential above (which is ASCII by construction) is safe to pass it. Node
|
||||
* has `Buffer`; reaching for it first would pull a Node built-in into the
|
||||
* browser bundle, which the #1769 build gate rejects outright.
|
||||
*/
|
||||
function base64Encode(value: string): string {
|
||||
if (typeof btoa === "function") return btoa(value);
|
||||
/* v8 ignore next 2 -- Node 22 and every supported browser define btoa; this is the belt-and-braces branch. */
|
||||
return Buffer.from(value, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
export interface RevokeTokenParams extends RevocationRequestParams {
|
||||
fetchFn: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the revocation request and classify the answer.
|
||||
*
|
||||
* RFC 7009 §2.2 makes a 200 the success case *and* the response to a token the
|
||||
* AS does not recognize, which is why an already-expired token is not an error
|
||||
* here. Anything else — a 4xx, a 5xx, a network failure, the timeout — is
|
||||
* reported as `failed` and goes no further than a warning at the call site.
|
||||
*/
|
||||
export async function revokeToken(
|
||||
params: RevokeTokenParams,
|
||||
): Promise<TokenRevocationOutcome> {
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
|
||||
try {
|
||||
// Inside the try: `encodeURIComponent` throws on a lone UTF-16 surrogate,
|
||||
// which is valid JSON and so can reach here from a persisted client id or
|
||||
// secret. Built outside, that would reject instead of returning a `failed`
|
||||
// outcome — and every caller has already cleared its local state by now, so
|
||||
// a rejection would break the documented best-effort guarantee.
|
||||
const { url, init } = buildRevocationRequest(params);
|
||||
// The signal alone is not enough to bound this. In the browser the fetch is
|
||||
// `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch`
|
||||
// and does not forward `init.signal`; the backend's outbound fetch gets no
|
||||
// signal either. So a wedged authorization server would hold the teardown
|
||||
// open indefinitely on exactly the path the timeout exists for. The race is
|
||||
// what actually enforces the deadline; the signal is kept because it does
|
||||
// cancel the direct-fetch paths (CLI, TUI, backend) rather than merely
|
||||
// abandoning them.
|
||||
const response = await withDeadline(
|
||||
params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
|
||||
timeoutMs,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: "failed",
|
||||
endpoint: url,
|
||||
detail:
|
||||
`revocation endpoint responded ${response.status} ${response.statusText}`.trim(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "revoked",
|
||||
tokenTypeHint: params.tokenTypeHint,
|
||||
endpoint: url,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
// `params.endpoint`, not the built `url`: construction is inside the try
|
||||
// now, so `url` may not exist on this path.
|
||||
status: "failed",
|
||||
endpoint: params.endpoint,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject with a timeout error if `promise` has not settled within `timeoutMs`.
|
||||
*
|
||||
* The underlying request is abandoned rather than cancelled — nothing here can
|
||||
* cancel a fetch the proxy already stripped the signal from. That is the right
|
||||
* trade for this caller: the point is that the *teardown* proceeds, and the
|
||||
* response, if it ever arrives, is a revocation we no longer need to wait for.
|
||||
* The timer is cleared on the settled path so a caller is never held awake by it.
|
||||
*/
|
||||
async function withDeadline<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(new Error(`revocation request timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, deadline]);
|
||||
} finally {
|
||||
/* v8 ignore next -- the Promise executor runs synchronously, so `timer` is
|
||||
always assigned by the time this runs; the guard exists only because
|
||||
TypeScript cannot see that. */
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlanOAuthRevocationParams {
|
||||
serverUrl: string;
|
||||
storage: OAuthStorage;
|
||||
/**
|
||||
* `false` plans nothing — the deliberate case from #2144, where a user wants
|
||||
* to watch a server cope with a client that walks off still holding live
|
||||
* tokens.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecuteOAuthRevocationParams {
|
||||
fetchFn: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
logger?: InspectorLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the revocation requests need, read out of the store **before** the
|
||||
* local clear empties it.
|
||||
*
|
||||
* Split from the sending on purpose. `clearAndPlanRevocation` takes the state
|
||||
* and deletes it in one atomic storage step; the requests then go out from this
|
||||
* copy, which nothing can invalidate. Revoking first and clearing afterwards
|
||||
* would delay the clear by however long the network takes, and a *fresh*
|
||||
* authorization completing in that window would be deleted by a clear
|
||||
* reasoning about the grant it replaced.
|
||||
*/
|
||||
export interface OAuthRevocationPlan {
|
||||
serverUrl: string;
|
||||
grants: StoredGrant[];
|
||||
/** Slots that could not be read; reported beside whatever the rest do. */
|
||||
failures: TokenRevocationOutcome[];
|
||||
endpoint?: string;
|
||||
supportedAuthMethods: string[];
|
||||
metadataIssuer?: string;
|
||||
/** Set when there is nothing to send, and why. */
|
||||
outcome?: TokenRevocationOutcome;
|
||||
}
|
||||
|
||||
/** A plan that sends nothing, carrying the reason. */
|
||||
function emptyPlan(
|
||||
serverUrl: string,
|
||||
outcome: TokenRevocationOutcome,
|
||||
failures: TokenRevocationOutcome[] = [],
|
||||
): OAuthRevocationPlan {
|
||||
return {
|
||||
serverUrl,
|
||||
grants: [],
|
||||
failures,
|
||||
supportedAuthMethods: [],
|
||||
outcome:
|
||||
failures.length > 0 ? aggregateOutcomes([...failures, outcome]) : outcome,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the server's OAuth state — deleting it — and build the revocation
|
||||
* requests from what was taken.
|
||||
*
|
||||
* The take and the delete are one atomic storage step
|
||||
* ({@link OAuthStorage.takeRevocationSnapshot}), which is what closes the
|
||||
* check-then-act window: separate reads followed by a separate clear each yield
|
||||
* at an `await`, and an OAuth completion landing in one of those gaps would
|
||||
* save a fresh grant that the clear then destroyed.
|
||||
*
|
||||
* The state is cleared **even when revocation is disabled or nothing can be
|
||||
* revoked** — clearing is what the caller asked for; the requests are the
|
||||
* optional part.
|
||||
*
|
||||
* Every grant is covered, not just the active issuer's: see
|
||||
* {@link collectGrants}. The metadata comes from the cache the OAuth flow
|
||||
* already populated rather than from a fresh discovery round-trip — the tokens
|
||||
* being revoked were minted by that same authorization server, so its cached
|
||||
* document is the one that describes them, and re-discovering would add two
|
||||
* network legs to a teardown for no new information.
|
||||
*/
|
||||
export async function clearAndPlanRevocation(
|
||||
params: PlanOAuthRevocationParams,
|
||||
): Promise<OAuthRevocationPlan> {
|
||||
const { serverUrl, storage } = params;
|
||||
|
||||
let snapshot: RevocationSnapshot;
|
||||
try {
|
||||
snapshot = await storage.takeRevocationSnapshot(serverUrl);
|
||||
} catch (err) {
|
||||
// The clear did not happen, and the caller has no other way to learn that.
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
if (params.enabled === false) {
|
||||
return emptyPlan(serverUrl, { status: "skipped", reason: "disabled" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { grants, failures } = await collectGrants(snapshot);
|
||||
if (grants.length === 0) {
|
||||
return emptyPlan(
|
||||
serverUrl,
|
||||
{ status: "skipped", reason: "no_tokens" },
|
||||
failures,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = parseServerMetadata(snapshot.serverMetadata);
|
||||
if (!metadata) {
|
||||
return emptyPlan(
|
||||
serverUrl,
|
||||
{ status: "skipped", reason: "no_metadata" },
|
||||
failures,
|
||||
);
|
||||
}
|
||||
if (!metadata.revocation_endpoint) {
|
||||
return emptyPlan(
|
||||
serverUrl,
|
||||
{ status: "skipped", reason: "no_endpoint" },
|
||||
failures,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
grants,
|
||||
failures,
|
||||
endpoint: metadata.revocation_endpoint,
|
||||
supportedAuthMethods: revocationAuthMethods(metadata),
|
||||
metadataIssuer: metadata.issuer,
|
||||
};
|
||||
} catch (err) {
|
||||
// The state is already gone; a snapshot we cannot interpret is reported,
|
||||
// not thrown, because there is nothing left for the caller to retry.
|
||||
return emptyPlan(serverUrl, {
|
||||
status: "failed",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached authorization-server metadata from a snapshot.
|
||||
*
|
||||
* Read structurally rather than schema-parsed: this is a document the SDK's own
|
||||
* discovery wrote and the store round-trips as-is, and only two fields are
|
||||
* used. A parse failure here would lose an otherwise revocable grant.
|
||||
*/
|
||||
function parseServerMetadata(raw: unknown): CachedMetadata | undefined {
|
||||
if (raw === null || typeof raw !== "object") return undefined;
|
||||
return raw as CachedMetadata;
|
||||
}
|
||||
|
||||
/** The three fields revocation reads out of cached AS metadata. */
|
||||
interface CachedMetadata {
|
||||
revocation_endpoint?: string;
|
||||
revocation_endpoint_auth_methods_supported?: string[];
|
||||
issuer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the requests a {@link clearAndPlanRevocation} snapshot describes.
|
||||
*
|
||||
* Safe to run **after** `storage.clear(serverUrl)` — that is the point: the
|
||||
* plan already holds the tokens, the credentials and the endpoint, so nothing
|
||||
* here reads the store, and the local clear is never waiting on the network.
|
||||
*
|
||||
* Best-effort throughout: every path returns a {@link TokenRevocationOutcome}
|
||||
* rather than throwing, because forgetting the tokens is what the caller
|
||||
* actually asked for and no failure on this leg should undo it.
|
||||
*/
|
||||
export async function executeOAuthRevocation(
|
||||
plan: OAuthRevocationPlan,
|
||||
params: ExecuteOAuthRevocationParams,
|
||||
): Promise<TokenRevocationOutcome> {
|
||||
const outcome = await runPlan(plan, params);
|
||||
const { logger } = params;
|
||||
if (outcome.status === "failed") {
|
||||
logger?.warn(
|
||||
{
|
||||
serverUrl: plan.serverUrl,
|
||||
endpoint: outcome.endpoint,
|
||||
detail: outcome.detail,
|
||||
},
|
||||
"Token revocation failed; local OAuth state was cleared anyway",
|
||||
);
|
||||
} else if (outcome.status === "skipped" && outcome.reason === "no_endpoint") {
|
||||
logger?.debug(
|
||||
{ serverUrl: plan.serverUrl },
|
||||
"Skipping token revocation: authorization server metadata has no revocation_endpoint",
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** One revocable grant held for a server, and which AS minted it. */
|
||||
export interface StoredGrant {
|
||||
/** Undefined for the legacy unkeyed slot, which predates issuer binding. */
|
||||
issuer?: string;
|
||||
token: string;
|
||||
tokenTypeHint: "refresh_token" | "access_token";
|
||||
/**
|
||||
* The credentials to authenticate the revocation request with: the
|
||||
* registration bound to this grant's issuer, falling back to the
|
||||
* preconfigured (static) one.
|
||||
*
|
||||
* Deliberately the **reverse** of `BaseOAuthClientProvider.clientInformation`,
|
||||
* which prefers the preregistered entry. That order answers "who should I
|
||||
* authenticate as *now*"; revocation asks "who minted *this* token", and the
|
||||
* two diverge — the store lets a preregistered client and an issuer-bound
|
||||
* dynamic registration coexist, so after a server is switched from DCR to a
|
||||
* configured `oauth.clientId` an older DCR grant would be revoked with the
|
||||
* configured client's credentials. RFC 7009 §2.2 answers 200 for a token the
|
||||
* server does not recognise as the caller's, so that reports `revoked` while
|
||||
* the grant stays live and the local record is already gone.
|
||||
*
|
||||
* Best available evidence, not proof: the store records client information
|
||||
* per issuer, not per token, so a server that re-registered dynamically under
|
||||
* one issuer still cannot say which registration minted which grant. Binding
|
||||
* the identity to the token at save time is the real fix and is a
|
||||
* storage-shape change.
|
||||
*/
|
||||
clientInformation?: OAuthClientInformation;
|
||||
}
|
||||
|
||||
/** What {@link collectGrants} found, including the slots it could not read. */
|
||||
interface CollectedGrants {
|
||||
grants: StoredGrant[];
|
||||
/**
|
||||
* One `failed` outcome per slot that could not be parsed. Kept apart from the
|
||||
* grants so a single corrupt slot cannot abandon the ones that are still
|
||||
* revocable — the state is gone either way, so the failure has to be reported
|
||||
* *beside* the successes rather than instead of them.
|
||||
*/
|
||||
failures: TokenRevocationOutcome[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every grant the snapshot took, deduplicated by **issuer and token**.
|
||||
*
|
||||
* The snapshot is of state that has already been deleted, so this covers every
|
||||
* grant the clear removed rather than just the active issuer's: a server that
|
||||
* authorized against issuers A and B has two grants here, not one. Otherwise
|
||||
* B would be revoked while A's grant stayed live at its authorization server —
|
||||
* the same leak this feature closes, one level down.
|
||||
*
|
||||
* Deduplication is by issuer *and* token because a token means nothing outside
|
||||
* the authorization server that minted it: two issuers that happen to mint the
|
||||
* same opaque string are two grants, and collapsing them would drop the second
|
||||
* before the issuer check could report it.
|
||||
*
|
||||
* Parsing happens here rather than in the snapshot: it is pure, so it belongs
|
||||
* after the mutation — running it inside would reintroduce the `await` the
|
||||
* atomic read exists to remove.
|
||||
*/
|
||||
async function collectGrants(
|
||||
snapshot: RevocationSnapshot,
|
||||
): Promise<CollectedGrants> {
|
||||
const grants: StoredGrant[] = [];
|
||||
const failures: TokenRevocationOutcome[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
// Parsed defensively: it is only a *fallback*, so a malformed preconfigured
|
||||
// registration must not abort grants that carry their own valid credentials.
|
||||
// Recorded and skipped, matching the per-slot salvage below.
|
||||
let preregistered: OAuthClientInformation | undefined;
|
||||
try {
|
||||
preregistered = await parseClient(snapshot.preregisteredClientInformation);
|
||||
} catch (err) {
|
||||
failures.push({
|
||||
status: "failed",
|
||||
detail: `could not read the preconfigured client registration: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
const add = async (
|
||||
issuer: string | undefined,
|
||||
rawTokens: unknown,
|
||||
rawClient: unknown,
|
||||
): Promise<void> => {
|
||||
const revocable = selectRevocableToken(await parseTokens(rawTokens));
|
||||
if (!revocable) return;
|
||||
|
||||
const key = `${issuer ?? "\u0000legacy"}\u0000${revocable.token}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
seenKeys.add(key);
|
||||
|
||||
// The registration bound to THIS grant wins, with the preconfigured entry
|
||||
// as the fallback — see the note on `StoredGrant.clientInformation`.
|
||||
const bound = await parseClient(rawClient);
|
||||
grants.push({
|
||||
issuer,
|
||||
...revocable,
|
||||
clientInformation: bound ?? preregistered,
|
||||
});
|
||||
};
|
||||
|
||||
/** Read one slot; a slot that cannot be parsed is reported, not fatal. */
|
||||
const addSafely = async (
|
||||
issuer: string | undefined,
|
||||
rawTokens: unknown,
|
||||
rawClient: unknown,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await add(issuer, rawTokens, rawClient);
|
||||
} catch (err) {
|
||||
const where =
|
||||
issuer === undefined ? "the unkeyed slot" : `issuer ${issuer}`;
|
||||
failures.push({
|
||||
status: "failed",
|
||||
detail: `could not read the stored grant for ${where}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const [issuer, slot] of Object.entries(snapshot.byIssuer)) {
|
||||
await addSafely(issuer, slot?.tokens, slot?.clientInformation);
|
||||
}
|
||||
await addSafely(
|
||||
undefined,
|
||||
snapshot.legacyTokens,
|
||||
snapshot.legacyClientInformation,
|
||||
);
|
||||
return { grants, failures };
|
||||
}
|
||||
|
||||
/** Parse stored tokens, or `undefined` when the slot held none. */
|
||||
async function parseTokens(raw: unknown): Promise<OAuthTokens | undefined> {
|
||||
return raw === undefined || raw === null
|
||||
? undefined
|
||||
: await OAuthTokensSchema.parseAsync(raw);
|
||||
}
|
||||
|
||||
/** Parse stored client information, or `undefined` when the slot held none. */
|
||||
async function parseClient(
|
||||
raw: unknown,
|
||||
): Promise<OAuthClientInformation | undefined> {
|
||||
return raw === undefined || raw === null
|
||||
? undefined
|
||||
: await OAuthClientInformationSchema.parseAsync(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine per-grant outcomes into the one this function reports.
|
||||
*
|
||||
* A **failure outranks a success**: a grant still live at the authorization
|
||||
* server is what the caller needs to surface, and reporting another grant's
|
||||
* success would leave that silent. A success outranks a skip for the same
|
||||
* reason in the other direction — "revoked" is the more specific truth.
|
||||
*
|
||||
* Exported for its own test. `computeOutcome` returns early when there are no
|
||||
* grants, so the empty case cannot arise from there — which is exactly why the
|
||||
* fallback needs testing somewhere: nothing else would ever exercise it, and a
|
||||
* function that returns `undefined` while typed otherwise is a trap for the
|
||||
* next caller.
|
||||
*/
|
||||
export function aggregateOutcomes(
|
||||
outcomes: TokenRevocationOutcome[],
|
||||
): TokenRevocationOutcome {
|
||||
return (
|
||||
outcomes.find((o) => o.status === "failed") ??
|
||||
outcomes.find((o) => o.status === "revoked") ??
|
||||
outcomes[0] ?? { status: "skipped", reason: "no_tokens" }
|
||||
);
|
||||
}
|
||||
|
||||
/** Send one plan's requests against a single shared deadline. */
|
||||
async function runPlan(
|
||||
plan: OAuthRevocationPlan,
|
||||
params: ExecuteOAuthRevocationParams,
|
||||
): Promise<TokenRevocationOutcome> {
|
||||
if (plan.outcome) return plan.outcome;
|
||||
const endpoint = plan.endpoint;
|
||||
/* v8 ignore next -- `clearAndPlanRevocation` always sets `outcome` when it sets
|
||||
no endpoint, so this is unreachable; the guard exists to narrow the type. */
|
||||
if (!endpoint) return { status: "skipped", reason: "no_endpoint" };
|
||||
|
||||
const outcomes: TokenRevocationOutcome[] = [...plan.failures];
|
||||
// ONE deadline for the whole teardown, not one per grant. `clear` deletes
|
||||
// every issuer slot, so a server with N of them would otherwise block for
|
||||
// N × the timeout — at which point the "short timeout" bounds a single
|
||||
// request and nothing the user experiences. Grants are revoked sequentially
|
||||
// on purpose (a burst of parallel requests to one authorization server is
|
||||
// not a kindness), so the budget is shared instead.
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
|
||||
for (const grant of plan.grants) {
|
||||
// Metadata is cached once per server, not per issuer, so it describes
|
||||
// whichever authorization server was discovered last. Sending another
|
||||
// issuer's token to *this* endpoint would disclose a bearer credential to a
|
||||
// server that never minted it — worse than not revoking. So an issuer-bound
|
||||
// grant must be able to PROVE the endpoint is its own, which means a
|
||||
// metadata document carrying no `issuer` is a mismatch rather than a free
|
||||
// pass: absence establishes nothing. The same reasoning rules out the
|
||||
// unkeyed legacy grant just below — see there.
|
||||
if (grant.issuer === undefined) {
|
||||
// An unkeyed (pre-SEP-2352) grant records no authorization server at all,
|
||||
// and "unknown" is not "whatever the cache currently holds": server-level
|
||||
// metadata is a single slot that a later discovery overwrites, while the
|
||||
// legacy token survives until the first issuer-stamped save. So the
|
||||
// endpoint on hand may belong to an authorization server that never
|
||||
// minted this token, and sending it there would disclose a bearer
|
||||
// credential to a stranger.
|
||||
//
|
||||
// Refusing costs such an entry its revocation — which is exactly the
|
||||
// behavior it had before this feature existed, so nothing regresses — and
|
||||
// it earns revocation back the moment it is re-authorized, since that
|
||||
// save is issuer-stamped. Not sending is the only answer that cannot be
|
||||
// wrong.
|
||||
outcomes.push({
|
||||
status: "failed",
|
||||
detail:
|
||||
"the stored grant is not bound to an authorization server (it predates issuer binding), so it could not be matched to this revocation endpoint and was cleared without revocation — re-authorize to make future clears revocable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (plan.metadataIssuer !== grant.issuer) {
|
||||
outcomes.push({
|
||||
status: "failed",
|
||||
detail:
|
||||
plan.metadataIssuer === undefined
|
||||
? `the cached authorization-server metadata names no issuer, so the grant bound to ${grant.issuer} could not be matched to this revocation endpoint and was cleared without revocation`
|
||||
: `the cached authorization-server metadata is for ${plan.metadataIssuer}, so the grant bound to ${grant.issuer} was cleared without revocation`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
outcomes.push({
|
||||
status: "failed",
|
||||
endpoint,
|
||||
detail: `the ${timeoutMs}ms revocation budget was exhausted before this grant was attempted${
|
||||
grant.issuer === undefined ? "" : ` (issuer ${grant.issuer})`
|
||||
}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
outcomes.push(
|
||||
await revokeToken({
|
||||
endpoint,
|
||||
token: grant.token,
|
||||
tokenTypeHint: grant.tokenTypeHint,
|
||||
clientInformation: grant.clientInformation,
|
||||
supportedAuthMethods: plan.supportedAuthMethods,
|
||||
fetchFn: params.fetchFn,
|
||||
timeoutMs: remainingMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return aggregateOutcomes(outcomes);
|
||||
}
|
||||
|
|
@ -32,6 +32,26 @@ export interface SaveClientInformationOptions {
|
|||
issuer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's revocation-relevant state, taken and deleted in one step by
|
||||
* {@link OAuthStorage.takeRevocationSnapshot}. Values are as stored — unparsed
|
||||
* — so validation can happen after the mutation rather than inside it.
|
||||
*/
|
||||
export interface RevocationSnapshot {
|
||||
/** Credentials per authorization-server issuer (SEP-2352). */
|
||||
byIssuer: Record<
|
||||
string,
|
||||
{ tokens?: unknown; clientInformation?: unknown } | undefined
|
||||
>;
|
||||
/** The legacy unkeyed slot, for entries predating issuer binding. */
|
||||
legacyTokens?: unknown;
|
||||
legacyClientInformation?: unknown;
|
||||
/** The static/preconfigured registration, if any. */
|
||||
preregisteredClientInformation?: unknown;
|
||||
/** Cached authorization-server metadata, if discovery ever ran. */
|
||||
serverMetadata?: unknown;
|
||||
}
|
||||
|
||||
export interface OAuthStorage {
|
||||
/**
|
||||
* Optional preload of persisted state into memory. Getters and setters load
|
||||
|
|
@ -182,6 +202,27 @@ export interface OAuthStorage {
|
|||
*/
|
||||
clearDiscoveryState(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Atomically read everything RFC 7009 revocation needs, and clear the
|
||||
* server's state in the same step (#2144).
|
||||
*
|
||||
* Split reads followed by a separate `clear` are a check-then-act: each
|
||||
* `await` yields, and an OAuth completion landing in one of those gaps saves
|
||||
* a fresh grant that the clear then deletes. Doing both against one
|
||||
* synchronous view of the in-memory state closes that window, and the network
|
||||
* requests are sent afterwards from the returned snapshot.
|
||||
*
|
||||
* The values are returned **unparsed**, exactly as stored. Schema validation
|
||||
* is pure and belongs after the mutation — running it in between would
|
||||
* reintroduce the `await` this exists to remove.
|
||||
*
|
||||
* ⚠️ In-process only. Nothing in this store takes a cross-process lock —
|
||||
* every mutation here is a read-modify-write over a loaded snapshot — so a
|
||||
* second Inspector writing concurrently can still lose an update. That is a
|
||||
* property of the store, not of this method; `clear` alone has always had it.
|
||||
*/
|
||||
takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot>;
|
||||
|
||||
/**
|
||||
* Clear all OAuth data for a server
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ import {
|
|||
} from "../auth/challenge.js";
|
||||
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
|
||||
import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js";
|
||||
import type { TokenRevocationOutcome } from "../auth/revocation.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||
import { createFetchTracker } from "./fetchTracking.js";
|
||||
|
|
@ -649,9 +650,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
>();
|
||||
private rawWireRequestCounter = 0;
|
||||
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
|
||||
// it makes the SDK send a `notifications/cancelled` for that request (the MCP
|
||||
// cancellation flow) and reject the pending call, which `callTool` surfaces as
|
||||
// a `ToolCallCancelledError`. Undefined when no ordinary call is in flight.
|
||||
// it hands the SDK the MCP cancellation flow for that request and rejects the
|
||||
// pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which
|
||||
// signal reaches the server is the transport's business, not ours: a
|
||||
// per-request-stream transport on a 2026-era connection has that request's
|
||||
// stream aborted, and everything else gets `notifications/cancelled` (#2140).
|
||||
// Undefined when no ordinary call is in flight.
|
||||
// Task-augmented calls have a server-side task and are cancelled via
|
||||
// `cancelRequestorTask` instead, so they don't use this (#1458).
|
||||
private activeToolCallAbortController?: AbortController;
|
||||
|
|
@ -824,6 +828,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return Promise.resolve();
|
||||
},
|
||||
initialConfig: oauthConfig,
|
||||
logger: this.logger,
|
||||
enterpriseManagedAuth: options.enterpriseManagedAuth,
|
||||
installEnterpriseManagedAuth: options.installEnterpriseManagedAuth,
|
||||
dispatchOAuthComplete: (detail) =>
|
||||
|
|
@ -1180,8 +1185,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
if (this.requestTimeout !== undefined) {
|
||||
opts.timeout = this.requestTimeout;
|
||||
}
|
||||
// When provided, aborting this signal makes the SDK send a
|
||||
// `notifications/cancelled` for the request and reject it (#1458).
|
||||
// When provided, aborting this signal cancels the request and rejects it
|
||||
// (#1458). The SDK picks the wire signal from the transport: an aborted
|
||||
// per-request SSE stream on a 2026-era Streamable HTTP connection, and
|
||||
// `notifications/cancelled` everywhere else (#2140).
|
||||
if (signal) {
|
||||
opts.signal = signal;
|
||||
}
|
||||
|
|
@ -2987,10 +2994,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
|
||||
/**
|
||||
* Cancel the in-flight ordinary (non-task) tool call started by
|
||||
* {@link callTool}. Aborting its request makes the SDK send a
|
||||
* `notifications/cancelled` to the server (the MCP cancellation flow) and
|
||||
* reject the pending call, which `callTool` surfaces as a
|
||||
* {@link ToolCallCancelledError}.
|
||||
* {@link callTool}. Aborting its request runs the MCP cancellation flow and
|
||||
* rejects the pending call, which `callTool` surfaces as a
|
||||
* {@link ToolCallCancelledError}. The SDK chooses the wire signal from the
|
||||
* transport: on a 2026-era Streamable HTTP connection it aborts that
|
||||
* request's own SSE response stream — the spec's cancellation signal —
|
||||
* rather than sending `notifications/cancelled`, which is the stdio
|
||||
* mechanism and remains in use there (#2140).
|
||||
*
|
||||
* Task-augmented calls have a server-side task and are cancelled via
|
||||
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
|
||||
|
|
@ -6731,10 +6741,27 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clears OAuth tokens and client information
|
||||
* Drop this server's stored OAuth state and revoke the grant at the
|
||||
* authorization server (RFC 7009, #2144).
|
||||
*
|
||||
* The request is planned from the stored state, the state is cleared, and
|
||||
* only then is the request sent — so the clear never waits on the network.
|
||||
*
|
||||
* The revocation is best-effort — an authorization server that advertises no
|
||||
* `revocation_endpoint` is left behaving exactly as before, and a network
|
||||
* error, a non-2xx or a timeout is reported in the returned outcome rather
|
||||
* than thrown — so the local clear always completes. Pass
|
||||
* `{ revoke: false }` to skip it.
|
||||
*/
|
||||
async clearOAuthTokens(): Promise<void> {
|
||||
await this.oauthManager?.clearOAuthTokens();
|
||||
async clearOAuthTokens(options?: {
|
||||
revoke?: boolean;
|
||||
}): Promise<TokenRevocationOutcome> {
|
||||
return (
|
||||
(await this.oauthManager?.clearOAuthTokens(options)) ?? {
|
||||
status: "skipped",
|
||||
reason: "no_tokens",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -203,6 +203,30 @@ export class MessageTrackingTransport implements Transport {
|
|||
return this.baseTransport.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the base transport's per-request-stream capability (#2140).
|
||||
*
|
||||
* The SDK's `Protocol.request` reads this off the transport it was handed —
|
||||
* which is always this wrapper — to decide how a cancelled request is
|
||||
* signalled: abort that request's own stream (the 2026-07-28 signal for
|
||||
* Streamable HTTP) or POST a `notifications/cancelled` (the stdio one).
|
||||
*
|
||||
* Not forwarding it made the wrapper *answer* the question, `undefined`, for
|
||||
* every client. So the Cancel button POSTed a notification even on a modern
|
||||
* Streamable HTTP connection, where a spec-compliant server acknowledges it
|
||||
* `202` and drops it — the tool kept running and only a disconnect really
|
||||
* cancelled anything. That hit the CLI and TUI as much as the web client:
|
||||
* their real `StreamableHTTPClientTransport` advertises the flag correctly
|
||||
* and this wrapper hid it.
|
||||
*
|
||||
* A base transport that says nothing stays `undefined` rather than becoming
|
||||
* `false`, since the SDK's check is `=== true` either way and inventing a
|
||||
* value here would misreport what the transport claimed.
|
||||
*/
|
||||
get hasPerRequestStream(): boolean | undefined {
|
||||
return this.baseTransport.hasPerRequestStream;
|
||||
}
|
||||
|
||||
// Implemented as a concrete method (rather than delegating the base
|
||||
// transport's optional `setProtocolVersion`) so the SDK Client always
|
||||
// invokes it after the initialize handshake — including for stdio, whose
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import type { OAuthClientInformation } from "@modelcontextprotocol/client";
|
|||
import { mcpAuth } from "../auth/mcpAuth.js";
|
||||
import type { OAuthStorage } from "../auth/storage.js";
|
||||
import { parseOAuthState } from "../auth/utils.js";
|
||||
import {
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
type TokenRevocationOutcome,
|
||||
} from "../auth/revocation.js";
|
||||
import type { InspectorLogger } from "../logging/index.js";
|
||||
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
|
||||
import type { ClientConfig } from "../client/types.js";
|
||||
import { EmaClientNotConfiguredError } from "../auth/ema/clientConfigError.js";
|
||||
|
|
@ -71,6 +77,8 @@ export interface OAuthManagerParams {
|
|||
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
|
||||
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
|
||||
dispatchOAuthError: (detail: { error: Error }) => void;
|
||||
/** Used for the best-effort RFC 7009 revocation warning (#2144). */
|
||||
logger?: InspectorLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -488,16 +496,48 @@ export class OAuthManager {
|
|||
}
|
||||
}
|
||||
|
||||
async clearOAuthTokens(): Promise<void> {
|
||||
/**
|
||||
* Drop this server's local OAuth state, and revoke the grant at the
|
||||
* authorization server (RFC 7009).
|
||||
*
|
||||
* The state is taken and deleted in one atomic step, and the requests are
|
||||
* sent afterwards from what was taken. Both halves matter: the requests need
|
||||
* state the clear destroys, and the clear must not wait on the network, or a
|
||||
* fresh authorization completing during a five-second revocation would be
|
||||
* deleted by a clear reasoning about the grant it replaced.
|
||||
*
|
||||
* Best-effort by construction: every failure is reported through the returned
|
||||
* outcome and the clear has already happened regardless, because forgetting
|
||||
* the tokens is what the caller actually asked for (#2144).
|
||||
*
|
||||
* `options.revoke === false` skips the request. That is not only an escape
|
||||
* hatch for an authorization server that mishandles it — disconnecting while
|
||||
* still holding live tokens is a case a user may want to reproduce
|
||||
* deliberately, to watch how a server under test copes with it.
|
||||
*/
|
||||
async clearOAuthTokens(options?: {
|
||||
revoke?: boolean;
|
||||
}): Promise<TokenRevocationOutcome> {
|
||||
if (!this.oauthConfig?.storage) {
|
||||
return;
|
||||
return { status: "skipped", reason: "no_tokens" };
|
||||
}
|
||||
|
||||
const serverUrl = this.getServerUrl();
|
||||
await this.oauthConfig.storage.clear(serverUrl);
|
||||
// Takes the state and deletes it in ONE atomic storage step; separate
|
||||
// reads followed by a separate clear are a check-then-act, and an OAuth
|
||||
// completion landing between them would be destroyed by the clear.
|
||||
const plan = await clearAndPlanRevocation({
|
||||
serverUrl,
|
||||
storage: this.oauthConfig.storage,
|
||||
enabled: options?.revoke,
|
||||
});
|
||||
|
||||
this.oauthFlowState = null;
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
return executeOAuthRevocation(plan, {
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
logger: this.params.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async isOAuthAuthorized(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,36 @@ export function createRemoteApp(
|
|||
// Auth errors may reject the wait via onerror while send() also throws.
|
||||
void responseWait.catch(() => {});
|
||||
}
|
||||
|
||||
// #2140: this route is held open for the whole call, so the browser
|
||||
// aborting its `POST /api/mcp/send` mid-flight *is* the client's
|
||||
// cancellation signal on a per-request-stream upstream. Forward it as
|
||||
// `requestSignal` so the upstream StreamableHTTP transport closes that
|
||||
// request's SSE response stream, which is what the 2026-07-28 spec makes
|
||||
// the cancellation signal. A transport with no per-request stream (stdio,
|
||||
// SSE) ignores the option, and a *cancel* never reaches it as an abort
|
||||
// anyway: `RemoteClientTransport` withholds `hasPerRequestStream` there, so
|
||||
// the SDK keeps sending `notifications/cancelled`. Forwarding the
|
||||
// disconnect unconditionally is still right — a browser that went away
|
||||
// mid-call is not waiting for the answer whatever the transport is.
|
||||
const upstreamAbort = new AbortController();
|
||||
const clientSignal = c.req.raw.signal;
|
||||
const onClientDisconnect = () => {
|
||||
upstreamAbort.abort(clientSignal.reason);
|
||||
// The response will never arrive now; release the wait so this handler
|
||||
// unwinds instead of sitting on a promise nothing can settle.
|
||||
if (requestId !== undefined) {
|
||||
session.cancelRequestWait(requestId);
|
||||
}
|
||||
};
|
||||
if (clientSignal.aborted) {
|
||||
onClientDisconnect();
|
||||
} else {
|
||||
clientSignal.addEventListener("abort", onClientDisconnect, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// SEP-2243: apply the client's mirrored headers to the upstream request,
|
||||
// restricted to the `Mcp-Param-` prefix so a client can't inject other
|
||||
|
|
@ -1031,6 +1061,7 @@ export function createRemoteApp(
|
|||
await session.transport.send(message, {
|
||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||
...(paramHeaders && { headers: paramHeaders }),
|
||||
requestSignal: upstreamAbort.signal,
|
||||
});
|
||||
if (responseWait) {
|
||||
await responseWait;
|
||||
|
|
@ -1051,6 +1082,7 @@ export function createRemoteApp(
|
|||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ ok: false, kind: "transport_error", error: msg });
|
||||
} finally {
|
||||
clientSignal.removeEventListener("abort", onClientDisconnect);
|
||||
session.endSend();
|
||||
}
|
||||
});
|
||||
|
|
@ -1392,6 +1424,7 @@ export function createRemoteApp(
|
|||
tokenUrl?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
requestRefreshToken?: boolean;
|
||||
revokeOnClear?: boolean;
|
||||
} => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
|
|
@ -1427,6 +1460,11 @@ export function createRemoteApp(
|
|||
) {
|
||||
return false;
|
||||
}
|
||||
// #2144 — shape only, same as the flag above: the read side keeps just an
|
||||
// explicit `false`, so a stray `true` reads back as the default anyway.
|
||||
if (o.revokeOnClear !== undefined && typeof o.revokeOnClear !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// A roots array: each entry must have a string `uri` and, when present, a
|
||||
|
|
@ -1540,7 +1578,7 @@ export function createRemoteApp(
|
|||
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "oauth" },
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`.",
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken?, revokeOnClear? }`.",
|
||||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
|
|
@ -1852,6 +1890,15 @@ export function createRemoteApp(
|
|||
error: "settings.oauthRequestRefreshToken must be a boolean",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.oauthRevokeOnClear !== undefined &&
|
||||
typeof obj.oauthRevokeOnClear !== "boolean"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.oauthRevokeOnClear must be a boolean",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.oauthOnInsufficientScope !== undefined &&
|
||||
obj.oauthOnInsufficientScope !== "reauthorize" &&
|
||||
|
|
@ -1984,6 +2031,10 @@ export function createRemoteApp(
|
|||
if (obj.oauthRequestRefreshToken === false) {
|
||||
value.oauthRequestRefreshToken = false;
|
||||
}
|
||||
// #2144: same shape — the default is on, so only the opt-out travels.
|
||||
if (obj.oauthRevokeOnClear === false) {
|
||||
value.oauthRevokeOnClear = false;
|
||||
}
|
||||
if (
|
||||
obj.oauthOnInsufficientScope === "reauthorize" ||
|
||||
obj.oauthOnInsufficientScope === "throw"
|
||||
|
|
|
|||
|
|
@ -261,6 +261,25 @@ export class RemoteClientTransport implements Transport {
|
|||
private readonly options: RemoteTransportOptions;
|
||||
private readonly config: import("../types.js").MCPServerConfig;
|
||||
|
||||
/**
|
||||
* Whether the upstream connection gives each request its own response stream
|
||||
* (#2140). The SDK's `Protocol.request` reads this: on a 2026-era connection
|
||||
* a transport advertising it has its per-request stream **aborted** as the
|
||||
* spec's cancellation signal, and no `notifications/cancelled` is sent; a
|
||||
* transport that does not gets the stdio mechanism instead.
|
||||
*
|
||||
* The real upstream transport lives on the backend, so this transport has to
|
||||
* answer for it. Streamable HTTP opens one POST — and one SSE response
|
||||
* stream — per request, so it qualifies; stdio and SSE multiplex every
|
||||
* request over one shared channel and must keep sending the notification.
|
||||
*
|
||||
* Advertising it is only half the fix: `requestSend` applies the SDK's
|
||||
* `requestSignal` to the browser-to-backend fetch, and `/api/mcp/send`
|
||||
* forwards that disconnect to the upstream `transport.send`, which is what
|
||||
* actually closes the stream the server is watching.
|
||||
*/
|
||||
readonly hasPerRequestStream: boolean;
|
||||
|
||||
/**
|
||||
* Intentionally returns undefined. The MCP Client checks transport.sessionId to detect
|
||||
* reconnects and skip initialize. Our _sessionId is the remote server's session ID, not
|
||||
|
|
@ -312,6 +331,7 @@ export class RemoteClientTransport implements Transport {
|
|||
) {
|
||||
this.options = options;
|
||||
this.config = config;
|
||||
this.hasPerRequestStream = config.type === "streamable-http";
|
||||
}
|
||||
|
||||
setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void {
|
||||
|
|
@ -757,10 +777,17 @@ export class RemoteClientTransport implements Transport {
|
|||
}),
|
||||
};
|
||||
|
||||
// #2140: aborting this fetch is the cancellation signal for a
|
||||
// per-request-stream upstream. The backend holds `/api/mcp/send` open for
|
||||
// the whole call (it awaits the JSON-RPC response), so the disconnect
|
||||
// reaches it mid-flight and it aborts the upstream request's SSE stream.
|
||||
// For stdio/SSE `hasPerRequestStream` is false, so the SDK never supplies
|
||||
// a `requestSignal` and this is undefined.
|
||||
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
...(options?.requestSignal && { signal: options.requestSignal }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -617,6 +617,11 @@ export function storedFieldsToInspectorSettings(
|
|||
if (stored.oauth?.requestRefreshToken === false) {
|
||||
settings.oauthRequestRefreshToken = false;
|
||||
}
|
||||
// Same inverted default as the flag above: revocation is on unless the entry
|
||||
// explicitly opted out. (#2144)
|
||||
if (stored.oauth?.revokeOnClear === false) {
|
||||
settings.oauthRevokeOnClear = false;
|
||||
}
|
||||
// Mirror the stdio working directory for the form. Like the OAuth fields, an
|
||||
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
|
||||
if (stored.cwd) settings.cwd = stored.cwd;
|
||||
|
|
@ -750,6 +755,10 @@ export function inspectorSettingsToStoredFields(
|
|||
if (settings.oauthRequestRefreshToken === false) {
|
||||
oauthFields.requestRefreshToken = false;
|
||||
}
|
||||
// Same omit-the-default rule as the flag above. (#2144)
|
||||
if (settings.oauthRevokeOnClear === false) {
|
||||
oauthFields.revokeOnClear = false;
|
||||
}
|
||||
if (Object.keys(oauthFields).length > 0) {
|
||||
out.oauth = oauthFields;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,14 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
* Inspector-specific. (#2068)
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
/**
|
||||
* Whether clearing this server's OAuth state also revokes the grant at the
|
||||
* authorization server (RFC 7009). Defaults to `true`; only `false` is
|
||||
* written to disk, so an entry that never touched the setting keeps a
|
||||
* minimal diff. See {@link InspectorServerSettings.oauthRevokeOnClear}.
|
||||
* Inspector-specific. (#2144)
|
||||
*/
|
||||
revokeOnClear?: boolean;
|
||||
/**
|
||||
* Custom query parameters appended to the OAuth **authorization request**
|
||||
* (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's
|
||||
|
|
@ -613,6 +621,12 @@ export interface OAuthSettings {
|
|||
* reason as `authorizationParams`; `undefined` means the default, on.
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
/**
|
||||
* Whether clearing the stored OAuth state revokes the grant at the
|
||||
* authorization server (#2144). Optional for the same reason as
|
||||
* `authorizationParams`; `undefined` means the default, on.
|
||||
*/
|
||||
revokeOnClear?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -818,6 +832,28 @@ export interface InspectorServerSettings {
|
|||
* the stored OAuth state is cleared.
|
||||
*/
|
||||
oauthRequestRefreshToken?: boolean;
|
||||
/**
|
||||
* Whether clearing this server's stored OAuth state also revokes the grant
|
||||
* at the authorization server, per RFC 7009 (#2144). `undefined` (the
|
||||
* default) means on; persisted as `oauth.revokeOnClear` only when explicitly
|
||||
* off.
|
||||
*
|
||||
* The request is built from the stored state *before* the clear and sent
|
||||
* *after* it, so the local delete never waits on the network — see
|
||||
* `core/auth/revocation.ts`.
|
||||
*
|
||||
* On is the right default because the alternative is silent: the Inspector
|
||||
* deletes its local copy and the access token — and the refresh token, which
|
||||
* is long-lived by design — stay valid at the authorization server until they
|
||||
* expire on their own, leaving it holding grants for sessions that ended
|
||||
* hours ago.
|
||||
*
|
||||
* Turning it off is a testing affordance rather than only an escape hatch: a
|
||||
* client that walks away still holding live tokens is a case a server author
|
||||
* may want to reproduce on purpose. It is read at clear time, not at connect
|
||||
* time, so toggling it takes effect on the next clear without reconnecting.
|
||||
*/
|
||||
oauthRevokeOnClear?: boolean;
|
||||
/**
|
||||
* When true, connect via the configured enterprise IdP (EMA) instead of
|
||||
* interactive OAuth to the MCP authorization server. Per-server OAuth
|
||||
|
|
|
|||
Loading…
Reference in a new issue