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
6 changed files with 69131 additions and 9001 deletions

View file

@ -0,0 +1,61 @@
/**
* Argv-parsing scaffold shared by every `examples/<story>/` pair.
*
* Intentionally **zero SDK API calls** in this module — it is pure
* `process.argv` plumbing plus an assert wrapper. Each story's
* `server.ts`/`client.ts` shows the real `@modelcontextprotocol/*` calls
* inline (the canonical shape; see `examples/CONTRIBUTING.md`). This module
* only DRYs the parts a reader is not here to learn: flag parsing and
* sibling-path resolution.
*
* Re-exported `check` is `node:assert/strict` for readable inline assertions.
*/
import { fileURLToPath } from 'node:url';
export { strict as check } from 'node:assert';
/**
* Resolve a sibling of the calling module to an absolute filesystem path
* (`fileURLToPath` handles Windows drive letters and percent-encoded segments,
* which `new URL(...).pathname` does not). Used by every story's stdio leg to
* spawn its companion `server.ts` — the path-resolution part of that line is
* scaffolding, so it lives here rather than being repeated in each `client.ts`.
*/
export function siblingPath(importMetaUrl: string | URL, name: string): string {
return fileURLToPath(new URL(name, importMetaUrl));
}
export type ExampleTransport = 'stdio' | 'http';
export type ExampleEra = 'modern' | 'legacy';
export interface ExampleArgs {
/** `'http'` under `--http`, otherwise `'stdio'`. */
transport: ExampleTransport;
/** `--port <N>` (or `$PORT`, or 3000) — meaningful on the server side. */
port: number;
/** `--http <url>` (or `http://127.0.0.1:<port>/mcp`) — meaningful on the client side. */
url: string;
/** `'legacy'` under `--legacy`, otherwise `'modern'` (negotiates 2026-07-28). */
era: ExampleEra;
}
/**
* Parse `process.argv` into the four knobs every example branches on.
*
* The example runner (`scripts/examples/run-examples.ts`) drives the same
* binary over each transport/era combination by passing `--http`, `--port`,
* `--http <url>` and `--legacy`; manual runs use the same flags.
*/
export function parseExampleArgs(defaultPort = 3000): ExampleArgs {
const argv = process.argv.slice(2);
const transport: ExampleTransport = argv.includes('--http') ? 'http' : 'stdio';
const era: ExampleEra = argv.includes('--legacy') ? 'legacy' : 'modern';
const portIdx = argv.indexOf('--port');
const port = portIdx === -1 ? Number(process.env.PORT ?? defaultPort) : Number(argv[portIdx + 1]);
const httpIdx = argv.indexOf('--http');
// A bare `argv[indexOf('--http') + 1]` reads `argv[0]` (the script path)
// when the flag is absent, so guard with `httpIdx === -1` first.
const url = httpIdx === -1 ? `http://127.0.0.1:${port}/mcp` : (argv[httpIdx + 1] ?? `http://127.0.0.1:${port}/mcp`);
return { transport, port, url, era };
}

View file

