Skip to Content

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:

LayerWhat it interceptsUse for
MCP MiddlewareMCP 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:

TypeScript
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:

TypeScript
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():

TEXT
Request enters ┌─────────────────────────────────────────────┐ │ ErrorHandling (catches all errors) │ │ ┌─────────────────────────────────────┐ │ │ │ Logging (logs all requests) │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ Your Middleware │ │ │ │ │ │ ↓ │ │ │ │ │ │ Handler │ │ │ │ │ │ ↓ │ │ │ │ │ └─────────────────────────────┘ │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ Response exits

The outermost middleware (ErrorHandling) runs first on request and last on response. This means:

  • If your middleware throws an error, ErrorHandling catches 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:

HookWhen it runs
onMessageEvery message (requests + notifications)
onRequestRequest messages only (expects response)
onNotificationNotification messages only (no response)
onCallToolTool invocations
onListToolsTool listing requests
onListResourcesResource listing requests
onReadResourceResource read requests
onListResourceTemplatesResource template listing requests
onListPromptsPrompt listing requests
onGetPromptPrompt retrieval requests

Within each middleware, hooks run in order: onMessageonRequest/onNotification → method-specific hook.

All hooks follow this pattern:

TypeScript
async onHookName( context: MiddlewareContext<T>, next: CallNext ): Promise<MCPMessage | undefined>
  • Call next(context) to continue the chain
  • Modify the result after calling next to 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:

TypeScript
const server = new MCPServer({ catalog: new ToolCatalog(), middlewareOptions: { logLevel: 'DEBUG', // More verbose logging maskErrorDetails: false, // Show full errors in development }, });
OptionTypeDefaultDescription
enableLoggingbooleantrueEnable logging middleware
logLevel'DEBUG' | 'INFO' | 'WARNING' | 'ERROR''INFO'Log level
enableErrorHandlingbooleantrueEnable error handling
maskErrorDetailsbooleantrueHide 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:

VariableDefaultDescription
MCP_MIDDLEWARE_ENABLE_LOGGINGtrueEnable logging middleware
MCP_MIDDLEWARE_LOG_LEVELINFOLog level
MCP_MIDDLEWARE_ENABLE_ERROR_HANDLINGtrueEnable error handling
MCP_MIDDLEWARE_MASK_ERROR_DETAILStrueMask error details
TypeScript
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:

TypeScript
const server = new MCPServer({ catalog: new ToolCatalog(), middlewareOptions: { enableErrorHandling: false }, middleware: [new MyCustomErrorHandler()], });

Composing Middleware

Use composeMiddleware to group related middleware into reusable units:

TypeScript
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:

TypeScript
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:

TypeScript
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

TypeScript
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:

TypeScript
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:

TypeScript
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 :

TypeScript
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:

PropertyPurpose
logSend logs via MCP protocol
progressReport progress for long-running operations
resourcesRead MCP resources
toolsCall other tools programmatically
promptsAccess MCP prompts
samplingCreate messages using the client’s model
uiElicit input from the user
notificationsSend notifications to the client

Most middleware won’t need mcpContext. Use console.error() for logging and metadata for passing data between middleware.

Last updated on

Middleware - Arcade MCP TypeScript Reference | Arcade Docs