Module 7: Testing, Debugging, and Integration

Debugging: Tools for MCP Servers

Debugging: Tools for MCP Servers

Capsule description

Tests tell you what is broken. Debugging tools tell you why. A test that fails with "expected 3, received 0" tells you something went wrong, but it doesn't tell you exactly where in your code chain those 3 results got lost. For that you need debugging tools.

In the MCP world, debugging has unique challenges. Your server communicates via JSON-RPC over stdio — you can't simply add a console.log because that breaks the protocol. Node.js or Python's standard debugging tools don't understand the MCP protocol. And when Claude Code tells you "Error calling tool," it doesn't give you enough information to diagnose the problem.

This capsule teaches you three tools that solve these problems: MCP Inspector for visual and interactive debugging, logging to record what happens inside your server without breaking stdio, and tracing to follow a request through the entire flow.


MCP Inspector: your main debugging tool

What MCP Inspector is

MCP Inspector is a visual tool that connects to your MCP server and lets you interact with it directly — invoke tools, read resources, list capabilities — all from a web interface in your browser. It's the equivalent of Postman for REST APIs, but for MCP servers.

You already used MCP Inspector in previous modules to test your servers. Now you're going to use it as a debugging tool, not just a testing one.

How to run MCP Inspector

# For a TypeScript server
npx @modelcontextprotocol/inspector node dist/index.js

# For a Python server
npx @modelcontextprotocol/inspector python server.py

# With additional arguments
npx @modelcontextprotocol/inspector node dist/index.js -- --config ./config.json

# On a specific port
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node dist/index.js

