Middleware
Most don’t need custom middleware. The SDK includes logging and error handling by default. Use middleware when you need request/response interception, custom authentication, or metrics.
Middleware intercepts messages before they reach your and after responses are generated.
Built-in Middleware
LoggingMiddleware
Logs all messages with timing. Enabled by default.
import { LoggingMiddleware } from 'arcade-mcp';
new LoggingMiddleware({ logLevel: 'INFO' })ErrorHandlingMiddleware
Catches errors and returns safe error responses. Enabled by default.
import { ErrorHandlingMiddleware } from 'arcade-mcp';
new ErrorHandlingMiddleware({ maskErrorDetails: true })Set maskErrorDetails: false in development to see full stack traces.
Custom Middleware
Extend the Middleware class and override handler methods:
import { Middleware, type MiddlewareContext, type CallNext } from 'arcade-mcp';
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;
}
}With stdio transport, use console.error() for logging. All stdout is protocol data.
Available Hooks
| Hook | When it runs |
|---|---|
onMessage | Every message (use for logging, timing) |
onRequest | All request messages |
onNotification | All notification messages |
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 |
Hook Signatures
All hooks follow the same pattern:
async onHookName(
context: MiddlewareContext<T>,
next: CallNext
): Promise<MCPMessage | undefined>- Call
next(context)to continue the chain - Modify
context.messagebefore callingnextto alter the request - Modify the result after calling
nextto alter the response - Throw an error to abort processing
- Return early (without calling
next) to short-circuit
Composing Middleware
Combine multiple middleware into a single handler:
import {
composeMiddleware,
LoggingMiddleware,
ErrorHandlingMiddleware,
} from 'arcade-mcp';
const composed = composeMiddleware(
new ErrorHandlingMiddleware({ maskErrorDetails: false }),
new LoggingMiddleware({ logLevel: 'DEBUG' }),
new TimingMiddleware()
);Pass middleware directly to MCPServer:
import { MCPServer, ToolCatalog } from 'arcade-mcp';
const server = new MCPServer({
catalog: new ToolCatalog(),
middleware: [
new ErrorHandlingMiddleware({ maskErrorDetails: false }),
new LoggingMiddleware({ logLevel: 'DEBUG' }),
],
});Use composeMiddleware when you need to combine middleware into reusable units:
const authAndLogging = composeMiddleware(
new AuthMiddleware(),
new LoggingMiddleware()
);
const server = new MCPServer({
catalog: new ToolCatalog(),
middleware: [authAndLogging, new MetricsMiddleware()],
});Middleware runs in order. The first wraps the second, which wraps the third.
ErrorHandlingMiddleware first means it catches errors from all subsequent middleware.
MiddlewareContext
passed to all middleware handlers:
interface MiddlewareContext<T = MCPMessage> {
/** The MCP message being processed */
message: T;
/** Mutable metadata to pass between middleware */
metadata: Record<string, unknown>;
/** Client session info (if available) */
session?: ServerSession;
}Sharing Data Between Middleware
Use metadata to pass data between middleware:
class AuthMiddleware extends Middleware {
async onMessage(context: MiddlewareContext, next: CallNext) {
const userId = await validateToken(context.message);
context.metadata.userId = userId; // Available to subsequent middleware
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);
}
}Example: Auth Middleware
import {
Middleware,
AuthorizationError,
type MiddlewareContext,
type CallNext,
} from 'arcade-mcp';
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);
}
}
// Usage
const auth = new ApiKeyAuthMiddleware(new Set(['key1', 'key2']));Example: Rate Limiting Middleware
import { Middleware, RetryableToolError, type MiddlewareContext, type CallNext } from 'arcade-mcp';
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.session?.id ?? '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);
}
}HTTP-Level Hooks
For HTTP-level customization, access the underlying Elysia instance via app.elysia:
import { MCPApp } from 'arcade-mcp';
const app = new MCPApp({ name: 'my-server' });
// Log requests
app.elysia.onRequest(({ request }) => {
console.error(`${request.method} ${new URL(request.url).pathname}`);
});
// Add response headers
app.elysia.onAfterHandle(({ set }) => {
set.headers['X-Powered-By'] = 'Arcade MCP';
});
app.run({ transport: 'http', port: 8000 });app.elysia gives you the underlying Elysia instance with full access to all lifecycle hooks.
See the Elysia lifecycle docs for available hooks.
For most use cases, middleware (the Middleware class) is sufficient. HTTP-level auth can use Elysia’s derive pattern; see the Elysia docs . CORS is configured via the cors option.