Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions examples/client/src/externalAuthServerClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env node

/**
* MCP Client for External Auth Server Example
*
* DEMO ONLY - NOT FOR PRODUCTION
*
* Connects to an MCP server that uses an external OAuth2 authorization server.
* Demonstrates the full OAuth flow:
*
* 1. Client connects to MCP server, receives 401 with resource metadata URL
* 2. Client fetches protected resource metadata to discover the external AS
* 3. Client fetches AS metadata (/.well-known/oauth-authorization-server)
* 4. Client dynamically registers with the AS
* 5. Client redirects user to AS for authorization (auto-approved in demo)
* 6. Client exchanges authorization code for JWT access token
* 7. Client connects to MCP server with the JWT Bearer token
*
* Usage:
* pnpm --filter @modelcontextprotocol/examples-client exec tsx src/externalAuthServerClient.ts [server-url]
*/

import { createServer } from 'node:http';

import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
import open from 'open';

import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js';

// --- Configuration ---

const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp';
const CALLBACK_PORT = 8090;
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;

// --- OAuth callback server ---

async function waitForOAuthCallback(): Promise<string> {
return new Promise<string>((resolve, reject) => {
const server = createServer((req, res) => {
if (req.url === '/favicon.ico') {
res.writeHead(404);
res.end();
return;
}

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>
`);
resolve(code);
setTimeout(() => server.close(), 3000);
} else if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: ${error}</p>
</body>
</html>
`);
reject(new Error(`OAuth authorization failed: ${error}`));
} else {
res.writeHead(400);
res.end('Bad request');
reject(new Error('No authorization code provided'));
}
});

server.listen(CALLBACK_PORT, () => {
console.log(`OAuth callback server listening on http://localhost:${CALLBACK_PORT}`);
});
});
}

// --- Helpers ---

async function openBrowser(url: string): Promise<void> {
console.log(`Opening browser for authorization: ${url}`);
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
console.error(`Refusing to open URL with unsupported scheme: ${url}`);
return;
}
await open(url);
} catch {
console.log(`Please manually open: ${url}`);
}
}

// --- Main ---

async function main(): Promise<void> {
const serverUrl = process.argv[2] || DEFAULT_SERVER_URL;

console.log('MCP Client with External Auth Server');
console.log(`Connecting to: ${serverUrl}`);
console.log();

// Set up OAuth client metadata for dynamic registration
const clientMetadata: OAuthClientMetadata = {
client_name: 'MCP External Auth Client',
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post'
};

// Create OAuth provider (handles token storage and redirect)
const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => {
openBrowser(redirectUrl.toString());
});

// Create MCP client
const client = new Client({ name: 'external-auth-client', version: '1.0.0' }, { capabilities: {} });

// Attempt connection with retry on auth challenge
async function attemptConnection(): Promise<void> {
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: oauthProvider
});

try {
console.log('Attempting connection...');
await client.connect(transport);
console.log('Connected successfully!');
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log('Authentication required. Starting OAuth flow with external AS...');
const callbackPromise = waitForOAuthCallback();
const authCode = await callbackPromise;
await transport.finishAuth(authCode);
console.log('Authorization complete. Reconnecting...');
await attemptConnection();
} else {
throw error;
}
}
}

await attemptConnection();

// List available tools
console.log('\nListing available tools...');
const toolsRequest: ListToolsRequest = { method: 'tools/list', params: {} };
const toolsResult = await client.request(toolsRequest);

if (toolsResult.tools && toolsResult.tools.length > 0) {
console.log('Available tools:');
for (const tool of toolsResult.tools) {
console.log(` - ${tool.name}: ${tool.description || '(no description)'}`);
}
}

// Call the greet tool
console.log('\nCalling greet tool...');
const greetResult = (await client.callTool({ name: 'greet', arguments: { name: 'World' } })) as CallToolResult;
for (const content of greetResult.content) {
if (content.type === 'text') {
console.log(` Result: ${content.text}`);
}
}

// Call the whoami tool
console.log('\nCalling whoami tool...');
const whoamiResult = (await client.callTool({ name: 'whoami', arguments: {} })) as CallToolResult;
for (const content of whoamiResult.content) {
if (content.type === 'text') {
console.log(` Result: ${content.text}`);
}
}

console.log('\nDone! All authenticated calls succeeded.');
process.exit(0);
}

// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down...');
process.exit(0);
});

try {
await main();
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
1 change: 1 addition & 0 deletions examples/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
| External auth server (RFC 8707) | MCP server + separate OAuth2 AS with JWT tokens and JWKS verification. | [`src/externalAuthServer/`](src/externalAuthServer/README.md) |

## OAuth demo flags (Streamable HTTP server)

Expand Down
1 change: 1 addition & 0 deletions examples/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"cors": "catalog:runtimeServerOnly",
"express": "catalog:runtimeServerOnly",
"hono": "catalog:runtimeServerOnly",
"jose": "catalog:runtimeClientOnly",
"valibot": "catalog:devTools",
"zod": "catalog:runtimeShared"
},
Expand Down
100 changes: 100 additions & 0 deletions examples/server/src/externalAuthServer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# External Auth Server Example

