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>
89 lines
4.4 KiB
TypeScript
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`);
|
|
});
|