Middleware
Most users don’t need custom middleware. The SDK includes logging and error
handling by default. Use middleware when you need to inspect message content
( names, arguments) — for HTTP-level concerns like request logging or headers,
use app.onRequest() instead.
Middleware intercepts messages before they reach your and after responses are generated.
MCP Middleware vs HTTP Hooks
The SDK has two extension points. Pick the right one:
| Layer | What it intercepts | Use for |
|---|---|---|
| MCP Middleware | MCP JSON-RPC messages (tools/call, resources/read, etc.) | Auth on tool calls, rate limiting, tool call logging, metrics |
HTTP Hooks (app.onRequest) | Raw HTTP requests (HTTP transport only) | Request logging, custom headers |
Rule of thumb: If you’re inspecting tool names or arguments, use middleware. If you’re inspecting HTTP headers or request paths, use app.onRequest().
Quick Start
Extend the Middleware class and override handler methods:
import {
Middleware,
type MiddlewareContext,
type CallNext,
} from 'arcade-mcp-server';
class TimingMiddleware extends Middleware {
async onMessage(context: MiddlewareContext, next: CallNext) {
const start = performance.now();
const result = await next(context);
const elapsed = performance.now() - start;
console.error(`Request took ${elapsed.toFixed(2)}ms`);
return result;
}
}Add it to your server:
import { MCPServer, ToolCatalog } from 'arcade-mcp-server';
const server = new MCPServer({
catalog: new ToolCatalog(),
middleware: [new TimingMiddleware()],
});With stdio transport, use console.error() for logging. All stdout is
reserved for protocol data.
How Middleware Executes
Middleware wraps your handlers like an onion. Each middleware can run code before and after calling next():
Request enters
↓
┌─────────────────────────────────────────────┐
│ ErrorHandling (catches all errors) │
│ ┌─────────────────────────────────────┐ │
│ │ Logging (logs all requests) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Your Middleware │ │ │
│ │ │ ↓ │ │ │
│ │ │ Handler │ │ │
│ │ │ ↓ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↓
Response exitsThe outermost middleware (ErrorHandling) runs first on request and last on response. This means:
- If your middleware throws an error,
ErrorHandlingcatches it - If auth fails, the error is logged and formatted safely
Middleware array order = outside-in. First in array wraps everything.
Available Hooks
Override these methods to intercept specific message types:
| Hook | When it runs |
|---|---|
onMessage | Every message (requests + notifications) |
onRequest | Request messages only (expects response) |
onNotification | Notification messages only (no response) |
onCallTool | Tool invocations |
onListTools | Tool listing requests |
onListResources | Resource listing requests |
onReadResource | Resource read requests |
onListResourceTemplates | Resource template listing requests |
onListPrompts | Prompt listing requests |
onGetPrompt | Prompt retrieval requests |
Within each middleware, hooks run in order: onMessage → onRequest/onNotification → method-specific hook.
All hooks follow this pattern:
async onHookName(
context: MiddlewareContext<T>,
next: CallNext
): Promise<MCPMessage | undefined>- Call
next(context)to continue the chain - Modify the result after calling
nextto alter the response - Throw an error to abort processing
- Return early (without calling
next) to short-circuit
Built-in Middleware
The SDK includes two middleware enabled by default:
LoggingMiddleware — Logs all messages with timing.
ErrorHandlingMiddleware — Catches errors and returns safe error responses.
You don’t need to add these manually. Use middlewareOptions to configure them:
const server = new MCPServer({
catalog: new ToolCatalog(),
middlewareOptions: {
logLevel: 'DEBUG', // More verbose logging
maskErrorDetails: false, // Show full errors in development
},
});| Option | Type | Default | Description |
|---|---|---|---|
enableLogging | boolean | true | Enable logging middleware |
logLevel | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'INFO' | Log level |
enableErrorHandling | boolean | true | Enable error handling |
maskErrorDetails | boolean | true | Hide error details (secure) |
In production, keep maskErrorDetails: true (the default) to avoid leaking
internal details. Set to false only in development.
Note: middlewareOptions configures built-in middleware. The middleware array is for your custom middleware only—built-in middleware is never added there.
Environment Variables
You can also configure via environment:
| Variable | Default | Description |
|---|---|---|
MCP_MIDDLEWARE_ENABLE_LOGGING | true | Enable logging middleware |
MCP_MIDDLEWARE_LOG_LEVEL | INFO | Log level |
MCP_MIDDLEWARE_ENABLE_ERROR_HANDLING | true | Enable error handling |
MCP_MIDDLEWARE_MASK_ERROR_DETAILS | true | Mask error details |
import { MCPServer, middlewareOptionsFromEnv, ToolCatalog } from 'arcade-mcp-server';
const server = new MCPServer({
catalog: new ToolCatalog(),
middlewareOptions: middlewareOptionsFromEnv(),
});See Settings for the complete configuration reference.
Using a Custom Error Handler
To replace the built-in error handler:
const server = new MCPServer({
catalog: new ToolCatalog(),
middlewareOptions: { enableErrorHandling: false },
middleware: [new MyCustomErrorHandler()],
});Composing Middleware
Use composeMiddleware to group related middleware into reusable units:
import { composeMiddleware } from 'arcade-mcp-server';
// Group auth-related middleware
const authStack = composeMiddleware(
new AuthMiddleware(),
new AuditMiddleware()
);
const server = new MCPServer({
catalog: new ToolCatalog(),
middleware: [authStack, new MetricsMiddleware()],
});This is equivalent to passing them in order:
const server = new MCPServer({
// ...
middleware: [new AuthMiddleware(), new AuditMiddleware(), new MetricsMiddleware()],
});Use composition when you want to reuse the same middleware group across multiple servers or share it as a package.
Example: Auth Middleware
Validate an passed via context.metadata:
import {
Middleware,
AuthorizationError,
type MiddlewareContext,
type CallNext,
} from 'arcade-mcp-server';
class ApiKeyAuthMiddleware extends Middleware {
constructor(private validKeys: Set<string>) {
super();
}
async onCallTool(context: MiddlewareContext, next: CallNext) {
const apiKey = context.metadata.apiKey as string | undefined;
if (!apiKey || !this.validKeys.has(apiKey)) {
throw new AuthorizationError('Invalid API key');
}
return next(context);
}
}How apiKey gets into metadata depends on your transport and session setup. For Arcade-managed auth, use requiresAuth on your instead — see Overview.
Example: Rate Limiting
import {
Middleware,
RetryableToolError,
type MiddlewareContext,
type CallNext,
} from 'arcade-mcp-server';
class RateLimitMiddleware extends Middleware {
private requests = new Map<string, number[]>();
constructor(
private maxRequests = 100,
private windowMs = 60_000
) {
super();
}
async onCallTool(context: MiddlewareContext, next: CallNext) {
const clientId = context.sessionId ?? 'anonymous';
const now = Date.now();
const recent = (this.requests.get(clientId) ?? []).filter(
(t) => t > now - this.windowMs
);
if (recent.length >= this.maxRequests) {
throw new RetryableToolError('Rate limit exceeded. Try again later.', {
retryAfterMs: this.windowMs,
});
}
this.requests.set(clientId, [...recent, now]);
return next(context);
}
}Lifecycle Hooks
For server startup, shutdown, and HTTP request hooks, use app.onStart(), app.onStop(), and app.onRequest().
See Transports for the full reference. For low-level control with MCPServer, use the lifespan option — see Server.
Advanced: MiddlewareContext
passed to all middleware handlers:
interface MiddlewareContext<T = MCPMessage> {
readonly message: T; // The MCP message being processed
readonly source: 'client' | 'server';
readonly type: 'request' | 'notification';
readonly method: string | null; // e.g., "tools/call", "resources/read"
readonly timestamp: Date;
readonly requestId: string | null;
readonly sessionId: string | null;
readonly mcpContext: Context | null; // Advanced: MCP runtime access
metadata: Record<string, unknown>; // Mutable: pass data between middleware
}Sharing Data Between Middleware
Use metadata to pass data down the chain:
class AuthMiddleware extends Middleware {
async onMessage(context: MiddlewareContext, next: CallNext) {
const userId = await validateToken(context.message);
context.metadata.userId = userId;
return next(context);
}
}
class AuditMiddleware extends Middleware {
async onCallTool(context: MiddlewareContext, next: CallNext) {
const userId = context.metadata.userId; // From AuthMiddleware
await logToolCall(userId, context.message);
return next(context);
}
}Creating Modified Context
Use object spread to create a modified :
class TransformMiddleware extends Middleware {
async onMessage(context: MiddlewareContext, next: CallNext) {
const modifiedContext = {
...context,
metadata: { ...context.metadata, transformed: true },
};
return next(modifiedContext);
}
}MCP Runtime Access (Advanced)
The mcpContext field provides access to protocol capabilities:
| Property | Purpose |
|---|---|
log | Send logs via MCP protocol |
progress | Report progress for long-running operations |
resources | Read MCP resources |
tools | Call other tools programmatically |
prompts | Access MCP prompts |
sampling | Create messages using the client’s model |
ui | Elicit input from the user |
notifications | Send notifications to the client |
Most middleware won’t need mcpContext. Use console.error() for logging
and metadata for passing data between middleware.