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:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69131 additions and 9001 deletions

View 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`.

View 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();

View 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."
}
}

View 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}`));