SEP-1046: Client credentials flow for M2M without user interaction (#1157)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Konstantin Konstantinov 2025-11-28 19:01:15 +02:00 • committed by GitHub
commit 4fb4d4d55e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1298 additions and 116 deletions

View file

@ -796,6 +796,30 @@ await server.connect(transport);
To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information.
### Node.js Web Crypto (globalThis.crypto) compatibility
Some parts of the SDK (for example, JWT-based client authentication in `auth-extensions.ts` via `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`.
- **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default.
- **Node.js v18.x**: `globalThis.crypto` may not be defined by default; in this repository we polyfill it for tests (see `vitest.setup.ts`), and you should do the same in your app if it is missing - or alternatively, run Node with `--experimental-global-webcrypto` as per your
Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto )
If you run tests or applications on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.ts`:
```typescript
import { webcrypto } from 'node:crypto';
if (typeof globalThis.crypto === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).crypto = webcrypto as unknown as Crypto;
}
```
For production use, you can either:
- Run on a Node.js version where `globalThis.crypto` is available by default (recommended), or
- Apply a similar polyfill early in your application's startup code when targeting older Node.js runtimes.
## Examples
### Echo Server
@ -1636,6 +1660,64 @@ const result = await client.callTool({
});
```
### OAuth client authentication helpers
For OAuth-secured MCP servers, the client `auth` module exposes a generic `OAuthClientProvider` interface, and `src/client/auth-extensions.ts` provides ready-to-use implementations for common machine-to-machine authentication flows:
- **ClientCredentialsProvider**: Uses the `client_credentials` grant with `client_secret_basic` authentication.
- **PrivateKeyJwtProvider**: Uses the `client_credentials` grant with `private_key_jwt` client authentication, signing a JWT assertion on each token request.
- **StaticPrivateKeyJwtProvider**: Similar to `PrivateKeyJwtProvider`, but accepts a pre-built JWT assertion string via `jwtBearerAssertion` and reuses it for token requests.
You can use these providers with the `StreamableHTTPClientTransport` and the high-level `auth()` helper:
```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
const serverUrl = new URL('https://mcp.example.com/');
// Example: client_credentials with client_secret_basic
const basicProvider = new ClientCredentialsProvider({
clientId: process.env.CLIENT_ID!,
clientSecret: process.env.CLIENT_SECRET!,
clientName: 'example-basic-client'
});
// Example: client_credentials with private_key_jwt (JWT signed locally)
const privateKeyJwtProvider = new PrivateKeyJwtProvider({
clientId: process.env.CLIENT_ID!,
privateKey: process.env.CLIENT_PRIVATE_KEY_PEM!,
algorithm: 'RS256',
clientName: 'example-private-key-jwt-client',
jwtLifetimeSeconds: 300
});
// Example: client_credentials with a pre-built JWT assertion
const staticJwtProvider = new StaticPrivateKeyJwtProvider({
clientId: process.env.CLIENT_ID!,
jwtBearerAssertion: process.env.CLIENT_ASSERTION!,
clientName: 'example-static-private-key-jwt-client'
});
const transport = new StreamableHTTPClientTransport(serverUrl, {
authProvider: privateKeyJwtProvider
});
const client = new Client({
name: 'example-client',
version: '1.0.0'
});
// Perform the OAuth flow (including dynamic client registration if needed)
await auth(privateKeyJwtProvider, { serverUrl, fetchFn: transport.fetch });
await client.connect(transport);
```
If you need lower-level control, you can also use `createPrivateKeyJwtAuth()` directly to implement `addClientAuthentication` on a custom `OAuthClientProvider`.
### Proxy Authorization Requests Upstream
You can proxy OAuth requests to an external authorization provider:

10
package-lock.json generated
View file

@ -18,6 +18,7 @@
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
@ -3228,6 +3229,15 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/jose": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.1.tgz",
"integrity": "sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",

View file

@ -95,6 +95,7 @@
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",

View file

