Skip to main content

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"],
});
});
Echo redirect_uris as an array

Willow 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

  1. 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.
  2. 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.
  3. As an end user, connect the server and complete the authorization. Because the example auto-approves, the flow returns immediately.
  4. Call the whoami tool 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.

Switching an already-connected server

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

SymptomCause
OAuth Discovery Failed — HTTP 400 when savingThe /register response did not echo redirect_uris as an array
Discovery cannot find endpointsThe metadata path is not reachable, or ISSUER points at localhost instead of the public URL
Tool call returns UnauthorizedThe token was not stored at /token, or the server restarted and lost its in-memory tokens map