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>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
1 changed files with 69131 additions and 9001 deletions
58
examples/oauth-client-credentials/client.ts
Normal file
58
examples/oauth-client-credentials/client.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Reference in a new issue