@ -0,0 +1,373 @@
import { describe, it, expect } from 'vitest';
import { auth } from './auth.js';
import {
ClientCredentialsProvider,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider,
createPrivateKeyJwtAuth
} from './auth-extensions.js';
import type { FetchLike } from '../shared/transport.js';
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
const AUTH_SERVER_URL = 'https://auth.example.com';
function createMockFetch(onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>): FetchLike {
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = input instanceof URL ? input : new URL(input);
// Protected resource metadata discovery
if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
return new Response(
JSON.stringify({
resource: RESOURCE_SERVER_URL,
authorization_servers: [AUTH_SERVER_URL]
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Authorization server metadata discovery
if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') {
return new Response(
JSON.stringify({
issuer: AUTH_SERVER_URL,
authorization_endpoint: `${AUTH_SERVER_URL}/authorize`,
token_endpoint: `${AUTH_SERVER_URL}/token`,
response_types_supported: ['code'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt']
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Token endpoint
if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') {
if (onTokenRequest) {
await onTokenRequest(url, init);
}
return new Response(
JSON.stringify({
access_token: 'test-access-token',
token_type: 'Bearer'
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
throw new Error(`Unexpected URL in mock fetch: ${url.toString()}`);
};
}
describe('auth-extensions providers (end-to-end with auth())', () => {
it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => {
const provider = new ClientCredentialsProvider({
clientId: 'my-client',
clientSecret: 'my-secret',
clientName: 'test-client'
});
const fetchMock = createMockFetch(async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
expect(params.get('client_assertion')).toBeNull();
const headers = new Headers(init?.headers);
const authHeader = headers.get('Authorization');
expect(authHeader).toBeTruthy();
const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64');
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
const provider = new PrivateKeyJwtProvider({
clientId: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
algorithm: 'HS256',
clientName: 'private-key-jwt-client'
});
let assertionFromRequest: string | null = null;
const fetchMock = createMockFetch(async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
assertionFromRequest = params.get('client_assertion');
expect(assertionFromRequest).toBeTruthy();
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
const parts = assertionFromRequest!.split('.');
expect(parts).toHaveLength(3);
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toBeNull();
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
expect(assertionFromRequest).toBeTruthy();
});
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
const provider = new PrivateKeyJwtProvider({
clientId: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
algorithm: 'none',
clientName: 'private-key-jwt-client'
});
const fetchMock = createMockFetch();
await expect(
auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
})
).rejects.toThrow('Unsupported algorithm none');
});
it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => {
const staticAssertion = 'header.payload.signature';
const provider = new StaticPrivateKeyJwtProvider({
clientId: 'static-client',
jwtBearerAssertion: staticAssertion,
clientName: 'static-private-key-jwt-client'
});
const fetchMock = createMockFetch(async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
expect(params.get('client_assertion')).toBe(staticAssertion);
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toBeNull();
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
});
describe('createPrivateKeyJwtAuth', () => {
const baseOptions = {
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'HS256'
};
it('creates an addClientAuthentication function that sets JWT assertion params', async () => {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const headers = new Headers();
const params = new URLSearchParams();
await addClientAuth(headers, params, 'https://auth.example.com/token', undefined);
expect(params.get('client_assertion')).toBeTruthy();
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
// Verify JWT structure (three dot-separated segments)
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('throws when globalThis.crypto is not available', async () => {
// Temporarily remove globalThis.crypto to simulate older Node.js runtimes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalAny = globalThis as any;
const originalCrypto = globalAny.crypto;
// Use delete so that typeof globalThis.crypto === 'undefined'
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete globalAny.crypto;
try {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions'
);
} finally {
// Restore original crypto to avoid affecting other tests
globalAny.crypto = originalCrypto;
}
});
it('creates a signed JWT when using a Uint8Array HMAC key', async () => {
const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long');
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: secret,
alg: 'HS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('creates a signed JWT when using a symmetric JWK key', async () => {
const jwk: Record<string, unknown> = {
kty: 'oct',
// "a-string-secret-at-least-256-bits-long" base64url-encoded
k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc',
alg: 'HS256'
};
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: jwk,
alg: 'HS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('creates a signed JWT when using an RSA PEM private key', async () => {
// Generate an RSA key pair on the fly
const jose = await import('jose');
const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true });
const pem = await jose.exportPKCS8(privateKey);
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: pem,
alg: 'RS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('uses metadata.issuer as audience when available', async () => {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', {
issuer: 'https://issuer.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code']
});
const assertion = params.get('client_assertion')!;
// Decode the payload to verify audience
const [, payloadB64] = assertion.split('.');
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
expect(payload.aud).toBe('https://issuer.example.com');
});
it('throws when using an unsupported algorithm', async () => {
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'none'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
'Unsupported algorithm none'
);
});
it('throws when jose cannot import an invalid RSA PEM key', async () => {
const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----';
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: badPem,
alg: 'RS256'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
/Invalid character/
);
});
it('throws when jose cannot import a mismatched JWK key', async () => {
const jwk: Record<string, unknown> = {
kty: 'oct',
k: 'c2VjcmV0LWtleQ', // "secret-key" base64url
alg: 'HS256'
};
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: jwk,
// Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail
alg: 'RS256'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/
);
});
});

View file

@ -0,0 +1,401 @@
/**
* OAuth provider extensions for specialized authentication flows.
*
* This module provides ready-to-use OAuthClientProvider implementations
* for common machine-to-machine authentication scenarios.
*/
import type { JWK } from 'jose';
import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../shared/auth.js';
import { AddClientAuthentication, OAuthClientProvider } from './auth.js';
/**
* Helper to produce a private_key_jwt client authentication function.
*
* Usage:
* const addClientAuth = createPrivateKeyJwtAuth({ issuer, subject, privateKey, alg, audience? });
* // pass addClientAuth as provider.addClientAuthentication implementation
*/
export function createPrivateKeyJwtAuth(options: {
issuer: string;
subject: string;
privateKey: string | Uint8Array | Record<string, unknown>;
alg: string;
audience?: string | URL;
lifetimeSeconds?: number;
claims?: Record<string, unknown>;
}): AddClientAuthentication {
return async (_headers, params, url, metadata) => {
// Lazy import to avoid heavy dependency unless used
if (typeof globalThis.crypto === 'undefined') {
throw new TypeError(
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)'
);
}
const jose = await import('jose');
const audience = String(options.audience ?? metadata?.issuer ?? url);
const lifetimeSeconds = options.lifetimeSeconds ?? 300;
const now = Math.floor(Date.now() / 1000);
const jti = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const baseClaims = {
iss: options.issuer,
sub: options.subject,
aud: audience,
exp: now + lifetimeSeconds,
iat: now,
jti
};
const claims = options.claims ? { ...baseClaims, ...options.claims } : baseClaims;
// Import key for the requested algorithm
const alg = options.alg;
let key: unknown;
if (typeof options.privateKey === 'string') {
if (alg.startsWith('RS') || alg.startsWith('ES') || alg.startsWith('PS')) {
key = await jose.importPKCS8(options.privateKey, alg);
} else if (alg.startsWith('HS')) {
key = new TextEncoder().encode(options.privateKey);
} else {
throw new Error(`Unsupported algorithm ${alg}`);
}
} else if (options.privateKey instanceof Uint8Array) {
if (alg.startsWith('HS')) {
key = options.privateKey;
} else {
// Assume PKCS#8 DER in Uint8Array for asymmetric algorithms
key = await jose.importPKCS8(new TextDecoder().decode(options.privateKey), alg);
}
} else {
// Treat as JWK
key = await jose.importJWK(options.privateKey as JWK, alg);
}
// Sign JWT
const assertion = await new jose.SignJWT(claims)
.setProtectedHeader({ alg, typ: 'JWT' })
.setIssuer(options.issuer)
.setSubject(options.subject)
.setAudience(audience)
.setIssuedAt(now)
.setExpirationTime(now + lifetimeSeconds)
.setJti(jti)
.sign(key as unknown as Uint8Array | CryptoKey);
params.set('client_assertion', assertion);
params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
};
}
/**
* Options for creating a ClientCredentialsProvider.
*/
export interface ClientCredentialsProviderOptions {
/**
* The client_id for this OAuth client.
*/
clientId: string;
/**
* The client_secret for client_secret_basic authentication.
*/
clientSecret: string;
/**
* Optional client name for metadata.
*/
clientName?: string;
}
/**
* OAuth provider for client_credentials grant with client_secret_basic authentication.
*
* This provider is designed for machine-to-machine authentication where
* the client authenticates using a client_id and client_secret.
*
* @example
* const provider = new ClientCredentialsProvider({
* clientId: 'my-client',
* clientSecret: 'my-secret'
* });
*
* const transport = new StreamableHTTPClientTransport(serverUrl, {
* authProvider: provider
* });
*/
export class ClientCredentialsProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
constructor(options: ClientCredentialsProviderOptions) {
this._clientInfo = {
client_id: options.clientId,
client_secret: options.clientSecret
};
this._clientMetadata = {
client_name: options.clientName ?? 'client-credentials-client',
redirect_uris: [],
grant_types: ['client_credentials'],
token_endpoint_auth_method: 'client_secret_basic'
};
}
get redirectUrl(): undefined {
return undefined;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
redirectToAuthorization(): void {
throw new Error('redirectToAuthorization is not used for client_credentials flow');
}
saveCodeVerifier(): void {
// Not used for client_credentials
}
codeVerifier(): string {
throw new Error('codeVerifier is not used for client_credentials flow');
}
prepareTokenRequest(scope?: string): URLSearchParams {
const params = new URLSearchParams({ grant_type: 'client_credentials' });
if (scope) params.set('scope', scope);
return params;
}
}
/**
* Options for creating a PrivateKeyJwtProvider.
*/
export interface PrivateKeyJwtProviderOptions {
/**
* The client_id for this OAuth client.
*/
clientId: string;
/**
* The private key for signing JWT assertions.
* Can be a PEM string, Uint8Array, or JWK object.
*/
privateKey: string | Uint8Array | Record<string, unknown>;
/**
* The algorithm to use for signing (e.g., 'RS256', 'ES256').
*/
algorithm: string;
/**
* Optional client name for metadata.
*/
clientName?: string;
/**
* Optional JWT lifetime in seconds (default: 300).
*/
jwtLifetimeSeconds?: number;
}
/**
* OAuth provider for client_credentials grant with private_key_jwt authentication.
*
* This provider is designed for machine-to-machine authentication where
* the client authenticates using a signed JWT assertion (RFC 7523 Section 2.2).
*
* @example
* const provider = new PrivateKeyJwtProvider({
* clientId: 'my-client',
* privateKey: pemEncodedPrivateKey,
* algorithm: 'RS256'
* });
*
* const transport = new StreamableHTTPClientTransport(serverUrl, {
* authProvider: provider
* });
*/
export class PrivateKeyJwtProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
addClientAuthentication: AddClientAuthentication;
constructor(options: PrivateKeyJwtProviderOptions) {
this._clientInfo = {
client_id: options.clientId
};
this._clientMetadata = {
client_name: options.clientName ?? 'private-key-jwt-client',
redirect_uris: [],
grant_types: ['client_credentials'],
token_endpoint_auth_method: 'private_key_jwt'
};
this.addClientAuthentication = createPrivateKeyJwtAuth({
issuer: options.clientId,
subject: options.clientId,
privateKey: options.privateKey,
alg: options.algorithm,
lifetimeSeconds: options.jwtLifetimeSeconds
});
}
get redirectUrl(): undefined {
return undefined;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
redirectToAuthorization(): void {
throw new Error('redirectToAuthorization is not used for client_credentials flow');
}
saveCodeVerifier(): void {
// Not used for client_credentials
}
codeVerifier(): string {
throw new Error('codeVerifier is not used for client_credentials flow');
}
prepareTokenRequest(scope?: string): URLSearchParams {
const params = new URLSearchParams({ grant_type: 'client_credentials' });
if (scope) params.set('scope', scope);
return params;
}
}
/**
* Options for creating a StaticPrivateKeyJwtProvider.
*/
export interface StaticPrivateKeyJwtProviderOptions {
/**
* The client_id for this OAuth client.
*/
clientId: string;
/**
* A pre-built JWT client assertion to use for authentication.
*
* This token should already contain the appropriate claims
* (iss, sub, aud, exp, etc.) and be signed by the client's key.
*/
jwtBearerAssertion: string;
/**
* Optional client name for metadata.
*/
clientName?: string;
}
/**
* OAuth provider for client_credentials grant with a static private_key_jwt assertion.
*
* This provider mirrors {@link PrivateKeyJwtProvider} but instead of constructing and
* signing a JWT on each request, it accepts a pre-built JWT assertion string and
* uses it directly for authentication.
*/
export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
addClientAuthentication: AddClientAuthentication;
constructor(options: StaticPrivateKeyJwtProviderOptions) {
this._clientInfo = {
client_id: options.clientId
};
this._clientMetadata = {
client_name: options.clientName ?? 'static-private-key-jwt-client',
redirect_uris: [],
grant_types: ['client_credentials'],
token_endpoint_auth_method: 'private_key_jwt'
};
const assertion = options.jwtBearerAssertion;
this.addClientAuthentication = async (_headers, params) => {
params.set('client_assertion', assertion);
params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
};
}
get redirectUrl(): undefined {
return undefined;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
redirectToAuthorization(): void {
throw new Error('redirectToAuthorization is not used for client_credentials flow');
}
saveCodeVerifier(): void {
// Not used for client_credentials
}
codeVerifier(): string {
throw new Error('codeVerifier is not used for client_credentials flow');
}
prepareTokenRequest(scope?: string): URLSearchParams {
const params = new URLSearchParams({ grant_type: 'client_credentials' });
if (scope) params.set('scope', scope);
return params;
}
}

View file

@ -14,6 +14,7 @@ import {
selectClientAuthMethod,
isHttpsUrl
} from './auth.js';
import { createPrivateKeyJwtAuth } from './auth-extensions.js';
import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js';
import { AuthorizationServerMetadata, OAuthTokens } from '../shared/auth.js';
import { expect, vi, type Mock } from 'vitest';
@ -1209,11 +1210,11 @@ describe('OAuth Authorization', () => {
headers: Headers,
params: URLSearchParams,
url: string | URL,
metadata: AuthorizationServerMetadata
metadata?: AuthorizationServerMetadata
) => {
headers.set('Authorization', 'Basic ' + btoa(validClientInfo.client_id + ':' + validClientInfo.client_secret));
params.set('example_url', typeof url === 'string' ? url : url.toString());
params.set('example_metadata', metadata.authorization_endpoint);
params.set('example_metadata', metadata?.authorization_endpoint ?? '');
params.set('example_param', 'example_value');
}
});
@ -1237,7 +1238,7 @@ describe('OAuth Authorization', () => {
expect(body.get('code_verifier')).toBe('verifier123');
expect(body.get('client_id')).toBeNull();
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
expect(body.get('example_url')).toBe('https://auth.example.com');
expect(body.get('example_url')).toBe('https://auth.example.com/token');
expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize');
expect(body.get('example_param')).toBe('example_value');
expect(body.get('client_secret')).toBeNull();
@ -1361,13 +1362,12 @@ describe('OAuth Authorization', () => {
href: 'https://auth.example.com/token'
}),
expect.objectContaining({
method: 'POST',
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded'
})
method: 'POST'
})
);
const headers = mockFetch.mock.calls[0][1].headers as Headers;
expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('refresh123');
@ -1417,7 +1417,7 @@ describe('OAuth Authorization', () => {
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('refresh123');
expect(body.get('client_id')).toBeNull();
expect(body.get('example_url')).toBe('https://auth.example.com');
expect(body.get('example_url')).toBe('https://auth.example.com/token');
expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize');
expect(body.get('example_param')).toBe('example_value');
expect(body.get('client_secret')).toBeNull();
@ -1578,6 +1578,103 @@ describe('OAuth Authorization', () => {
vi.clearAllMocks();
});
it('performs client_credentials with private_key_jwt when provider has addClientAuthentication', async () => {
// Arrange: metadata discovery for PRM and AS
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://api.example.com/mcp-server',
authorization_servers: ['https://auth.example.com']
})
});
}
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']
})
});
}
if (urlString.includes('/token')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: 'cc_jwt_token',
token_type: 'bearer',
expires_in: 3600
})
});
}
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
});
// Create a provider with client_credentials grant and addClientAuthentication
// redirectUrl returns undefined to indicate non-interactive flow
const ccProvider: OAuthClientProvider = {
get redirectUrl() {
return undefined;
},
get clientMetadata() {
return {
redirect_uris: [],
client_name: 'Test Client',
grant_types: ['client_credentials']
};
},
clientInformation: vi.fn().mockResolvedValue({
client_id: 'client-id'
}),
tokens: vi.fn().mockResolvedValue(undefined),
saveTokens: vi.fn().mockResolvedValue(undefined),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }),
addClientAuthentication: createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'HS256'
})
};
const result = await auth(ccProvider, {
serverUrl: 'https://api.example.com/mcp-server'
});
expect(result).toBe('AUTHORIZED');
// Find the token request
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
expect(tokenCall).toBeDefined();
const [, init] = tokenCall!;
const body = init.body as URLSearchParams;
// grant_type MUST be client_credentials, not the JWT-bearer grant
expect(body.get('grant_type')).toBe('client_credentials');
// private_key_jwt client authentication parameters
expect(body.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
expect(body.get('client_assertion')).toBeTruthy();
// resource parameter included based on PRM
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
});
it('falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata', async () => {
// Setup: First call to protected resource metadata fails (404)
// Second call to auth server metadata succeeds

View file

@ -30,6 +30,16 @@ import {
} from '../server/auth/errors.js';
import { FetchLike } from '../shared/transport.js';
/**
* Function type for adding client authentication to token requests.
*/
export type AddClientAuthentication = (
headers: Headers,
params: URLSearchParams,
url: string | URL,
metadata?: AuthorizationServerMetadata
) => void | Promise<void>;
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
*
@ -40,8 +50,10 @@ import { FetchLike } from '../shared/transport.js';
export interface OAuthClientProvider {
/**
* The URL to redirect the user agent to after authorization.
* Return undefined for non-interactive flows that don't require user interaction
* (e.g., client_credentials, jwt-bearer).
*/
get redirectUrl(): string | URL;
get redirectUrl(): string | URL | undefined;
/**
* External URL the server should use to fetch client metadata document
@ -122,12 +134,7 @@ export interface OAuthClientProvider {
* @param url - The token endpoint URL being called
* @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods
*/
addClientAuthentication?(
headers: Headers,
params: URLSearchParams,
url: string | URL,
metadata?: AuthorizationServerMetadata
): void | Promise<void>;
addClientAuthentication?: AddClientAuthentication;
/**
* If defined, overrides the selection and validation of the
@ -144,6 +151,44 @@ export interface OAuthClientProvider {
* This avoids requiring the user to intervene manually.
*/
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
/**
* Prepares grant-specific parameters for a token request.
*
* This optional method allows providers to customize the token request based on
* the grant type they support. When implemented, it returns the grant type and
* any grant-specific parameters needed for the token exchange.
*
* If not implemented, the default behavior depends on the flow:
* - For authorization code flow: uses code, code_verifier, and redirect_uri
* - For client_credentials: detected via grant_types in clientMetadata
*
* @param scope - Optional scope to request
* @returns Grant type and parameters, or undefined to use default behavior
*
* @example
* // For client_credentials grant:
* prepareTokenRequest(scope) {
* return {
* grantType: 'client_credentials',
* params: scope ? { scope } : {}
* };
* }
*
* @example
* // For authorization_code grant (default behavior):
* async prepareTokenRequest() {
* return {
* grantType: 'authorization_code',
* params: {
* code: this.authorizationCode,
* code_verifier: await this.codeVerifier(),
* redirect_uri: String(this.redirectUrl)
* }
* };
* }
*/
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;
}
export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
@ -419,18 +464,16 @@ async function authInternal(
}
}
// Exchange authorization code for tokens
if (authorizationCode !== undefined) {
const codeVerifier = await provider.codeVerifier();
const tokens = await exchangeAuthorization(authorizationServerUrl, {
// Non-interactive flows (e.g., client_credentials, jwt-bearer) don't need a redirect URL
const nonInteractiveFlow = !provider.redirectUrl;
// Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows
if (authorizationCode !== undefined || nonInteractiveFlow) {
const tokens = await fetchToken(provider, authorizationServerUrl, {
metadata,
clientInformation,
authorizationCode,
codeVerifier,
redirectUri: provider.redirectUrl,
resource,
addClientAuthentication: provider.addClientAuthentication,
fetchFn: fetchFn
authorizationCode,
fetchFn
});
await provider.saveTokens(tokens);
@ -966,6 +1009,84 @@ export async function startAuthorization(
return { authorizationUrl, codeVerifier };
}
/**
* Prepares token request parameters for an authorization code exchange.
*
* This is the default implementation used by fetchToken when the provider
* doesn't implement prepareTokenRequest.
*
* @param authorizationCode - The authorization code received from the authorization endpoint
* @param codeVerifier - The PKCE code verifier
* @param redirectUri - The redirect URI used in the authorization request
* @returns URLSearchParams for the authorization_code grant
*/
export function prepareAuthorizationCodeRequest(
authorizationCode: string,
codeVerifier: string,
redirectUri: string | URL
): URLSearchParams {
return new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
code_verifier: codeVerifier,
redirect_uri: String(redirectUri)
});
}
/**
* Internal helper to execute a token request with the given parameters.
* Used by exchangeAuthorization, refreshAuthorization, and fetchToken.
*/
async function executeTokenRequest(
authorizationServerUrl: string | URL,
{
metadata,
tokenRequestParams,
clientInformation,
addClientAuthentication,
resource,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
tokenRequestParams: URLSearchParams;
clientInformation?: OAuthClientInformationMixed;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
resource?: URL;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
const headers = new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
});
if (resource) {
tokenRequestParams.set('resource', resource.href);
}
if (addClientAuthentication) {
await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata);
} else if (clientInformation) {
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams);
}
const response = await (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers,
body: tokenRequestParams
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return OAuthTokensSchema.parse(await response.json());
}
/**
* Exchanges an authorization code for an access token with the given server.
*
@ -1000,51 +1121,16 @@ export async function exchangeAuthorization(
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
const grantType = 'authorization_code';
const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri);
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
if (metadata?.grant_types_supported && !metadata.grant_types_supported.includes(grantType)) {
throw new Error(`Incompatible auth server: does not support grant type ${grantType}`);
}
// Exchange code for tokens
const headers = new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
return executeTokenRequest(authorizationServerUrl, {
metadata,
tokenRequestParams,
clientInformation,
addClientAuthentication,
resource,
fetchFn
});
const params = new URLSearchParams({
grant_type: grantType,
code: authorizationCode,
code_verifier: codeVerifier,
redirect_uri: String(redirectUri)
});
if (addClientAuthentication) {
addClientAuthentication(headers, params, authorizationServerUrl, metadata);
} else {
// Determine and apply client authentication method
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
applyClientAuthentication(authMethod, clientInformation, headers, params);
}
if (resource) {
params.set('resource', resource.href);
}
const response = await (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers,
body: params
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return OAuthTokensSchema.parse(await response.json());
}
/**
@ -1077,52 +1163,96 @@ export async function refreshAuthorization(
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
const grantType = 'refresh_token';
let tokenUrl: URL;
if (metadata) {
tokenUrl = new URL(metadata.token_endpoint);
if (metadata.grant_types_supported && !metadata.grant_types_supported.includes(grantType)) {
throw new Error(`Incompatible auth server: does not support grant type ${grantType}`);
}
} else {
tokenUrl = new URL('/token', authorizationServerUrl);
}
// Exchange refresh token
const headers = new Headers({
'Content-Type': 'application/x-www-form-urlencoded'
});
const params = new URLSearchParams({
grant_type: grantType,
const tokenRequestParams = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
});
if (addClientAuthentication) {
addClientAuthentication(headers, params, authorizationServerUrl, metadata);
} else {
// Determine and apply client authentication method
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
applyClientAuthentication(authMethod, clientInformation, headers, params);
}
if (resource) {
params.set('resource', resource.href);
}
const response = await (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers,
body: params
const tokens = await executeTokenRequest(authorizationServerUrl, {
metadata,
tokenRequestParams,
clientInformation,
addClientAuthentication,
resource,
fetchFn
});
if (!response.ok) {
throw await parseErrorResponse(response);
// Preserve original refresh token if server didn't return a new one
return { refresh_token: refreshToken, ...tokens };
}
/**
* Unified token fetching that works with any grant type via provider.prepareTokenRequest().
*
* This function provides a single entry point for obtaining tokens regardless of the
* OAuth grant type. The provider's prepareTokenRequest() method determines which grant
* to use and supplies the grant-specific parameters.
*
* @param provider - OAuth client provider that implements prepareTokenRequest()
* @param authorizationServerUrl - The authorization server's base URL
* @param options - Configuration for the token request
* @returns Promise resolving to OAuth tokens
* @throws {Error} When provider doesn't implement prepareTokenRequest or token fetch fails
*
* @example
* // Provider for client_credentials:
* class MyProvider implements OAuthClientProvider {
* prepareTokenRequest(scope) {
* const params = new URLSearchParams({ grant_type: 'client_credentials' });
* if (scope) params.set('scope', scope);
* return params;
* }
* // ... other methods
* }
*
* const tokens = await fetchToken(provider, authServerUrl, { metadata });
*/
export async function fetchToken(
provider: OAuthClientProvider,
authorizationServerUrl: string | URL,
{
metadata,
resource,
authorizationCode,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
resource?: URL;
/** Authorization code for the default authorization_code grant flow */
authorizationCode?: string;
fetchFn?: FetchLike;
} = {}
): Promise<OAuthTokens> {
const scope = provider.clientMetadata.scope;
// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
let tokenRequestParams: URLSearchParams | undefined;
if (provider.prepareTokenRequest) {
tokenRequestParams = await provider.prepareTokenRequest(scope);
}
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
// Default to authorization_code grant if no custom prepareTokenRequest
if (!tokenRequestParams) {
if (!authorizationCode) {
throw new Error('Either provider.prepareTokenRequest() or authorizationCode is required');
}
if (!provider.redirectUrl) {
throw new Error('redirectUrl is required for authorization_code flow');
}
const codeVerifier = await provider.codeVerifier();
tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl);
}
const clientInformation = await provider.clientInformation();
return executeTokenRequest(authorizationServerUrl, {
metadata,
tokenRequestParams,
clientInformation: clientInformation ?? undefined,
addClientAuthentication: provider.addClientAuthentication,
resource,
fetchFn
});
}
/**

View file

@ -42,6 +42,12 @@ Example client with OAuth:
npx tsx src/examples/client/simpleOAuthClient.ts <optional-server-url> <optional-client-metadata-url>
```
Client credentials (machine-to-machine) example:
```bash
npx tsx src/examples/client/simpleClientCredentials.ts
```
### Backwards Compatible Client
A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates:

View file

@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Example demonstrating client_credentials grant for machine-to-machine authentication.
*
* Supports two authentication methods based on environment variables:
*
* 1. client_secret_basic (default):
* MCP_CLIENT_ID - OAuth client ID (required)
* MCP_CLIENT_SECRET - OAuth client secret (required)
*
* 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set):
* MCP_CLIENT_ID - OAuth client ID (required)
* MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required)
* MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256)
*
* Common:
* MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp)
*/
import { Client } from '../../client/index.js';
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../client/auth-extensions.js';
import { OAuthClientProvider } from '../../client/auth.js';
const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp';
function createProvider(): OAuthClientProvider {
const clientId = process.env.MCP_CLIENT_ID;
if (!clientId) {
console.error('MCP_CLIENT_ID environment variable is required');
process.exit(1);
}
// If private key is provided, use private_key_jwt authentication
const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM;
if (privateKeyPem) {
const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256';
console.log('Using private_key_jwt authentication');
return new PrivateKeyJwtProvider({
clientId,
privateKey: privateKeyPem,
algorithm
});
}
// Otherwise, use client_secret_basic authentication
const clientSecret = process.env.MCP_CLIENT_SECRET;
if (!clientSecret) {
console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required');
process.exit(1);
}
console.log('Using client_secret_basic authentication');
return new ClientCredentialsProvider({
clientId,
clientSecret
});
}
async function main() {
const provider = createProvider();
const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), {
authProvider: provider
});
await client.connect(transport);
console.log('Connected successfully.');
const tools = await client.listTools();
console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
await transport.close();
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View file

