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

28
examples/oauth/README.md Normal file
View file

@ -0,0 +1,28 @@
# oauth
The **authorization-code** OAuth grant — the interactive "user signs in and approves" flow — against an in-repo OAuth-protected MCP server.
- `server.ts` — `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:PORT+1`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:PORT/mcp`, advertising the AS via
`createProtectedResourceMetadataRouter` (RFC 9728). DEMO ONLY — the AS auto-signs-in a fixed user, and with `OAUTH_DEMO_AUTO_CONSENT=1` it also auto-approves the consent screen.
- `client.ts` — **CI-runnable headless flow.** Drives the same SDK auth machinery as the browser client, but instead of `open()`ing the authorization URL it follows the 302 chain itself with `fetch(..., { redirect: 'manual' })` (the demo AS's auto-sign-in + auto-consent collapse
every interactive step into a redirect), reads the callback query off the final `Location` header, calls `transport.finishAuth(url.searchParams)` (so the SDK reads `code` + `iss` per RFC 9207), reconnects, and asserts `ctx.authInfo` round-trips. This is what `pnpm run:examples` runs.
- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — **manual real-browser flow.** Full authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server on `:8090`, exchanges the code, then drops into a small `list`/`call`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
- `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens.
## Run it
```bash
# headless (what CI does) — terminal 1: AS (:3001) + protected RS (:3000/mcp), auto-consent on
OAUTH_DEMO_AUTO_CONSENT=1 pnpm --filter @mcp-examples/oauth server
# terminal 2: follows the 302 chain, exchanges the code, asserts whoami
pnpm --filter @mcp-examples/oauth client -- --http http://127.0.0.1:3000/mcp
# manual real-browser flow — terminal 1: same server (auto-consent optional)
pnpm --filter @mcp-examples/oauth server
# terminal 2: opens a browser to the demo AS, callback server on :8090, then a list/call REPL
pnpm --filter @mcp-examples/oauth client:browser
```
For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`.

159
examples/oauth/client.ts Normal file
View file

@ -0,0 +1,159 @@
/**
* Self-verifying **authorization-code** OAuth client — the CI-runnable headless
* twin of {@link ./simpleOAuthClient.ts}.
*
* `simpleOAuthClient.ts` is the manual real-user example: it `open()`s the
* authorization URL in a real browser, the user signs in and clicks **Approve**
* on the consent screen, the browser is redirected to a local callback server,
* and the example reads the `code` off that callback. THIS file drives the
* exact same SDK auth machinery but follows the redirect chain itself — which
* only works because the demo Authorization Server is started with
* `OAUTH_DEMO_AUTO_CONSENT=1` so its `/sign-in` page auto-signs-in a fixed
* demo user and its `/authorize` endpoint auto-consents (skips the Approve
* screen and 302s straight back to `redirect_uri?code=...`).
*
* Flow:
* 1. Connect with an {@linkcode InMemoryOAuthClientProvider} → 401 → SDK auth
* driver discovers PRM → AS metadata → registers a client (DCR) → builds
* the authorization URL → calls our `redirectToAuthorization` hook (we
* capture the URL) → throws {@linkcode UnauthorizedError}.
* 2. Follow that URL with `fetch(..., { redirect: 'manual' })`, forwarding
* `Set-Cookie` → `Cookie` across hops, until the AS 302s to our
* `redirect_uri` with `?code=...`. No callback server is bound — the code
* is read straight off the `Location` header.
* 3. `transport.finishAuth(code)` → SDK exchanges the code (+ PKCE verifier)
* for tokens at the AS `/token` endpoint and saves them on the provider.
* 4. Reconnect with a fresh transport (same provider, now holding tokens) →
* Bearer header → 200. Call `whoami` and assert `ctx.authInfo` round-trips.
*
* HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the
* canonical stdio branch does not apply.
*/
import { check, parseExampleArgs } from '@mcp-examples/shared';
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
// The redirect target the AS will 302 back to with `?code=...`. In the real
// browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the
// browser has somewhere to land; headlessly we never bind it — we read the
// `code` off the final 302's `Location` header instead.
const CALLBACK_URL = 'http://127.0.0.1:8090/callback';
/**
* Follow an authorization URL through the demo AS's redirect chain
* (authorize → /sign-in → authorize → redirect_uri?code=...) and return the
* `code`. This is the headless stand-in for "the user's browser navigates the
* login + consent pages": cookies are forwarded hop-to-hop the way a browser
* would, and the demo AS's auto-sign-in + `autoConsent` collapse every
* interactive step into a 302.
*/
async function followAuthorizationRedirects(authorizationUrl: URL): Promise<URLSearchParams> {
let next = authorizationUrl.href;
// Crude cookie jar — enough for a single-origin demo AS.
const jar = new Map<string, string>();
for (let hop = 0; hop < 10; hop++) {
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
// In a real client this is `open(authorizationUrl)` — we follow the redirect
// chain headlessly because the demo AS auto-signs-in and auto-approves.
const res = await fetch(next, { redirect: 'manual', headers: cookie ? { cookie } : {} });
for (const sc of res.headers.getSetCookie()) {
const pair = sc.split(';', 1)[0] ?? '';
const eq = pair.indexOf('=');
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
const location = res.headers.get('location');
if (!location || res.status < 300 || res.status >= 400) {
const body = await res.text().catch(() => '');
throw new Error(`expected a redirect at hop ${hop} (${next}); got ${res.status}\n${body.slice(0, 400)}`);
}
const resolved = new globalThis.URL(location, next);
// In a real deployment, the browser would render the consent page here and
// the user would click Approve; the demo AS's `autoConsent` flag simulates
// that approval, so the chain ends in a 302 straight to `redirect_uri`.
if (resolved.href.startsWith(CALLBACK_URL)) {
const code = resolved.searchParams.get('code');
const error = resolved.searchParams.get('error');
if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`);
if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`);
return resolved.searchParams;
}
next = resolved.href;
}
throw new Error('authorization redirect chain did not terminate at the callback within 10 hops');
}
const { url, era } = parseExampleArgs();
const client = new Client(
{ name: 'oauth-headless-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
);
// ---- 1. Kick off the SDK auth driver --------------------------------------
// The SDK builds the authorization URL and hands it to
// `redirectToAuthorization` — in `simpleOAuthClient.ts` that opens a browser;
// here we just capture it.
let capturedAuthorizationUrl: URL | undefined;
const clientMetadata: OAuthClientMetadata = {
client_name: 'Headless OAuth MCP Client (CI)',
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',
token_endpoint_auth_method: 'client_secret_post'
};
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => {
capturedAuthorizationUrl = authUrl;
});
const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider });
let challenged = false;
try {
await client.connect(firstTransport);
} catch (error) {
// Under `--legacy` the transport surfaces `UnauthorizedError` directly;
// under `mode: 'auto'` the version-negotiation probe is what got 401'd
// and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause`
// is the original `UnauthorizedError`. Either way the auth driver has
// already run by the time we land here — DCR done, auth URL captured.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
challenged = true;
}
check.ok(challenged, 'first connect must 401 and throw UnauthorizedError');
check.ok(capturedAuthorizationUrl, 'SDK auth driver should have produced an authorization URL');
check.ok(provider.clientInformation()?.client_id, 'dynamic client registration should have run');
// ---- 2. Follow the authorization URL headlessly ---------------------------
// (the browser-and-user stand-in; see `followAuthorizationRedirects`).
const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!);
// ---- 3. Exchange the code for tokens --------------------------------------
// In the browser flow the local callback server hands the redirect query to
// `transport.finishAuth`; we read it off the final `Location` header instead.
// The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss`
// against the recorded issuer, then POSTs `grant_type=authorization_code`
// (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens
// on `provider`.
await firstTransport.finishAuth(callbackParams);
const tokens = provider.tokens();
check.ok(tokens?.access_token, 'token exchange should have yielded an access_token');
check.equal(tokens?.token_type, 'Bearer');
// ---- 4. Reconnect with the now-populated provider -------------------------
// A fresh transport reads the saved Bearer token from `provider` and the
// protected `/mcp` endpoint lets us through.
const transport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider });
await client.connect(transport);
const result = await client.callTool({ name: 'whoami', arguments: {} });
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
const seen = JSON.parse(text) as { clientId?: string; scopes?: string[] };
// `ctx.authInfo` round-trips: the clientId the AS minted at DCR time is the
// one the Resource Server's verifier sees on the Bearer token.
check.equal(seen.clientId, provider.clientInformation()?.client_id, 'ctx.authInfo.clientId round-trips the DCR client_id');
check.ok(seen.scopes?.includes('openid'), 'ctx.authInfo.scopes carries a granted scope');
await client.close();

