Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/brave-donkeys-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

`SdkError` and `SdkHttpError` accept standard `ErrorOptions` as an optional fourth constructor argument and forward it to `Error`, so a wrapped error is reachable through the standard `Error.cause` chain. Version-negotiation probe failures (`SdkErrorCode.EraNegotiationFailed`) now use it: the underlying `TypeError: fetch failed` and the DNS or socket error beneath it surface via `error.cause`, so pino, Sentry, and `util.inspect` render `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` instead of stopping at the `SdkError` (#2657). The previous `error.data.cause` slot is still populated for compatibility but is deprecated and slated for removal; read `error.cause` instead.
5 changes: 5 additions & 0 deletions .changeset/plenty-plums-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Preserve the exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathless `resource` such as `https://example.com` was normalized to `https://example.com/` via `URL.href`, which breaks authorization servers that require the `resource` parameter to match the published value exactly (Microsoft Entra ID rejects it with `AADSTS9010010`). The exported OAuth helpers (`startAuthorization`, `exchangeAuthorization`, `refreshAuthorization`, `fetchToken`, `executeTokenRequest`) now also accept a `string` for `resource`; `selectResourceURL` still returns a `URL`, and a provider's `validateResourceURL` result is used unchanged. Fixes #1968.
39 changes: 30 additions & 9 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,11 +1243,17 @@ async function authInternal(
await provider.saveDiscoveryState?.(freshDiscoveryState);
}

const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
// Send the metadata's resource indicator verbatim: `selectResourceURL` returns a parsed
// `URL`, and `URL.href` appends "/" to a pathless indicator such as `https://example.com`,
// which exact-match authorization servers reject (#1968). A URL returned by the
// provider's own `validateResourceURL` is used as returned.
const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata);
const resource: string | URL | undefined =
selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource;

// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
if (resource) {
await provider.saveResourceUrl?.(String(resource));
await provider.saveResourceUrl?.(resourceIndicatorToString(resource));
}

// Scope selection used consistently for DCR and the authorization request.
Expand Down Expand Up @@ -1460,6 +1466,17 @@ export function isHttpsUrl(value?: string): boolean {
}
}

/**
* Selects the RFC 8707 resource indicator for an MCP server: the provider's
* {@linkcode OAuthClientProvider.validateResourceURL | validateResourceURL} result when
* implemented, otherwise the protected resource metadata's `resource` (checked against the
* server URL with `checkResourceAllowed`), or `undefined` when there is no metadata.
*
* The result is a parsed `URL`, so a pathless indicator such as `https://example.com` has
* the `href` `https://example.com/`. {@linkcode auth} therefore sends the metadata string
* verbatim instead of this URL's `href` (#1968); callers that emit the `resource`
* parameter themselves should do the same.
*/
export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
Expand Down Expand Up @@ -2032,6 +2049,10 @@ export async function discoverOAuthServerInfo(
};
}

function resourceIndicatorToString(resource: string | URL): string {
return typeof resource === 'string' ? resource : resource.href;
}

/**
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
*/
Expand All @@ -2050,7 +2071,7 @@ export async function startAuthorization(
redirectUrl: string | URL;
scope?: string;
state?: string;
resource?: URL;
resource?: string | URL;
}
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
let authorizationUrl: URL;
Expand Down Expand Up @@ -2098,7 +2119,7 @@ export async function startAuthorization(
}

if (resource) {
authorizationUrl.searchParams.set('resource', resource.href);
authorizationUrl.searchParams.set('resource', resourceIndicatorToString(resource));
}

