typescript-sdk/examples/oauth/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

89 lines
4.4 KiB
TypeScript

/**
* In-repo OAuth-protected MCP server for the **authorization-code** flow — the
* demo Resource Server that {@link ./client.ts} (headless, CI) and
* {@link ./simpleOAuthClient.ts} (manual, real browser) authenticate against.
*
* One process, two listeners on adjacent ports:
*
* - `:PORT+1` — the demo **Authorization Server** (`setupAuthServer` from
* `@mcp-examples/shared`, backed by better-auth's OIDC plugin). It
* implements the `authorization_code` grant only and auto-signs-in a fixed
* demo user. With `OAUTH_DEMO_AUTO_CONSENT=1` it also **auto-consents** —
* the `/authorize` endpoint skips the consent UI and 302s straight back to
* `redirect_uri?code=...`, so the whole browser dance becomes a chain of
* redirects a headless client can follow.
* - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind
* `requireBearerAuth({ verifier: demoTokenVerifier })`, advertising the AS
* via `createProtectedResourceMetadataRouter` (RFC 9728) so the client's
* discovery from a `401` `WWW-Authenticate` challenge works.
*
* DEMO ONLY — NOT FOR PRODUCTION. The demo AS auto-approves a fixed user; CORS
* allows every origin; tokens are validated in-process against the same demo
* AS instance.
*
* HTTP-only (Bearer auth has no stdio equivalent), so the canonical
* `if (transport === 'stdio')` branch does not apply.
*/
import { parseExampleArgs } from '@mcp-examples/shared';
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared/auth';
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import cors from 'cors';
import * as z from 'zod/v4';
function buildServer(ctx: McpRequestContext): McpServer {
const server = new McpServer({ name: 'oauth-protected-example', version: '1.0.0' });
server.registerTool(
'whoami',
{ description: 'Returns the authenticated subject and granted scopes.', inputSchema: z.object({}) },
async () => ({
content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }]
})
);
return server;
}
const { port } = parseExampleArgs();
const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 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}`);
// ---- Authorization Server (better-auth OIDC; authorization_code only) ----
// `autoConsent` is the demo-only switch that turns the consent screen into an
// immediate 302 — set by the runner so `./client.ts` can run without a browser.
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, autoConsent: process.env.OAUTH_DEMO_AUTO_CONSENT === '1' });
// ---- Resource Server (MCP) ----
const handler = createMcpHandler(buildServer);
const app = createMcpExpressApp();
// DEMO ONLY — restrict `origin` in production. `exposedHeaders` lists the
// response headers a browser-based MCP client must be able to read.
app.use(
cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version']
})
);
// RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp
// — the client discovers the AS from the 401 challenge → this route → AS metadata.
app.use(createProtectedResourceMetadataRouter('/mcp'));
const auth = requireBearerAuth({
verifier: demoTokenVerifier,
requiredScopes: [],
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(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`);
console.error(` Protected Resource Metadata: http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp`);
});