Skip to content

Commit 9efa772

Browse files
committed
feat(examples): add external auth server example (RFC 8707)
Adds a new example demonstrating MCP authentication with an external OAuth2 authorization server, as requested in #658. The example consists of three components: - External auth server: standalone OAuth2 AS that issues JWT tokens with JWKS endpoint for signature verification - MCP resource server: validates JWT tokens via JWKS, serves protected resource metadata pointing to the external AS - Client: discovers AS via resource metadata, authenticates, connects Implements RFC 8707 (resource indicators), RFC 9728 (protected resource metadata), RFC 9068 (JWT access tokens), RFC 7591 (dynamic client registration), and RFC 7636 (PKCE). Closes #658
1 parent ccb78f2 commit 9efa772

File tree

7 files changed

+911
-0
lines changed

7 files changed

+911
-0
lines changed
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* MCP Client for External Auth Server Example
5+
*
6+
* DEMO ONLY - NOT FOR PRODUCTION
7+
*
8+
* Connects to an MCP server that uses an external OAuth2 authorization server.
9+
* Demonstrates the full OAuth flow:
10+
*
11+
* 1. Client connects to MCP server, receives 401 with resource metadata URL
12+
* 2. Client fetches protected resource metadata to discover the external AS
13+
* 3. Client fetches AS metadata (/.well-known/oauth-authorization-server)
14+
* 4. Client dynamically registers with the AS
15+
* 5. Client redirects user to AS for authorization (auto-approved in demo)
16+
* 6. Client exchanges authorization code for JWT access token
17+
* 7. Client connects to MCP server with the JWT Bearer token
18+
*
19+
* Usage:
20+
* pnpm --filter @modelcontextprotocol/examples-client exec tsx src/externalAuthServerClient.ts [server-url]
21+
*/
22+
23+
import { createServer } from 'node:http';
24+
25+
import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
26+
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
27+
import open from 'open';
28+
29+
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js';
30+
31+
// --- Configuration ---
32+
33+
const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp';
34+
const CALLBACK_PORT = 8090;
35+
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
36+
37+
// --- OAuth callback server ---
38+
39+
async function waitForOAuthCallback(): Promise<string> {
40+
return new Promise<string>((resolve, reject) => {
41+
const server = createServer((req, res) => {
42+
if (req.url === '/favicon.ico') {
43+
res.writeHead(404);
44+
res.end();
45+
return;
46+
}
47+
48+
const parsedUrl = new URL(req.url || '', 'http://localhost');
49+
const code = parsedUrl.searchParams.get('code');
50+
const error = parsedUrl.searchParams.get('error');
51+
52+
if (code) {
53+
console.log(`Authorization code received: ${code.slice(0, 10)}...`);
54+
res.writeHead(200, { 'Content-Type': 'text/html' });
55+
res.end(`
56+
<html>
57+
<body>
58+
<h1>Authorization Successful!</h1>
59+
<p>You can close this window and return to the terminal.</p>
60+
<script>setTimeout(() => window.close(), 2000);</script>
61+
</body>
62+
</html>
63+
`);
64+
resolve(code);
65+
setTimeout(() => server.close(), 3000);
66+
} else if (error) {
67+
res.writeHead(400, { 'Content-Type': 'text/html' });
68+
res.end(`
69+
<html>
70+
<body>
71+
<h1>Authorization Failed</h1>
72+
<p>Error: ${error}</p>
73+
</body>
74+
</html>
75+
`);
76+
reject(new Error(`OAuth authorization failed: ${error}`));
77+
} else {
78+
res.writeHead(400);
79+
res.end('Bad request');
80+
reject(new Error('No authorization code provided'));
81+
}
82+
});
83+
84+
server.listen(CALLBACK_PORT, () => {
85+
console.log(`OAuth callback server listening on http://localhost:${CALLBACK_PORT}`);
86+
});
87+
});
88+
}
89+
90+
// --- Helpers ---
91+
92+
async function openBrowser(url: string): Promise<void> {
93+
console.log(`Opening browser for authorization: ${url}`);
94+
try {
95+
const parsed = new URL(url);
96+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
97+
console.error(`Refusing to open URL with unsupported scheme: ${url}`);
98+
return;
99+
}
100+
await open(url);
101+
} catch {
102+
console.log(`Please manually open: ${url}`);
103+
}
104+
}
105+
106+
// --- Main ---
107+
108+
async function main(): Promise<void> {
109+
const serverUrl = process.argv[2] || DEFAULT_SERVER_URL;
110+
111+
console.log('MCP Client with External Auth Server');
112+
console.log(`Connecting to: ${serverUrl}`);
113+
console.log();
114+
115+
// Set up OAuth client metadata for dynamic registration
116+
const clientMetadata: OAuthClientMetadata = {
117+
client_name: 'MCP External Auth Client',
118+
redirect_uris: [CALLBACK_URL],
119+
grant_types: ['authorization_code', 'refresh_token'],
120+
response_types: ['code'],
121+
token_endpoint_auth_method: 'client_secret_post'
122+
};
123+
124+
// Create OAuth provider (handles token storage and redirect)
125+
const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => {
126+
openBrowser(redirectUrl.toString());
127+
});
128+
129+
// Create MCP client
130+
const client = new Client({ name: 'external-auth-client', version: '1.0.0' }, { capabilities: {} });
131+
132+
// Attempt connection with retry on auth challenge
133+
async function attemptConnection(): Promise<void> {
134+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
135+
authProvider: oauthProvider
136+
});
137+
138+
try {
139+
console.log('Attempting connection...');
140+
await client.connect(transport);
141+
console.log('Connected successfully!');
142+
} catch (error) {
143+
if (error instanceof UnauthorizedError) {
144+
console.log('Authentication required. Starting OAuth flow with external AS...');
145+
const callbackPromise = waitForOAuthCallback();
146+
const authCode = await callbackPromise;
147+
await transport.finishAuth(authCode);
148+
console.log('Authorization complete. Reconnecting...');
149+
await attemptConnection();
150+
} else {
151+
throw error;
152+
}
153+
}
154+
}
155+
156+
await attemptConnection();
157+
158+
// List available tools
159+
console.log('\nListing available tools...');
160+
const toolsRequest: ListToolsRequest = { method: 'tools/list', params: {} };
161+
const toolsResult = await client.request(toolsRequest);
162+
163+
if (toolsResult.tools && toolsResult.tools.length > 0) {
164+
console.log('Available tools:');
165+
for (const tool of toolsResult.tools) {
166+
console.log(` - ${tool.name}: ${tool.description || '(no description)'}`);
167+
}
168+
}
169+
170+
// Call the greet tool
171+
console.log('\nCalling greet tool...');
172+
const greetResult = (await client.callTool({ name: 'greet', arguments: { name: 'World' } })) as CallToolResult;
173+
for (const content of greetResult.content) {
174+
if (content.type === 'text') {
175+
console.log(` Result: ${content.text}`);
176+
}
177+
}
178+
179+
// Call the whoami tool
180+
console.log('\nCalling whoami tool...');
181+
const whoamiResult = (await client.callTool({ name: 'whoami', arguments: {} })) as CallToolResult;
182+
for (const content of whoamiResult.content) {
183+
if (content.type === 'text') {
184+
console.log(` Result: ${content.text}`);
185+
}
186+
}
187+
188+
console.log('\nDone! All authenticated calls succeeded.');
189+
process.exit(0);
190+
}
191+
192+
// Handle graceful shutdown
193+
process.on('SIGINT', () => {
194+
console.log('\nShutting down...');
195+
process.exit(0);
196+
});
197+
198+
try {
199+
await main();
200+
} catch (error) {
201+
console.error('Error:', error);
202+
process.exit(1);
203+
}

