# How to add logging and monitoring to an MCP server

- Tool: MCP
- Difficulty: Intermediate
- Time required: 20-30 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+, pino or winston, optional Sentry/Datadog
- Last updated: March 2026

## TL;DR

Add structured logging and monitoring to your MCP server using pino or winston, writing all logs to stderr since stdout is reserved for the JSON-RPC protocol. Integrate Sentry for error tracking and Datadog or Prometheus for metrics. This tutorial covers log levels, correlation IDs, performance timing, and alerting on tool failures.

## Adding Structured Logging and Monitoring to MCP Servers

MCP servers communicate over stdout using JSON-RPC, which means any stray console.log call corrupts the protocol stream. This tutorial shows how to set up structured logging that safely writes to stderr, captures tool call metrics, and integrates with error tracking (Sentry) and monitoring (Datadog) services. Proper observability is essential for debugging production MCP servers where you cannot attach a debugger.

## Before you start

- A working MCP server with one or more tools
- Node.js 18+ and npm installed
- Optional: Sentry account and DSN for error tracking
- Optional: Datadog agent or Prometheus endpoint for metrics

## Step-by-step guide

### 1. Install pino and configure stderr-only logging

Pino is a fast, structured JSON logger for Node.js. Configure it to write exclusively to stderr using the destination option. Set the log level from an environment variable. Pino's structured JSON output is ideal for log aggregation services that parse JSON logs. Never use console.log in an MCP server — it writes to stdout and corrupts the JSON-RPC stream.

```
npm install pino pino-pretty

// src/logger.ts
import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  transport: process.env.NODE_ENV === "development" ? {
    target: "pino-pretty",
    options: { destination: 2 },  // 2 = stderr file descriptor
  } : undefined,
}, pino.destination(2));  // Always write to stderr (fd 2)

// Usage:
// logger.info({ tool: "read_file", path: "/src" }, "Tool called");
// logger.error({ err: error, tool: "write_file" }, "Tool failed");
```

**Expected result:** A logger instance that writes structured JSON to stderr, never to stdout.

### 2. Create a tool call logging wrapper

Build a higher-order function that wraps tool handlers with automatic logging. It logs the tool name and parameters at call start, measures execution duration, and logs the result status (success, error) with timing. This gives you a complete audit trail of every tool invocation without modifying individual tool handlers.

```
// src/tool-logger.ts
import { logger } from "./logger.js";
import { randomUUID } from "crypto";

export function withLogging<T>(
  toolName: string,
  handler: (params: T) => Promise<any>
): (params: T) => Promise<any> {
  return async (params: T) => {
    const requestId = randomUUID().slice(0, 8);
    const start = performance.now();

    logger.info({
      requestId,
      tool: toolName,
      params: redactSensitive(params as Record<string, unknown>),
    }, `Tool call started: ${toolName}`);

    try {
      const result = await handler(params);
      const durationMs = Math.round(performance.now() - start);

      logger.info({
        requestId,
        tool: toolName,
        durationMs,
        isError: result.isError || false,
      }, `Tool call completed: ${toolName} (${durationMs}ms)`);

      return result;
    } catch (error) {
      const durationMs = Math.round(performance.now() - start);

      logger.error({
        requestId,
        tool: toolName,
        durationMs,
        err: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
      }, `Tool call failed: ${toolName}`);

      return {
        content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
        isError: true,
      };
    }
  };
}

function redactSensitive(params: Record<string, unknown>): Record<string, unknown> {
  const redacted = { ...params };
  for (const key of ["password", "token", "apiKey", "secret"]) {
    if (key in redacted) redacted[key] = "[REDACTED]";
  }
  return redacted;
}
```

**Expected result:** A withLogging wrapper that logs tool invocations with timing, status, and redacted parameters.

### 3. Integrate Sentry for automatic error tracking

Sentry captures unhandled errors and can also track specific tool failures. Initialize Sentry at server startup, and add Sentry.captureException calls in the tool error handler. Tag each error with the tool name and request ID so you can filter and search in the Sentry dashboard. This catches errors you might miss in log files.