Demonstrates MCP authentication using an **external OAuth2 authorization server** separate from the MCP resource server. This follows the RFC 8707 pattern where the authorization server (AS) and resource server (RS) are independent services.

## Architecture

```
┌──────────┐ ┌────────────────────┐ ┌──────────────────────┐
│ │ 1. 401 │ │ │ │
│ Client │◄────────│ MCP Resource │ │ External OAuth AS │
│ │ │ Server (:3000) │ │ (:3001) │
│ │ │ │ │ │
│ │ 2. Fetch protected resource │ │ - /authorize │
│ │────────►│ metadata │ │ - /token │
│ │◄────────│ (points to AS) │ │ - /register │
│ │ │ │ │ - /jwks │
│ │ 3. OAuth flow │ │ - /.well-known/ │
│ │────────────────────────────────────────►│ oauth-authz-srv │
│ │◄────────────────────────────────────────│ │
│ │ │ │ │ │
│ │ 4. MCP │ │ 5. JWT │ │
│ │ + JWT │ │ verify │ │
│ │────────►│ │────────►│ /jwks │
│ │◄────────│ │◄────────│ │
└──────────┘ └────────────────────┘ └──────────────────────┘
```

## How it works

1. Client connects to MCP server, gets a 401 with `resource_metadata` URL in the `WWW-Authenticate` header
2. Client fetches `/.well-known/oauth-protected-resource/mcp` from the MCP server
3. Protected resource metadata contains `authorization_servers: ["http://localhost:3001"]`
4. Client fetches `/.well-known/oauth-authorization-server` from the external AS
5. Client dynamically registers, gets redirected for authorization, exchanges code for JWT token
6. Client retries MCP connection with the JWT Bearer token
7. MCP server verifies the JWT signature via the AS's JWKS endpoint, checks issuer and audience

## Key concepts

- **RFC 9728 (Protected Resource Metadata)**: The MCP server advertises which authorization server(s) clients should use
- **RFC 8707 (Resource Indicators)**: The `resource` parameter binds tokens to a specific MCP server URL. The JWT `aud` claim is set to the MCP server's URL.
- **RFC 9068 (JWT Access Tokens)**: Tokens are self-contained JWTs, verified via JWKS without calling back to the AS
- **RFC 7591 (Dynamic Client Registration)**: Clients register themselves with the AS on first use

## Running the example

From the SDK root:

```bash
# Terminal 1: Start the external authorization server
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthServer/authServer.ts

# Terminal 2: Start the MCP resource server
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthServer/resourceServer.ts

# Terminal 3: Run the client
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/externalAuthServerClient.ts
```

Or from within the example directories:

```bash
# Terminal 1
cd examples/server && pnpm tsx src/externalAuthServer/authServer.ts

# Terminal 2
cd examples/server && pnpm tsx src/externalAuthServer/resourceServer.ts

# Terminal 3
cd examples/client && pnpm tsx src/externalAuthServerClient.ts
```

## Environment variables

| Variable | Default | Description |
| ----------------- | --------------------------- | ---------------------------------------------------------- |
| `AUTH_PORT` | `3001` | Port for the external authorization server |
| `MCP_PORT` | `3000` | Port for the MCP resource server |
| `AUTH_SERVER_URL` | `http://localhost:3001` | URL of the external AS (used by resource server) |
| `MCP_SERVER_URL` | `http://localhost:3000/mcp` | URL of the MCP resource (used by auth server for audience) |

## Differences from simpleStreamableHttp --oauth

The existing `simpleStreamableHttp.ts --oauth` example uses `better-auth` as a co-located auth server running inside the same process. This example demonstrates a fully **decoupled** architecture:

| | simpleStreamableHttp --oauth | externalAuthServer |
| ------------------ | ------------------------------- | ------------------------- |
| Auth server | Co-located (better-auth) | Separate process |
| Token format | Opaque (better-auth session) | JWT (RFC 9068) |
| Token verification | Database lookup via better-auth | JWKS (no shared state) |
| Token binding | Session-based | Audience claim (RFC 8707) |
| Dependencies | better-auth, better-sqlite3 | jose (JWT/JWKS only) |

## Extending this example

- **Add token introspection**: Implement `/introspect` on the AS for opaque token support
- **Add token revocation**: Implement `/revoke` on the AS for logout flows
- **Add OIDC**: Extend the AS to return ID tokens alongside access tokens
- **Add scopes**: Check `scope` claims in the JWT for fine-grained access control
- **Production deployment**: Replace in-memory stores with a database, add real user authentication
Loading
Loading