Minimal MCP Server in TypeScript: API Keys, Scopes and Rate Limits
You can make an MCP server safe to hand to a real AI client with about 150 lines of TypeScript. You need three pieces: API keys that are stored as hashes and carry scopes, a token bucket for each client, and one wrapper
You can make an MCP server safe to hand to a real AI client with about 150 lines of TypeScript. You need three pieces: API keys that are stored as hashes and carry scopes, a token bucket for each client, and one wrapper that checks and logs every tool call. This post builds all of it, with two working tools over the Streamable HTTP transport.
Most MCP tutorials stop at a "hello world" tool over stdio. That works on your laptop. Once the server is on a network and agents you don't control are calling it, you have to know who is calling, what each caller is allowed to do, and how often it can do it.
What we are building
- A stateless
POST /mcpendpoint using Streamable HTTP -
get_order_status, which requires theorders:readscope -
create_support_ticket, which requires thetickets:writescope - A key check on every request and a scope check on every tool call
- A token bucket for each client and one JSON log line for each tool call
npm i @modelcontextprotocol/sdk express zod
npm i -D typescript tsx @types/express @types/node
Step 1: Issue keys and store only the hash
Treat a key like a password. The client keeps the plain key, and the server only keeps its SHA-256 hash. If your config leaks, nobody can use the hashes to call your tools.
node -e "const c=require('crypto');const k='mcp_'+c.randomBytes(24).toString('base64url');console.log(k);console.log(c.createHash('sha256').update(k).digest('hex'))"
Give the first line to the client and put the second line in your environment.
// auth.ts
import { createHash } from "node:crypto";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
type ClientKey = { clientId: string; scopes: string[] };
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
// Production: load from a database or secret store.
const KEYS = new Map<string, ClientKey>([
[process.env.KEY_HASH_SUPPORT ?? "", { clientId: "support-bot", scopes: ["orders:read", "tickets:write"] }],
[process.env.KEY_HASH_ANALYTICS ?? "", { clientId: "analytics-agent", scopes: ["orders:read"] }],
]);
KEYS.delete(""); // ignore unset env vars
export function authenticate(header?: string): AuthInfo | null {
if (!header || !header.startsWith("Bearer ")) return null;
const entry = KEYS.get(sha256(header.slice(7).trim()));
if (!entry) return null;
return { token: "redacted", clientId: entry.clientId, scopes: entry.scopes };
}
Scopes are the important part. The analytics agent can read orders but cannot open tickets. If its prompt is hijacked, the damage stops at the scopes on its key.
Step 2: A token bucket for each client
A token bucket lets a client make a short burst of calls and then holds it to a steady rate. That fits agents well, because they often fire several calls in a row and then go quiet.
// ratelimit.ts
type Bucket = { tokens: number; last: number };
type Limit = { capacity: number; perSec: number };
const LIMITS: Record<string, Limit> = {
"support-bot": { capacity: 30, perSec: 0.5 },
default: { capacity: 10, perSec: 0.2 },
};
const buckets = new Map<string, Bucket>();
export function take(clientId: string) {
const lim = LIMITS[clientId] ?? LIMITS.default;
const now = Date.now();
const b = buckets.get(clientId) ?? { tokens: lim.capacity, last: now };
b.tokens = Math.min(lim.capacity, b.tokens + ((now - b.last) / 1000) * lim.perSec);
b.last = now;
buckets.set(clientId, b);
if (b.tokens >= 1) {
b.tokens -= 1;
return { ok: true, retryAfterSec: 0 };
}
return { ok: false, retryAfterSec: Math.ceil((1 - b.tokens) / lim.perSec) };
}
The bucket is keyed by clientId, not by IP address. Several agents can share one egress IP, and one agent can rotate across many.
Step 3: One wrapper for scopes, throttling and logs
Don't copy these checks into every tool. Put them in one wrapper so no tool can skip them.
// guard.ts
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { take } from "./ratelimit.js";
type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean };
const fail = (text: string): ToolResult => ({ isError: true, content: [{ type: "text", text }] });
export function guarded<A extends object>(
tool: string,
scope: string,
fn: (args: A, clientId: string) => Promise<ToolResult>
) {
return async (args: A, extra: { authInfo?: AuthInfo }): Promise<ToolResult> => {
const started = Date.now();
const clientId = extra.authInfo?.clientId ?? "unknown";
let outcome = "ok";
try {
if (!extra.authInfo?.scopes.includes(scope)) {
outcome = "forbidden";
return fail("This key lacks the scope " + scope + ".");
}
const rl = take(clientId);
if (!rl.ok) {
outcome = "throttled";
return fail("Rate limit reached. Retry in " + rl.retryAfterSec + " seconds.");
}
const res = await fn(args, clientId);
if (res.isError) outcome = "tool_error";
return res;
} catch {
outcome = "exception";
return fail("Internal error. The call was logged.");
} finally {
console.log(JSON.stringify({
ts: new Date().toISOString(), clientId, tool, outcome,
ms: Date.now() - started, argKeys: Object.keys(args ?? {}),
}));
}
};
}
Two decisions here:
- Throttles and missing scopes come back as tool errors, not HTTP errors. The model can read the message, tell the user, or wait and retry. A raw 429 in the middle of a JSON-RPC exchange usually shows up as a generic client failure.
- Logs record argument names, not values. Order IDs and ticket text can contain personal data. Log values only for fields you know are safe.
Step 4: The two tools
The data lives in memory so the example runs as-is. Replace the Maps with calls to your database.
// tools.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { guarded } from "./guard.js";
const orders = new Map([["A-1001", { status: "shipped", carrier: "DHL", eta: "2026-10-12" }]]);
const tickets: { id: string; orderId: string; summary: string; openedBy: string }[] = [];
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
export function buildServer() {
const server = new McpServer({ name: "orders-mcp", version: "0.1.0" });
server.registerTool("get_order_status", {
description: "Get shipping status, carrier and ETA for one order. Input is an order ID like A-1001.",
inputSchema: { orderId: z.string().min(3).max(20) },
}, guarded<{ orderId: string }>("get_order_status", "orders:read", async ({ orderId }) => {
const o = orders.get(orderId);
return o ? text(JSON.stringify(o)) : { ...text("No order " + orderId), isError: true };
}));
server.registerTool("create_support_ticket", {
description: "Open a support ticket for an existing order. Use only when the user asks for help with that order.",
inputSchema: { orderId: z.string().min(3).max(20), summary: z.string().min(10).max(500) },
}, guarded<{ orderId: string; summary: string }>("create_support_ticket", "tickets:write",
async ({ orderId, summary }, clientId) => {
if (!orders.has(orderId)) return { ...text("No order " + orderId), isError: true };
const id = "T-" + (tickets.length + 1);
tickets.push({ id, orderId, summary, openedBy: clientId });
return text("Created ticket " + id);
}));
return server;
}
The model treats a tool description as instructions, so write it that way. Say when the model should use the tool, not only what the tool does. The max(500) on summary stops a confused agent from pasting a whole conversation into your ticket system.
Step 5: Put it on HTTP
// index.ts
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { authenticate } from "./auth.js";
import { buildServer } from "./tools.js";
const app = express();
app.use(express.json({ limit: "100kb" }));
app.post("/mcp", async (req, res) => {
const auth = authenticate(req.headers.authorization);
if (!auth) {
res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, message: "Invalid API key" }, id: null });
return;
}
(req as typeof req & { auth?: AuthInfo }).auth = auth;
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.all("/mcp", (_req, res) => { res.status(405).end(); });
app.listen(3000);
A bad key gets a 401 before any MCP processing starts. When you set req.auth, the SDK hands it to each tool handler as extra.authInfo, which is what the wrapper reads. In stateless mode every request gets a new server instance, so no session state can leak between clients.
Test it
Run npx tsx index.ts, then open npx @modelcontextprotocol/inspector. Point it at http://localhost:3000/mcp and add an Authorization: Bearer mcp_... header. Check these four things:
- With no header, you get a 401.
- With the analytics key,
create_support_ticketreturns the missing-scope message. - With the analytics key, the 11th rapid
get_order_statuscall returns the throttle message. - Each call prints one JSON log line with the right
outcome.
If all four pass, the server does what it claims.
Before production
This server is minimal. Close these gaps before real traffic hits it:
- Shared rate limits. In-memory buckets only work on a single instance. Move them to Redis when you run more than one.
- Key rotation. Allow two active hashes per client so you can rotate keys without downtime, and give keys an expiry date.
-
Log shipping. Send the JSON lines to your log store and set alerts for bursts of
forbiddenorexception. - Untrusted output. Whatever your tools return goes straight into the model's context. Read up on prompt injection through tool results before you expose fields that customers write.
- OAuth for third-party clients. Scoped keys work for your own agents. Clients that act on behalf of end users need a proper OAuth flow.
Geminate Solutions uses the same order when it puts an MCP server in front of internal systems: identity first, then scopes, then limits, then logs.
For deployment, observability and auth choices, the full guide on running an MCP server in production covers the rest.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.