@ -15,7 +15,7 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import { toNodeHandler } from 'better-auth/node';
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
import cors from 'cors';
import type { Request, Response as ExpressResponse, Router } from 'express';
import type { NextFunction, Request, Response as ExpressResponse, Router } from 'express';
import express from 'express';
import type { DemoAuth } from './auth';
@ -34,6 +34,22 @@ export interface SetupAuthServerOptions {
* Only use for debugging purposes.
*/
dangerousLoggingEnabled?: boolean;
/**
* DEMO ONLY. When `true`, the `/api/auth/mcp/authorize` endpoint skips the
* consent screen entirely and immediately 302s back to the client's
* `redirect_uri` with an authorization `code` — exactly what would happen
* after a real user clicked **Approve**. Mechanically this strips the OIDC
* `prompt` parameter from the request before it reaches better-auth, so the
* MCP plugin's authorize handler takes its no-consent fast path. Combined
* with the `/sign-in` page that auto-signs-in the demo user, the entire
* authorization-code flow becomes a deterministic chain of 302s a headless
* client can follow with `fetch(..., { redirect: 'manual' })`.
*
* The `examples/oauth/` server enables this when
* `OAUTH_DEMO_AUTO_CONSENT=1` so the CI client (`client.ts`) can drive the
* full browser flow without a browser. NEVER enable in production.
*/
autoConsent?: boolean;
}
// Store auth instance globally so it can be used for token verification
@ -88,7 +104,7 @@ async function ensureDemoUserExists(auth: DemoAuth): Promise<void> {
* @param options - Server configuration
*/
export function setupAuthServer(options: SetupAuthServerOptions): void {
const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options;
const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false, autoConsent = false } = options;
// Create better-auth instance with MCP plugin
const auth = createDemoAuth({
@ -116,6 +132,58 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
// toNodeHandler bypasses Express methods
const betterAuthHandler = toNodeHandler(auth);
// The issuer identifier this AS publishes in its metadata; must exactly match the
// `issuer` value better-auth emits at /.well-known/oauth-authorization-server.
const issuer = authServerUrl.toString().replace(/\/$/, '');
const issuerOrigin = new URL(issuer).origin;
// RFC 9207 (SEP-2468): append `iss` to every authorization-response redirect (success
// and error) that targets the client's redirect_uri. better-auth does not emit `iss`
// itself yet, so intercept the 302 Location header. Internal hops (to /sign-in or back
// to /api/auth/mcp/authorize) are left untouched.
authApp.use('/api/auth/mcp/authorize', (_req: Request, res: ExpressResponse, next: NextFunction) => {
const originalWriteHead = res.writeHead.bind(res);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.writeHead = function (statusCode: number, ...args: any[]) {
const headers = args.find(a => typeof a === 'object' && a !== null) as Record<string, string> | undefined;
const loc = headers?.location ?? headers?.Location ?? (res.getHeader('Location') as string | undefined);
if (statusCode >= 300 && statusCode < 400 && loc && !loc.startsWith('/') && new URL(loc).origin !== issuerOrigin) {
const u = new URL(loc);
u.searchParams.set('iss', issuer);
if (headers && 'location' in headers) headers.location = u.href;
else if (headers && 'Location' in headers) headers.Location = u.href;
else res.setHeader('Location', u.href);
}
return originalWriteHead(statusCode, ...args);
} as typeof res.writeHead;
next();
});
// DEMO ONLY: simulate the user clicking "Approve" on the consent screen.
// The SDK auth driver appends `prompt=consent` whenever it requests the
// `offline_access` scope (per OIDC §11). With a real user, better-auth
// would render a consent UI and wait for an explicit Approve; here we drop
// `prompt` from the query before it reaches better-auth so its authorize
// handler takes the no-consent fast path and 302s straight back to
// `redirect_uri?code=...`. See {@link SetupAuthServerOptions.autoConsent}.
if (autoConsent) {
authApp.use((req: Request, _res: ExpressResponse, next: NextFunction) => {
const qmark = req.url.indexOf('?');
if (req.path === '/api/auth/mcp/authorize' && qmark !== -1) {
const search = new URLSearchParams(req.url.slice(qmark + 1));
if (search.has('prompt')) {
search.delete('prompt');
const qs = search.toString();
// toNodeHandler reconstructs the Fetch Request from req.url
// (req.baseUrl is empty at the app level), so rewriting it
// here is what better-auth's handler will see.
req.url = `/api/auth/mcp/authorize${qs ? `?${qs}` : ''}`;
}
}
next();
});
}
// Mount better-auth handler BEFORE body parsers
// toNodeHandler reads the raw request body, so Express must not consume it first
if (dangerousLoggingEnabled) {
@ -166,7 +234,15 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
// OAuth metadata endpoints using better-auth's built-in handlers
// Add explicit OPTIONS handler for CORS preflight
authApp.options('/.well-known/oauth-authorization-server', cors());
authApp.get('/.well-known/oauth-authorization-server', cors(), toNodeHandler(oAuthDiscoveryMetadata(auth)));
// Wrap better-auth's metadata to advertise RFC 9207 support (the `iss` middleware
// above makes that claim true).
const discoveryHandler = oAuthDiscoveryMetadata(auth);
authApp.get('/.well-known/oauth-authorization-server', cors(), async (req: Request, res: ExpressResponse) => {
const upstream = await discoveryHandler(new Request(new URL(req.originalUrl, issuer)));
const body = (await upstream.json()) as Record<string, unknown>;
body.authorization_response_iss_parameter_supported = true;
res.status(upstream.status).json(body);
});
// Body parsers for non-better-auth routes (like /sign-in)
authApp.use(express.json());

View file

@ -0,0 +1,135 @@
/**
* Minimal OAuth 2.0 Authorization Server supporting the **`client_credentials`**
* grant only — for the machine-to-machine MCP example.
*
* DEMO ONLY — NOT FOR PRODUCTION
*
* The full {@link setupAuthServer} (better-auth/OIDC) only supports the
* `authorization_code` grant; this is the headless counterpart so the
* `oauth-client-credentials/` example can be fully self-verifying without a
* browser.
*
* Exposes RFC 8414 metadata at `/.well-known/oauth-authorization-server` and a
* `/token` endpoint that accepts `client_secret_basic` or `client_secret_post`
* authentication. Issued access tokens are random opaque strings tracked in an
* in-memory map and validated by {@link clientCredentialsTokenVerifier}.
*/
import { randomBytes } from 'node:crypto';
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import cors from 'cors';
import express from 'express';
export interface RegisteredClient {
clientId: string;
clientSecret: string;
/** Scopes the AS is willing to grant this client (defaults to whatever it asks for). */
allowedScopes?: string[];
}
export interface ClientCredentialsAuthServerOptions {
/** Public base URL of this AS (issuer). */
authServerUrl: URL;
/** Pre-registered confidential clients. */
clients: RegisteredClient[];
}
export interface ClientCredentialsAuthServer {
app: express.Application;
metadata: OAuthMetadata;
/** Pass to `requireBearerAuth({ verifier })` on the Resource Server. */
verifier: OAuthTokenVerifier;
}
/** Tokens issued by the most-recently-created `client_credentials` AS. */
const issuedTokens = new Map<string, AuthInfo>();
/**
* Builds (but does not `listen()`) a minimal `client_credentials`-only
* Authorization Server. The caller mounts `app` on the port matching
* `authServerUrl`.
*/
export function createClientCredentialsAuthServer(options: ClientCredentialsAuthServerOptions): ClientCredentialsAuthServer {
const { authServerUrl, clients } = options;
const issuer = authServerUrl.href.replace(/\/$/, '');
const clientById = new Map(clients.map(c => [c.clientId, c]));
const metadata: OAuthMetadata = {
issuer,
token_endpoint: `${issuer}/token`,
// Required by the RFC 8414 schema even though this AS doesn't implement the endpoint.
authorization_endpoint: `${issuer}/authorize`,
response_types_supported: [],
grant_types_supported: ['client_credentials'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
scopes_supported: ['mcp:tools', 'mcp:read']
};
const app = express();
app.use(cors());
app.use(express.urlencoded({ extended: false }));
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
res.json(metadata);
});
app.post('/token', (req, res) => {
const body = req.body as Record<string, string>;
if (body.grant_type !== 'client_credentials') {
res.status(400).json({ error: 'unsupported_grant_type' });
return;
}
// RFC 6749 §2.3.1 — try Basic, then body.
let id: string | undefined;
let secret: string | undefined;
const authz = req.header('authorization');
if (authz?.startsWith('Basic ')) {
const decoded = Buffer.from(authz.slice(6), 'base64').toString('utf8');
const sep = decoded.indexOf(':');
id = decodeURIComponent(decoded.slice(0, sep));
secret = decodeURIComponent(decoded.slice(sep + 1));
} else {
id = body.client_id;
secret = body.client_secret;
}
const client = id ? clientById.get(id) : undefined;
if (!client || client.clientSecret !== secret) {
res.status(401).set('WWW-Authenticate', 'Basic realm="oauth"').json({ error: 'invalid_client' });
return;
}
const requested = (body.scope ?? '').split(' ').filter(Boolean);
const granted = client.allowedScopes ? requested.filter(s => client.allowedScopes!.includes(s)) : requested;
const accessToken = randomBytes(24).toString('base64url');
const expiresIn = 3600;
issuedTokens.set(accessToken, {
token: accessToken,
clientId: client.clientId,
scopes: granted,
expiresAt: Math.floor(Date.now() / 1000) + expiresIn
});
res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope: granted.join(' ') });
});
return { app, metadata, verifier: clientCredentialsTokenVerifier };
}
/**
* `OAuthTokenVerifier` that validates Bearer tokens against the in-memory
* issued-tokens map of {@link createClientCredentialsAuthServer}.
*/
export const clientCredentialsTokenVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<AuthInfo> {
const info = issuedTokens.get(token);
if (!info) throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
// Model expiry explicitly even in the demo so copy-paste users don't ship a fail-open verifier.
// `requireBearerAuth` also independently rejects when `AuthInfo.expiresAt` is in the past.
if (info.expiresAt !== undefined && Math.floor(Date.now() / 1000) >= info.expiresAt) {
issuedTokens.delete(token);
throw new OAuthError(OAuthErrorCode.InvalidToken, 'token expired');
}
return info;
}
};

