使用说明
MCP Toolkit
TypeScript middleware toolkit for MCP servers - authentication, caching, rate limiting, CORS, logging (beta).
Stop reimplementing auth, caching, rate limiting, CORS, and logging for every MCP server. MCP Toolkit provides drop-in packages that work with the TypeScript SDK.
- One line per concern.
withAuth(server, ...),withRateLimit(server, ...),withCache(server, ...),withCors(server, ...)wrap every tool registered afterwards; your tool handlers do not change. - Zero runtime dependencies. Each package is a single TypeScript file on Node's own
crypto,fsandMap. The SDK is a peer dependency, never bundled. - Scales past one process. Pass a Redis or Valkey store and the cache and the rate limit are shared by every replica. The token bucket runs as one Lua script, so the limit stays exact under concurrency.
- Tested. 85 unit tests (
node --test, no test framework) cover every package, including the ordering, header-lookup and bypass cases listed under Limitations. 6 end-to-end tests drive a realMcpServerfrom a real SDKClient, over the in-memory transport and over Streamable HTTP, and 4 more run the Redis stores against Redis 8 and Valkey 9 in CI. - Measured. The full stack (cors, api-key auth, rate limit, cache) added 3.6 to 5.8 microseconds per tool call across four benchmark runs on a laptop; see Performance.
Works with @modelcontextprotocol/sdk 1.x (verified on 1.32.1). The SDK v2 split
packages (@modelcontextprotocol/server) are not supported yet.
Packages
| Package | Description | Status |
|---|---|---|
@mcp-toolkit/auth |
API key and JWT authentication | Beta |
@mcp-toolkit/cache |
Response caching with TTL and LRU, in memory or Redis / Valkey | Beta |
@mcp-toolkit/rate-limit |
Token bucket rate limiting, in memory or Redis / Valkey | Beta |
@mcp-toolkit/logger |
Structured logging with JSON output and log levels | Beta |
@mcp-toolkit/cors |
Origin validation middleware | Beta |
Quick Start
Install
The @mcp-toolkit/* packages are not yet published to npm. Until then, clone and build from source:
git clone https://github.com/Sagargupta16/mcp-toolkit.git
cd mcp-toolkit
npm install
npm run build
Usage with TypeScript SDK
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { withAuth } from "@mcp-toolkit/auth";
import { withCache } from "@mcp-toolkit/cache";
import { withRateLimit } from "@mcp-toolkit/rate-limit";
import { createLogger } from "@mcp-toolkit/logger";
// Logs go to stderr by default, which is the only stream a stdio MCP server may
// write non-protocol bytes to.
const logger = createLogger({ level: "info", format: "json" });
const apiKey = process.env.MCP_API_KEY;
if (!apiKey) throw new Error("MCP_API_KEY is not set");
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
// Add middleware. Order matters - see below.
withAuth(server, {
type: "api-key",
keys: [apiKey],
});
withRateLimit(server, {
strategy: "token-bucket",
maxTokens: 100,
refillRate: 10,
});
withCache(server, {
ttl: 300,
maxSize: 1000,
strategy: "lru",
});
// Define tools - middleware applies automatically.
// inputSchema is a Zod raw shape (a plain object of Zod schemas), not JSON Schema.
// The deprecated `server.tool()` is wrapped the same way.
server.registerTool(
"get-data",
{
description: "Fetch data with auth + cache + rate limiting",
inputSchema: { query: z.string().describe("Search query") },
},
async ({ query }) => {
logger.info("Fetching data", { query });
const result = await fetchData(query);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
},
);
// The packages compile to CommonJS, so there is no top-level await here.
const transport = new StdioServerTransport();
server.connect(transport).catch((error) => {
logger.error("Failed to start", error as Error);
process.exit(1);
});
Order matters
Each with* call wraps the server's tool registration methods, so the last
middleware applied is the innermost one at call time. Apply them outermost
first: cors, auth, rate-limit, cache.
withCors(server, { allowedOrigins: ["https://claude.ai"] }); // outermost
withAuth(server, { type: "api-key", keys: [apiKey] });
withRateLimit(server, { strategy: "token-bucket", maxTokens: 100, refillRate: 10 });
withCache(server, { strategy: "lru", ttl: 300, maxSize: 1000 }); // innermost
Getting this backwards is a data leak, not a style problem:
// WRONG - cache is outside auth, so a cache hit returns before auth ever runs.
// An unauthenticated caller gets another user's cached response.
withCache(server, { strategy: "lru", ttl: 300 });
withAuth(server, { type: "api-key", keys: [apiKey] });
With auth applied first, withCache prefixes each key with the caller's
payload.sub. Note what that is worth per strategy: the built-in api-key
strategy reports the same sub ("api-key-user") for every valid key, so all API
key callers share one cache namespace. Use jwt or custom auth, which carry a
real per-caller sub, when cached responses differ by caller.
Package Details
Auth
Multiple authentication strategies:
// API Key
withAuth(server, { type: "api-key", header: "X-API-Key", keys: ["key1", "key2"] });
// JWT
withAuth(server, { type: "jwt", secret: process.env.JWT_SECRET, algorithms: ["HS256"] });
// Custom
withAuth(server, { type: "custom", verify: async (token) => isValid(token) });
Cache
Response caching with multiple strategies:
withCache(server, {
strategy: "lru", // lru | ttl
ttl: 300, // seconds
maxSize: 1000, // max entries
keyGenerator: (toolName, args) => `${toolName}:${JSON.stringify(args)}`,
});
Both strategy values are served by one LRU-with-TTL cache: an entry leaves when it
exceeds ttl or when maxSize is reached, whichever comes first. "ttl" does not
currently select a different eviction policy.
Shared state (Redis / Valkey)
By default the cache and the token buckets live in process memory, so three replicas behind a load balancer allow three times the rate limit and cache three times. Give both a store and they share one Redis or Valkey:
import { createClient } from "redis";
import { withCache, redisCacheStore } from "@mcp-toolkit/cache";
import { withRateLimit, redisRateLimitStore } from "@mcp-toolkit/rate-limit";
const redis = await createClient({ url: process.env.REDIS_URL }).connect();
const command = (args: string[]) => redis.sendCommand(args);
withRateLimit(server, {
strategy: "token-bucket",
maxTokens: 60,
refillRate: 1,
store: redisRateLimitStore(command),
});
withCache(server, { strategy: "ttl", ttl: 300, store: redisCacheStore(command) });
The stores take a function that sends one command, not a client, so the packages keep
zero runtime dependencies and any client works (ioredis:
(args) => redis.call(args[0]!, ...args.slice(1))). The rate limit runs refill, take and
write back as one EVAL, on the Redis server's clock, so two replicas cannot both spend
the last token. Details in the cache
and rate-limit READMEs, and a
runnable server in examples/shared-state-redis.ts.
Rate Limit
Protect your server from abuse:
withRateLimit(server, {
strategy: "token-bucket",
maxTokens: 100,
refillRate: 10, // per second
onLimited: (req) => logger.warn("Rate limited", { tool: req.toolName }),
});
Logger
Structured logging built for MCP servers:
const logger = createLogger({
level: "info", // debug | info | warn | error
format: "json", // json | text
transports: ["stderr", { type: "file", path: "./mcp-server.log" }],
});
Transports default to ["stderr"]. Do not switch to "stdout" on a stdio server:
per the MCP stdio transport spec
(2026-07-28 revision), the server "MUST NOT write anything to its stdout that is not a
valid MCP message", and "MAY write UTF-8 strings to stderr for any logging purposes".
A log line on stdout corrupts the JSON-RPC stream. "stdout" is only safe when the
server does not speak MCP over stdio.
CORS
Validate request origins when using HTTP or SSE transport:
import { withCors } from "@mcp-toolkit/cors";
withCors(server, {
allowedOrigins: ["https://myapp.com"],
});
The origin is read only from the real HTTP headers the SDK exposes at
extra.requestInfo.headers, case-insensitively. A caller cannot supply it through
params._meta: that is JSON-RPC body content the caller writes, so honouring it would
let any client name an allowed origin. A request with no origin is blocked whenever an
allowlist is configured, which is why applying withCors to a stdio server rejects
every call: stdio has no Origin to validate.
allowedMethods is optional and best-effort. The SDK's RequestInfo carries only
headers and url, so a tool handler usually cannot see the HTTP method at all; when
it is undetectable the request is allowed through. Enforce methods at your HTTP layer,
not here.
Architecture
There is no middleware runtime. Each withX(server, options) call replaces
server.tool and server.registerTool with a version that wraps the handler before
registering it, so a tool registered afterwards runs inside every layer applied so far.
A guard on the returned RegisteredTool re-wraps a handler swapped in later through
update({ callback }), so the layers cannot be dropped after registration.
The spec behind the picture is docs/diagrams/request-path.yaml, rendered with orrery.
A thrown error reaches the SDK, which returns it to the client as a tool error.
@mcp-toolkit/logger is not a layer: it is a plain logger your handlers (and callbacks
such as onLimited) call.
Design decisions
| Decision | Why |
|---|---|
| Patch the registration methods instead of the request router | McpServer has no per-call middleware hook (the SDK's own middleware is Express-level OAuth). Wrapping the handler at registration works on every transport without forking the SDK or replacing its tools/call handler. |
| Last applied runs innermost | It follows from wrapping at registration. Documented loudly because the wrong order puts the cache in front of auth. |
Cache keys carry the caller's sub |
Without it one caller's identity-dependent response is served to the next. |
Origin read only from real HTTP headers |
params._meta is body content the caller writes, so trusting it would let any client name an allowed origin. |
Credentials accepted from headers or params._meta |
stdio has no headers; _meta is its only per-request channel. |
Bounded rate-limit buckets (maxBuckets, idle eviction) |
Bucket keys come from callers, so an unbounded map is a memory-exhaustion vector. |
Stores take a (args) => Promise command function, not a Redis client |
Keeps zero runtime dependencies, and node-redis, ioredis and Valkey clients all fit without an adapter package. |
Redis token bucket is one Lua script using TIME |
A read-then-write from Node would let two replicas spend the same token; the server clock removes host clock skew. Each bucket expires after one refill window, which bounds the key count. |
| Store errors fail the call | A rate limiter that lets traffic through when Redis is down is not a rate limiter. Wrap the cache store if you want it to fail open. |
Logger defaults to stderr |
On stdio, stdout is the JSON-RPC stream. |
| No internal shared package | Each package publishes on its own. The registration-patching block is copied into four packages and kept byte-identical. |
Performance
bench/overhead.ts measures what the middleware adds to a tool
call, two ways: calling the wrapped handler directly (the middleware's own cost) and
calling a real McpServer from a real Client over the SDK's in-memory transport
(what a tool call pays end to end, JSON-RPC and zod validation included). Every case is
warmed up, then timed in 15 interleaved rounds; the tables show the median round.
npm run build
npm run bench
One run on 2026-10-18, Node 26.10.0, Windows 11, Intel Core i9-14900HX:
| Layers (handler level) | us per call | Added (us) |
|---|---|---|
| bare handler (baseline) | 0.16 | - |
| cors | 0.31 | 0.15 |
| auth (api-key) | 1.90 | 1.74 |
| auth (jwt HS256) | 10.94 | 10.78 |
| rate-limit | 0.46 | 0.30 |
| cache, every call a miss | 2.38 | 2.22 |
| cache, every call a hit | 1.31 | 1.15 |
| cors + auth (api-key) + rate-limit + cache (miss) | 5.57 | 5.41 |
| cors + auth (jwt) + rate-limit + cache (miss) | 16.01 | 15.85 |
| Through the SDK | us per tools/call |
Added (us) |
|---|---|---|
bare McpServer |
12.66 | - |
| auth + rate-limit + cache, cache miss | 20.50 | 7.84 |
| auth + rate-limit + cache, cache hit | 18.85 | 6.19 |
Read these as orders of magnitude, not exact figures: four runs on the same laptop the same day put the full api-key stack between 3.75 and 5.98 us. What holds in every run is that the stack costs a few microseconds per call, JWT verification is the most expensive layer (HMAC plus JSON parsing on every call), and all of it is noise next to any tool that does I/O. The Redis stores add one network round trip each and are not in these tables.
Limitations and compatibility
This is beta software. What does not work yet, stated plainly:
- Tool registration only. The middleware patches
server.tool()andserver.registerTool(). It does not intercept resources, prompts, or completions, so those run unauthenticated and unlimited. - Task-based tool handlers are not wrapped. A handler registered as an object with
a
createTaskmethod (the SDK's task execution shape) is not a function, so the patch forwards it untouched and no middleware runs for that tool. - Credentials come from request metadata, not from a session. A credential is
looked up in the HTTP headers at
extra.requestInfo.headers(Streamable HTTP and SSE) or in the MCP request'sparams._meta(the only per-request channel stdio offers).params._metais caller-written body content, which is fine for a credential the caller has to present anyway, but it is why thewithCorsorigin check ignores it. There is no session-level authentication and no integration with the SDK's own OAuth support. - The
api-keystrategy does not distinguish callers. Every valid key authenticates as one subject ("api-key-user"), so per-caller behaviour that keys offpayload.sub-- notably cache scoping -- treats all API key holders as one caller. Usejwtorcustomauth for a real per-caller identity. - The JWT verifier is HMAC-only.
HS256,HS384andHS512are verified with Node'scrypto.RS*,ES*andPS*throw; usetype: "custom"with a real library such asjosefor asymmetric algorithms. withCorsis an origin allowlist, not CORS. It is evaluated per tool call, so there is no preflight handling and no response headers, and it cannot reliably see the HTTP method. Under stdio there is no origin at all, so an allowlist blocks everything.- State is in-process memory unless you pass a store. By default cache entries and token buckets live in the current Node process, so nothing is shared across workers, replicas, or restarts. The built-in Redis / Valkey stores fix that for the cache and the rate limit; there is no store for anything else.
- The cache
strategyfield does not change eviction."lru"and"ttl"both use one LRU-with-TTL cache.
Examples
See the examples/ directory (and its README for
how to run them):
Contributing
Contributions welcome - new middleware, bug fixes, or docs improvements.
- Fork this repo
- Create a feature branch (
git checkout -b feat/my-middleware) - Add your code with tests
- Submit a PR
See CONTRIBUTING.md for full guidelines.
More AI Developer Tools
| Project | Description |
|---|---|
| claude-cost-optimizer | Save 30-60% on Claude Code costs - proven strategies and benchmarks |
| ai-git-hooks | AI-powered git hooks - auto-review diffs, generate commit messages, security scanning |
| claude-code-recipes | Copy-paste recipes for Claude Code - commands, subagents, hooks, skills, MCP integration |
| agent-recipes | AI agent workflows for real-world dev tasks - code review, testing, security |