View file

@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Two auth patterns through the same `authProvider` option.
*
* The transport accepts either a minimal `AuthProvider` (just `token()` +
* optional `onUnauthorized()`) or a full `OAuthClientProvider`, adapting
* the latter automatically. This means your connect/call code is identical
* regardless of which pattern fits your deployment.
*
* HOST-MANAGED — token lives in an enclosing app
* The app fetches and stores tokens; the MCP client just reads them.
* On 401, there is nothing to refresh — signal the UI and throw so the
* user can re-authenticate through the host's flow.
*
* USER-CONFIGURED — OAuth credentials supplied directly
* Pass a built-in or custom OAuthClientProvider. The transport handles
* the full OAuth flow: token refresh on 401, or redirect for interactive
* authorization.
*/
import type { AuthProvider } from '@modelcontextprotocol/client';
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
// --- Stubs for host-app integration points ---------------------------------
/** Whatever the host app uses to store session state (e.g., cookies, keychain, in-memory). */
interface HostSessionStore {
getMcpToken(): string | undefined;
}
/** Whatever the host app uses to surface UI prompts. */
interface HostUi {
showReauthPrompt(message: string): void;
}
// --- MODE A: Host-managed auth ---------------------------------------------
function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, ui: HostUi): StreamableHTTPClientTransport {
const authProvider: AuthProvider = {
// Called before every request — just read whatever the host has.
token: async () => session.getMcpToken(),
// Called on 401 — don't refresh (the host owns the token), signal the UI and bail.
// The transport will retry once after this returns, so we throw to stop it:
// the user needs to act before a retry makes sense.
onUnauthorized: async () => {
ui.showReauthPrompt('MCP connection lost — click to reconnect');
throw new UnauthorizedError('Host token rejected — user action required');
}
};
return new StreamableHTTPClientTransport(serverUrl, { authProvider });
}
// --- MODE B: User-configured OAuth -----------------------------------------
function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport {
// Built-in OAuth provider — the transport adapts it to AuthProvider internally.
// On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized,
// which runs token refresh (or redirect for interactive flows).
const authProvider = new ClientCredentialsProvider({ clientId, clientSecret });
return new StreamableHTTPClientTransport(serverUrl, { authProvider });
}
// --- Same caller code for both modes ---------------------------------------
async function connectAndList(transport: StreamableHTTPClientTransport): Promise<void> {
const client = new Client({ name: 'dual-mode-example', version: '1.0.0' }, { capabilities: {} });
await client.connect(transport);
const tools = await client.listTools();
console.log('Tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
await transport.close();
}
// --- Driver ----------------------------------------------------------------
async function main() {
const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp');
const mode = process.argv[2] || 'host';
let transport: StreamableHTTPClientTransport;
if (mode === 'host') {
// Simulate a host app with a session-stored token and a UI hook.
const session: HostSessionStore = { getMcpToken: () => process.env.MCP_TOKEN };
const ui: HostUi = { showReauthPrompt: msg => console.error(`[UI] ${msg}`) };
transport = createHostManagedTransport(serverUrl, session, ui);
} else if (mode === 'oauth') {
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode');
process.exit(1);
}
transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret);
} else {
console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`);
process.exit(1);
}
// Same connect/list code regardless of mode — the transport abstracts the difference.
await connectAndList(transport);
}
try {
await main();
} catch (error) {
console.error('Error:', error);
process.exitCode = 1;
}

View file

@ -0,0 +1,35 @@
{
"name": "@mcp-examples/oauth",
"private": true,
"type": "module",
"scripts": {
"server": "tsx server.ts",
"client": "tsx client.ts",
"client:browser": "tsx simpleOAuthClient.ts"
},
"dependencies": {
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/express": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"cors": "catalog:runtimeServerOnly",
"open": "^11.0.0",
"zod": "catalog:runtimeShared"
},
"devDependencies": {
"@types/cors": "catalog:devTools",
"tsx": "catalog:devTools"
},
"example": {
"transports": [
"http"
],
"era": "dual",
"path": "/mcp",
"env": {
"OAUTH_DEMO_AUTO_CONSENT": "1"
},
"//": "client.ts drives the full authorization-code flow headlessly because OAUTH_DEMO_AUTO_CONSENT=1 makes the demo AS auto-sign-in + auto-approve, collapsing the browser dance into a 302 chain. simpleOAuthClient.ts is the manual real-browser flow — run via `pnpm client:browser`."
}
}

89
examples/oauth/server.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* 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`);
});

