Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
132 changes: 132 additions & 0 deletions src/examples/server/inMemoryOAuthProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { randomUUID } from 'node:crypto';
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokens } from 'src/shared/auth.js';
import { Response } from "express";
import { AuthInfo } from 'src/server/auth/types.js';


/**
* Simple in-memory implementation of OAuth clients store for demo purposes.
* In production, this should be backed by a persistent database.
*/
export class InMemoryClientsStore 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;
}
}

/**
* Simple in-memory implementation of OAuth server provider for demo purposes.
* In production, this should be backed by a persistent database with proper security measures.
*/
export class InMemoryAuthProvider implements OAuthServerProvider {
clientsStore = new InMemoryClientsStore();
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,
});

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,
_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}`);
}

// Remove the used code
this.codes.delete(authorizationCode);

// Generate access token
const token = randomUUID();

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

// Store the token
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 < Date.now() || tokenData.type === 'refresh') {

Check failure on line 121 in src/examples/server/inMemoryOAuthProvider.ts

View workflow job for this annotation

GitHub Actions / build

Property 'type' does not exist on type 'AuthInfo'.

Check failure on line 121 in src/examples/server/inMemoryOAuthProvider.ts

View workflow job for this annotation

GitHub Actions / build

'tokenData.expiresAt' is possibly 'undefined'.
throw new Error('Invalid or expired token');
}

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

Check failure on line 129 in src/examples/server/inMemoryOAuthProvider.ts

View workflow job for this annotation

GitHub Actions / build

'tokenData.expiresAt' is possibly 'undefined'.
};
}
}
104 changes: 93 additions & 11 deletions src/examples/server/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
import { z } from 'zod';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { mcpAuthRouter, mcpProtectedResourceRouter, getOAuthProtectedResourceMetadataUrl } 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 { InMemoryAuthProvider } from './inMemoryOAuthProvider.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 +46,7 @@
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 +165,67 @@
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) {
const provider = new InMemoryAuthProvider();

// Create auth middleware for MCP endpoints
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);

// Create separate auth server app
const authApp = express();
authApp.use(express.json());

// Add OAuth routes to the auth server
authApp.use(mcpAuthRouter({
provider,
issuerUrl: authServerUrl,
baseUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
// This endpoint is set up on the Authorization server, but really shouldn't be.
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe add a link to the spec and explanation this is only for demo

Copy link
Member Author

Choose a reason for hiding this comment

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

yea let me see about re-working this... we should make it easier to provide just the backwards-compat endpoint

protectedResourceOptions: {
serverUrl: mcpServerUrl,
resourceName: 'MCP Demo Server',
},
}));

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

// Add protected resource metadata to the main MCP server
app.use(mcpProtectedResourceRouter({
issuerUrl: authServerUrl,
serverUrl: mcpServerUrl,
scopesSupported: ['mcp:tools'],
resourceName: 'MCP Demo Server',
}));

authMiddleware = requireBearerAuth({
provider,
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 +293,27 @@
});
}
}
});
};

// 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 +324,17 @@

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 +352,17 @@
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
6 changes: 3 additions & 3 deletions src/server/auth/handlers/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import express, { RequestHandler } from "express";
import { OAuthMetadata } from "../../../shared/auth.js";
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js";
import cors from 'cors';
import { allowedMethods } from "../middleware/allowedMethods.js";

export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();

Expand All @@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
});

return router;
}
}
Loading
Loading