examples/server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts
3838
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
3939
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
4040
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
41+
| External auth server (RFC 8707) | MCP server + separate OAuth2 AS with JWT tokens and JWKS verification. | [`src/externalAuthServer/`](src/externalAuthServer/) |
4142

4243
## OAuth demo flags (Streamable HTTP server)
4344

examples/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"cors": "catalog:runtimeServerOnly",
4343
"express": "catalog:runtimeServerOnly",
4444
"hono": "catalog:runtimeServerOnly",
45+
"jose": "catalog:runtimeClientOnly",
4546
"zod": "catalog:runtimeShared"
4647
},
4748
"devDependencies": {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# External Auth Server Example
2+
3+
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.
4+
5+
## Architecture
6+
7+
```
8+
┌──────────┐ ┌────────────────────┐ ┌──────────────────────┐
9+
│ │ 1. 401 │ │ │ │
10+
│ Client │◄────────│ MCP Resource │ │ External OAuth AS │
11+
│ │ │ Server (:3000) │ │ (:3001) │
12+
│ │ │ │ │ │
13+
│ │ 2. Fetch protected resource │ │ - /authorize │
14+
│ │────────►│ metadata │ │ - /token │
15+
│ │◄────────│ (points to AS) │ │ - /register │
16+
│ │ │ │ │ - /jwks │
17+
│ │ 3. OAuth flow │ │ - /.well-known/ │
18+
│ │────────────────────────────────────────►│ oauth-authz-srv │
19+
│ │◄────────────────────────────────────────│ │
20+
│ │ │ │ │ │
21+
│ │ 4. MCP │ │ 5. JWT │ │
22+
│ │ + JWT │ │ verify │ │
23+
│ │────────►│ │────────►│ /jwks │
24+
│ │◄────────│ │◄────────│ │
25+
└──────────┘ └────────────────────┘ └──────────────────────┘
26+
```
27+
28+
## How it works
29+
30+
1. Client connects to MCP server, gets a 401 with `resource_metadata` URL in the `WWW-Authenticate` header
31+
2. Client fetches `/.well-known/oauth-protected-resource/mcp` from the MCP server
32+
3. Protected resource metadata contains `authorization_servers: ["http://localhost:3001"]`
33+
4. Client fetches `/.well-known/oauth-authorization-server` from the external AS
34+
5. Client dynamically registers, gets redirected for authorization, exchanges code for JWT token
35+
6. Client retries MCP connection with the JWT Bearer token
36+
7. MCP server verifies the JWT signature via the AS's JWKS endpoint, checks issuer and audience
37+
38+
## Key concepts
39+
40+
- **RFC 9728 (Protected Resource Metadata)**: The MCP server advertises which authorization server(s) clients should use
41+
- **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.
42+
- **RFC 9068 (JWT Access Tokens)**: Tokens are self-contained JWTs, verified via JWKS without calling back to the AS
43+
- **RFC 7591 (Dynamic Client Registration)**: Clients register themselves with the AS on first use
44+
45+
## Running the example
46+
47+
From the SDK root:
48+
49+
```bash
50+
# Terminal 1: Start the external authorization server
51+
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthServer/authServer.ts
52+
53+
# Terminal 2: Start the MCP resource server
54+
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthServer/resourceServer.ts
55+
56+
# Terminal 3: Run the client
57+
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/externalAuthServerClient.ts
58+
```
59+
60+
Or from within the example directories:
61+
62+
```bash
63+
# Terminal 1
64+
cd examples/server && pnpm tsx src/externalAuthServer/authServer.ts
65+
66+
# Terminal 2
67+
cd examples/server && pnpm tsx src/externalAuthServer/resourceServer.ts
68+
69+
# Terminal 3
70+
cd examples/client && pnpm tsx src/externalAuthServerClient.ts
71+
```
72+
73+
## Environment variables
74+
75+
| Variable | Default | Description |
76+
| ----------------- | --------------------------- | ---------------------------------------------------------- |
77+
| `AUTH_PORT` | `3001` | Port for the external authorization server |
78+
| `MCP_PORT` | `3000` | Port for the MCP resource server |
79+
| `AUTH_SERVER_URL` | `http://localhost:3001` | URL of the external AS (used by resource server) |
80+
| `MCP_SERVER_URL` | `http://localhost:3000/mcp` | URL of the MCP resource (used by auth server for audience) |
81+
82+
## Differences from simpleStreamableHttp --oauth
83+
84+
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:
85+
86+
| | simpleStreamableHttp --oauth | externalAuthServer |
87+
| ------------------ | ------------------------------- | ------------------------- |
88+
| Auth server | Co-located (better-auth) | Separate process |
89+
| Token format | Opaque (better-auth session) | JWT (RFC 9068) |
90+
| Token verification | Database lookup via better-auth | JWKS (no shared state) |
91+
| Token binding | Session-based | Audience claim (RFC 8707) |
92+
| Dependencies | better-auth, better-sqlite3 | jose (JWT/JWKS only) |
93+
94+
## Extending this example
95+
96+
- **Add token introspection**: Implement `/introspect` on the AS for opaque token support
97+
- **Add token revocation**: Implement `/revoke` on the AS for logout flows
98+
- **Add OIDC**: Extend the AS to return ID tokens alongside access tokens
99+
- **Add scopes**: Check `scope` claims in the JWT for fine-grained access control
100+
- **Production deployment**: Replace in-memory stores with a database, add real user authentication

0 commit comments

Comments
 (0)