Middleware Pipeline
Every tool call in @casys/mcp-server flows through a composable middleware chain — the same onion model you know from Hono, Koa, or Express. This is how auth, rate limiting, validation, and your custom logic all compose together without tangling.
How it works
Section titled “How it works”Request → Rate Limit → Auth → Custom Middlewares → Scope Check → Validation → Backpressure → Handler ↓Response ← Rate Limit ← Auth ← Custom Middlewares ← Scope Check ← Validation ← Backpressure ← ResultEach middleware receives a context object and a next() function. Call next() to pass control downstream. The response flows back through the same chain in reverse — so a single middleware can act on both the request and the response.
Writing a middleware
Section titled “Writing a middleware”A middleware is just an async function. Here’s a logger that times every tool call:
import type { Middleware } from "@casys/mcp-server";
const logging: Middleware = async (ctx, next) => { const start = performance.now(); console.log(`→ ${ctx.toolName}`, ctx.args);
const result = await next();
const ms = (performance.now() - start).toFixed(0); console.log(`← ${ctx.toolName} (${ms}ms)`); return result;};That’s it. No base class, no decorators, no registration boilerplate.
Registering middlewares
Section titled “Registering middlewares”Use server.use() to add your middlewares. They slot in between the built-in layers (rate limit, auth) and the built-in checks (validation, backpressure):
const server = new ConcurrentMCPServer({ name: "my-server", version: "1.0.0",});
server.use(logging);server.use(metrics);server.use(caching);
server.registerTool(/* ... */);await server.startHttp({ port: 3000 });Pipeline order
Section titled “Pipeline order”Here’s exactly what runs for every tool call, and in what order:
| Order | Layer | Source | What it does |
|---|---|---|---|
| 1 | Rate Limit | Built-in (if configured) | Sliding-window per-client throttling |
| 2 | Auth | Built-in (if configured) | JWT/Bearer token validation |
| 3 | Your middlewares | server.use() | Your code, in registration order |
| 4 | Scope Check | Built-in (if auth + scopes) | Verifies token scopes match requiredScopes |
| 5 | Validation | Built-in (if configured) | JSON Schema validation via ajv |
| 6 | Backpressure | Built-in | Queue/sleep/reject based on concurrency |
| 7 | Handler | registerTool() | Your tool handler function |
The context object
Section titled “The context object”Every middleware and handler receives the same context:
interface MiddlewareContext { toolName: string; // Which tool is being called args: Record<string, unknown>; // The tool's input arguments request?: Request; // HTTP Request (HTTP transport only) sessionId?: string; // Session ID (HTTP transport only) [key: string]: unknown; // Extensible — add your own fields}Enriching context
Section titled “Enriching context”Middlewares can attach data for downstream middlewares and the handler. This is the recommended way to share cross-cutting data:
const tenantResolver: Middleware = async (ctx, next) => { const tenantId = ctx.request?.headers.get("x-tenant-id"); if (!tenantId) { throw new Error("Missing X-Tenant-Id header"); } ctx.tenantId = tenantId; // Now available to all downstream middlewares + handler return next();};Short-circuiting
Section titled “Short-circuiting”Don’t call next() to skip the rest of the pipeline. This is how you build caching:
const cache = new Map<string, { result: unknown; expiry: number }>();
const caching: Middleware = async (ctx, next) => { const key = `${ctx.toolName}:${JSON.stringify(ctx.args)}`; const cached = cache.get(key);
if (cached && cached.expiry > Date.now()) { return cached.result; // Entire downstream pipeline is skipped }
const result = await next(); cache.set(key, { result, expiry: Date.now() + 60_000 }); return result;};Error handling
Section titled “Error handling”Errors thrown anywhere in the chain propagate back through all middlewares. Put your error handler first so it wraps everything:
const errorHandler: Middleware = async (ctx, next) => { try { return await next(); } catch (error) { console.error(`Tool ${ctx.toolName} failed:`, error); return { error: true, message: String(error) }; }};
// First middleware = outermost layer = catches all errorsserver.use(errorHandler);server.use(logging);server.use(caching);Recipes
Section titled “Recipes”Request timing with metrics export
Section titled “Request timing with metrics export”const timing: Middleware = async (ctx, next) => { const start = performance.now(); try { return await next(); } finally { const duration = performance.now() - start; metrics.record(ctx.toolName, duration); }};Tool-specific access control
Section titled “Tool-specific access control”const adminOnly: Middleware = async (ctx, next) => { if (ctx.toolName.startsWith("admin_")) { const role = ctx.authInfo?.claims?.role; if (role !== "admin") { throw new Error("Admin access required"); } } return next();};Request/response transformation
Section titled “Request/response transformation”const sanitizer: Middleware = async (ctx, next) => { // Sanitize input before handler if (typeof ctx.args.query === "string") { ctx.args.query = ctx.args.query.trim().slice(0, 1000); }
const result = await next();
// Transform output after handler return { ...result, processedAt: new Date().toISOString() };};See Also
Section titled “See Also”- Authentication (OAuth2) — The built-in auth middleware and OIDC presets
- ConcurrentMCPServer API — Full
use()andregisterTool()reference - Standalone Components — Use RateLimiter, RequestQueue as standalone utilities