Skip to content

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.

Middleware Pipeline
Request → Rate Limit → Auth → Custom Middlewares → Scope Check → Validation → Backpressure → Handler
Response ← Rate Limit ← Auth ← Custom Middlewares ← Scope Check ← Validation ← Backpressure ← Result

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

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.

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 });

Here’s exactly what runs for every tool call, and in what order:

OrderLayerSourceWhat it does
1Rate LimitBuilt-in (if configured)Sliding-window per-client throttling
2AuthBuilt-in (if configured)JWT/Bearer token validation
3Your middlewaresserver.use()Your code, in registration order
4Scope CheckBuilt-in (if auth + scopes)Verifies token scopes match requiredScopes
5ValidationBuilt-in (if configured)JSON Schema validation via ajv
6BackpressureBuilt-inQueue/sleep/reject based on concurrency
7HandlerregisterTool()Your tool handler function

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
}

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();
};

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

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 errors
server.use(errorHandler);
server.use(logging);
server.use(caching);
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);
}
};
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();
};
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() };
};