```
npm install @sentry/node

// src/monitoring.ts
import * as Sentry from "@sentry/node";

export function initMonitoring(): void {
  if (process.env.SENTRY_DSN) {
    Sentry.init({
      dsn: process.env.SENTRY_DSN,
      environment: process.env.NODE_ENV || "development",
      tracesSampleRate: 0.1,  // Sample 10% of transactions
    });
    console.error("[monitoring] Sentry initialized");
  }
}

export function captureToolError(
  toolName: string,
  error: Error,
  params: Record<string, unknown>
): void {
  Sentry.withScope((scope) => {
    scope.setTag("mcp.tool", toolName);
    scope.setContext("tool_params", params);
    Sentry.captureException(error);
  });
}
```

**Expected result:** Sentry captures tool errors with tool name tags and parameter context for debugging.

### 4. Track tool call metrics for dashboards and alerting

Track key metrics for each tool: call count, error count, and response time percentiles. Use a simple in-memory metrics collector that exposes data via a get_metrics tool or a Prometheus-compatible HTTP endpoint. These metrics let you build dashboards showing tool usage patterns and set alerts for error rate spikes or latency degradation.

```
// src/metrics.ts
import { logger } from "./logger.js";

interface ToolMetrics {
  calls: number;
  errors: number;
  totalMs: number;
  maxMs: number;
  lastCallAt: number;
}

const metrics = new Map<string, ToolMetrics>();

export function recordToolCall(toolName: string, durationMs: number, isError: boolean): void {
  let m = metrics.get(toolName);
  if (!m) {
    m = { calls: 0, errors: 0, totalMs: 0, maxMs: 0, lastCallAt: 0 };
    metrics.set(toolName, m);
  }
  m.calls++;
  if (isError) m.errors++;
  m.totalMs += durationMs;
  m.maxMs = Math.max(m.maxMs, durationMs);
  m.lastCallAt = Date.now();

  // Alert on high error rate
  if (m.calls > 10 && m.errors / m.calls > 0.5) {
    logger.warn({ tool: toolName, errorRate: (m.errors / m.calls).toFixed(2) },
      `High error rate detected for ${toolName}`);
  }
}

export function getMetricsSummary(): Record<string, any> {
  const summary: Record<string, any> = {};
  for (const [tool, m] of metrics) {
    summary[tool] = {
      totalCalls: m.calls,
      errorCount: m.errors,
      errorRate: m.calls > 0 ? (m.errors / m.calls * 100).toFixed(1) + "%" : "0%",
      avgMs: m.calls > 0 ? Math.round(m.totalMs / m.calls) : 0,
      maxMs: m.maxMs,
      lastCallAt: new Date(m.lastCallAt).toISOString(),
    };
  }
  return summary;
}
```

**Expected result:** An in-memory metrics tracker that records call counts, error rates, and latency per tool.

### 5. Register a metrics tool and wire everything together

Register a get_server_metrics tool that returns the current metrics summary. This lets the AI (or an admin) check server health. Wire the logging wrapper, metrics recording, and Sentry integration into your tool registration flow. Each tool call is automatically logged, timed, and tracked.

```
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { logger } from "./logger.js";
import { withLogging } from "./tool-logger.js";
import { initMonitoring } from "./monitoring.js";
import { getMetricsSummary } from "./metrics.js";

initMonitoring();

const server = new McpServer({ name: "monitored-server", version: "1.0.0" });

server.tool(
  "read_file",
  "Read a file's contents",
  { filePath: z.string() },
  withLogging("read_file", async ({ filePath }) => {
    const fs = await import("fs/promises");
    const content = await fs.readFile(filePath, "utf-8");
    return { content: [{ type: "text", text: content }] };
  })
);

server.tool(
  "get_server_metrics",
  "Get server performance metrics for all tools",
  {},
  async () => {
    const summary = getMetricsSummary();
    return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  logger.info("MCP server started with logging and monitoring");
}
main().catch(e => { logger.fatal(e, "Server failed to start"); process.exit(1); });
```

**Expected result:** MCP server with structured logging, error tracking, and a metrics tool for observability.

