Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The following examples can be found in the examples directory:
- [Fastify API Example](./examples/example-fastify-api/README.md)
- [NestJS API Example](./examples/example-nestjs-api/README.md)
- [Express Web App Example](./examples/example-express-web/README.md)
- [Express MCP Server Example](./examples/example-express-mcp/README.md)

Before running the examples, you need to install the dependencies for the monorepo and build all the packages.

Expand Down
4 changes: 4 additions & 0 deletions examples/example-express-mcp/.env.example
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
43 changes: 43 additions & 0 deletions examples/example-express-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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
```

## Configuration

Rename `.env.example` to `.env` and configure the domain and audience:

```ts
AUTH0_DOMAIN=
AUTH0_AUDIENCE=
```

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.

## Auth0 Tenant Setup
Comment thread
patrickkang marked this conversation as resolved.
Outdated

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.
25 changes: 25 additions & 0 deletions examples/example-express-mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"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",
"jose": "^6.0.12",
"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"
}
}
188 changes: 188 additions & 0 deletions examples/example-express-mcp/src/auth0.ts
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>;
};
}
19 changes: 19 additions & 0 deletions examples/example-express-mcp/src/index.ts
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();
90 changes: 90 additions & 0 deletions examples/example-express-mcp/src/server.ts
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) => {
Comment thread
frederikprijck marked this conversation as resolved.
Outdated
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;
}
Loading