View file

@ -0,0 +1,426 @@
#!/usr/bin/env node
import { createServer } from 'node:http';
import { createInterface } from 'node:readline';
import { URL } from 'node:url';
import type { ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
import open from 'open';
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
// Configuration
const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp';
const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
/** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */
function escHtml(s: string): string {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#x27;');
}
/**
* Interactive MCP client with OAuth authentication
* Demonstrates the complete OAuth flow with browser-based authorization
*/
class InteractiveOAuthClient {
private client: Client | null = null;
private readonly rl = createInterface({
input: process.stdin,
output: process.stdout
});
constructor(
private serverUrl: string,
private clientMetadataUrl?: string
) {}
/**
* Prompts user for input via readline
*/
private async question(query: string): Promise<string> {
return new Promise(resolve => {
this.rl.question(query, resolve);
});
}
/**
* Opens the authorization URL in the user's default browser
*/
private static readonly ALLOWED_SCHEMES = new Set(['http:', 'https:']);
private async openBrowser(url: string): Promise<void> {
console.log(`🌐 Opening browser for authorization: ${url}`);
try {
const parsed = new URL(url);
if (!InteractiveOAuthClient.ALLOWED_SCHEMES.has(parsed.protocol)) {
console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`);
return;
}
} catch {
console.error(`Invalid URL: ${url}`);
return;
}
try {
await open(url);
} catch {
console.log(`Please manually open: ${url}`);
}
}
/**
* Example OAuth callback handler - in production, use a more robust approach
* for handling callbacks and storing tokens
*/
/**
* Starts a temporary HTTP server to receive the OAuth callback
*/
private async waitForOAuthCallback(): Promise<URLSearchParams> {
return new Promise<URLSearchParams>((resolve, reject) => {
const server = createServer((req, res) => {
// Ignore favicon requests
if (req.url === '/favicon.ico') {
res.writeHead(404);
res.end();
return;
}
console.log(`📥 Received callback: ${req.url}`);
const parsedUrl = new URL(req.url || '', 'http://localhost');
const code = parsedUrl.searchParams.get('code');
const error = parsedUrl.searchParams.get('error');
if (code) {
console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
// Hand back the whole query — finishAuth() reads `code` + `iss` (RFC 9207) itself.
resolve(parsedUrl.searchParams);
setTimeout(() => server.close(), 3000);
} else if (error) {
console.log(`❌ Authorization error: ${error}`);
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: ${escHtml(error)}</p>
</body>
</html>
`);
reject(new Error(`OAuth authorization failed: ${error}`));
} else {
console.log(`❌ No authorization code or error in callback`);
res.writeHead(400);
res.end('Bad request');
reject(new Error('No authorization code provided'));
}
});
server.listen(CALLBACK_PORT, () => {
console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
});
});
}
private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
console.log('🚢 Creating transport with OAuth provider...');
const baseUrl = new URL(this.serverUrl);
const transport = new StreamableHTTPClientTransport(baseUrl, {
authProvider: oauthProvider
});
console.log('🚢 Transport created');
try {
console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
await this.client!.connect(transport);
console.log('✅ Connected successfully');
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log('🔐 OAuth required - waiting for authorization...');
const callbackParams = await this.waitForOAuthCallback();
// Pass the whole callback query — the SDK extracts `code` and validates
// `iss` against the recorded issuer (RFC 9207) before exchanging the code.
await transport.finishAuth(callbackParams);
console.log('🔐 Authorization code received:', callbackParams.get('code'));
console.log('🔌 Reconnecting with authenticated transport...');
await this.attemptConnection(oauthProvider);
} else {
console.error('❌ Connection failed with non-auth error:', error);
throw error;
}
}
}
/**
* Establishes connection to the MCP server with OAuth authentication
*/
async connect(): Promise<void> {
console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
const clientMetadata: OAuthClientMetadata = {
client_name: 'Simple OAuth MCP Client',
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',
token_endpoint_auth_method: 'client_secret_post'
};
console.log('🔐 Creating OAuth provider...');
const oauthProvider = new InMemoryOAuthClientProvider(
CALLBACK_URL,
clientMetadata,
(redirectUrl: URL) => {
console.log(`📌 OAuth redirect handler called - opening browser`);
console.log(`Opening browser to: ${redirectUrl.toString()}`);
this.openBrowser(redirectUrl.toString());
},
this.clientMetadataUrl
);
console.log('🔐 OAuth provider created');
console.log('👤 Creating MCP client...');
this.client = new Client(
{
name: 'simple-oauth-client',
version: '1.0.0'
},
{ capabilities: {} }
);
console.log('👤 Client created');
console.log('🔐 Starting OAuth flow...');
await this.attemptConnection(oauthProvider);
// Start interactive loop
await this.interactiveLoop();
}
/**
* Main interactive loop for user commands
*/
async interactiveLoop(): Promise<void> {
console.log('\n🎯 Interactive MCP Client with OAuth');
console.log('Commands:');
console.log(' list - List available tools');
console.log(' call <tool_name> [args] - Call a tool');
console.log(' stream <tool_name> [args] - (disabled; returns when the SEP-2663 tasks extension lands)');
console.log(' quit - Exit the client');
console.log();
while (true) {
try {
const command = await this.question('mcp> ');
if (!command.trim()) {
continue;
}
if (command === 'quit') {
console.log('\n👋 Goodbye!');
this.close();
process.exit(0);
} else if (command === 'list') {
await this.listTools();
} else if (command.startsWith('call ')) {
await this.handleCallTool(command);
} else if (command.startsWith('stream ')) {
await this.handleStreamTool(command);
} else {
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'");
}
} catch (error) {
if (error instanceof Error && error.message === 'SIGINT') {
console.log('\n\n👋 Goodbye!');
break;
}
console.error('❌ Error:', error);
}
}
}
private async listTools(): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
try {
const request: ListToolsRequest = {
method: 'tools/list',
params: {}
};
const result = await this.client.request(request);
if (result.tools && result.tools.length > 0) {
console.log('\n📋 Available tools:');
for (const [index, tool] of result.tools.entries()) {
console.log(`${index + 1}. ${tool.name}`);
if (tool.description) {
console.log(` Description: ${tool.description}`);
}
console.log();
}
} else {
console.log('No tools available');
}
} catch (error) {
console.error('❌ Failed to list tools:', error);
}
}
private async handleCallTool(command: string): Promise<void> {
const parts = command.split(/\s+/);
const toolName = parts[1];
if (!toolName) {
console.log('❌ Please specify a tool name');
return;
}
// Parse arguments (simple JSON-like format)
let toolArgs: Record<string, unknown> = {};
if (parts.length > 2) {
const argsString = parts.slice(2).join(' ');
try {
toolArgs = JSON.parse(argsString);
} catch {
console.log('❌ Invalid arguments format (expected JSON)');
return;
}
}
await this.callTool(toolName, toolArgs);
}
private async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
try {
const result = await this.client.callTool({
name: toolName,
arguments: toolArgs
});
console.log(`\n🔧 Tool '${toolName}' result:`);
if (result.content) {
for (const content of result.content) {
if (content.type === 'text') {
console.log(content.text);
} else {
console.log(content);
}
}
} else {
console.log(result);
}
} catch (error) {
console.error(`❌ Failed to call tool '${toolName}':`, error);
}
}
private async handleStreamTool(command: string): Promise<void> {
const parts = command.split(/\s+/);
const toolName = parts[1];
if (!toolName) {
console.log('❌ Please specify a tool name');
return;
}
// Parse arguments (simple JSON-like format)
let toolArgs: Record<string, unknown> = {};
if (parts.length > 2) {
const argsString = parts.slice(2).join(' ');
try {
toolArgs = JSON.parse(argsString);
} catch {
console.log('❌ Invalid arguments format (expected JSON)');
return;
}
}
await this.streamTool(toolName, toolArgs);
}
private async streamTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
// The streaming-tool demo (callToolStream) was removed with the 2025-11
// experimental tasks (SEP-2663); it returns when the tasks extension lands.
void toolName;
void toolArgs;
console.log('Streaming tool demo removed with the 2025-11 experimental tasks (SEP-2663); returns when the tasks extension lands.');
}
close(): void {
this.rl.close();
if (this.client) {
// Note: Client doesn't have a close method in the current implementation
// This would typically close the transport connection
}
}
}
/**
* Main entry point
*/
async function main(): Promise<void> {
const args = process.argv.slice(2);
const serverUrl = args[0] || DEFAULT_SERVER_URL;
const clientMetadataUrl = args[1];
console.log('🚀 Simple MCP OAuth Client');
console.log(`Connecting to: ${serverUrl}`);
if (clientMetadataUrl) {
console.log(`Client Metadata URL: ${clientMetadataUrl}`);
}
console.log();
const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl);
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Goodbye!');
client.close();
process.exit(0);
});
try {
await client.connect();
} catch (error) {
console.error('Failed to start client:', error);
process.exit(1);
} finally {
client.close();
}
}
try {
// Run if this file is executed directly
await main();
} catch (error) {
console.error('Error running client:', error);
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
}

