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 ngrok (ngrok http 3000, after adding your account authtoken) 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 crypto = require("crypto");
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 clients = new Map(); // client ID -> Set of redirect URIs
const codes = new Map(); // auth code -> authorization details
const tokens = new Map(); // access token -> { user }

const rand = (prefix) => `${prefix}${crypto.randomBytes(16).toString("hex")}`;
const pkceChallenge = (verifier) =>
crypto.createHash("sha256").update(verifier).digest("base64url");

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 || {};

if (
!Array.isArray(redirect_uris) ||
redirect_uris.length === 0 ||
!redirect_uris.every((uri) => typeof uri === "string")
) {
return res.status(400).json({ error: "invalid_redirect_uri" });
}

const client_id = rand("client-");
clients.set(client_id, new Set(redirect_uris));

res.status(201).json({
client_id,
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.

The server stores the redirect URIs against the generated client ID so the authorization endpoint can accept only an exact registered callback.

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 {
client_id,
redirect_uri,
state,
code_challenge,
code_challenge_method,
} = req.query;

const redirectUris = clients.get(client_id);
if (!redirectUris?.has(redirect_uri)) {
return res.status(400).send("Invalid client or redirect URI");
}
if (!code_challenge || code_challenge_method !== "S256") {
return res.status(400).send("PKCE with S256 is required");
}

// Authenticate the real user here before issuing a code.
const code = rand("code-");
codes.set(code, {
user: "alice@example.com",
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
});
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 { code, client_id, redirect_uri, code_verifier } = req.body;
const entry = codes.get(code);
const wrongClient = client_id && client_id !== entry?.clientId;
const wrongRedirect = redirect_uri !== entry?.redirectUri;
const wrongVerifier =
typeof code_verifier !== "string" ||
pkceChallenge(code_verifier) !== entry?.codeChallenge;

if (!entry || wrongClient || wrongRedirect || wrongVerifier) {
return res.status(400).json({ error: "invalid_grant" });
}

codes.delete(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, but it accepts only a callback registered for the client and verifies the PKCE code_verifier before issuing a token. A real server also authenticates the user (with a login form, your session, or an upstream IdP) before calling codes.set, and stores clients, codes, and tokens in a durable store with expiration.

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 crypto = require("crypto");
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 clients = new Map();
const codes = new Map();
const tokens = new Map();
const rand = (prefix) => `${prefix}${crypto.randomBytes(16).toString("hex")}`;
const pkceChallenge = (verifier) =>
crypto.createHash("sha256").update(verifier).digest("base64url");

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 || {};

if (
!Array.isArray(redirect_uris) ||
redirect_uris.length === 0 ||
!redirect_uris.every((uri) => typeof uri === "string")
) {
return res.status(400).json({ error: "invalid_redirect_uri" });
}

const client_id = rand("client-");
clients.set(client_id, new Set(redirect_uris));

res.status(201).json({
client_id,
redirect_uris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
});
});

app.get("/authorize", (req, res) => {
const {
client_id,
redirect_uri,
state,
code_challenge,
code_challenge_method,
} = req.query;

const redirectUris = clients.get(client_id);
if (!redirectUris?.has(redirect_uri)) {
return res.status(400).send("Invalid client or redirect URI");
}
if (!code_challenge || code_challenge_method !== "S256") {
return res.status(400).send("PKCE with S256 is required");
}

const code = rand("code-");
codes.set(code, {
user: "alice@example.com",
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
});
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 { code, client_id, redirect_uri, code_verifier } = req.body;
const entry = codes.get(code);
const wrongClient = client_id && client_id !== entry?.clientId;
const wrongRedirect = redirect_uri !== entry?.redirectUri;
const wrongVerifier =
typeof code_verifier !== "string" ||
pkceChallenge(code_verifier) !== entry?.codeChallenge;

if (!entry || wrongClient || wrongRedirect || wrongVerifier) {
return res.status(400).json({ error: "invalid_grant" });
}

codes.delete(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
Authorization returns Invalid client or redirect URIWillow has not registered this client, or the callback does not exactly match the registered redirect URI
Token exchange returns invalid_grantThe authorization code, redirect URI, client ID, or PKCE verifier does not match the authorization request
Tool call returns UnauthorizedThe token was not stored at /token, or the server restarted and lost its in-memory tokens map