MCP Inspector opens a web interface (by default at http://localhost:6274) with three main panels.

Tools panel

┌─────────────────────────────────────────────────────────┐
│  MCP Inspector                                          │
├──────────────┬──────────────────────────────────────────┤
│              │                                          │
│  Tools       │  Tool: read_file                         │
│  ──────────  │  ──────────────                          │
│  > read_file │  Parameters:                             │
│  > list_files│  ┌──────────────────────────────────┐    │
│              │  │ filePath: [/tmp/test.txt       ] │    │
│  Resources   │  └──────────────────────────────────┘    │
│  ──────────  │                                          │
│  > status:// │  [Run Tool]                              │
│              │                                          │
│              │  Result:                                 │
│              │  ┌──────────────────────────────────┐    │
│              │  │ {                                │    │
│              │  │   "type": "text",                │    │
│              │  │   "text": "Hello, MCP!"          │    │
│              │  │ }                                │    │
│              │  └──────────────────────────────────┘    │
└──────────────┴──────────────────────────────────────────┘

In this panel you can:

  • See all the registered tools and their schemas
  • Fill in the parameters with test values
  • Run the tool and see the complete result
  • See whether the result includes isError: true
  • Verify the output format (text, JSON, etc.)

Resources panel

Similar to the tools panel, but for resources:

  • List all the available resources
  • Read each resource and see its content
  • Verify the mimeType and data format
  • Test resource templates with different parameters

JSON-RPC messages panel

This is the most useful panel for debugging. It shows the raw JSON-RPC messages between the Inspector (client) and your server:

┌─────────────────────────────────────────────┐
│  Messages                                    │
├─────────────────────────────────────────────┤
│  → Request: tools/call                       │
│  {                                           │
│    "method": "tools/call",                   │
│    "params": {                               │
│      "name": "read_file",                    │
│      "arguments": {                          │
│        "filePath": "/tmp/test.txt"           │
│      }                                       │
│    }                                         │
│  }                                           │
│                                              │
│  ← Response:                                 │
│  {                                           │
│    "content": [{                             │
│      "type": "text",                         │
│      "text": "Hello, MCP!"                   │
│    }]                                        │
│  }                                           │
└─────────────────────────────────────────────┘

This panel shows you exactly what the client sent and what your server responded. If something fails, you see the exact error here.

Debugging flow with MCP Inspector

When something doesn't work, follow this process:

1. Connect your server to MCP Inspector
   npx @modelcontextprotocol/inspector node dist/index.js

2. Verify capabilities
   Do all your tools and resources appear?
   If not → the server doesn't register them correctly

3. Invoke the problematic tool
   Does it return the expected result?
   If not → check the tool's handler

4. Review the JSON-RPC messages
   Does the request have the correct parameters?
   Does the response have the correct format?

5. Test with edge-case inputs
   Empty strings, negative numbers, special characters
   Does your server handle them without crashing?

Logging in MCP servers

The problem: stdio vs logging

In an MCP server that uses the stdio transport, stdout is reserved for the MCP protocol. Every byte you send to stdout must be a valid JSON-RPC message. If you add a console.log("debug: processing file"), that text gets mixed with the protocol messages and causes a parse error.

// ❌ THIS BREAKS THE STDIO PROTOCOL
console.log("Processing request...");

// The client receives:
// Processing request...
// {"jsonrpc":"2.0","result":...}
// ^ Error: "Processing request..." is not valid JSON-RPC

Solution: log to stderr

The standard solution is to send logs to stderr, not stdout. stderr doesn't interfere with the MCP protocol:

// ✅ CORRECT: log to stderr
console.error("[INFO] Processing request...");
console.error("[DEBUG] filePath:", filePath);
console.error("[ERROR] File not found:", path);

// stdout stays clean for JSON-RPC
// stderr shows your logs

Implement a logger for MCP servers (TypeScript)

// src/logger.ts
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";

const LOG_LEVELS: Record<LogLevel, number> = {
  DEBUG: 0,
  INFO: 1,
  WARN: 2,
  ERROR: 3,
};

const currentLevel: LogLevel = (process.env.LOG_LEVEL as LogLevel) || "INFO";

function shouldLog(level: LogLevel): boolean {
  return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}

export const logger = {
  debug: (msg: string, data?: unknown) => {
    if (shouldLog("DEBUG")) {
      console.error(`[DEBUG] ${new Date().toISOString()} ${msg}`, data ?? "");
    }
  },
  info: (msg: string, data?: unknown) => {
    if (shouldLog("INFO")) {
      console.error(`[INFO] ${new Date().toISOString()} ${msg}`, data ?? "");
    }
  },
  warn: (msg: string, data?: unknown) => {
    if (shouldLog("WARN")) {
      console.error(`[WARN] ${new Date().toISOString()} ${msg}`, data ?? "");
    }
  },
  error: (msg: string, data?: unknown) => {
    if (shouldLog("ERROR")) {
      console.error(`[ERROR] ${new Date().toISOString()} ${msg}`, data ?? "");
    }
  },
};

Use the logger in your MCP server

import { logger } from "./logger.js";

server.tool(
  "read_file",
  "Reads the content of a file",
  { filePath: z.string().describe("Path to the file") },
  async ({ filePath }) => {
    logger.info("read_file invoked", { filePath });

    try {
      const content = await fs.readFile(filePath, "utf-8");
      logger.debug("File read", { size: content.length });
      return { content: [{ type: "text" as const, text: content }] };
    } catch (error) {
      logger.error("Error reading file", { filePath, error: (error as Error).message });
      return {
        content: [{ type: "text" as const, text: `Error: ${(error as Error).message}` }],
        isError: true,
      };
    }
  }
);

See the logs

When you run your server with MCP Inspector, the stderr logs appear in the terminal where you ran the command:

# Terminal where you run the server
$ npx @modelcontextprotocol/inspector node dist/index.js

# Output in stderr (your logs):
[INFO] 2026-03-13T10:30:00.000Z read_file invoked { filePath: '/tmp/test.txt' }
[DEBUG] 2026-03-13T10:30:00.015Z File read { size: 42 }
[INFO] 2026-03-13T10:30:05.000Z list_files invoked { directory: '/tmp' }
[ERROR] 2026-03-13T10:30:10.000Z Error reading file { filePath: '/does/not/exist', error: 'ENOENT' }

Implement logging in Python

# logger.py
import sys
import logging
from datetime import datetime

logger = logging.getLogger("mcp-server")
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter(
    "[%(levelname)s] %(asctime)s %(message)s",
    datefmt="%Y-%m-%dT%H:%M:%S"
))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
# server.py
from logger import logger

@mcp.tool()
async def read_file(file_path: str) -> str:
    """Reads the content of a file."""
    logger.info(f"read_file invoked: {file_path}")
    try:
        with open(file_path, "r") as f:
            content = f.read()
        logger.debug(f"File read: {len(content)} bytes")
        return content
    except FileNotFoundError:
        logger.error(f"File not found: {file_path}")
        raise ValueError(f"File not found: {file_path}")

What to log (and what not to)

LogDon't log
Each tool invocation (name + params)The complete content of large files
Errors with contextSensitive data (passwords, tokens)
Execution times of slow operationsEvery executed line of code
Client connection/disconnectionComplete JSON-RPC messages (use Inspector)
Summarized results (count, size)Complete stack traces in production

Request tracing

Measure response times

When a tool is slow, you need to know where the time is spent. Implement tracing with timestamps:

server.tool(
  "search_files",
  "Searches files by pattern",
  { pattern: z.string(), directory: z.string() },
  async ({ pattern, directory }) => {
    const start = Date.now();
    logger.info("search_files start", { pattern, directory });

    const readDirStart = Date.now();
    const entries = await fs.readdir(directory, { recursive: true, withFileTypes: true });
    logger.debug(`readdir completed in ${Date.now() - readDirStart}ms`, { entries: entries.length });

    const filterStart = Date.now();
    const matches = entries
      .filter((e) => e.isFile() && e.name.includes(pattern))
      .map((e) => path.join(e.parentPath || e.path, e.name));
    logger.debug(`filter completed in ${Date.now() - filterStart}ms`, { matches: matches.length });

    const total = Date.now() - start;
    logger.info(`search_files completed in ${total}ms`, { matches: matches.length });

    return {
      content: [{ type: "text" as const, text: JSON.stringify({ matches, count: matches.length, timeMs: total }, null, 2) }],
    };
  }
);

