typescript-sdk/examples/oauth-client-credentials/server.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

77 lines
3.5 KiB
TypeScript

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