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
4 changed files with 69131 additions and 9001 deletions
41
examples/oauth-client-credentials/README.md
Normal file
41
examples/oauth-client-credentials/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# oauth-client-credentials
|
||||
|
||||
OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, fully self-verifying with no browser.
|
||||
|
||||
`client_credentials` is the grant a backend service uses to authenticate **as itself** (not on behalf of a user): it presents a pre-registered `client_id`/`client_secret` directly to the Authorization Server's token endpoint and receives a Bearer access token. There is no
|
||||
redirect, no authorization code, no user consent screen.
|
||||
|
||||
The interactive **authorization-code** flow (the one that opens a browser and asks a human to sign in) lives under [`../oauth/`](../oauth/README.md); the runner drives it headlessly via the demo AS's `OAUTH_DEMO_AUTO_CONSENT=1` auto-approve mode.
|
||||
|
||||
## What runs
|
||||
|
||||
- `server.ts` starts two listeners in one process:
|
||||
- the MCP **resource server** on `--port` — `createMcpHandler` behind `requireBearerAuth` from `@modelcontextprotocol/express`, advertising the AS via `mcpAuthMetadataRouter` (RFC 9728 + RFC 8414).
|
||||
- a minimal **`client_credentials`-only Authorization Server** on `--port + 1` (`createClientCredentialsAuthServer` from `@mcp-examples/shared`). The repo's full better-auth/OIDC demo AS only implements `authorization_code`, so this story ships its own purpose-built AS.
|
||||
- `client.ts` first asserts a bare request is `401` with a `WWW-Authenticate` challenge, then connects with a `ClientCredentialsProvider` on the transport. The SDK auth driver discovers the AS from the challenge, posts `grant_type=client_credentials` (HTTP Basic auth) to
|
||||
`/token`, attaches the returned Bearer token, and the `whoami` tool's `ctx.authInfo` carries the granted `clientId` and `scopes` end to end.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
pnpm --filter @mcp-examples/oauth-client-credentials server -- --http --port 3000
|
||||
pnpm --filter @mcp-examples/oauth-client-credentials client -- --http http://127.0.0.1:3000/mcp
|
||||
```
|
||||
|
||||
HTTP-only; runs on both protocol eras (the client honours `--legacy` via `parseExampleArgs().era`).
|
||||
|
||||
## `private_key_jwt` client authentication
|
||||
|
||||
To authenticate the `client_credentials` grant with a signed JWT assertion (RFC 7523 §2.2) instead of a shared secret, swap `ClientCredentialsProvider` for `PrivateKeyJwtProvider`:
|
||||
|
||||
```ts
|
||||
import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client';
|
||||
|
||||
const authProvider = new PrivateKeyJwtProvider({
|
||||
clientId: 'my-service',
|
||||
privateKey: pemEncodedKey,
|
||||
algorithm: 'RS256'
|
||||
});
|
||||
```
|
||||
|
||||
The full snippet lives in the client guide (`docs/client.md` → `auth_privateKeyJwt`). There is no runnable leg for it in this story — the in-repo `client_credentials` AS only implements `client_secret_basic`/`client_secret_post`.
|
||||
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();
|
||||
28
examples/oauth-client-credentials/package.json
Normal file
28
examples/oauth-client-credentials/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@mcp-examples/oauth-client-credentials",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/express": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"transports": [
|
||||
"http"
|
||||
],
|
||||
"era": "dual",
|
||||
"path": "/mcp",
|
||||
"//": "OAuth client_credentials is HTTP-layer and era-agnostic; the client honours --legacy via parseExampleArgs().era."
|
||||
}
|
||||
}
|
||||
77
examples/oauth-client-credentials/server.ts
Normal file
77
examples/oauth-client-credentials/server.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* OAuth 2.0 **`client_credentials`** grant — the machine-to-machine story.
|
||||
*
|
||||
* One process hosts both halves on adjacent ports:
|
||||
*
|
||||
* - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind
|
||||
* `requireBearerAuth`, advertising the AS via `mcpAuthMetadataRouter`
|
||||
* (RFC 9728 Protected Resource Metadata + RFC 8414 AS metadata).
|
||||
* - `:PORT+1` — a minimal in-repo **Authorization Server** that supports the
|
||||
* `client_credentials` grant only (`@mcp-examples/shared`'s
|
||||
* `createClientCredentialsAuthServer`). The full better-auth/OIDC demo AS
|
||||
* only implements `authorization_code`, hence this purpose-built one.
|
||||
*
|
||||
* The client (`./client.ts`) discovers the AS from a 401 challenge, exchanges
|
||||
* its `client_id`/`client_secret` for a Bearer token at `/token`, and reaches
|
||||
* the `whoami` tool — which echoes `ctx.authInfo` so the client can assert the
|
||||
* granted scopes round-tripped end to end. HTTP-only by definition.
|
||||
*/
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { createClientCredentialsAuthServer } from '@mcp-examples/shared/auth';
|
||||
import {
|
||||
createMcpExpressApp,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter,
|
||||
requireBearerAuth
|
||||
} from '@modelcontextprotocol/express';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const { port } = parseExampleArgs();
|
||||
const AUTH_PORT = port + 1;
|
||||
// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the
|
||||
// runner passes the client byte-for-byte — the SDK auth driver enforces that.
|
||||
const mcpServerUrl = new URL(`http://127.0.0.1:${port}/mcp`);
|
||||
const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}/`);
|
||||
|
||||
// Demo confidential client. DEMO ONLY — never hard-code real credentials.
|
||||
export const DEMO_CLIENT = { clientId: 'demo-m2m-client', clientSecret: 'demo-m2m-secret', allowedScopes: ['mcp:tools', 'mcp:read'] };
|
||||
|
||||
// ---- Authorization Server (client_credentials only) ----
|
||||
const as = createClientCredentialsAuthServer({ authServerUrl, clients: [DEMO_CLIENT] });
|
||||
as.app.listen(AUTH_PORT, () => console.error(`[auth-server] client_credentials AS on ${authServerUrl.href}`));
|
||||
|
||||
// ---- Resource Server (MCP) ----
|
||||
const handler = createMcpHandler(ctx => {
|
||||
const server = new McpServer({ name: 'oauth-client-credentials-example', version: '1.0.0' });
|
||||
server.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Returns the authenticated client and its granted scopes.', inputSchema: z.object({}) },
|
||||
async () => ({
|
||||
content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }]
|
||||
})
|
||||
);
|
||||
return server;
|
||||
});
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata: as.metadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools', 'mcp:read'],
|
||||
resourceName: 'oauth-client-credentials example'
|
||||
})
|
||||
);
|
||||
const auth = requireBearerAuth({
|
||||
verifier: as.verifier,
|
||||
requiredScopes: ['mcp:tools'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
|
||||
// to the factory as `ctx.authInfo`.
|
||||
const node = toNodeHandler(handler);
|
||||
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
|
||||
|
||||
app.listen(port, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`));
|
||||
Loading…
Reference in a new issue