return { authorizationUrl, codeVerifier };
Expand Down Expand Up @@ -2147,7 +2168,7 @@ export async function executeTokenRequest(
tokenRequestParams: URLSearchParams;
clientInformation?: OAuthClientInformationMixed;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
resource?: URL;
resource?: string | URL;
/**
* SEP-1932 / RFC 9449 §5: when set, signs a DPoP proof into the token request's `DPoP`
* header — the prerequisite for obtaining a DPoP-bound access token. On a `400
Expand All @@ -2166,7 +2187,7 @@ export async function executeTokenRequest(
});

if (resource) {
tokenRequestParams.set('resource', resource.href);
tokenRequestParams.set('resource', resourceIndicatorToString(resource));
}

if (!addClientAuthentication && clientInformation) {
Expand Down Expand Up @@ -2269,7 +2290,7 @@ export async function exchangeAuthorization(
iss?: string;
codeVerifier: string;
redirectUri: string | URL;
resource?: URL;
resource?: string | URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
dpop?: DpopSession;
Expand Down Expand Up @@ -2321,7 +2342,7 @@ export async function refreshAuthorization(
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
refreshToken: string;
resource?: URL;
resource?: string | URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
dpop?: DpopSession;
Expand Down Expand Up @@ -2386,7 +2407,7 @@ export async function fetchToken(
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
resource?: URL;
resource?: string | URL;
/** Authorization code for the default `authorization_code` grant flow */
authorizationCode?: string;
/**
Expand Down
11 changes: 8 additions & 3 deletions packages/client/src/client/probeClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,14 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext):
}
return {
kind: 'error',
error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, {
cause: error
})
error: new SdkError(
SdkErrorCode.EraNegotiationFailed,
`Version negotiation probe failed: ${describeError(error)}`,
// Keep data.cause for existing consumers while also exposing the
// standard Error.cause chain (#2657).
{ cause: error },
{ cause: error }
)
};
}

Expand Down
87 changes: 85 additions & 2 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1496,7 +1496,8 @@ describe('OAuth Authorization', () => {

it('calls saveDiscoveryState after discovery when provider implements it', async () => {
const saveDiscoveryState = vi.fn();
const provider = createMockProvider({ saveDiscoveryState });
const saveResourceUrl = vi.fn();
const provider = createMockProvider({ saveDiscoveryState, saveResourceUrl });

mockFetch.mockImplementation(url => {
const urlString = url.toString();
Expand Down Expand Up @@ -1529,6 +1530,9 @@ describe('OAuth Authorization', () => {
authorizationServerMetadata: validAuthMetadata
})
);
expect(saveResourceUrl).toHaveBeenCalledWith('https://resource.example.com');
const authorizationUrl = vi.mocked(provider.redirectToAuthorization).mock.calls[0]![0];
expect(authorizationUrl.searchParams.get('resource')).toBe('https://resource.example.com');
});

it('restores full discovery state from cache including resource metadata', async () => {
Expand Down Expand Up @@ -1580,7 +1584,7 @@ describe('OAuth Authorization', () => {
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
expect(tokenCall).toBeDefined();
const body = tokenCall![1].body as URLSearchParams;
expect(body.get('resource')).toBe('https://resource.example.com/');
expect(body.get('resource')).toBe('https://resource.example.com');
});

it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => {
Expand Down Expand Up @@ -1787,6 +1791,16 @@ describe('OAuth Authorization', () => {
expect(codeVerifier).toBe('test_verifier');
});

it('preserves a string resource indicator without URL normalization', async () => {
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
redirectUrl: 'http://localhost:3000/callback',
resource: 'https://api.example.com'
});

expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com');
});

it('includes scope parameter when provided', async () => {
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
Expand Down Expand Up @@ -3353,6 +3367,75 @@ describe('OAuth Authorization', () => {
expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/');
});

it('sends a pathless PRM resource verbatim on the authorization and token requests (#1968)', async () => {
// RFC 9728 publishes the resource identifier and RFC 8707 requires it to be
// sent unchanged. `new URL('https://example.com').href` is 'https://example.com/',
// and authorization servers that match the indicator exactly (Microsoft Entra
// ID: AADSTS9010010) reject the extra slash.
mockFetch.mockImplementation(url => {
const urlString = url.toString();

if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://example.com',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['https://example.com/mcp:tools']
})
});
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
});
} else if (urlString.includes('/token')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ access_token: 'access123', token_type: 'bearer', expires_in: 3600 })
});
}

return Promise.resolve({ ok: false, status: 404 });
});

(mockProvider.clientInformation as Mock).mockResolvedValue({
client_id: 'test-client',
client_secret: 'test-secret'
});
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
(mockProvider.codeVerifier as Mock).mockResolvedValue('verifier123');
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);

// Authorization request: the redirect carries the metadata value byte for byte.
const redirectResult = await auth(mockProvider, { serverUrl: 'https://example.com/mcp' });
expect(redirectResult).toBe('REDIRECT');
const authUrl: URL = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]![0];
expect(authUrl.searchParams.get('resource')).toBe('https://example.com');

// Token request: the authorization-code exchange sends the same value.
const exchangeResult = await auth(mockProvider, {
serverUrl: 'https://example.com/mcp',
authorizationCode: 'code123'
});
expect(exchangeResult).toBe('AUTHORIZED');
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
expect(tokenCall).toBeDefined();
const body = tokenCall![1].body as URLSearchParams;
expect(body.get('resource')).toBe('https://example.com');
});

it('excludes resource parameter when Protected Resource Metadata is not present', async () => {
// Mock metadata discovery where protected resource metadata is not available (404)
// but authorization server metadata is available
Expand Down
3 changes: 3 additions & 0 deletions packages/client/test/client/probeAuthSeam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ describe('stamped-seam fault injection (identity-preserving auth outcomes, never
expect(out.settled).toBe('rejected');
expect(out.error).toBeInstanceOf(SdkError);
expect((out.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
// The failure rides the standard cause chain (#2657); the legacy data.cause
// slot is kept populated for compatibility until it is removed.
expect((out.error as SdkError).cause).toBe(netError);
expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError);
});
});
19 changes: 19 additions & 0 deletions packages/client/test/client/probeClassifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,25 @@ describe('row: network outage → typed connect error (Node)', () => {
const verdict = classify({ kind: 'network-error', error: new TypeError('fetch failed') }, { environment: 'node' });
expect(verdict.kind).toBe('error');
});

test('the underlying network error is reachable via Error.cause (#2657)', () => {
// Node's fetch wraps the socket/DNS failure: `TypeError: fetch failed` with
// the error that actually names the failure (ENOTFOUND / ECONNREFUSED /
// ETIMEDOUT) on its own `cause`.
const dnsError = Object.assign(new Error('getaddrinfo ENOTFOUND unreachable.invalid'), { code: 'ENOTFOUND' });
const fetchError = new TypeError('fetch failed', { cause: dnsError });
const verdict = classify({ kind: 'network-error', error: fetchError });
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
// Walking `.cause` (what loggers and error reporters do) must reach the
// error that names the failure instead of dead-ending on the SdkError.
expect(verdict.error.cause).toBe(fetchError);
expect((verdict.error.cause as Error).cause).toBe(dnsError);
// The legacy data.cause slot stays populated too (kept for compatibility,
// slated for removal).
expect(((verdict.error as SdkError).data as { cause?: unknown }).cause).toBe(fetchError);
}
});
});

describe('row: timeout — transport-aware verdict', () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/core-internal/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,23 @@ export class SdkError extends Error {
return brandedHasInstance(this, value);
}

/**
* @param code - Stable string code identifying the failure ({@linkcode SdkErrorCode}).
* @param message - Human-readable description.
* @param data - Optional structured payload (for example the HTTP status carried by
* {@linkcode SdkHttpError}). Opaque to the SDK: a `cause` key inside `data` is not
* promoted to `Error.cause`.
* @param options - Standard `ErrorOptions`, forwarded to `Error`. Pass the underlying
* failure as `{ cause }` so it is reachable through the `Error.cause` chain that
* loggers and error trackers walk.
*/
constructor(
public readonly code: SdkErrorCode,
message: string,
public readonly data?: unknown
public readonly data?: unknown,
options?: ErrorOptions
) {
super(message);
super(message, options);
this.name = 'SdkError';
stampErrorBrands(this, new.target);
}
Expand Down Expand Up @@ -187,8 +198,11 @@ export class SdkHttpError extends SdkError {

declare readonly data: SdkHttpErrorData;

constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) {
super(code, message, data);
/**
* @param options - Standard `ErrorOptions`, forwarded to `Error` (see {@linkcode SdkError}).
*/
constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData, options?: ErrorOptions) {
super(code, message, data, options);
this.name = 'SdkHttpError';
}

Expand Down
42 changes: 42 additions & 0 deletions packages/core-internal/test/types/errorSurfacePins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,48 @@ describe('SdkError', () => {
expect(error.code).toBe('CLIENT_HTTP_FAILED_TO_OPEN_STREAM');
expect(error.data).toMatchObject({ status: 404 });
});

// Cause plumbing (#2657): a wrapped error travels on the standard `Error.cause`
// chain via `ErrorOptions`, never through the opaque `data` payload, so pino /
// Sentry / `util.inspect` reach the root failure without SDK-specific handling.
test('forwards ErrorOptions.cause onto Error.cause without touching data', () => {
const root = new TypeError('fetch failed');
const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed', undefined, {
cause: root
});
expect(error.cause).toBe(root);
expect(error.data).toBeUndefined();
// Same non-enumerable own property the native Error constructor installs,
// so serializers that copy enumerable fields do not emit it twice.
expect(Object.getOwnPropertyDescriptor(error, 'cause')?.enumerable).toBe(false);
});

test('does not promote a `cause` key inside data to Error.cause', () => {
const root = new Error('boom');
const error = new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: 60_000, cause: root });
expect(error.cause).toBeUndefined();
expect(error.data).toEqual({ timeout: 60_000, cause: root });
});

test('carries data and cause independently when both are passed', () => {
const root = new Error('boom');
const error = new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: 60_000 }, { cause: root });
expect(error.cause).toBe(root);
expect(error.data).toEqual({ timeout: 60_000 });
});

test('SdkHttpError forwards ErrorOptions.cause and keeps the HTTP status', () => {
const root = new Error('socket hang up');
const error = new SdkHttpError(
SdkErrorCode.ClientHttpFailedToOpenStream,
'Failed to open SSE stream: Bad Gateway',
{ status: 502, statusText: 'Bad Gateway' },
{ cause: root }
);
expect(error.cause).toBe(root);
expect(error.status).toBe(502);
expect(error.statusText).toBe('Bad Gateway');
});
});

describe('protocol version constants', () => {
Expand Down
Loading