feat: introduce minimal AuthProvider interface with OAuthClientProvider adapter (#1710)
This commit is contained in:
parent
13a0d345c0
commit
e563e63bd2
3 changed files with 1102 additions and 293 deletions
|
|
@ -35,12 +35,110 @@ export type AddClientAuthentication = (
|
|||
metadata?: AuthorizationServerMetadata
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Context passed to {@linkcode AuthProvider.onUnauthorized} when the server
|
||||
* responds with 401. Provides everything needed to refresh credentials.
|
||||
*/
|
||||
export interface UnauthorizedContext {
|
||||
/** The 401 response — inspect `WWW-Authenticate` for resource metadata, scope, etc. */
|
||||
response: Response;
|
||||
/** The MCP server URL, for passing to {@linkcode auth} or discovery helpers. */
|
||||
serverUrl: URL;
|
||||
/** Fetch function configured with the transport's `requestInit`, for making auth requests. */
|
||||
fetchFn: FetchLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal interface for authenticating MCP client transports with bearer tokens.
|
||||
*
|
||||
* Transports call {@linkcode AuthProvider.token | token()} before every request
|
||||
* to obtain the current token, and {@linkcode AuthProvider.onUnauthorized | onUnauthorized()}
|
||||
* (if provided) when the server responds with 401, giving the provider a chance
|
||||
* to refresh credentials before the transport retries once.
|
||||
*
|
||||
* For simple cases (API keys, gateway-managed tokens), implement only `token()`:
|
||||
* ```typescript
|
||||
* const authProvider: AuthProvider = { token: async () => process.env.API_KEY };
|
||||
* ```
|
||||
*
|
||||
* For OAuth flows, pass an {@linkcode OAuthClientProvider} directly — transports
|
||||
* accept either shape and adapt OAuth providers automatically via {@linkcode adaptOAuthProvider}.
|
||||
*/
|
||||
export interface AuthProvider {
|
||||
/**
|
||||
* Returns the current bearer token, or `undefined` if no token is available.
|
||||
* Called before every request.
|
||||
*/
|
||||
token(): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Called when the server responds with 401. If provided, the transport will
|
||||
* await this, then retry the request once. If the retry also gets 401, or if
|
||||
* this method is not provided, the transport throws {@linkcode UnauthorizedError}.
|
||||
*
|
||||
* Implementations should refresh tokens, re-authenticate, etc. — whatever is
|
||||
* needed so the next `token()` call returns a valid token.
|
||||
*/
|
||||
onUnauthorized?(ctx: UnauthorizedContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard distinguishing `OAuthClientProvider` from a minimal `AuthProvider`.
|
||||
* Transports use this at construction time to classify the `authProvider` option.
|
||||
*
|
||||
* Checks for `tokens()` + `clientInformation()` — two required `OAuthClientProvider`
|
||||
* methods that a minimal `AuthProvider` `{ token: ... }` would never have.
|
||||
*/
|
||||
export function isOAuthClientProvider(provider: AuthProvider | OAuthClientProvider | undefined): provider is OAuthClientProvider {
|
||||
if (provider == null) return false;
|
||||
const p = provider as OAuthClientProvider;
|
||||
return typeof p.tokens === 'function' && typeof p.clientInformation === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard `onUnauthorized` behavior for OAuth providers: extracts
|
||||
* `WWW-Authenticate` parameters from the 401 response and runs {@linkcode auth}.
|
||||
* Used by {@linkcode adaptOAuthProvider} to bridge `OAuthClientProvider` to `AuthProvider`.
|
||||
*/
|
||||
export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx: UnauthorizedContext): Promise<void> {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response);
|
||||
const result = await auth(provider, {
|
||||
serverUrl: ctx.serverUrl,
|
||||
resourceMetadataUrl,
|
||||
scope,
|
||||
fetchFn: ctx.fetchFn
|
||||
});
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts an `OAuthClientProvider` to the minimal `AuthProvider` interface that
|
||||
* transports consume. Called once at transport construction — the transport stores
|
||||
* the adapted provider for `_commonHeaders()` and 401 handling, while keeping the
|
||||
* original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 upscoping).
|
||||
*/
|
||||
export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
|
||||
return {
|
||||
token: async () => {
|
||||
const tokens = await provider.tokens();
|
||||
return tokens?.access_token;
|
||||
},
|
||||
onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements an end-to-end OAuth client to be used with one MCP server.
|
||||
*
|
||||
* This client relies upon a concept of an authorized "session," the exact
|
||||
* meaning of which is application-defined. Tokens, authorization codes, and
|
||||
* code verifiers should not cross different sessions.
|
||||
*
|
||||
* Transports accept `OAuthClientProvider` directly via the `authProvider` option —
|
||||
* they adapt it to {@linkcode AuthProvider} internally via {@linkcode adaptOAuthProvider}.
|
||||
* No changes are needed to existing implementations.
|
||||
*/
|
||||
export interface OAuthClientProvider {
|
||||
/**
|
||||
|
|
@ -382,7 +480,7 @@ export function applyClientAuthentication(
|
|||
/**
|
||||
* Applies HTTP Basic authentication (RFC 6749 Section 2.3.1)
|
||||
*/
|
||||
function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void {
|
||||
export function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void {
|
||||
if (!clientSecret) {
|
||||
throw new Error('client_secret_basic authentication requires a client_secret');
|
||||
}
|
||||
|
|
@ -394,7 +492,7 @@ function applyBasicAuth(clientId: string, clientSecret: string | undefined, head
|
|||
/**
|
||||
* Applies POST body authentication (RFC 6749 Section 2.3.1)
|
||||
*/
|
||||
function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void {
|
||||
export function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void {
|
||||
params.set('client_id', clientId);
|
||||
if (clientSecret) {
|
||||
params.set('client_secret', clientSecret);
|
||||
|
|
@ -404,7 +502,7 @@ function applyPostAuth(clientId: string, clientSecret: string | undefined, param
|
|||
/**
|
||||
* Applies public client authentication (RFC 6749 Section 2.1)
|
||||
*/
|
||||
function applyPublicAuth(clientId: string, params: URLSearchParams): void {
|
||||
export function applyPublicAuth(clientId: string, params: URLSearchParams): void {
|
||||
params.set('client_id', clientId);
|
||||
}
|
||||
|
||||
|
|
@ -1304,7 +1402,7 @@ export function prepareAuthorizationCodeRequest(
|
|||
* Internal helper to execute a token request with the given parameters.
|
||||
* Used by {@linkcode exchangeAuthorization}, {@linkcode refreshAuthorization}, and {@linkcode fetchToken}.
|
||||
*/
|
||||
async function executeTokenRequest(
|
||||
export async function executeTokenRequest(
|
||||
authorizationServerUrl: string | URL,
|
||||
{
|
||||
metadata,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError,
|
|||
import type { ErrorEvent, EventSourceInit } from 'eventsource';
|
||||
import { EventSource } from 'eventsource';
|
||||
|
||||
import type { AuthResult, OAuthClientProvider } from './auth.js';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
|
||||
import type { AuthProvider, OAuthClientProvider } from './auth.js';
|
||||
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js';
|
||||
|
||||
export class SseError extends Error {
|
||||
constructor(
|
||||
|
|
@ -23,18 +23,19 @@ export type SSEClientTransportOptions = {
|
|||
/**
|
||||
* An OAuth client provider to use for authentication.
|
||||
*
|
||||
* When an `authProvider` is specified and the SSE connection is started:
|
||||
* 1. The connection is attempted with any existing access token from the `authProvider`.
|
||||
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
|
||||
* 3. If token refresh fails or no access token exists, and auth is required, {@linkcode OAuthClientProvider.redirectToAuthorization} is called, and an {@linkcode UnauthorizedError} will be thrown from {@linkcode index.Protocol.connect | connect}/{@linkcode SSEClientTransport.start | start}.
|
||||
* {@linkcode AuthProvider.token | token()} is called before every request to obtain the
|
||||
* bearer token. When the server responds with 401, {@linkcode AuthProvider.onUnauthorized | onUnauthorized()}
|
||||
* is called (if provided) to refresh credentials, then the request is retried once. If
|
||||
* the retry also gets 401, or `onUnauthorized` is not provided, {@linkcode UnauthorizedError}
|
||||
* is thrown.
|
||||
*
|
||||
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call {@linkcode SSEClientTransport.finishAuth} with the authorization code before retrying the connection.
|
||||
* For simple bearer tokens: `{ token: async () => myApiKey }`.
|
||||
*
|
||||
* If an `authProvider` is not provided, and auth is required, an {@linkcode UnauthorizedError} will be thrown.
|
||||
*
|
||||
* {@linkcode UnauthorizedError} might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected.
|
||||
* For OAuth flows, pass an {@linkcode index.OAuthClientProvider | OAuthClientProvider} implementation.
|
||||
* Interactive flows: after {@linkcode UnauthorizedError}, redirect the user, then call
|
||||
* {@linkcode SSEClientTransport.finishAuth | finishAuth} with the authorization code before reconnecting.
|
||||
*/
|
||||
authProvider?: OAuthClientProvider;
|
||||
authProvider?: AuthProvider | OAuthClientProvider;
|
||||
|
||||
/**
|
||||
* Customizes the initial SSE request to the server (the request that begins the stream).
|
||||
|
|
@ -71,7 +72,8 @@ export class SSEClientTransport implements Transport {
|
|||
private _scope?: string;
|
||||
private _eventSourceInit?: EventSourceInit;
|
||||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
private _authProvider?: AuthProvider;
|
||||
private _oauthProvider?: OAuthClientProvider;
|
||||
private _fetch?: FetchLike;
|
||||
private _fetchWithInit: FetchLike;
|
||||
private _protocolVersion?: string;
|
||||
|
|
@ -86,43 +88,23 @@ export class SSEClientTransport implements Transport {
|
|||
this._scope = undefined;
|
||||
this._eventSourceInit = opts?.eventSourceInit;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._authProvider = opts?.authProvider;
|
||||
if (isOAuthClientProvider(opts?.authProvider)) {
|
||||
this._oauthProvider = opts.authProvider;
|
||||
this._authProvider = adaptOAuthProvider(opts.authProvider);
|
||||
} else {
|
||||
this._authProvider = opts?.authProvider;
|
||||
}
|
||||
this._fetch = opts?.fetch;
|
||||
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
|
||||
}
|
||||
|
||||
private async _authThenStart(): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider');
|
||||
}
|
||||
|
||||
let result: AuthResult;
|
||||
try {
|
||||
result = await auth(this._authProvider, {
|
||||
serverUrl: this._url,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
scope: this._scope,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return await this._startOrAuth();
|
||||
}
|
||||
private _last401Response?: Response;
|
||||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: RequestInit['headers'] & Record<string, string> = {};
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens();
|
||||
if (tokens) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`;
|
||||
}
|
||||
const token = await this._authProvider?.token();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (this._protocolVersion) {
|
||||
headers['mcp-protocol-version'] = this._protocolVersion;
|
||||
|
|
@ -149,10 +131,13 @@ export class SSEClientTransport implements Transport {
|
|||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401 && response.headers.has('www-authenticate')) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
if (response.status === 401) {
|
||||
this._last401Response = response;
|
||||
if (response.headers.has('www-authenticate')) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
@ -162,7 +147,24 @@ export class SSEClientTransport implements Transport {
|
|||
|
||||
this._eventSource.onerror = event => {
|
||||
if (event.code === 401 && this._authProvider) {
|
||||
this._authThenStart().then(resolve, reject);
|
||||
if (this._authProvider.onUnauthorized && this._last401Response) {
|
||||
const response = this._last401Response;
|
||||
this._last401Response = undefined;
|
||||
this._eventSource?.close();
|
||||
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
|
||||
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
|
||||
() => this._startOrAuth().then(resolve, reject),
|
||||
// onUnauthorized failed → not yet reported.
|
||||
error => {
|
||||
this.onerror?.(error);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const error = new UnauthorizedError();
|
||||
reject(error);
|
||||
this.onerror?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -221,11 +223,11 @@ export class SSEClientTransport implements Transport {
|
|||
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
|
||||
*/
|
||||
async finishAuth(authorizationCode: string): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider');
|
||||
if (!this._oauthProvider) {
|
||||
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, {
|
||||
const result = await auth(this._oauthProvider, {
|
||||
serverUrl: this._url,
|
||||
authorizationCode,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
|
|
@ -244,6 +246,10 @@ export class SSEClientTransport implements Transport {
|
|||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
return this._send(message, false);
|
||||
}
|
||||
|
||||
private async _send(message: JSONRPCMessage, isAuthRetry: boolean): Promise<void> {
|
||||
if (!this._endpoint) {
|
||||
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
|
||||
}
|
||||
|
|
@ -261,27 +267,33 @@ export class SSEClientTransport implements Transport {
|
|||
|
||||
const response = await (this._fetch ?? fetch)(this._endpoint, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text?.().catch(() => null);
|
||||
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
|
||||
const result = await auth(this._authProvider, {
|
||||
serverUrl: this._url,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
scope: this._scope,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
if (response.headers.has('www-authenticate')) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
}
|
||||
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this.send(message);
|
||||
if (this._authProvider.onUnauthorized && !isAuthRetry) {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
await response.text?.().catch(() => {});
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this._send(message, true);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) {
|
||||
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const text = await response.text?.().catch(() => null);
|
||||
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
} from '@modelcontextprotocol/core';
|
||||
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
||||
|
||||
import type { AuthResult, OAuthClientProvider } from './auth.js';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
|
||||
import type { AuthProvider, OAuthClientProvider } from './auth.js';
|
||||
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js';
|
||||
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
|
||||
|
|
@ -85,18 +85,21 @@ export type StreamableHTTPClientTransportOptions = {
|
|||
/**
|
||||
* An OAuth client provider to use for authentication.
|
||||
*
|
||||
* When an `authProvider` is specified and the connection is started:
|
||||
* 1. The connection is attempted with any existing access token from the `authProvider`.
|
||||
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
|
||||
* 3. If token refresh fails or no access token exists, and auth is required, {@linkcode OAuthClientProvider.redirectToAuthorization} is called, and an {@linkcode UnauthorizedError} will be thrown from {@linkcode index.Protocol.connect | connect}/{@linkcode StreamableHTTPClientTransport.start | start}.
|
||||
* {@linkcode AuthProvider.token | token()} is called before every request to obtain the
|
||||
* bearer token. When the server responds with 401, {@linkcode AuthProvider.onUnauthorized | onUnauthorized()}
|
||||
* is called (if provided) to refresh credentials, then the request is retried once. If
|
||||
* the retry also gets 401, or `onUnauthorized` is not provided, {@linkcode UnauthorizedError}
|
||||
* is thrown.
|
||||
*
|
||||
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call {@linkcode StreamableHTTPClientTransport.finishAuth} with the authorization code before retrying the connection.
|
||||
* For simple bearer tokens: `{ token: async () => myApiKey }`.
|
||||
*
|
||||
* If an `authProvider` is not provided, and auth is required, an {@linkcode UnauthorizedError} will be thrown.
|
||||
*
|
||||
* {@linkcode UnauthorizedError} might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected.
|
||||
* For OAuth flows, pass an {@linkcode index.OAuthClientProvider | OAuthClientProvider} implementation
|
||||
* directly — the transport adapts it to `AuthProvider` internally. Interactive flows: after
|
||||
* {@linkcode UnauthorizedError}, redirect the user, then call
|
||||
* {@linkcode StreamableHTTPClientTransport.finishAuth | finishAuth} with the authorization code before
|
||||
* reconnecting.
|
||||
*/
|
||||
authProvider?: OAuthClientProvider;
|
||||
authProvider?: AuthProvider | OAuthClientProvider;
|
||||
|
||||
/**
|
||||
* Customizes HTTP requests to the server.
|
||||
|
|
@ -138,13 +141,13 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
private _resourceMetadataUrl?: URL;
|
||||
private _scope?: string;
|
||||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
private _authProvider?: AuthProvider;
|
||||
private _oauthProvider?: OAuthClientProvider;
|
||||
private _fetch?: FetchLike;
|
||||
private _fetchWithInit: FetchLike;
|
||||
private _sessionId?: string;
|
||||
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
|
||||
private _protocolVersion?: string;
|
||||
private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
|
||||
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
|
||||
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
|
||||
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
|
@ -158,7 +161,12 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
this._resourceMetadataUrl = undefined;
|
||||
this._scope = undefined;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._authProvider = opts?.authProvider;
|
||||
if (isOAuthClientProvider(opts?.authProvider)) {
|
||||
this._oauthProvider = opts.authProvider;
|
||||
this._authProvider = adaptOAuthProvider(opts.authProvider);
|
||||
} else {
|
||||
this._authProvider = opts?.authProvider;
|
||||
}
|
||||
this._fetch = opts?.fetch;
|
||||
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
|
||||
this._sessionId = opts?.sessionId;
|
||||
|
|
@ -166,38 +174,11 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
|
||||
}
|
||||
|
||||
private async _authThenStart(): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider');
|
||||
}
|
||||
|
||||
let result: AuthResult;
|
||||
try {
|
||||
result = await auth(this._authProvider, {
|
||||
serverUrl: this._url,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
scope: this._scope,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return await this._startOrAuthSse({ resumptionToken: undefined });
|
||||
}
|
||||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: RequestInit['headers'] & Record<string, string> = {};
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens();
|
||||
if (tokens) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`;
|
||||
}
|
||||
const token = await this._authProvider?.token();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (this._sessionId) {
|
||||
|
|
@ -215,7 +196,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
});
|
||||
}
|
||||
|
||||
private async _startOrAuthSse(options: StartSSEOptions): Promise<void> {
|
||||
private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise<void> {
|
||||
const { resumptionToken } = options;
|
||||
|
||||
try {
|
||||
|
|
@ -237,13 +218,34 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await response.text?.().catch(() => {});
|
||||
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Need to authenticate
|
||||
return await this._authThenStart();
|
||||
if (response.headers.has('www-authenticate')) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
}
|
||||
|
||||
if (this._authProvider.onUnauthorized && !isAuthRetry) {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
await response.text?.().catch(() => {});
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this._startOrAuthSse(options, true);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) {
|
||||
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
await response.text?.().catch(() => {});
|
||||
|
||||
// 405 indicates that the server does not offer an SSE stream at GET endpoint
|
||||
// This is an expected case that should not trigger an error
|
||||
if (response.status === 405) {
|
||||
|
|
@ -439,11 +441,11 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
|
||||
*/
|
||||
async finishAuth(authorizationCode: string): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider');
|
||||
if (!this._oauthProvider) {
|
||||
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, {
|
||||
const result = await auth(this._oauthProvider, {
|
||||
serverUrl: this._url,
|
||||
authorizationCode,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
|
|
@ -467,6 +469,14 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
async send(
|
||||
message: JSONRPCMessage | JSONRPCMessage[],
|
||||
options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void }
|
||||
): Promise<void> {
|
||||
return this._send(message, options, false);
|
||||
}
|
||||
|
||||
private async _send(
|
||||
message: JSONRPCMessage | JSONRPCMessage[],
|
||||
options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined,
|
||||
isAuthRetry: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { resumptionToken, onresumptiontoken } = options || {};
|
||||
|
|
@ -500,38 +510,36 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text?.().catch(() => null);
|
||||
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Prevent infinite recursion when server returns 401 after successful auth
|
||||
if (this._hasCompletedAuthFlow) {
|
||||
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after successful authentication', {
|
||||
status: 401,
|
||||
text
|
||||
// Store WWW-Authenticate params for interactive finishAuth() path
|
||||
if (response.headers.has('www-authenticate')) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
}
|
||||
|
||||
if (this._authProvider.onUnauthorized && !isAuthRetry) {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
await response.text?.().catch(() => {});
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this._send(message, options, true);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) {
|
||||
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
this._resourceMetadataUrl = resourceMetadataUrl;
|
||||
this._scope = scope;
|
||||
|
||||
const result = await auth(this._authProvider, {
|
||||
serverUrl: this._url,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
scope: this._scope,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// Mark that we completed auth flow
|
||||
this._hasCompletedAuthFlow = true;
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this.send(message);
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (response.status === 403 && this._authProvider) {
|
||||
const text = await response.text?.().catch(() => null);
|
||||
|
||||
if (response.status === 403 && this._oauthProvider) {
|
||||
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
|
||||
|
||||
if (error === 'insufficient_scope') {
|
||||
|
|
@ -555,18 +563,18 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
// Mark that upscoping was tried.
|
||||
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
|
||||
const result = await auth(this._authProvider, {
|
||||
const result = await auth(this._oauthProvider, {
|
||||
serverUrl: this._url,
|
||||
resourceMetadataUrl: this._resourceMetadataUrl,
|
||||
scope: this._scope,
|
||||
fetchFn: this._fetch
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return this.send(message);
|
||||
return this._send(message, options, isAuthRetry);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -576,8 +584,6 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
});
|
||||
}
|
||||
|
||||
// Reset auth loop flag on successful response
|
||||
this._hasCompletedAuthFlow = false;
|
||||
this._lastUpscopingHeader = undefined;
|
||||
|
||||
// If the response is 202 Accepted, there's no body to process
|
||||
|
|
|
|||
Loading…
Reference in a new issue