Skip to content
6 changes: 6 additions & 0 deletions .changeset/oauth-client-credentials-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/client': patch
---

Accept RFC 8414 authorization-server metadata without an authorization endpoint for client-credentials-only servers, while producing a clear error if an authorization-code flow is attempted without an endpoint.
2 changes: 0 additions & 2 deletions examples/shared/src/clientCredentialsAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ export function createClientCredentialsAuthServer(options: ClientCredentialsAuth
const metadata: OAuthMetadata = {
issuer,
token_endpoint: `${issuer}/token`,
// Required by the RFC 8414 schema even though this AS doesn't implement the endpoint.
authorization_endpoint: `${issuer}/authorize`,
response_types_supported: [],
grant_types_supported: ['client_credentials'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
Expand Down
6 changes: 4 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2095,11 +2095,13 @@ export async function startAuthorization(
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
let authorizationUrl: URL;
if (metadata) {
authorizationUrl = new URL(metadata.authorization_endpoint);

if (!metadata.response_types_supported.includes(AUTHORIZATION_CODE_RESPONSE_TYPE)) {
throw new Error(`Incompatible auth server: does not support response type ${AUTHORIZATION_CODE_RESPONSE_TYPE}`);
}
if (!metadata.authorization_endpoint) {
throw new Error('Incompatible auth server: missing authorization_endpoint');
}
authorizationUrl = new URL(metadata.authorization_endpoint);

if (
metadata.code_challenge_methods_supported &&
Expand Down
26 changes: 26 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,22 @@ describe('OAuth Authorization', () => {
code_challenge_methods_supported: ['S256']
};

it('accepts client-credentials-only OAuth metadata without an authorization endpoint', async () => {
const metadata = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/token',
grant_types_supported: ['client_credentials'],
response_types_supported: []
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => metadata
});

await expect(discoverOAuthMetadata('https://auth.example.com')).resolves.toEqual(metadata);
});

it('returns metadata when discovery succeeds', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -1781,6 +1797,16 @@ describe('OAuth Authorization', () => {
client_name: 'Test Client'
};

it('rejects authorization-code login when authorization_endpoint is absent', async () => {
await expect(
startAuthorization('https://auth.example.com', {
metadata: { ...validMetadata, authorization_endpoint: undefined },
clientInformation: validClientInfo,
redirectUrl: 'http://localhost:3000/callback'
})
).rejects.toThrow('Incompatible auth server: missing authorization_endpoint');
});

it('generates authorization URL with PKCE challenge', async () => {
const { authorizationUrl, codeVerifier } = await startAuthorization('https://auth.example.com', {
metadata: undefined,
Expand Down
24 changes: 24 additions & 0 deletions packages/core-internal/test/shared/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ describe('OAuthMetadataSchema', () => {
expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow();
});

it('accepts client credentials metadata without an authorization endpoint', () => {
const metadata = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/oauth/token',
grant_types_supported: ['client_credentials'],
response_types_supported: []
};

expect(OAuthMetadataSchema.parse(metadata)).toMatchObject(metadata);
});

it('rejects metadata with javascript: URLs', () => {
const metadata = {
issuer: 'https://auth.example.com',
Expand Down Expand Up @@ -85,6 +96,19 @@ describe('OpenIdProviderMetadataSchema', () => {
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow();
});

it('continues to require an authorization endpoint for OpenID providers', () => {
const metadata = {
issuer: 'https://auth.example.com',
token_endpoint: 'https://auth.example.com/oauth/token',
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256']
};

expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow();
});

it('rejects metadata with javascript: in jwks_uri', () => {
const metadata = {
issuer: 'https://auth.example.com',
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export const OAuthProtectedResourceMetadataSchema = z.looseObject({
*/
export const OAuthMetadataSchema = z.looseObject({
issuer: z.string(),
authorization_endpoint: SafeUrlSchema,
// RFC 8414 §2: client_credentials-only servers need no authorization endpoint.
authorization_endpoint: SafeUrlSchema.optional(),
token_endpoint: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
Expand Down
4 changes: 2 additions & 2 deletions packages/server-legacy/src/auth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const createOAuthMetadata = (options: {
baseUrl?: URL;
serviceDocumentationUrl?: URL;
scopesSupported?: string[];
}): OAuthMetadata => {
}): OAuthMetadata & { authorization_endpoint: string } => {
const issuer = options.issuerUrl;
const baseUrl = options.baseUrl;

Expand All @@ -97,7 +97,7 @@ export const createOAuthMetadata = (options: {
const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined;
const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined;

const metadata: OAuthMetadata = {
const metadata: OAuthMetadata & { authorization_endpoint: string } = {
issuer: issuer.href,
service_documentation: options.serviceDocumentationUrl?.href,

Expand Down
Loading