View file

@ -0,0 +1,77 @@
import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server';
/**
* Simple in-memory implementation of the EventStore interface for resumability
* This is primarily intended for examples and testing, not for production use
* where a persistent storage solution would be more appropriate.
*/
export class InMemoryEventStore implements EventStore {
private events: Map<string, { streamId: string; message: JSONRPCMessage }> = new Map();
/**
* Generates a unique event ID for a given stream ID
*/
private generateEventId(streamId: string): string {
return `${streamId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Extracts the stream ID from an event ID
*/
private getStreamIdFromEventId(eventId: string): string {
const parts = eventId.split('_');
return parts.length > 0 ? parts[0]! : '';
}
/**
* Stores an event with a generated event ID
* Implements EventStore.storeEvent
*/
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
const eventId = this.generateEventId(streamId);
this.events.set(eventId, { streamId, message });
return eventId;
}
/**
* Replays events that occurred after a specific event ID
* Implements EventStore.replayEventsAfter
*/
async replayEventsAfter(
lastEventId: string,
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
): Promise<string> {
if (!lastEventId || !this.events.has(lastEventId)) {
return '';
}
// Extract the stream ID from the event ID
const streamId = this.getStreamIdFromEventId(lastEventId);
if (!streamId) {
return '';
}
let foundLastEvent = false;
// Sort events by eventId for chronological ordering
const sortedEvents = [...this.events.entries()].toSorted((a, b) => a[0].localeCompare(b[0]));
for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) {
// Only include events from the same stream
if (eventStreamId !== streamId) {
continue;
}
// Start sending events after we find the lastEventId
if (eventId === lastEventId) {
foundLastEvent = true;
continue;
}
if (foundLastEvent) {
await send(eventId, message);
}
}
return streamId;
}
}

View file

@ -1,7 +1,7 @@
// Auth configuration
export type { CreateDemoAuthOptions, DemoAuth } from './auth';
export { createDemoAuth } from './auth';
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
export type { SetupAuthServerOptions } from './authServer';
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer';
// Argv-parse + assert scaffold (NO SDK calls — those go inline in each story).
// This barrel is intentionally args-only so that the ~25 non-auth stories do
// not eagerly evaluate better-auth/express/cors/better-sqlite3 just by
// importing `parseExampleArgs`. The OAuth scaffolding lives at the `./auth`
// subpath — see `./indexAuth.ts`.
export type { ExampleArgs, ExampleEra, ExampleTransport } from './args';
export { check, parseExampleArgs, siblingPath } from './args';

View file

@ -0,0 +1,19 @@
// Auth + resumability scaffolding for the handful of stories that need it
// (`oauth`, `oauth-client-credentials`, `sse-polling`, `repl`). Kept off the
// root barrel so the other ~25 stories do not eagerly evaluate
// better-auth/express/cors/better-sqlite3 via `parseExampleArgs`.
// Auth configuration
export type { CreateDemoAuthOptions, DemoAuth } from './auth';
export { createDemoAuth } from './auth';
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
export type { SetupAuthServerOptions } from './authServer';
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer';
// In-memory EventStore for resumability examples (sse-polling, repl)
export { InMemoryEventStore } from './inMemoryEventStore';
// Minimal client_credentials-only AS (machine-to-machine; no browser)
export type { ClientCredentialsAuthServer, ClientCredentialsAuthServerOptions, RegisteredClient } from './clientCredentialsAuthServer';
export { clientCredentialsTokenVerifier, createClientCredentialsAuthServer } from './clientCredentialsAuthServer';