Skip to content

feat: Add support for separate Authorization Server / Resource server in server flow (spec: DRAFT-2025-v2) #503

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from 22 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
3 changes: 3 additions & 0 deletions src/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03

```bash
npx tsx src/examples/server/simpleStreamableHttp.ts

# To add a demo of authentication to this example, use:
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth
```

##### JSON Response Mode Server
Expand Down
200 changes: 200 additions & 0 deletions src/examples/server/demoInMemoryOAuthProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
import express, { Request, Response } from "express";
import { AuthInfo } from 'src/server/auth/types.js';
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';


export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
private clients = new Map<string, OAuthClientInformationFull>();

async getClient(clientId: string) {
return this.clients.get(clientId);
}

async registerClient(clientMetadata: OAuthClientInformationFull) {
this.clients.set(clientMetadata.client_id, clientMetadata);
return clientMetadata;
}
}

/**
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
*
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
* for example:
* - Persistent token storage
* - Rate limiting
*/
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
clientsStore = new DemoInMemoryClientsStore();
private codes = new Map<string, {
params: AuthorizationParams,
client: OAuthClientInformationFull}>();
private tokens = new Map<string, AuthInfo>();

async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): Promise<void> {
const code = randomUUID();

const searchParams = new URLSearchParams({
code,
});
if (params.state !== undefined) {
searchParams.set('state', params.state);
}

this.codes.set(code, {
client,
params
});

const targetUrl = new URL(client.redirect_uris[0]);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
}

async challengeForAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string
): Promise<string> {

// Store the challenge with the code data
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}

return codeData.params.codeChallenge;
}

async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
// Note: code verifier is checked in token.ts by default
// it's unused here for that reason.
_codeVerifier?: string
): Promise<OAuthTokens> {
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}

if (codeData.client.client_id !== client.client_id) {
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
}

this.codes.delete(authorizationCode);
const token = randomUUID();

const tokenData = {
token,
clientId: client.client_id,
scopes: codeData.params.scopes || [],
expiresAt: Date.now() + 3600000, // 1 hour
type: 'access'
};

this.tokens.set(token, tokenData);

return {
access_token: token,
token_type: 'bearer',
expires_in: 3600,
scope: (codeData.params.scopes || []).join(' '),
};
}

async exchangeRefreshToken(
_client: OAuthClientInformationFull,
_refreshToken: string,
_scopes?: string[]
): Promise<OAuthTokens> {
throw new Error('Not implemented for example demo');
}

async verifyAccessToken(token: string): Promise<AuthInfo> {
const tokenData = this.tokens.get(token);
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
throw new Error('Invalid or expired token');
}

return {
token,
clientId: tokenData.clientId,
scopes: tokenData.scopes,
expiresAt: Math.floor(tokenData.expiresAt / 1000),
};
}
}


export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
// Create separate auth server app
// NOTE: This is a separate app on a separate port to illustrate
// how to separate an OAuth Authorization Server from a Resource
// server in the SDK. The SDK is not intended to be provide a standalone
// authorization server.
const provider = new DemoInMemoryAuthProvider();
const authApp = express();
authApp.use(express.json());
// For introspection requests
authApp.use(express.urlencoded());

// Add OAuth routes to the auth server
// NOTE: this will also add a protected resource metadata route,
// but it won't be used, so leave it.
authApp.use(mcpAuthRouter({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
}));

authApp.post('/introspect', async (req: Request, res: Response) => {
try {
const { token } = req.body;
if (!token) {
res.status(400).json({ error: 'Token is required' });
return;
}

const tokenInfo = await provider.verifyAccessToken(token);
res.json({
active: true,
client_id: tokenInfo.clientId,
scope: tokenInfo.scopes.join(' '),
exp: tokenInfo.expiresAt
});
return
} catch (error) {
res.status(401).json({
active: false,
error: 'Unauthorized',
error_description: `Invalid token: ${error}`
});
}
});

const auth_port = authServerUrl.port;
// Start the auth server
authApp.listen(auth_port, () => {
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
});

// Note: we could fetch this from the server, but then we end up
// with some top level async which gets annoying.
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
})

oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href;

return oauthMetadata;
}
117 changes: 106 additions & 11 deletions src/examples/server/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
import { OAuthMetadata } from 'src/shared/auth.js';

// Check for OAuth flag
const useOAuth = process.argv.includes('--oauth');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth adding to README in src/examples/README.md as I just copy-pasted command and forgot to add it 🙈


// Create an MCP server with implementation details
const getServer = () => {
Expand Down Expand Up @@ -40,7 +47,7 @@ const getServer = () => {
name: z.string().describe('Name to greet'),
},
{
title: 'Multiple Greeting Tool',
title: 'Multiple Greeting Tool',
readOnlyHint: true,
openWorldHint: false
},
Expand Down Expand Up @@ -159,14 +166,79 @@ const getServer = () => {
return server;
};

const MCP_PORT = 3000;
const AUTH_PORT = 3001;

const app = express();
app.use(express.json());

// Set up OAuth if enabled
let authMiddleware = null;
if (useOAuth) {
// Create auth middleware for MCP endpoints
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);

const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl);

const tokenVerifier = {
verifyAccessToken: async (token: string) => {
const endpoint = oauthMetadata.introspection_endpoint;

if (!endpoint) {
throw new Error('No token verification endpoint available in metadata');
}

const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: token
}).toString()
});


if (!response.ok) {
throw new Error(`Invalid or expired token: ${await response.text()}`);
}

const data = await response.json();

// Convert the response to AuthInfo format
return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(' ') : [],
expiresAt: data.exp,
};
}
}
// Add metadata routes to the main MCP server
app.use(mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: mcpServerUrl,
scopesSupported: ['mcp:tools'],
resourceName: 'MCP Demo Server',
}));

authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: ['mcp:tools'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
});
}

// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};

app.post('/mcp', async (req: Request, res: Response) => {
// MCP POST endpoint with optional auth
const mcpPostHandler = async (req: Request, res: Response) => {
console.log('Received MCP request:', req.body);
if (useOAuth && req.auth) {
console.log('Authenticated user:', req.auth);
}
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined;
Expand Down Expand Up @@ -234,16 +306,27 @@ app.post('/mcp', async (req: Request, res: Response) => {
});
}
}
});
};

// Set up routes with conditional auth middleware
if (useOAuth && authMiddleware) {
app.post('/mcp', authMiddleware, mcpPostHandler);
} else {
app.post('/mcp', mcpPostHandler);
}

// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
app.get('/mcp', async (req: Request, res: Response) => {
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

if (useOAuth && req.auth) {
console.log('Authenticated SSE connection from user:', req.auth);
}

// Check for Last-Event-ID header for resumability
const lastEventId = req.headers['last-event-id'] as string | undefined;
if (lastEventId) {
Expand All @@ -254,10 +337,17 @@ app.get('/mcp', async (req: Request, res: Response) => {

const transport = transports[sessionId];
await transport.handleRequest(req, res);
});
};

// Set up GET route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.get('/mcp', authMiddleware, mcpGetHandler);
} else {
app.get('/mcp', mcpGetHandler);
}

// Handle DELETE requests for session termination (according to MCP spec)
app.delete('/mcp', async (req: Request, res: Response) => {
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
Expand All @@ -275,12 +365,17 @@ app.delete('/mcp', async (req: Request, res: Response) => {
res.status(500).send('Error processing session termination');
}
}
});
};

// Set up DELETE route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
} else {
app.delete('/mcp', mcpDeleteHandler);
}

// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
app.listen(MCP_PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
});

// Handle server shutdown
Expand Down
Loading