Tutorial: Build an API-Key MCP Server with Express
This tutorial builds a working MCP server that Willow connects to in Proxy API Key mode. Each user supplies their own API key when they connect, and Willow forwards that key to your server on every tool call. Your server validates it against its own store.
By the end you will have a single server.js you can run, connect in Willow, and call a tool against. For the forwarding contract on its own, see the API key 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 apikey-mcp-server && cd apikey-mcp-server
npm init -y
npm install express
Create server.js with the app setup and a store of valid keys:
const express = require("express");
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
// Map of valid API keys to the user they belong to. Use a real store in production.
const KEYS = new Map([
["sk_live_alice_123", "alice@example.com"],
["sk_live_bob_456", "bob@example.com"],
]);
Step 2: Read the forwarded key
By default Willow sends the user's key as the raw Authorization header value, with no Bearer prefix. If you configure a custom header, it also arrives there. This helper reads either and rejects an unsubstituted placeholder:
function resolveUser(req) {
const raw = req.get("x-api-key") || req.get("authorization") || "";
const key = raw.replace(/^Bearer\s+/i, "").trim();
if (!key || key.includes("{{")) return null; // unsubstituted placeholder
return KEYS.get(key) || null;
}
The key.includes("{{") guard matters: if a user has not finished connecting, Willow forwards the literal placeholder text (for example {{apiKey}}) rather than a key.
Step 3: Serve MCP over HTTP
The handler answers initialize, tools/list, and tools/call with the JSON-RPC shapes from Programmatic Gateway Access; see Build an MCP Server Behind Willow for how Willow discovers and proxies them.
const TOOL = {
name: "whoami",
description: "Returns the user the forwarded API key belongs to.",
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-apikey-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 user = resolveUser(req);
if (!user) {
return reply({ content: [{ type: "text", text: "Invalid or missing API key" }], isError: true });
}
return reply({ content: [{ type: "text", text: `Hello, ${user}` }], isError: false });
}
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
});
app.listen(PORT, () => console.log(`MCP server listening on :${PORT}`));
Step 4: Run and expose the server
# Expose port 3000, for example:
ngrok http 3000
# Then start the server:
node server.js
Step 5: 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 Setup tab, select Proxy API Key, then Save Changes. By default Willow sends the key in the
Authorizationheader, which the server already reads. See Configure Proxy API Key for the admin-side detail. -
To also send the key as
X-Api-Key, open the Settings > MCP Configuration and add a header referencing{{apiKey}}:{"type": "http","url": "https://your-subdomain.ngrok.app/mcp","headers": {"X-Api-Key": "{{apiKey}}"}} -
As an end user, connect the server and enter one of the keys from the
KEYSmap, for examplesk_live_alice_123. -
Call the
whoamitool from an MCP client or Chat.
The tool returns Hello, alice@example.com, confirming Willow forwarded the exact key the user entered and your server resolved 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 API Key to take effect.
Complete server
const express = require("express");
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const KEYS = new Map([
["sk_live_alice_123", "alice@example.com"],
["sk_live_bob_456", "bob@example.com"],
]);
const TOOL = {
name: "whoami",
description: "Returns the user the forwarded API key belongs to.",
inputSchema: { type: "object", properties: {} },
};
function resolveUser(req) {
const raw = req.get("x-api-key") || req.get("authorization") || "";
const key = raw.replace(/^Bearer\s+/i, "").trim();
if (!key || key.includes("{{")) return null;
return KEYS.get(key) || null;
}
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-apikey-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 user = resolveUser(req);
if (!user) {
return reply({ content: [{ type: "text", text: "Invalid or missing API key" }], isError: true });
}
return reply({ content: [{ type: "text", text: `Hello, ${user}` }], isError: false });
}
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
});
app.listen(PORT, () => console.log(`MCP server listening on :${PORT}`));
Troubleshooting
| Symptom | Cause |
|---|---|
Tool returns Invalid or missing API key with a value like {{apiKey}} | The user has not completed the connection, so no key is bound and the placeholder forwards literally |
Key arrives with a Bearer prefix | You configured a custom Authorization: Bearer {{apiKey}} header; the helper strips it, but adjust if you read a different header |
| No key at all | The MCP configuration references an identifier that does not match a defined setup key |