## Complete code example

File: `src/logger.ts`

```typescript
import pino from "pino";

// CRITICAL: MCP uses stdout for JSON-RPC. All logs MUST go to stderr (fd 2).
export const logger = pino(
  {
    level: process.env.LOG_LEVEL || "info",
    base: { service: "mcp-server" },
    timestamp: pino.stdTimeFunctions.isoTime,
    formatters: {
      level(label) { return { level: label }; },
    },
  },
  pino.destination(2) // fd 2 = stderr
);

// Tool call logging wrapper
export function withLogging<T>(
  toolName: string,
  handler: (params: T) => Promise<any>
): (params: T) => Promise<any> {
  return async (params: T) => {
    const start = performance.now();
    try {
      const result = await handler(params);
      const ms = Math.round(performance.now() - start);
      logger.info({ tool: toolName, ms, ok: !result.isError }, "tool.call");
      return result;
    } catch (error) {
      const ms = Math.round(performance.now() - start);
      logger.error({ tool: toolName, ms, err: error }, "tool.error");
      return {
        content: [{ type: "text" as const,
          text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
        isError: true as const,
      };
    }
  };
}

// Redact sensitive fields before logging
export function redact(obj: Record<string, unknown>): Record<string, unknown> {
  const copy = { ...obj };
  for (const k of ["password", "token", "apiKey", "secret", "authorization"]) {
    if (k in copy) copy[k] = "[REDACTED]";
  }
  return copy;
}
```

## Common mistakes

- **Using console.log instead of a stderr logger, corrupting the MCP JSON-RPC protocol stream** — undefined Fix: Never use console.log in MCP servers. Use pino with destination(2) or console.error for all logging.
- **Logging sensitive parameters like passwords, API keys, or tokens** — undefined Fix: Implement a redaction function that replaces known sensitive field names with [REDACTED] before logging.
- **Not including timing information in logs, making performance debugging impossible** — undefined Fix: Use performance.now() to measure and log execution duration for every tool call.
- **Setting log level to debug in production, creating excessive log volume and costs** — undefined Fix: Use LOG_LEVEL=info in production and LOG_LEVEL=debug only for development or temporary troubleshooting.

## Best practices

- Always log to stderr (fd 2) — stdout is reserved for MCP JSON-RPC communication
- Use structured JSON logs (pino) for machine-parseable output in production
- Include tool name, request ID, duration, and status in every log entry
- Redact sensitive parameters before logging
- Set up Sentry or equivalent for automatic error capture with tool context
- Track metrics per tool: call count, error count, average latency
- Alert on high error rates (>50%) or latency degradation (>2x baseline)
- Use log levels appropriately: error for failures, warn for degradation, info for operations

## Frequently asked questions

### Why can't I use console.log in MCP servers?

MCP uses stdout for the JSON-RPC protocol between client and server. Any data written to stdout by console.log corrupts the protocol stream, causing parse errors and disconnections. Always use console.error or a logger configured to write to stderr.

### Which logging library is best for MCP servers?

Pino is recommended for its performance and structured JSON output. Winston is a good alternative if you need more transport options. Both must be configured to write to stderr, not stdout.

### How do I view MCP server logs in real time?

For stdio servers, stderr output goes to the client's error stream. Redirect it to a file in your MCP config: add 2>>/tmp/mcp-server.log to the command args. Then tail the file.

### Should I log tool parameters?

Log parameters for debugging, but always redact sensitive fields like passwords, tokens, and API keys. In high-security environments, log only the parameter names without values.

### How do I send MCP server metrics to Datadog?

Export metrics in StatsD or Prometheus format. For StatsD, use the hot-shots npm package to send metrics to the local Datadog agent. For Prometheus, expose a /metrics HTTP endpoint on a separate port from the MCP server.

### Can I use OpenTelemetry with MCP servers?

Yes. OpenTelemetry works well for distributed tracing across MCP clients and servers. Instrument tool handlers as spans and export to Jaeger, Zipkin, or your preferred backend.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-add-logging-and-monitoring-to-mcp
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-add-logging-and-monitoring-to-mcp
