Skip to content

Commit 38f4ba6

Browse files
committed
add express mcp server example
1 parent df7ddb9 commit 38f4ba6

12 files changed

Lines changed: 1655 additions & 509 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The following examples can be found in the examples directory:
2222
- [Fastify API Example](./examples/example-fastify-api/README.md)
2323
- [NestJS API Example](./examples/example-nestjs-api/README.md)
2424
- [Express Web App Example](./examples/example-express-web/README.md)
25+
- [Express MCP Server Example](./examples/example-express-mcp/README.md)
2526

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
AUTH0_DOMAIN=
2+
AUTH0_AUDIENCE=
3+
PORT=3001
4+
MCP_SERVER_URL=http://localhost:3001
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Example Express MCP Server with Auth0 Integration
2+
3+
This is a practical example of securing a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs) server
4+
with Auth0 using the Express framework.
5+
6+
## Install dependencies
7+
8+
Install the dependencies using npm:
9+
10+
```bash
11+
npm install
12+
```
13+
14+
## Configuration
15+
16+
Rename `.env.example` to `.env` and configure the domain and audience:
17+
18+
```ts
19+
AUTH0_DOMAIN=
20+
AUTH0_AUDIENCE=
21+
```
22+
23+
With the configuration in place, the example can be started by running:
24+
25+
```bash
26+
npm run start
27+
```
28+
29+
## Testing
30+
31+
Use an MCP client like [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test your server interactively:
32+
33+
```bash
34+
npx @modelcontextprotocol/inspector
35+
```
36+
37+
The server will start up and the UI will be accessible at http://localhost:6274.
38+
39+
In the MCP Inspector, select `Streamable HTTP` as the `Transport Type` and enter `http://localhost:3001/mcp` as the URL.
40+
41+
## Auth0 Tenant Setup
42+
43+
TODO
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "example-express-mcp",
3+
"version": "0.1.0-beta.1",
4+
"description": "Express MCP Server Example — Example implementation of an MCP server using Express.js with Auth0",
5+
"type": "module",
6+
"scripts": {
7+
"start": "tsx src/index.ts --project tsconfig.json"
8+
},
9+
"dependencies": {
10+
"@auth0/auth0-api-js": "*",
11+
"@modelcontextprotocol/sdk": "^1.17.0",
12+
"cors": "^2.8.5",
13+
"dotenv": "^17.2.1",
14+
"express": "^5.1.0",
15+
"jose": "^6.0.12",
16+
"zod": "^3.25.76"
17+
},
18+
"devDependencies": {
19+
"@types/cors": "^2.8.19",
20+
"@types/express": "^5.0.3",
21+
"ts-node": "^10.9.2",
22+
"tsx": "^4.20.3",
23+
"typescript": "^5.8.3"
24+
}
25+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Auth0 authentication configuration for Express MCP server.
3+
*/
4+
5+
import { type RequestHandler, type Router } from "express";
6+
import { AUTH0_DOMAIN, AUDIENCE, MCP_SERVER_URL } from "./config.js";
7+
import { AccessTokenClaims, Auth } from "./types.js";
8+
import { SCOPES_SUPPORTED } from "./tools.js";
9+
import { ApiClient, VerifyAccessTokenError } from "@auth0/auth0-api-js";
10+
import {
11+
getOAuthProtectedResourceMetadataUrl,
12+
mcpAuthMetadataRouter,
13+
} from "@modelcontextprotocol/sdk/server/auth/router.js";
14+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
15+
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
16+
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
17+
18+
/**
19+
* Returns a router that includes MCP authorization metadata endpoints.
20+
*/
21+
export async function authMetadataRouter(): Promise<Router> {
22+
const oauthMetadata = await discoverAuthorizationServerMetadata(
23+
new URL(`https://${AUTH0_DOMAIN}`)
24+
);
25+
26+
if (!oauthMetadata) {
27+
throw new Error(`Failed to fetch OAuth metadata from ${AUTH0_DOMAIN}`);
28+
}
29+
30+
return mcpAuthMetadataRouter({
31+
resourceServerUrl: new URL(MCP_SERVER_URL),
32+
scopesSupported: SCOPES_SUPPORTED,
33+
oauthMetadata,
34+
});
35+
}
36+
37+
/**
38+
* Returns a middleware that verifies the access token and extracts user information.
39+
*/
40+
export function authMiddleware(): RequestHandler {
41+
const apiClient = new ApiClient({
42+
domain: AUTH0_DOMAIN,
43+
audience: AUDIENCE,
44+
});
45+
46+
const verify = async (token: string): Promise<Auth> => {
47+
try {
48+
const decoded = (await apiClient.verifyAccessToken({
49+
accessToken: token,
50+
})) as AccessTokenClaims;
51+
52+
const clientId = decoded.client_id ?? decoded.azp;
53+
if (!clientId) {
54+
throw new Error(
55+
"Token is missing required client identification (client_id or azp claim)."
56+
);
57+
}
58+
59+
return {
60+
token,
61+
clientId,
62+
scopes:
63+
typeof decoded.scope === "string"
64+
? decoded.scope.split(" ").filter(Boolean)
65+
: [],
66+
...(decoded.exp && { expiresAt: decoded.exp }),
67+
extra: {
68+
sub: decoded.sub,
69+
...(decoded.client_id && { client_id: decoded.client_id }),
70+
...(decoded.azp && { azp: decoded.azp }),
71+
...(decoded.name && { name: decoded.name }),
72+
...(decoded.email && { email: decoded.email }),
73+
},
74+
};
75+
} catch (error) {
76+
if (error instanceof VerifyAccessTokenError) {
77+
throw new InvalidTokenError(error.message);
78+
}
79+
throw error;
80+
}
81+
};
82+
83+
return requireBearerAuth({
84+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(
85+
new URL(MCP_SERVER_URL)
86+
),
87+
verifier: { verifyAccessToken: verify },
88+
});
89+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import "dotenv/config";
2+
3+
export const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN as string;
4+
export const PORT = parseInt(process.env.PORT ?? "3000", 10);
5+
export const MCP_SERVER_URL =
6+
process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}`;
7+
export const AUDIENCE = process.env.AUTH0_AUDIENCE ?? MCP_SERVER_URL;
8+
9+
// Validate required environment variables
10+
if (!AUTH0_DOMAIN) {
11+
throw new Error("AUTH0_DOMAIN environment variable is required");
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { PORT } from "./config.js";
2+
import { createMcpServer, createExpressApp } from "./server.js";
3+
4+
async function main() {
5+
try {
6+
const mcpServer = createMcpServer();
7+
const app = await createExpressApp(mcpServer);
8+
9+
app.listen(PORT, () => {
10+
console.log(`Example Express MCP Server listening on port ${PORT}`);
11+
});
12+
} catch (error) {
13+
console.error("Failed to start server:", error);
14+
process.exit(1);
15+
}
16+
}
17+
18+
main();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Express app with Auth0-based authentication and MCP transport.
3+
*/
4+
5+
import express, { type Request, type Response } from "express";
6+
import cors from "cors";
7+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9+
import { allowedMethods } from "@modelcontextprotocol/sdk/server/auth/middleware/allowedMethods.js";
10+
import { tools } from "./tools.js";
11+
import { authMetadataRouter, authMiddleware } from "./auth.js";
12+
13+
/**
14+
* Set up MCP server and register tools.
15+
*/
16+
export function createMcpServer(): McpServer {
17+
const server = new McpServer({
18+
name: "Example Express MCP Server",
19+
version: "1.0.0",
20+
});
21+
22+
for (const tool of tools) {
23+
server.registerTool(tool.name, tool.config, tool.handler);
24+
}
25+
26+
return server;
27+
}
28+
29+
/**
30+
* Create Express app with CORS, Auth0, and MCP handling.
31+
*/
32+
export async function createExpressApp(
33+
mcpServer: McpServer
34+
): Promise<express.Application> {
35+
const app = express();
36+
37+
// Enable CORS
38+
app.use(
39+
cors({
40+
origin: "http://localhost:6274", // MCP Inspector; adjust as needed for production
41+
exposedHeaders: ["Mcp-Session-Id"],
42+
allowedHeaders: ["Content-Type", "mcp-session-id"],
43+
})
44+
);
45+
46+
app.use(express.json());
47+
48+
// Add metadata routes
49+
app.use(await authMetadataRouter());
50+
51+
// Add auth middleware
52+
app.use(authMiddleware());
53+
54+
// Handle MCP requests
55+
app
56+
.route("/mcp")
57+
.post(async (req: Request, res: Response) => {
58+
try {
59+
const transport = new StreamableHTTPServerTransport({
60+
sessionIdGenerator: undefined,
61+
});
62+
await mcpServer.connect(transport);
63+
await transport.handleRequest(req, res, req.body);
64+
res.on("close", () => transport.close());
65+
} catch (error) {
66+
console.error("MCP request failed:", error);
67+
res.status(500).json({
68+
jsonrpc: "2.0",
69+
error: { code: -32603, message: "Internal server error" },
70+
id: null,
71+
});
72+
}
73+
})
74+
.all(allowedMethods(["POST"]));
75+
76+
return app;
77+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* MCP tools with scope-based authorization.
3+
*/
4+
5+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
7+
import { z, ZodRawShape } from "zod";
8+
import { Auth } from "./types.js";
9+
10+
export const SCOPES_SUPPORTED = ["tool:whoami", "tool:greet"];
11+
12+
/**
13+
* Wraps a tool handler with scope validation.
14+
* This function ensures that the tool can only be executed if the user has the required OAuth scopes.
15+
*/
16+
export function requireScopes<T extends ZodRawShape>(
17+
requiredScopes: readonly string[],
18+
handler: ToolCallback<T>
19+
): ToolCallback<T> {
20+
return (async (args, context) => {
21+
if (!context.authInfo) {
22+
throw new Error(
23+
"Authentication information is required to execute this tool."
24+
);
25+
}
26+
const userScopes = context.authInfo.scopes;
27+
const hasScopes = requiredScopes.every((scope) =>
28+
userScopes.includes(scope)
29+
);
30+
31+
if (!hasScopes) {
32+
throw new Error(`Missing required scopes: ${requiredScopes.join(", ")}`);
33+
}
34+
35+
return handler(args, context);
36+
}) as ToolCallback<T>;
37+
}
38+
39+
const greetToolInputSchema = {
40+
name: z
41+
.string()
42+
.optional()
43+
.describe("The name to greet (defaults to 'World')"),
44+
} as const;
45+
46+
/**
47+
* Tool definitions
48+
*/
49+
export const tools = [
50+
{
51+
name: "greet",
52+
config: {
53+
title: "Greet Tool",
54+
description: "Greets a user",
55+
inputSchema: greetToolInputSchema,
56+
annotations: { readOnlyHint: false },
57+
},
58+
handler: requireScopes<typeof greetToolInputSchema>(
59+
["tool:greet"],
60+
async (payload, context) => {
61+
const { name } = payload;
62+
const authInfo = context.authInfo as Auth;
63+
const userId = authInfo.extra?.sub;
64+
return {
65+
content: [
66+
{
67+
type: "text",
68+
text: `Hello, ${name}! You are authenticated as: ${userId}`,
69+
},
70+
],
71+
};
72+
}
73+
),
74+
},
75+
{
76+
name: "whoami",
77+
config: {
78+
title: "Whoami Tool",
79+
description: "Greets a user",
80+
annotations: { readOnlyHint: false },
81+
},
82+
handler: requireScopes(["tool:greet"], async (payload, context) => {
83+
const name = payload.name ?? "World";
84+
const authInfo = context.authInfo as Auth;
85+
const userId = authInfo?.extra?.sub;
86+
return {
87+
content: [
88+
{
89+
type: "text",
90+
text: `Hello, ${name}! You are authenticated as: ${userId}`,
91+
},
92+
],
93+
};
94+
}),
95+
},
96+
];

0 commit comments

Comments
 (0)