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>
58 lines
2.8 KiB
TypeScript
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();
|