Building & Running MCP Servers

Difficulty

The SDKs exist precisely so implementers don't have to hand-roll JSON-RPC framing, the initialize handshake, or pagination cursors themselves.

Python (FastMCP-style high-level API)

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo-server")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

if __name__ == "__main__":
    mcp.run(transport="stdio")

TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

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

server.tool("add", "Add two numbers", { a: z.number(), b: z.number() },
  async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] })
);

await server.connect(new StdioServerTransport());

What the SDK handles for you

Hand-implementing the protocol is a reasonable learning exercise, but for a real server it means re-solving problems the SDK already handles correctly and keeps updated as the spec evolves:

  • Deriving inputSchema from typed function signatures (Python type hints, Zod schemas in TypeScript) instead of hand-writing JSON Schema.
  • The full initialize/initialized handshake and capability negotiation.
  • Transport wiring — swapping stdio for streamable-http is usually a one-line change, as covered in the transports section.
  • Pagination cursor handling for */list methods, if your catalog is large enough to need it.

Related Resources

Here's the shortest realistic path from an empty directory to a working MCP server a host can actually talk to.

1. Install and scaffold

pip install mcp
# server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("notes-server")

2. Register a tool with real logic

import json
from pathlib import Path

NOTES_FILE = Path("notes.json")

@mcp.tool()
def add_note(title: str, body: str) -> str:
    """Add a new note with a title and body text."""
    notes = json.loads(NOTES_FILE.read_text()) if NOTES_FILE.exists() else []
    notes.append({"title": title, "body": body})
    NOTES_FILE.write_text(json.dumps(notes))
    return f"Saved note '{title}'"

@mcp.tool()
def list_notes() -> str:
    """List all saved note titles."""
    if not NOTES_FILE.exists():
        return "No notes yet."
    notes = json.loads(NOTES_FILE.read_text())
    return "\n".join(n["title"] for n in notes)

3. Run it over stdio

if __name__ == "__main__":
    mcp.run(transport="stdio")
python server.py   # runs and waits for JSON-RPC on stdin

4. Test before connecting a real host

npx @modelcontextprotocol/inspector python server.py

The Inspector launches your server, performs the handshake, and gives you a UI to call add_note/list_notes directly — confirming the schema and handler logic work before any host is involved.

5. Wire it into a host

Add an entry to the host's MCP config (for Claude Desktop, claude_desktop_config.json) pointing at the command to launch your server, restart the host, and the tools become available in conversation.

What to check once it's "working"

Confirm error cases too, not just the happy path. What happens if add_note is called with a missing title? Does the schema reject it before your handler even runs, or does your handler crash? A minimal server is only genuinely done once bad input is handled gracefully, not just the expected case.

Related Resources

Connecting a locally built server to a host is mostly a matter of telling the host what command to run.

Example config

{
  "mcpServers": {
    "notes-server": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxx"
      }
    }
  }
}

What the host does with this

On startup, or after a config reload, the host spawns each listed command as a child process. It connects a stdio transport to its stdin/stdout, then runs the initialize handshake. Once connected, that server's tools, resources, and prompts become available in the conversation, typically shown in a UI element listing active connections.

Common configuration pitfalls

  • Relative paths. The host may launch the process from a different working directory than expected. An args entry like "./server.py" can fail to resolve, so absolute paths are safer.
  • Missing dependencies on PATH. Say the host launches python, but the machine's shell environment (where dependencies were installed) isn't the same environment the host process inherits. Imports fail silently from the host's perspective — it just sees the server exit immediately.
  • Secrets in plaintext config. env values like API tokens sit in a plaintext JSON file on disk. Treat that file with the same care as any other credentials store, and prefer OS-level secret managers where a host supports referencing them instead of literal values.

Debugging a server that won't connect

Most hosts write server stderr output, and connection failures, to a log file the user can inspect. Checking there is usually the fastest way to find out whether the process failed to start at all, crashed during the handshake, or connected but failed on a specific request.

Related Resources

Debugging an MCP server has one hard constraint that doesn't exist in most other server-side debugging: the most obvious tool (print()) is actively dangerous over stdio.

Wrong way

@mcp.tool()
def fetch_price(symbol: str) -> str:
    print(f"DEBUG: fetching {symbol}")   # corrupts the stdio transport
    ...

Right way #1: log to stderr

import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
logger = logging.getLogger("price-server")

@mcp.tool()
def fetch_price(symbol: str) -> str:
    logger.debug(f"fetching {symbol}")
    ...

Right way #2: MCP's own logging capability

# Sends a notifications/message the connected client can display,
# instead of writing to a local stream the client never sees at all.
await ctx.session.send_log_message(level="info", data=f"fetching {symbol}")

This is useful specifically because it surfaces log messages inside the host's own UI, rather than requiring the user to go find a separate log file.

Right way #3: MCP Inspector for interactive debugging

npx @modelcontextprotocol/inspector python server.py

The Inspector gives you a browser-based UI to send raw tools/call/resources/read requests and inspect exact request/response payloads. That's far faster for iterating on a new tool than repeatedly reconfiguring a full host and restarting it for every change.

The habit to build

Before shipping any server built with a language or library you haven't used for MCP before, double check whether any dependency defaults to logging to stdout. Some CLI-oriented libraries do, and that's the single most common source of "my server works standalone but breaks the moment a host connects to it" bugs.

Related Resources

Before ever pointing a real host at a new server, it's worth confirming the server itself behaves correctly in isolation — that's exactly the gap the Inspector fills.

Launching it against a local server

npx @modelcontextprotocol/inspector python server.py

This starts your server as a subprocess, connects to it, runs the handshake, and opens a local web UI where you can:

  • Browse the tool/resource/prompt catalogs returned by tools/list, resources/list, prompts/list.
  • Fill in a form for a tool's arguments and invoke tools/call directly, seeing the exact result (including isError and any structuredContent).
  • Read a specific resource by URI and inspect its returned content.
  • Watch the raw JSON-RPC traffic in both directions, which is invaluable for spotting a malformed response or an unexpected error code.

Why this beats testing through a full host first

A full host — Claude Desktop, an IDE — adds an LLM in the loop deciding whether to call your tool at all. That introduces a second source of uncertainty: is the failure in your server, or in the model's tool selection and argument construction? The Inspector removes that variable entirely. You invoke exactly the call you intend, with exactly the arguments you choose, and see exactly what comes back.

A typical development loop

  1. Write/modify a tool.
  2. Run the Inspector, invoke it directly with a few representative and edge-case inputs.
  3. Confirm the schema validates correctly and the result shape (content, isError, structuredContent) is what you expect.
  4. Only then connect the server to a real host and test the end-to-end, model-driven experience.

Skipping straight to host-based testing for every iteration is slower. It also makes it much harder to tell whether a bug is in your server's logic, or just in how the model happened to phrase its call that particular time.

Related Resources