typescript-sdk/examples/oauth-client-credentials/client.ts
Felix Weinberger 1823aae891
Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-26 14:01:10 +01:00

58 lines
2.8 KiB
TypeScript

/**
* Self-verifying `client_credentials` client.
*
* 1. A bare request is `401` with a `WWW-Authenticate` challenge that names the
* Protected Resource Metadata URL.
* 2. A `Client` with a {@linkcode ClientCredentialsProvider} on its transport
* follows that challenge → AS metadata → `POST /token` with
* `grant_type=client_credentials` (HTTP Basic `client_id:client_secret`) →
* Bearer token → reaches the `whoami` tool, whose `ctx.authInfo` carries the
* granted scopes.
*
* No browser, no readline. The SDK's auth driver does the discovery; the only
* thing the caller supplies is the pre-registered client's id+secret.
*/
import { check, parseExampleArgs } from '@mcp-examples/shared';
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const { url, era } = parseExampleArgs();
// Unauthenticated → 401 + WWW-Authenticate naming the PRM URL.
const unauth = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
});
check.equal(unauth.status, 401, 'bare request must be 401');
check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/);
check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/);
// Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes.
const provider = new ClientCredentialsProvider({
clientId: 'demo-m2m-client',
clientSecret: 'demo-m2m-secret',
scope: 'mcp:tools mcp:read'
});
const client = new Client(
{ name: 'client-credentials-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
);
await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: provider }));
const tokens = provider.tokens();
check.ok(tokens?.access_token, 'ClientCredentialsProvider obtained an access_token');
check.equal(tokens?.token_type, 'Bearer');
const result = await client.callTool({ name: 'whoami', arguments: {} });
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
const seen = JSON.parse(text) as { clientId: string; scopes: string[] };
check.equal(seen.clientId, 'demo-m2m-client', 'ctx.authInfo.clientId round-trips');
check.ok(seen.scopes.includes('mcp:tools'), 'ctx.authInfo.scopes carries the granted scope');
// Expiry: both the demo verifier and `requireBearerAuth` reject when
// `AuthInfo.expiresAt` is in the past, so an expired token would 401 here
// exactly like the bare-request leg above. Minting an expired token would
// mean reaching past the AS's public surface, so the path is documented
// rather than exercised.
await client.close();