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:
parent
e4227d135c
commit
1823aae891
6 changed files with 69131 additions and 9001 deletions
61
examples/shared/src/args.ts
Normal file
61
examples/shared/src/args.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
135
examples/shared/src/clientCredentialsAuthServer.ts
Normal file
135
examples/shared/src/clientCredentialsAuthServer.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
77
examples/shared/src/inMemoryEventStore.ts
Normal file
77
examples/shared/src/inMemoryEventStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
19
examples/shared/src/indexAuth.ts
Normal file
19
examples/shared/src/indexAuth.ts
Normal 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';
|
||||
Loading…
Reference in a new issue