Middleware
Middleware intercepts tool calls at three stages: before (pre-execution), after (post-execution), and onError (on failure).
Structure
interface AirMiddleware {
name: string;
before?: (ctx: MiddlewareContext) => Promise<MiddlewareResult | void>;
after?: (ctx: MiddlewareContext & { result: any; duration: number }) => Promise<void>;
onError?: (ctx: MiddlewareContext, error: Error) => Promise<any>;
}All three hooks are optional. Implement only what you need.
MiddlewareContext
interface MiddlewareContext {
tool: AirToolDef; // Tool being called
params: Record<string, any>; // Request params (modifiable in before)
requestId: string; // Unique request ID (UUID)
serverName: string; // Server name
startedAt: number; // Call start timestamp (ms)
meta: Record<string, any>; // Shared metadata between middleware
}meta starts as an empty object. Use it to pass data between middleware.
before — pre-execution
Called before the tool handler runs. Can modify params, abort the call, or add metadata.
MiddlewareResult
interface MiddlewareResult {
params?: Record<string, any>; // Modified params (undefined = keep original)
abort?: boolean; // true = skip remaining middleware + handler
abortResponse?: any; // Response when aborting (passed through normalizeResult)
meta?: Record<string, any>; // Add to shared metadata
}Modify params
const limitMiddleware: AirMiddleware = {
name: 'limit-enforcer',
before: async (ctx) => {
return {
params: {
...ctx.params,
limit: Math.min(ctx.params.limit || 100, 100),
},
};
},
};INFO
When you return params, all subsequent middleware and the handler receive the modified params. This also applies to the builtin validation middleware — after Zod validation passes, params are replaced with result.data (the parsed/cleaned data).
Abort early
const blockMiddleware: AirMiddleware = {
name: 'blocker',
before: async (ctx) => {
if (ctx.params.blocked) {
return {
abort: true,
abortResponse: 'Request blocked',
};
}
},
};When abort: true, all remaining before middleware, the handler, and after middleware are skipped. abortResponse is passed through normalizeResult() and returned as the MCP response.
Add metadata
const timingMiddleware: AirMiddleware = {
name: 'timing',
before: async (ctx) => {
ctx.meta.startTime = performance.now();
},
after: async (ctx) => {
const elapsed = performance.now() - ctx.meta.startTime;
console.log(`${ctx.tool.name}: ${elapsed.toFixed(1)}ms`);
},
};after — post-execution
Called after the handler completes successfully. ctx includes result (handler return value) and duration (execution time in ms).
const auditMiddleware: AirMiddleware = {
name: 'audit',
after: async (ctx) => {
console.log(JSON.stringify({
tool: ctx.tool.name,
requestId: ctx.requestId,
duration: ctx.duration,
timestamp: new Date().toISOString(),
}));
},
};WARNING
Errors thrown in after middleware are silently ignored. The result is already finalized, so after errors cannot affect the response.
onError — error handling
Called when the handler or a before middleware throws an error.
const errorReporter: AirMiddleware = {
name: 'error-reporter',
onError: async (ctx, error) => {
await reportToSentry({
error: error.message,
tool: ctx.tool.name,
requestId: ctx.requestId,
});
// undefined → pass to next onError middleware
return undefined;
},
};Error recovery
If onError returns a value, that value becomes the normal response (error is recovered):
const gracefulMiddleware: AirMiddleware = {
name: 'graceful',
onError: async (ctx, error) => {
if (error.message.includes('ECONNREFUSED')) {
return 'Service temporarily unavailable. Please try again later.';
}
return undefined; // Pass to next handler
},
};Error handling order
Handler throws
→ Run registered middleware onError in order
→ If any returns a value → used as normal response (rest skipped)
→ If all return undefined → errorBoundaryMiddleware returns error messageBuiltin middleware
Auto-registered by defineServer(). Don't add these manually.
errorBoundaryMiddleware
Catches all errors and converts to MCP protocol format. Sits at the outermost layer of the chain.
// Error response format
{
content: [{ type: 'text', text: '[-32603] Internal error: something broke' }],
isError: true,
}Error logs go to stderr:
[air:error] search (a1b2c3d4-...): Connection refusedvalidationMiddleware
Converts tool params definitions to Zod schemas and validates every call.
On validation failure, returns a detailed error message:
[Validation] Invalid parameters for "search":
- query: Expected string, received number (expected: string, got: number)
- limit: Expected number, received string (expected: number, got: string)
Expected schema:
- query: string
- limit: number (optional)On success, params are replaced with Zod's parsed data (result.data). Since .passthrough() is used, extra fields not in the schema pass through.
Register in server
defineServer({
middleware: [timingMiddleware, auditMiddleware],
});middleware array runs after all plugin middleware. Put reusable logic in use (plugins), server-specific logic in middleware.
MCP error codes
import { AirError, McpErrors } from '@airmcp-dev/core';
McpErrors.toolNotFound('missing'); // -32601
McpErrors.invalidParams('bad email'); // -32602
McpErrors.internal('db failed'); // -32603
McpErrors.forbidden('not allowed'); // -32000
McpErrors.rateLimited('search', 30000); // -32001
McpErrors.threatDetected('injection', 'high'); // -32002
McpErrors.timeout('slow', 10000); // -32003
// Custom error
throw new AirError('Insufficient credits', -32010, { required: 100, available: 42 });Full execution flow
Request arrives
↓
errorBoundaryMiddleware.before (error capture)
validationMiddleware.before (validate + clean params)
↓
builtinLoggerPlugin.before
builtinMetricsPlugin.before
↓
use[0].before → use[1].before → ... (plugin middleware)
↓
middleware[0].before → middleware[1].before → ... (user middleware)
↓
handler() (tool handler)
↓
middleware[0].after → middleware[1].after → ...
use[0].after → use[1].after → ...
builtinMetricsPlugin.after (record calls, latency)
builtinLoggerPlugin.after (log output)
↓
normalizeResult() (convert return value → MCP content)
↓
Response returned