Skip to content

Commit 75b354b

Browse files
Route transport requests through a single internal request door
Both client HTTP transports now build one module-internal object that owns the endpoint URL, fetch, requestInit, oauthRequestInit, and redirect policy, and issue every data-plane request and OAuth fetch through it instead of holding those as class fields. Also folds the four duplicated token/registration redirect guards into one assertNotRedirected helper with unchanged messages.
1 parent 359728a commit 75b354b

6 files changed

Lines changed: 144 additions & 116 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,18 @@ export function prepareAuthorizationCodeRequest(
20462046
});
20472047
}
20482048

2049+
/**
2050+
* Token and registration responses are terminal (RFC 6749 §5, RFC 7591 §3.2):
2051+
* a redirect — including one the runtime filters to `opaqueredirect` — is an error.
2052+
*/
2053+
export function assertNotRedirected(response: Response, endpoint: 'Token' | 'Registration'): void {
2054+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
2055+
throw new Error(
2056+
`${endpoint} endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); ${endpoint.toLowerCase()} responses are terminal`
2057+
);
2058+
}
2059+
}
2060+
20492061
/**
20502062
* Internal helper to execute a token request with the given parameters.
20512063
* Used by {@linkcode exchangeAuthorization}, {@linkcode refreshAuthorization}, and {@linkcode fetchToken}.
@@ -2091,15 +2103,9 @@ export async function executeTokenRequest(
20912103
method: 'POST',
20922104
headers,
20932105
body: tokenRequestParams,
2094-
// Token responses are terminal (RFC 6749 §5): a redirect is not followed.
20952106
redirect: 'manual'
20962107
});
2097-
2098-
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
2099-
throw new Error(
2100-
`Token endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); token responses are terminal`
2101-
);
2102-
}
2108+
assertNotRedirected(response, 'Token');
21032109

21042110
if (!response.ok) {
21052111
throw await parseErrorResponse(response);
@@ -2374,15 +2380,9 @@ export async function registerClient(
23742380
'Content-Type': 'application/json'
23752381
},
23762382
body: JSON.stringify(submittedMetadata),
2377-
// Registration responses are terminal (RFC 7591 §3.2): a redirect is not followed.
23782383
redirect: 'manual'
23792384
});
2380-
2381-
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
2382-
throw new Error(
2383-
`Registration endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); registration responses are terminal`
2384-
);
2385-
}
2385+
assertNotRedirected(response, 'Registration');
23862386

23872387
if (!response.ok) {
23882388
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });

packages/client/src/client/crossAppAccess.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal';
1212
import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal';
1313

1414
import type { ClientAuthMethod } from './auth';
15-
import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth';
15+
import { applyClientAuthentication, assertNotRedirected, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth';
1616

1717
/**
1818
* Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange.
@@ -155,12 +155,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
155155
body: params.toString(),
156156
redirect: 'manual'
157157
});
158-
159-
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
160-
throw new Error(
161-
`Token endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); token responses are terminal`
162-
);
163-
}
158+
assertNotRedirected(response, 'Token');
164159

165160
if (!response.ok) {
166161
const errorBody = await response.json().catch(() => ({}));
@@ -289,12 +284,7 @@ export async function exchangeJwtAuthGrant(options: {
289284
body: params.toString(),
290285
redirect: 'manual'
291286
});
292-
293-
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
294-
throw new Error(
295-
`Token endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); token responses are terminal`
296-
);
297-
}
287+
assertNotRedirected(response, 'Token');
298288

299289
if (!response.ok) {
300290
const errorBody = await response.json().catch(() => ({}));

packages/client/src/client/sse.ts

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
22
import {
33
brandedHasInstance,
4-
createFetchWithInit,
54
JSONRPCMessageSchema,
6-
normalizeHeaders,
75
SdkError,
86
SdkErrorCode,
97
SdkHttpError,
@@ -23,8 +21,8 @@ import {
2321
} from './auth';
2422
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
2523
import type { IssuerMismatchError } from './authErrors';
26-
import type { RedirectPolicy } from './transportRedirect';
27-
import { fetchWithRedirectPolicy } from './transportRedirect';
24+
import type { RedirectPolicy, TransportHttp } from './transportRedirect';
25+
import { createTransportHttp } from './transportRedirect';
2826

2927
export class SseError extends Error {
3028
static {
@@ -133,20 +131,15 @@ export type SSEClientTransportOptions = {
133131
*/
134132
export class SSEClientTransport implements Transport {
135133
private _eventSource?: EventSource;
136-
private _endpoint?: URL;
137134
private _abortController?: AbortController;
138135
private _url: URL;
139136
private _resourceMetadataUrl?: URL;
140137
private _scope?: string;
141138
private _eventSourceInit?: EventSourceInit;
142-
private _requestInit?: RequestInit;
143139
private _authProvider?: AuthProvider;
144140
private _oauthProvider?: OAuthClientProvider;
145141
private _skipIssuerMetadataValidation?: boolean;
146-
private _fetch?: FetchLike;
147-
/** Fetch for OAuth requests — deliberately merges `oauthRequestInit`, never `requestInit`. */
148-
private _authFetch: FetchLike;
149-
private _redirectPolicy: RedirectPolicy;
142+
private readonly _http: TransportHttp;
150143
private _protocolVersion?: string;
151144

152145
onclose?: () => void;
@@ -158,7 +151,6 @@ export class SSEClientTransport implements Transport {
158151
this._resourceMetadataUrl = undefined;
159152
this._scope = undefined;
160153
this._eventSourceInit = opts?.eventSourceInit;
161-
this._requestInit = opts?.requestInit;
162154
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
163155
if (isOAuthClientProvider(opts?.authProvider)) {
164156
this._oauthProvider = opts.authProvider;
@@ -168,9 +160,13 @@ export class SSEClientTransport implements Transport {
168160
} else {
169161
this._authProvider = opts?.authProvider;
170162
}
171-
this._fetch = opts?.fetch;
172-
this._redirectPolicy = opts?.redirectPolicy ?? 'manual';
173-
this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit);
163+
this._http = createTransportHttp({
164+
url,
165+
fetch: opts?.fetch,
166+
requestInit: opts?.requestInit,
167+
oauthRequestInit: opts?.oauthRequestInit,
168+
redirectPolicy: opts?.redirectPolicy
169+
});
174170
}
175171

176172
private _last401Response?: Response;
@@ -185,7 +181,7 @@ export class SSEClientTransport implements Transport {
185181
headers['mcp-protocol-version'] = this._protocolVersion;
186182
}
187183

188-
const extraHeaders = normalizeHeaders(this._requestInit?.headers);
184+
const extraHeaders = this._http.requestInitHeaders();
189185

190186
return new Headers({
191187
...headers,
@@ -194,7 +190,6 @@ export class SSEClientTransport implements Transport {
194190
}
195191

196192
private _startOrAuth(): Promise<void> {
197-
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch;
198193
return new Promise((resolve, reject) => {
199194
this._eventSource = new EventSource(this._url.href, {
200195
...this._eventSourceInit,
@@ -207,14 +202,12 @@ export class SSEClientTransport implements Transport {
207202
}
208203
let response: Response;
209204
try {
210-
response = await fetchWithRedirectPolicy({
211-
fetchFn: fetchImpl,
205+
response = await this._http.eventSourceGet({
206+
fetchOverride: this._eventSourceInit?.fetch as FetchLike | undefined,
212207
url,
213-
method: 'GET',
214208
headers,
215209
requestInit: init as RequestInit,
216210
signal: (init as RequestInit | undefined)?.signal ?? undefined,
217-
redirectPolicy: this._redirectPolicy,
218211
crossOriginHeaders
219212
});
220213
} catch (error) {
@@ -248,7 +241,7 @@ export class SSEClientTransport implements Transport {
248241
const response = this._last401Response;
249242
this._last401Response = undefined;
250243
this._eventSource?.close();
251-
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._authFetch }).then(
244+
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._http.oauthFetch }).then(
252245
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
253246
() => this._startOrAuth().then(resolve, reject),
254247
// onUnauthorized failed → not yet reported.
@@ -278,10 +271,7 @@ export class SSEClientTransport implements Transport {
278271
const messageEvent = event as MessageEvent;
279272

280273
try {
281-
this._endpoint = new URL(messageEvent.data, this._url);
282-
if (this._endpoint.origin !== this._url.origin) {
283-
throw new Error(`Endpoint origin does not match connection origin: ${this._endpoint.origin}`);
284-
}
274+
this._http.setMessageEndpoint(new URL(messageEvent.data, this._url));
285275
} catch (error) {
286276
reject(error);
287277
this.onerror?.(error as Error);
@@ -348,7 +338,7 @@ export class SSEClientTransport implements Transport {
348338
iss,
349339
this._oauthProvider,
350340
this._url,
351-
{ fetchFn: this._authFetch, resourceMetadataUrl: this._resourceMetadataUrl }
341+
{ fetchFn: this._http.oauthFetch, resourceMetadataUrl: this._resourceMetadataUrl }
352342
);
353343

354344
const result = await auth(this._oauthProvider, {
@@ -357,7 +347,7 @@ export class SSEClientTransport implements Transport {
357347
iss: issParam,
358348
resourceMetadataUrl: this._resourceMetadataUrl,
359349
scope: this._scope,
360-
fetchFn: this._authFetch,
350+
fetchFn: this._http.oauthFetch,
361351
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
362352
});
363353
if (result !== 'AUTHORIZED') {
@@ -376,23 +366,14 @@ export class SSEClientTransport implements Transport {
376366
}
377367

378368
private async _send(message: JSONRPCMessage, isAuthRetry: boolean): Promise<void> {
379-
if (!this._endpoint) {
369+
if (!this._http.messageEndpoint) {
380370
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
381371
}
382372

383373
try {
384374
const headers = await this._commonHeaders();
385375
headers.set('content-type', 'application/json');
386-
const response = await fetchWithRedirectPolicy({
387-
fetchFn: this._fetch ?? fetch,
388-
url: this._endpoint,
389-
method: 'POST',
390-
headers,
391-
requestInit: this._requestInit,
392-
body: JSON.stringify(message),
393-
signal: this._abortController?.signal,
394-
redirectPolicy: this._redirectPolicy
395-
});
376+
const response = await this._http.mcpRequest('POST', headers, JSON.stringify(message), this._abortController?.signal);
396377
if (!response.ok) {
397378
if (response.status === 401 && this._authProvider) {
398379
if (response.headers.has('www-authenticate')) {
@@ -405,7 +386,7 @@ export class SSEClientTransport implements Transport {
405386
await this._authProvider.onUnauthorized({
406387
response,
407388
serverUrl: this._url,
408-
fetchFn: this._authFetch
389+
fetchFn: this._http.oauthFetch
409390
});
410391
await response.text?.().catch(() => {});
411392
// Purposely _not_ awaited, so we don't call onerror twice

0 commit comments

Comments
 (0)