Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/loopback-localhost-subdomains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Treat hostnames ending in `.localhost` as loopback for the SEP-2207 token-endpoint https guard (RFC 6761 §6.3), so host-based multi-tenant local OAuth works.
13 changes: 11 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,9 +829,18 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void
params.set('client_id', clientId);
}

/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
/**
* Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3).
* Includes bare `localhost` and any name ending in `.localhost` (RFC 6761 §6.3).
*/
function isLoopbackHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
return (
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname === '::1'
);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/client/authErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError {
constructor(tokenEndpoint: string) {
super(
`Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` +
`OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).`
`OAuth token requests MUST use TLS (localhost / *.localhost / 127.0.0.1 / ::1 are exempt).`
);
this.tokenEndpoint = tokenEndpoint;
}
Expand Down
42 changes: 24 additions & 18 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2462,6 +2462,8 @@ describe('OAuth Authorization', () => {
it('assertSecureTokenEndpoint: throws on non-loopback http, returns URL for loopback', () => {
expect(() => assertSecureTokenEndpoint('http://10.0.0.5/token')).toThrow(InsecureTokenEndpointError);
expect(assertSecureTokenEndpoint('http://127.0.0.1:3000/token')).toBeInstanceOf(URL);
expect(assertSecureTokenEndpoint('http://tenant.example.localhost:3300/token')).toBeInstanceOf(URL);
expect(assertSecureTokenEndpoint('http://localhost/token')).toBeInstanceOf(URL);
});

it('rejects a non-https token_endpoint before sending credentials', async () => {
Expand Down Expand Up @@ -2530,24 +2532,27 @@ describe('OAuth Authorization', () => {
expect(mockFetch.mock.calls.some(c => c[0].toString().includes('/token'))).toBe(false);
});

it.each(['http://localhost:9001/token', 'http://127.0.0.1:9001/token', 'http://[::1]:9001/token'])(
'permits loopback host %s',
async tokenEndpoint => {
mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' }));
await expect(
refreshAuthorization('http://localhost:9001', {
metadata: {
issuer: 'http://localhost:9001',
authorization_endpoint: 'http://localhost:9001/authorize',
token_endpoint: tokenEndpoint,
response_types_supported: ['code']
},
clientInformation,
refreshToken: 'rt'
})
).resolves.toBeDefined();
}
);
it.each([
'http://localhost:9001/token',
'http://127.0.0.1:9001/token',
'http://[::1]:9001/token',
'http://tenant.example.localhost:3300/token',
'http://app.localhost:9001/token'
])('permits loopback host %s', async tokenEndpoint => {
mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' }));
await expect(
refreshAuthorization('http://localhost:9001', {
metadata: {
issuer: 'http://localhost:9001',
authorization_endpoint: 'http://localhost:9001/authorize',
token_endpoint: tokenEndpoint,
response_types_supported: ['code']
},
clientInformation,
refreshToken: 'rt'
})
).resolves.toBeDefined();
});
});

// SEP-2207 verify-only: behaviors already correct at the v2 baseline,
Expand Down Expand Up @@ -4368,6 +4373,7 @@ describe('OAuth Authorization', () => {
describe('SEP-837: application_type heuristic default', () => {
it.each([
['http://localhost:3000/callback', 'native'],
['http://tenant.example.localhost:3300/callback', 'native'],
['http://127.0.0.1:8080/cb', 'native'],
['http://[::1]:8080/cb', 'native'],
['myapp://oauth/callback', 'native'],
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,7 +2345,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
'client-auth:token-endpoint:https-guard': {
source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant',
behavior:
"The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.",
"The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / *.localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.",
transports: ['streamableHttp'],
addedInSpecVersion: '2026-07-28',
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
Expand Down
Loading