MCP服务器中间件工具包

MCP Toolkit 是一款面向 TypeScript MCP 服务器的中间件工具包,提供认证、缓存、限流、CORS 和日志记录等即插即用功能,提升 MCP 服务器开发效率。

  • by Sagargupta16
  • TypeScript
  • 本地部署
  • 4 星标
  • 更新于
在 GitHub 查看 Sagargupta16/mcp-toolkit

使用说明

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, fs and Map. 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 real McpServer from a real SDK Client, 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() and server.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 createTask method (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's params._meta (the only per-request channel stdio offers). params._meta is caller-written body content, which is fine for a credential the caller has to present anyway, but it is why the withCors origin check ignores it. There is no session-level authentication and no integration with the SDK's own OAuth support.
  • The api-key strategy does not distinguish callers. Every valid key authenticates as one subject ("api-key-user"), so per-caller behaviour that keys off payload.sub -- notably cache scoping -- treats all API key holders as one caller. Use jwt or custom auth for a real per-caller identity.
  • The JWT verifier is HMAC-only. HS256, HS384 and HS512 are verified with Node's crypto. RS*, ES* and PS* throw; use type: "custom" with a real library such as jose for asymmetric algorithms.
  • withCors is 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 strategy field 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.

  1. Fork this repo
  2. Create a feature branch (git checkout -b feat/my-middleware)
  3. Add your code with tests
  4. 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

License

MIT