Skip to main content

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

  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 Setup tab, select Proxy API Key, then Save Changes. By default Willow sends the key in the Authorization header, which the server already reads. See Configure Proxy API Key for the admin-side detail.

  3. 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}}"
    }
    }
  4. As an end user, connect the server and enter one of the keys from the KEYS map, for example sk_live_alice_123.

  5. Call the whoami tool 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.

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 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

SymptomCause
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 prefixYou configured a custom Authorization: Bearer {{apiKey}} header; the helper strips it, but adjust if you read a different header
No key at allThe MCP configuration references an identifier that does not match a defined setup key