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
7 changed files with 8524 additions and 1598 deletions
|
|
@ -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