Tracing output

[INFO] 2026-03-13T10:30:00.000Z search_files start { pattern: '.ts', directory: '/project' }
[DEBUG] 2026-03-13T10:30:00.250Z readdir completed in 250ms { entries: 1500 }
[DEBUG] 2026-03-13T10:30:00.255Z filter completed in 5ms { matches: 87 }
[INFO] 2026-03-13T10:30:00.256Z search_files completed in 256ms { matches: 87 }

With this output, you know exactly that 98% of the time is spent in readdir, not in the filtering. That tells you where to optimize.

Request ID for distributed tracing

When you debug connection problems, it's useful to assign a unique ID to each request:

import crypto from "crypto";

function withTracing(handler: Function) {
  return async (...args: unknown[]) => {
    const requestId = crypto.randomUUID().slice(0, 8);
    logger.info(`[${requestId}] Request start`);
    try {
      const result = await handler(...args);
      logger.info(`[${requestId}] Request completed`);
      return result;
    } catch (error) {
      logger.error(`[${requestId}] Request failed`, { error: (error as Error).message });
      throw error;
    }
  };
}

Debugging common problems

Problem 1: "Tool doesn't appear in MCP Inspector"

Symptoms: You run MCP Inspector, but your tool isn't in the list.

Diagnosis:

# 1. Verify that the server compiles without errors
npx tsc --noEmit

# 2. Verify that the build is up to date
npm run build

# 3. Run the server standalone and check stderr
node dist/index.js 2>&1 | head -5

Common causes:

  • The build isn't up to date (you forgot npm run build)
  • A syntax error that prevents the tool's registration
  • The tool is registered conditionally and the condition fails

Problem 2: "The tool returns an empty result"

Symptoms: MCP Inspector shows a result but content is empty or text is empty.

Diagnosis: Add logging in the tool's handler:

async ({ filePath }) => {
  logger.debug("Handler executed", { filePath });
  const content = await fs.readFile(filePath, "utf-8");
  logger.debug("Content read", { length: content.length, preview: content.slice(0, 50) });
  // ... return
}

Common causes:

  • The variable has the value but the return doesn't include it
  • JSON.stringify of an object with undefined properties
  • Incorrect path to the file

Problem 3: "The server freezes when it receives a request"

Symptoms: You send a request and never receive a response.

Diagnosis:

// Add a timeout to async operations
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> => {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
};

// Usage
const content = await withTimeout(fs.readFile(filePath, "utf-8"), 5000);

Common causes:

  • An I/O operation that never resolves (a file on a network filesystem)
  • A deadlock in async code
  • Awaiting a Promise that never resolves

Exercises

Exercise 1: Add logging to a tool (Easy)

Take one of the tools from your MCP server (from modules 4 or 5) and add logging with the stderr pattern. Log: the invocation with parameters, a successful result (summarized), and errors with context.

See solution
server.tool(
  "count_lines",
  "Counts the lines of a file",
  { filePath: z.string() },
  async ({ filePath }) => {
    console.error(`[INFO] count_lines invoked: ${filePath}`);
    try {
      const content = await fs.readFile(filePath, "utf-8");
      const lines = content.split("\n").length;
      console.error(`[INFO] count_lines result: ${lines} lines`);
      return { content: [{ type: "text" as const, text: `${lines} lines` }] };
    } catch (error) {
      console.error(`[ERROR] count_lines failed: ${(error as Error).message}`);
      return {
        content: [{ type: "text" as const, text: `Error: ${(error as Error).message}` }],
        isError: true,
      };
    }
  }
);

Exercise 2: Implement the configurable logger (Easy)

Implement the logger.ts module shown in this capsule, and configure it to use LOG_LEVEL=DEBUG in development and LOG_LEVEL=WARN in production. Verify that it works by running your server with different levels.

See solution
# Development — see all the logs
LOG_LEVEL=DEBUG npx @modelcontextprotocol/inspector node dist/index.js

# Production — only warnings and errors
LOG_LEVEL=WARN node dist/index.js

Verify that with LOG_LEVEL=WARN, the messages from logger.debug() and logger.info() don't appear in stderr.

Exercise 3: Debugging with MCP Inspector (Medium)

Introduce an intentional bug in one of your tools (e.g., change the name of a parameter in the schema but not in the handler). Use MCP Inspector to diagnose the problem by following the 5-step debugging flow described in this capsule.