@ -114,6 +114,7 @@ describe('Revocation Handler', () => {
}
throw new InvalidTokenError('Token is invalid or expired');
}
// No revokeToken method
};

View file

@ -135,10 +135,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
res.status(200).json(tokens);
break;
}
// Not supported right now
//case "client_credentials":
// Additional auth methods will not be added on the server side of the SDK.
case 'client_credentials':
default:
throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.');
}

View file

@ -32,26 +32,18 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew
if (!result.success) {
throw new InvalidRequestError(String(result.error));
}
const { client_id, client_secret } = result.data;
const client = await clientsStore.getClient(client_id);
if (!client) {
throw new InvalidClientError('Invalid client_id');
}
// If client has a secret, validate it
if (client.client_secret) {
// Check if client_secret is required but not provided
if (!client_secret) {
throw new InvalidClientError('Client secret is required');
}
// Check if client_secret matches
if (client.client_secret !== client_secret) {
throw new InvalidClientError('Invalid client_secret');
}
// Check if client_secret has expired
if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) {
throw new InvalidClientError('Client secret has expired');
}

View file

@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node'
environment: 'node',
setupFiles: ['./vitest.setup.ts']
}
});

8
vitest.setup.ts Normal file
View file

@ -0,0 +1,8 @@
import { webcrypto } from 'node:crypto';
// Polyfill globalThis.crypto for environments (e.g. Node 18) where it is not defined.
// This is necessary for the tests to run in Node 18, specifically for the jose library, which relies on the globalThis.crypto object.
if (typeof globalThis.crypto === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).crypto = webcrypto as unknown as Crypto;
}