Tutorial: Build an OAuth MCP Server with Express
This tutorial builds a working MCP server that Willow connects to in Proxy OAuth mode. Your server acts as its own OAuth authorization server: Willow discovers its endpoints, registers a client, runs the authorization-code flow for each user, and forwards the token your server issues on every tool call.
By the end you will have a single server.js you can run, connect in Willow, and call a tool against. For the endpoint contract on its own, see the OAuth server reference.
Prerequisites
- Node.js 18 or later
- A way to expose your local server at a public HTTPS URL that Willow's gateway can reach, such as a tunnel (
ngrok http 3000) for local testing, or a deployed host - Admin access to a Willow organization
Step 1: Set up the project
mkdir oauth-mcp-server && cd oauth-mcp-server
npm init -y
npm install express
Create server.js. The next steps fill it in section by section; the complete file is at the end.
Start the file with the app setup and in-memory stores:
const express = require("express");
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const PORT = process.env.PORT || 3000;
// Set ISSUER to your public HTTPS URL once deployed, e.g. https://mcp.example.com
const ISSUER = process.env.ISSUER || `http://localhost:${PORT}`;
// In-memory stores. Use a real database in production.
const codes = new Map(); // auth code -> { user }
const tokens = new Map(); // access token -> { user }
const rand = (p) => p + Math.random().toString(36).slice(2, 12);
ISSUER must be the public URL Willow will reach, because it appears in your discovery metadata. Set it to your tunnel or deployment URL when you run the server.
Step 2: Serve MCP over HTTP
Your server answers three MCP methods: initialize, tools/list, and tools/call. These use the JSON-RPC shapes from Programmatic Gateway Access; see Build an MCP Server Behind Willow for how Willow discovers and proxies them. Define one tool and the handler:
const TOOL = {
name: "whoami",
description: "Returns the identity the server resolved from the OAuth token.",
inputSchema: { type: "object", properties: {} },
};
app.post("/mcp", (req, res) => {
const { id, method } = req.body || {};
const reply = (result) => res.json({ jsonrpc: "2.0", id, result });
if (method === "initialize") {
return reply({
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "example-oauth-mcp", version: "1.0.0" },
});
}
if (method === "notifications/initialized") return res.status(202).end();
if (method === "tools/list") return reply({ tools: [TOOL] });
if (method === "tools/call") {
const token = (req.get("authorization") || "").replace(/^Bearer\s+/i, "");
const session = tokens.get(token);
if (!session) {
return reply({ content: [{ type: "text", text: "Unauthorized" }], isError: true });
}
return reply({ content: [{ type: "text", text: `Hello, ${session.user}` }], isError: false });
}
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
});
Willow accepts a plain JSON response for each request. The tools/call handler reads the Authorization: Bearer <token> header Willow forwards, looks the token up, and rejects the call if it is unknown.
Step 3: Advertise your OAuth endpoints
Willow discovers your endpoints from the well-known metadata path:
app.get("/.well-known/oauth-authorization-server", (req, res) => {
res.json({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: `${ISSUER}/token`,
registration_endpoint: `${ISSUER}/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
});
});
Including registration_endpoint lets Willow register itself automatically. Without it, an admin has to paste a Client ID under Proxy OAuth Advanced settings.
Step 4: Handle dynamic client registration
Willow registers a client by POSTing its callback URL to your registration endpoint. Echo the redirect URIs back as an array:
app.post("/register", (req, res) => {
const { redirect_uris = [] } = req.body || {};
res.status(201).json({
client_id: rand("client-"),
redirect_uris, // Echo back as an array, or Willow rejects the registration.
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
});
});
redirect_uris as an arrayWillow validates the registration response and expects redirect_uris to be an array. Omit it, or return it in another shape, and connecting the server fails with OAuth Discovery Failed — HTTP 400 and redirect_uris expected array, received undefined.
Step 5: Authorize and issue tokens
The authorization endpoint authenticates the user, then redirects back to Willow's callback with a code. The token endpoint exchanges that code for an access token:
app.get("/authorize", (req, res) => {
const { redirect_uri, state } = req.query;
// Authenticate the real user here before issuing a code.
const code = rand("code-");
codes.set(code, { user: "alice@example.com" });
const url = new URL(redirect_uri);
url.searchParams.set("code", code);
if (state) url.searchParams.set("state", state);
res.redirect(302, url.toString());
});
app.post("/token", (req, res) => {
const entry = codes.get(req.body.code);
if (!entry) return res.status(400).json({ error: "invalid_grant" });
codes.delete(req.body.code);
const token = rand("token-");
tokens.set(token, { user: entry.user });
res.json({ access_token: token, token_type: "Bearer", expires_in: 3600 });
});
app.listen(PORT, () => console.log(`MCP server listening, issuer ${ISSUER}`));
This example logs every user in as alice@example.com. A real server authenticates the user (a login form, your session, an upstream IdP) before calling codes.set, and verifies the PKCE code_verifier if you advertised code_challenge_methods_supported.
Step 6: Run and expose the server
Start it, pointing ISSUER at the public URL:
# In one terminal, expose port 3000, for example:
ngrok http 3000
# In another, start the server with the public URL as ISSUER:
ISSUER=https://your-subdomain.ngrok.app node server.js
Confirm the metadata is reachable:
curl https://your-subdomain.ngrok.app/.well-known/oauth-authorization-server
Step 7: Connect it in Willow
- In the Willow admin app, go to Build > MCP Servers and add a custom MCP server with the HTTP URL
https://your-subdomain.ngrok.app/mcp. - On the server's Setup tab, select Proxy OAuth, then select Discover OAuth Settings. Willow reads your metadata and registers a client. Select Save Changes. See Configure Proxy OAuth for the admin-side detail.
- As an end user, connect the server and complete the authorization. Because the example auto-approves, the flow returns immediately.
- Call the
whoamitool from an MCP client or Chat.
The tool returns Hello, alice@example.com, confirming Willow forwarded the token your /token endpoint issued and your server validated it.
Changing a server's auth mode does not rewrite existing user connections. If a user connected under a different mode, they must disconnect and reconnect for Proxy OAuth to take effect.
Complete server
const express = require("express");
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const PORT = process.env.PORT || 3000;
const ISSUER = process.env.ISSUER || `http://localhost:${PORT}`;
const codes = new Map();
const tokens = new Map();
const rand = (p) => p + Math.random().toString(36).slice(2, 12);
const TOOL = {
name: "whoami",
description: "Returns the identity the server resolved from the OAuth token.",
inputSchema: { type: "object", properties: {} },
};
app.post("/mcp", (req, res) => {
const { id, method } = req.body || {};
const reply = (result) => res.json({ jsonrpc: "2.0", id, result });
if (method === "initialize") {
return reply({
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "example-oauth-mcp", version: "1.0.0" },
});
}
if (method === "notifications/initialized") return res.status(202).end();
if (method === "tools/list") return reply({ tools: [TOOL] });
if (method === "tools/call") {
const token = (req.get("authorization") || "").replace(/^Bearer\s+/i, "");
const session = tokens.get(token);
if (!session) {
return reply({ content: [{ type: "text", text: "Unauthorized" }], isError: true });
}
return reply({ content: [{ type: "text", text: `Hello, ${session.user}` }], isError: false });
}
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
});
app.get("/.well-known/oauth-authorization-server", (req, res) => {
res.json({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: `${ISSUER}/token`,
registration_endpoint: `${ISSUER}/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
});
});
app.post("/register", (req, res) => {
const { redirect_uris = [] } = req.body || {};
res.status(201).json({
client_id: rand("client-"),
redirect_uris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
});
});
app.get("/authorize", (req, res) => {
const { redirect_uri, state } = req.query;
const code = rand("code-");
codes.set(code, { user: "alice@example.com" });
const url = new URL(redirect_uri);
url.searchParams.set("code", code);
if (state) url.searchParams.set("state", state);
res.redirect(302, url.toString());
});
app.post("/token", (req, res) => {
const entry = codes.get(req.body.code);
if (!entry) return res.status(400).json({ error: "invalid_grant" });
codes.delete(req.body.code);
const token = rand("token-");
tokens.set(token, { user: entry.user });
res.json({ access_token: token, token_type: "Bearer", expires_in: 3600 });
});
app.listen(PORT, () => console.log(`MCP server listening, issuer ${ISSUER}`));
Troubleshooting
| Symptom | Cause |
|---|---|
OAuth Discovery Failed — HTTP 400 when saving | The /register response did not echo redirect_uris as an array |
| Discovery cannot find endpoints | The metadata path is not reachable, or ISSUER points at localhost instead of the public URL |
Tool call returns Unauthorized | The token was not stored at /token, or the server restarted and lost its in-memory tokens map |