See solution
// Intentional bug: schema says "filePath", the handler uses "path"
server.tool(
  "read_file",
  "Reads a file",
  { filePath: z.string() },
  async (args) => {
    // Bug: args.filePath has the value, but we access args.path (undefined)
    const content = await fs.readFile((args as any).path, "utf-8");
    return { content: [{ type: "text" as const, text: content }] };
  }
);

// In MCP Inspector:
// 1. The tool appears ✅
// 2. You fill filePath with a valid path ✅
// 3. You run it → Error: "The argument 'path' must be a string" ❌
// 4. You check the JSON-RPC: the request has filePath, but the error says "path"
// 5. Diagnosis: a mismatch between the schema and the handler

Exercise 4: Performance tracing (Medium)

Add time tracing to a tool that does I/O (reading files, calling an API). Run the tool 5 times and report the average time. Identify which operation consumes the most time.

See solution
server.tool(
  "analyze_directory",
  "Analyzes a directory",
  { directory: z.string() },
  async ({ directory }) => {
    const timings: Record<string, number> = {};
    const totalStart = Date.now();

    let start = Date.now();
    const entries = await fs.readdir(directory, { withFileTypes: true });
    timings.readdir = Date.now() - start;

    start = Date.now();
    const stats = await Promise.all(
      entries.filter(e => e.isFile()).map(async (e) => {
        const stat = await fs.stat(path.join(directory, e.name));
        return { name: e.name, size: stat.size };
      })
    );
    timings.stats = Date.now() - start;

    timings.total = Date.now() - totalStart;
    console.error(`[TRACE] analyze_directory timings:`, JSON.stringify(timings));

    return {
      content: [{ type: "text" as const, text: JSON.stringify({ files: stats.length, timings }, null, 2) }],
    };
  }
);

Exercise 5: Logger with output to a file (Hard)

Extend the logger so that, in addition to writing to stderr, it writes to a rotating log file. Configure the file's path via the LOG_FILE environment variable.

See solution
import fs from "fs";
import path from "path";

const logFile = process.env.LOG_FILE;
let logStream: fs.WriteStream | null = null;

if (logFile) {
  const dir = path.dirname(logFile);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  logStream = fs.createWriteStream(logFile, { flags: "a" });
}

function writeLog(level: string, msg: string, data?: unknown) {
  const line = `[${level}] ${new Date().toISOString()} ${msg} ${data ? JSON.stringify(data) : ""}`;
  console.error(line);
  if (logStream) {
    logStream.write(line + "\n");
  }
}

export const logger = {
  debug: (msg: string, data?: unknown) => writeLog("DEBUG", msg, data),
  info: (msg: string, data?: unknown) => writeLog("INFO", msg, data),
  warn: (msg: string, data?: unknown) => writeLog("WARN", msg, data),
  error: (msg: string, data?: unknown) => writeLog("ERROR", msg, data),
};
# Usage
LOG_FILE=./logs/mcp-server.log LOG_LEVEL=DEBUG node dist/index.js

Quick troubleshooting

"MCP Inspector doesn't connect"

Verify that the path to the executable is correct and that the server starts without errors:

# Test that the server starts
node dist/index.js < /dev/null
# If there are errors, you'll see them in stderr

"The logs don't appear"

Verify that you use console.error (stderr), not console.log (stdout). In MCP Inspector, the stderr logs appear in the terminal, not in the UI.

"The server crashes when connecting"

Make sure npm run build is up to date. A mismatch between your source code and the compiled build causes silent errors.


Summary

In this capsule you learned:

  • MCP Inspector is your main debugging tool — it shows tools, resources, and raw JSON-RPC messages
  • Logging to stderr is mandatory in MCP servers with the stdio transport — console.log breaks the protocol
  • A configurable logger with levels (DEBUG/INFO/WARN/ERROR) lets you control the verbosity
  • Tracing with timestamps shows you where the time is spent in slow operations
  • Request IDs make it easier to follow a specific request through the logs
  • The debugging flow is: connect Inspector → verify capabilities → invoke tool → review JSON-RPC → test edge cases
  • The 3 most common problems (invisible tool, empty result, frozen server) have specific diagnoses and solutions

The combination of MCP Inspector (visual) + logging (automatic) + tracing (performance) gives you complete visibility over what your server does.


Additional resources

  1. MCP Inspector — Official visual debugging tool
  2. Node.js console.error — Writing to stderr in Node.js
  3. Python logging module — Python's standard logging
  4. JSON-RPC 2.0 Specification — MCP's underlying protocol
  5. Winston Logger — Advanced logger for Node.js (alternative)
  6. MCP Specification — Error Handling — How MCP handles errors

Next capsule: Configure Claude Code — connect your MCP server to Claude Code with settings, permissions, and step-by-step verification.