MP-301a · Module 2

Logging & Observability Middleware

3 min read

Middleware in MCP servers follows the same pattern as middleware in HTTP frameworks: a function that wraps your handler, runs code before and after it, and can modify the request or response. The logging middleware is the first one you should build because every other pattern depends on having visibility into what is happening. A well-structured logging middleware captures the tool name, sanitized arguments, execution duration, result status, and any errors — all in structured JSON sent to stderr.

The middleware pattern in MCP is a higher-order function that takes a tool handler and returns a new handler with the same signature. This composability is the key benefit: you can stack middleware (logging → caching → rate-limiting → handler) without modifying any of the individual pieces. Each middleware is independently testable, independently removable, and independently configurable. The order matters — logging should be outermost so it captures the total duration including cache lookups and rate-limit delays.

type ToolHandler = (args: Record<string, unknown>) => Promise<ToolResult>;

interface ToolResult {
  content: { type: string; text: string }[];
  isError?: boolean;
}

// Composable logging middleware
function withLogging(toolName: string, handler: ToolHandler): ToolHandler {
  return async (args) => {
    const requestId = crypto.randomUUID().slice(0, 8);
    const start = Date.now();

    console.error(JSON.stringify({
      event: "tool_call_start",
      requestId,
      tool: toolName,
      args: sanitize(args), // strip secrets, truncate large values
      ts: new Date().toISOString(),
    }));

    try {
      const result = await handler(args);
      console.error(JSON.stringify({
        event: "tool_call_end",
        requestId,
        tool: toolName,
        durationMs: Date.now() - start,
        isError: result.isError ?? false,
      }));
      return result;
    } catch (err) {
      console.error(JSON.stringify({
        event: "tool_call_error",
        requestId,
        tool: toolName,
        error: (err as Error).message,
        durationMs: Date.now() - start,
      }));
      throw err;
    }
  };
}

function sanitize(args: Record<string, unknown>): Record<string, unknown> {
  const clean = { ...args };
  for (const key of ["password", "token", "secret", "apiKey"]) {
    if (key in clean) clean[key] = "[REDACTED]";
  }
  return clean;
}
  1. Define the middleware signature A middleware is a function that takes (toolName, handler) and returns a new handler. Keep this signature consistent across all middleware for composability.
  2. Build the sanitizer Create a redaction function that strips known secret field names and truncates oversized values. Apply it to arguments before logging.
  3. Stack with compose Create a compose function: `const handler = compose(withLogging, withCache, withRateLimit)(rawHandler)`. Outermost middleware runs first on the way in, last on the way out.