View file

@ -0,0 +1,98 @@
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens
} from '@modelcontextprotocol/client';
import { validateClientMetadataUrl } from '@modelcontextprotocol/client';
/**
* In-memory OAuth client provider for demonstration purposes.
* In production, you should persist tokens and client credentials securely.
*
* Tokens and client credentials are stored as single-slot blobs. The SDK stamps an
* `issuer` field onto every value it saves; round-tripping the blob unchanged means
* a credential issued by one authorization server is never reused at another (the
* SDK reads the stamp back as a key-not-found and re-registers / re-authorizes).
* To hold credentials for several authorization servers at once, key your storage
* on the `ctx.issuer` argument instead.
*/
export class InMemoryOAuthClientProvider implements OAuthClientProvider {
private _clientInformation?: OAuthClientInformationMixed;
private _tokens?: OAuthTokens;
private _codeVerifier?: string;
private _discoveryState?: OAuthDiscoveryState;
constructor(
private readonly _redirectUrl: string | URL,
private readonly _clientMetadata: OAuthClientMetadata,
onRedirect?: (url: URL) => void,
public readonly clientMetadataUrl?: string
) {
// Validate clientMetadataUrl at construction time (fail-fast)
validateClientMetadataUrl(clientMetadataUrl);
this._onRedirect =
onRedirect ||
(url => {
console.log(`Redirect to: ${url.toString()}`);
});
}
private _onRedirect: (url: URL) => void;
get redirectUrl(): string | URL {
return this._redirectUrl;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformationMixed | undefined {
return this._clientInformation;
}
saveClientInformation(clientInformation: OAuthClientInformationMixed): void {
this._clientInformation = clientInformation;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
redirectToAuthorization(authorizationUrl: URL): void {
this._onRedirect(authorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void {
this._codeVerifier = codeVerifier;
}
codeVerifier(): string {
if (!this._codeVerifier) {
throw new Error('No code verifier saved');
}
return this._codeVerifier;
}
saveDiscoveryState(state: OAuthDiscoveryState): void {
this._discoveryState = state;
}
discoveryState(): OAuthDiscoveryState | undefined {
return this._discoveryState;
}
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
if (scope === 'all' || scope === 'client') this._clientInformation = undefined;
if (scope === 'all' || scope === 'tokens') this._tokens = undefined;
if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined;
if (scope === 'all' || scope === 'discovery') this._discoveryState = undefined;
}
}

View file

@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Example demonstrating the minimal AuthProvider for bearer token authentication.
*
* AuthProvider is the base interface for all client auth. For simple cases where
* tokens are managed externally — pre-configured API tokens, gateway/proxy patterns,
* or tokens obtained through a separate auth flow — implement only `token()`.
*
* For OAuth flows (client_credentials, private_key_jwt, etc.), use the built-in
* providers which implement both `token()` and `onUnauthorized()`.
*
* Environment variables:
* MCP_SERVER_URL - Server URL (default: http://127.0.0.1:3000/mcp)
* MCP_TOKEN - Bearer token to use for authentication (required)
*/
import type { AuthProvider } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp';
async function main() {
const token = process.env.MCP_TOKEN;
if (!token) {
console.error('MCP_TOKEN environment variable is required');
process.exit(1);
}
// AuthProvider with just token() — the simplest possible auth.
// token() is called before every request, so it can handle refresh internally.
// With no onUnauthorized(), a 401 throws UnauthorizedError immediately.
const authProvider: AuthProvider = {
token: async () => token
};
const client = new Client({ name: 'auth-provider-example', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { authProvider });
await client.connect(transport);
console.log('Connected successfully.');
const tools = await client.listTools();
console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
await transport.close();
}
try {
await main();
} catch (error) {
console.error('Error running client:', error);
process.exitCode = 1;
}