-
Notifications
You must be signed in to change notification settings - Fork 16
feat(example): add express mcp server example #46
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
frederikprijck
merged 9 commits into
auth0:main
from
patrickkang:add-example-express-mcp
Sep 5, 2025
Merged
Changes from 8 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
38f4ba6
add express mcp server example
patrickkang 5cecb00
change the default port
patrickkang e826805
move authMiddleware
patrickkang 19b498e
clean up tools
patrickkang ae378e0
placeholder link to auth0 tenant setup guide in readme
patrickkang cfb87ac
clean up
patrickkang c47c103
clean up
patrickkang f25f741
clean up
patrickkang 4023fcf
refactor to follow the existing pattern
patrickkang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| AUTH0_DOMAIN= | ||
| AUTH0_AUDIENCE= | ||
| PORT=3001 | ||
| MCP_SERVER_URL=http://localhost:3001 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # Example Express MCP Server with Auth0 Integration | ||
|
|
||
| This is a practical example of securing a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs) server | ||
| with Auth0 using the Express framework. | ||
|
|
||
| ## Install dependencies | ||
|
|
||
| Install the dependencies using npm: | ||
|
|
||
| ```bash | ||
| npm install | ||
| ``` | ||
|
|
||
| ## Auth0 Tenant Setup | ||
|
|
||
| For detailed instructions on setting up your Auth0 tenant for MCP server integration, please refer to the [Auth0 Tenant Setup guide](../example-fastmcp-mcp/README.md#auth0-tenant-setup) in the FastMCP example. | ||
|
|
||
| ## Configuration | ||
|
|
||
| Rename `.env.example` to `.env` and configure the domain and audience: | ||
|
|
||
| ``` | ||
| # Auth0 tenant domain | ||
| AUTH0_DOMAIN=example-tenant.us.auth0.com | ||
|
|
||
| # Auth0 API Identifier | ||
| AUTH0_AUDIENCE=http://localhost:3001 | ||
| ``` | ||
|
|
||
| With the configuration in place, the example can be started by running: | ||
|
|
||
| ```bash | ||
| npm run start | ||
| ``` | ||
|
|
||
| ## Testing | ||
|
|
||
| Use an MCP client like [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test your server interactively: | ||
|
|
||
| ```bash | ||
| npx @modelcontextprotocol/inspector | ||
| ``` | ||
|
|
||
| The server will start up and the UI will be accessible at http://localhost:6274. | ||
|
|
||
| In the MCP Inspector, select `Streamable HTTP` as the `Transport Type` and enter `http://localhost:3001/mcp` as the URL. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| { | ||
| "name": "example-express-mcp", | ||
| "version": "0.1.0-beta.1", | ||
| "description": "Express MCP Server Example — Example implementation of an MCP server using Express.js with Auth0", | ||
| "type": "module", | ||
| "scripts": { | ||
| "start": "tsx src/index.ts --project tsconfig.json" | ||
| }, | ||
| "dependencies": { | ||
| "@auth0/auth0-api-js": "*", | ||
| "@modelcontextprotocol/sdk": "^1.17.0", | ||
| "cors": "^2.8.5", | ||
| "dotenv": "^17.2.1", | ||
| "express": "^5.1.0", | ||
| "zod": "^3.25.76" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/cors": "^2.8.19", | ||
| "@types/express": "^5.0.3", | ||
| "ts-node": "^10.9.2", | ||
| "tsx": "^4.20.3", | ||
| "typescript": "^5.8.3" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import { ApiClient, VerifyAccessTokenError } from "@auth0/auth0-api-js"; | ||
| import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; | ||
| import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; | ||
| import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; | ||
| import { | ||
| getOAuthProtectedResourceMetadataUrl, | ||
| mcpAuthMetadataRouter, | ||
| } from "@modelcontextprotocol/sdk/server/auth/router.js"; | ||
| import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; | ||
| import type { RequestHandler } from "express"; | ||
| import { ZodRawShape } from "zod"; | ||
| import { Auth, Auth0McpOptions } from "./types.js"; | ||
| import { MCP_TOOL_SCOPES } from "./tools.js"; | ||
|
|
||
| export function createAuth0Mcp(opts: Auth0McpOptions) { | ||
| const verify = createVerifier(opts); | ||
| const requireScopes = createScopeValidator(); | ||
|
|
||
| const authMetadataRouter = createAuthMetadataRouter(opts); | ||
| const authMiddleware = createAuthMiddleware(opts, verify); | ||
|
|
||
| return { | ||
| /** | ||
| * Creates an Express router that exposes OAuth metadata endpoints needed for MCP clients. | ||
| */ | ||
| authMetadataRouter: () => authMetadataRouter, | ||
|
|
||
| /** | ||
| * Creates Express middleware for protecting MCP endpoints. | ||
| * Validates Bearer tokens and populates req.auth with user information. | ||
| */ | ||
| authMiddleware: () => authMiddleware, | ||
|
|
||
| /** | ||
| * Wraps an MCP tool handler to enforce required OAuth scopes. | ||
| */ | ||
| requireScopes, | ||
| }; | ||
| } | ||
|
|
||
| function isNonEmptyString(value: unknown): value is string { | ||
| return typeof value === "string" && value.length > 0; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a JWT token verifier for Auth0-issued access tokens. | ||
| * | ||
| * This function returns a reusable `verify` function that validates JWT signatures, | ||
| * token claims, and extracts user identity information for MCP integration using | ||
| * the official @auth0/auth0-api-js library. | ||
| */ | ||
| function createVerifier(opts: Auth0McpOptions) { | ||
| const apiClient = new ApiClient({ | ||
| domain: opts.domain, | ||
| audience: opts.audience, | ||
| }); | ||
| return async function verify(token: string): Promise<Auth> { | ||
| try { | ||
| const decoded = await apiClient.verifyAccessToken({ | ||
| accessToken: token, | ||
| }); | ||
|
|
||
| if (!isNonEmptyString(decoded.sub)) { | ||
| throw new InvalidTokenError( | ||
| "Token is missing required subject (sub) claim" | ||
| ); | ||
| } | ||
|
|
||
| let clientId: string | null = null; | ||
| if (isNonEmptyString(decoded.client_id)) { | ||
| clientId = decoded.client_id; | ||
| } else if (isNonEmptyString(decoded.azp)) { | ||
| clientId = decoded.azp; | ||
| } | ||
|
|
||
| if (!clientId) { | ||
| throw new InvalidTokenError( | ||
| "Token is missing required client identification (client_id or azp claim)." | ||
| ); | ||
| } | ||
|
|
||
| return { | ||
| token, | ||
| clientId, | ||
| scopes: | ||
| typeof decoded.scope === "string" | ||
| ? decoded.scope.split(" ").filter(Boolean) | ||
| : [], | ||
| ...(decoded.exp && { expiresAt: decoded.exp }), | ||
| extra: { | ||
| sub: decoded.sub, | ||
| ...(isNonEmptyString(decoded.client_id) && { | ||
| client_id: decoded.client_id, | ||
| }), | ||
| ...(isNonEmptyString(decoded.azp) && { azp: decoded.azp }), | ||
| ...(isNonEmptyString(decoded.name) && { name: decoded.name }), | ||
| ...(isNonEmptyString(decoded.email) && { email: decoded.email }), | ||
| }, | ||
| }; | ||
| } catch (error) { | ||
| if (error instanceof VerifyAccessTokenError) { | ||
| throw new InvalidTokenError(error.message); | ||
| } | ||
| throw error; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a router that includes MCP authorization metadata endpoints. | ||
| */ | ||
| async function createAuthMetadataRouter(opts: Auth0McpOptions) { | ||
| const oauthMetadata = await discoverAuthorizationServerMetadata( | ||
| new URL(`https://${opts.domain}`) | ||
| ); | ||
|
|
||
| if (!oauthMetadata) { | ||
| throw new Error(`Failed to fetch OAuth metadata from ${opts.domain}`); | ||
| } | ||
|
|
||
| return mcpAuthMetadataRouter({ | ||
| oauthMetadata, | ||
| resourceServerUrl: opts.resourceServerUrl, | ||
| resourceName: opts.resourceName, | ||
| scopesSupported: ["openid", ...MCP_TOOL_SCOPES], | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Returns an Express middleware that protects MCP endpoints. | ||
| * This middleware validates Bearer tokens, and checks for required scopes. | ||
| */ | ||
| function createAuthMiddleware( | ||
| opts: Auth0McpOptions, | ||
| verifier: (token: string) => Promise<Auth> | ||
| ): RequestHandler { | ||
| return requireBearerAuth({ | ||
| resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl( | ||
| opts.resourceServerUrl | ||
| ), | ||
| verifier: { verifyAccessToken: verifier }, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Wraps an MCP tool handler to enforce required OAuth scopes. | ||
| * | ||
| * This is a higher-order function that adds scope-based authorization to MCP tools. | ||
| * It validates that the authenticated user's JWT token contains all required scopes | ||
| * before allowing access to the wrapped tool. | ||
| */ | ||
| function createScopeValidator() { | ||
| /** | ||
| * Wraps a tool handler with scope validation. | ||
| * This function ensures that the tool can only be executed if the user has the required OAuth scopes. | ||
| */ | ||
| return function requireScopes<T extends ZodRawShape>( | ||
| requiredScopes: readonly string[], | ||
| handler: (args: T, extra: { authInfo: Auth }) => Promise<CallToolResult> | ||
| ): ToolCallback<T> { | ||
| return (async (args, extra) => { | ||
| // To support both context-only and payload+context handlers | ||
| let context = extra; | ||
| if (!extra) { | ||
| context = args as Parameters<ToolCallback<T>>[1]; | ||
| } | ||
|
|
||
| if (!context.authInfo) { | ||
| throw new Error( | ||
| "Authentication information is required to execute this tool." | ||
| ); | ||
| } | ||
| const userScopes = context.authInfo.scopes; | ||
| const hasScopes = requiredScopes.every((scope) => | ||
| userScopes.includes(scope) | ||
| ); | ||
|
|
||
| if (!hasScopes) { | ||
| throw new Error( | ||
| `Missing required scopes: ${requiredScopes.join(", ")}` | ||
| ); | ||
| } | ||
|
|
||
| return handler(args as T, { authInfo: context.authInfo as Auth }); | ||
| }) as ToolCallback<T>; | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import "dotenv/config"; | ||
| import { createExpressApp, createMcpServer } from "./server.js"; | ||
|
|
||
| async function main() { | ||
| try { | ||
| const PORT = parseInt(process.env.PORT ?? "3001", 10); | ||
| const mcpServer = createMcpServer(); | ||
| const app = await createExpressApp(mcpServer); | ||
|
|
||
| app.listen(PORT, () => { | ||
| console.log(`Example Express MCP Server listening on port ${PORT}`); | ||
| }); | ||
| } catch (error) { | ||
| console.error("Failed to start server:", error); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { allowedMethods } from "@modelcontextprotocol/sdk/server/auth/middleware/allowedMethods.js"; | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | ||
| import cors from "cors"; | ||
| import express, { | ||
| type Request, | ||
| type Response, | ||
| type Application, | ||
| } from "express"; | ||
| import { createAuth0Mcp } from "./auth0.js"; | ||
| import { registerTools } from "./tools.js"; | ||
|
|
||
| const PORT = parseInt(process.env.PORT ?? "3001", 10); | ||
| const MCP_SERVER_RESOURCE_NAME = "Example Express MCP Server"; | ||
| const MCP_SERVER_URL = process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}`; | ||
| const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN as string; | ||
| const AUDIENCE = process.env.AUTH0_AUDIENCE ?? MCP_SERVER_URL; | ||
|
|
||
| // Validate required environment variables | ||
| if (!AUTH0_DOMAIN) { | ||
| throw new Error("AUTH0_DOMAIN environment variable is required"); | ||
| } | ||
|
|
||
| const auth = createAuth0Mcp({ | ||
| resourceName: MCP_SERVER_RESOURCE_NAME, | ||
| resourceServerUrl: new URL(MCP_SERVER_URL), | ||
| domain: AUTH0_DOMAIN, | ||
| audience: AUDIENCE, | ||
| }); | ||
|
|
||
| /** | ||
| * Set up MCP server and register tools. | ||
| */ | ||
| export function createMcpServer(): McpServer { | ||
| const server = new McpServer({ | ||
| name: MCP_SERVER_RESOURCE_NAME, | ||
| version: "1.0.0", | ||
| }); | ||
|
|
||
| registerTools(server, auth); | ||
|
|
||
| return server; | ||
| } | ||
|
|
||
| /** | ||
| * Create Express app with CORS, Auth0, and MCP handling. | ||
| */ | ||
| export async function createExpressApp( | ||
| mcpServer: McpServer | ||
| ): Promise<Application> { | ||
| const app = express(); | ||
|
|
||
| // Enable CORS | ||
| app.use( | ||
| cors({ | ||
| origin: "http://localhost:6274", // MCP Inspector; adjust as needed for production | ||
| exposedHeaders: ["Mcp-Session-Id"], | ||
| allowedHeaders: ["Content-Type", "mcp-session-id"], | ||
| }) | ||
| ); | ||
|
|
||
| app.use(express.json()); | ||
|
|
||
| // Add metadata routes | ||
| app.use(await auth.authMetadataRouter()); | ||
|
|
||
| // Handle MCP requests | ||
| app | ||
| .route("/mcp") | ||
| .post(auth.authMiddleware(), async (req: Request, res: Response) => { | ||
| try { | ||
| const transport = new StreamableHTTPServerTransport({ | ||
| sessionIdGenerator: undefined, | ||
| }); | ||
| await mcpServer.connect(transport); | ||
| await transport.handleRequest(req, res, req.body); | ||
| res.on("close", () => transport.close()); | ||
| } catch (error) { | ||
| console.error("MCP request failed:", error); | ||
| res.status(500).json({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32603, message: "Internal server error" }, | ||
| id: null, | ||
| }); | ||
| } | ||
| }) | ||
| .all(allowedMethods(["POST"])); | ||
|
|
||
| return app; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.