Skip to content

Building an MCP Server: A Complete Walkthrough

6 min read · updated August 3, 2026

An MCP server is a program that reads JSON-RPC from standard input and writes JSON-RPC to standard output. The SDK hides that, which is convenient until something does not work — at which point the frames below are the only thing that tells you where the failure is.

The whole server

Using the official Python SDK (pip install mcp). This is a complete, runnable server: two tools and one resource.

# server.py
import sys, sqlite3, logging
from mcp.server.fastmcp import FastMCP

logging.basicConfig(stream=sys.stderr, level=logging.INFO)  # NOT stdout
log = logging.getLogger("issues")

mcp = FastMCP("issues")
DB = sqlite3.connect("issues.db", check_same_thread=False)

@mcp.tool()
def search_issues(query: str, limit: int = 10) -> str:
    """Full-text search over open issue titles and bodies.

    Returns at most 'limit' matches as one line each:
    "#<id> <state> <title>". Use get_issue for the full body and
    comments of a single issue. Searches open issues only.

    Args:
        query: Words to match. Not SQL, not a regex.
        limit: 1-50, default 10.
    """
    limit = max(1, min(limit, 50))
    rows = DB.execute(
        "SELECT id, state, title FROM issues "
        "WHERE state = 'open' AND issues MATCH ? LIMIT ?",
        (query, limit)).fetchall()
    if not rows:
        return ("No open issues matched. Try fewer or broader words; "
                "this search does not do stemming.")
    return "\n".join("#%d %s %s" % r for r in rows)

@mcp.tool()
def close_issue(issue_id: int, reason: str) -> str:
    """Close one open issue. IRREVERSIBLE from this server -- there is
    no reopen tool. Requires a reason, which is recorded as a comment.

    Args:
        issue_id: Numeric id as shown by search_issues.
        reason: One sentence, shown to the issue's author.
    """
    cur = DB.execute("UPDATE issues SET state='closed' "
                     "WHERE id=? AND state='open'", (issue_id,))
    if cur.rowcount == 0:
        return "No open issue #%d -- it may already be closed." % issue_id
    DB.execute("INSERT INTO comments(issue_id, body) VALUES (?,?)",
               (issue_id, "Closed by agent: " + reason))
    DB.commit()
    log.info("closed issue %d", issue_id)
    return "Closed #%d." % issue_id

@mcp.resource("issues://labels")
def labels() -> str:
    """The label vocabulary, for filtering. Read this before guessing."""
    return "\n".join(r[0] for r in DB.execute("SELECT name FROM labels"))

if __name__ == "__main__":
    mcp.run()          # defaults to the stdio transport

The decorator derives the JSON Schema from the type hints and the tool’s description from the docstring, which is why the docstrings above are written as instructions to a model rather than notes to a colleague — return shape, boundary against the sibling tool, irreversibility, argument ranges. Everything in tool description design applies here verbatim, because the docstring is the description that ends up in the context window.

Two decisions inside that file are worth calling out. search_issues returns a formatted string rather than JSON, because the consumer is a model and one line per issue with the id first is directly actionable, whereas a nested object costs tokens to serialise and attention to read. And close_issue returns a distinguishable message when it matched nothing rather than silently reporting success — a tool that cannot fail visibly teaches the model that its actions always work, which is the belief you least want it to hold about a write.

The frames it exchanges

One line of JSON per message, newline-delimited, over stdin and stdout. After the handshake in the protocol overview, a session looks like this:

--> {"jsonrpc":"2.0","id":2,"method":"tools/list"}

<-- {"jsonrpc":"2.0","id":2,"result":{"tools":[
      {"name":"search_issues",
       "description":"Full-text search over open issue titles and ...",
       "inputSchema":{"type":"object",
         "properties":{"query":{"type":"string"},
                       "limit":{"type":"integer","default":10}},
         "required":["query"]}},
      {"name":"close_issue", "...": "..."}]}}

--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
      "name":"search_issues",
      "arguments":{"query":"timeout retry","limit":5}}}

<-- {"jsonrpc":"2.0","id":3,"result":{
      "content":[{"type":"text",
                  "text":"#41 open Retry storm on 429\n#88 open ..."}],
      "isError":false}}

Three observations that save time later. The result is a content array of blocks, not a bare string — text, images and embedded resources are all possible, and a client that only renders the first block will silently drop the rest. inputSchema is ordinary JSON Schema, so anything expressible there (enums, patterns, minimums) is available and worth using. And because it is all newline-delimited JSON on stdio, you can drive the server by hand:

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 | python server.py

Note also what is absent from the model’s view: the JSON-RPC id is the client’s bookkeeping and has nothing to do with the provider’s tool_call_id. The host translates between the two, and conflating them is a recurring source of mismatched results in hand-written hosts — the symptom is a tool result attached to the wrong call, which the model then reasons about perfectly sensibly and wrongly.

If that prints a tools list, your server works and the problem is in the client’s configuration. If it prints nothing, read the next section. The official MCP Inspector is the friendlier version of the same test and is worth having, but the pipe above needs nothing installed and is unambiguous about which side is broken.

The stdout mistake

On the stdio transport, standard output is the wire. A single stray print() — a debug line, a deprecation warning, a library’s startup banner, a progress bar — is injected into the JSON-RPC stream, and the client fails to parse a message that looks perfectly fine to you. The symptom is a server that connects and then appears to have no tools, or a client that hangs after initialisation.

The rule is absolute: log to stderr, always. Note that this means third-party libraries matter too, so configure logging before importing anything chatty and check for banners that write to stdout at import time. If you want the discipline enforced rather than remembered, redirect at the top of the module:

import sys
_real_stdout, sys.stdout = sys.stdout, sys.stderr
# hand _real_stdout to the transport; everything else that prints
# now goes harmlessly to stderr.

Two kinds of error

MCP distinguishes protocol errors from tool errors, and conflating them is the second most common mistake. A protocol error is a JSON-RPC error object — unknown method, malformed params, server misconfigured — and it goes to the client, not the model:

{"jsonrpc":"2.0","id":3,
 "error":{"code":-32602,"message":"Unknown tool: serch_issues"}}

A tool error is a normal, successful JSON-RPC result carrying "isError": true and an explanatory text block. That one goes to the model, which can read it and adapt — the same principle as the tool result channel in error recovery. So: “issue #41 is already closed” is a result with isError set, not a JSON-RPC error. Raise a protocol error and the model never learns what happened; some clients will simply drop the turn.

A one-line test for which you are holding: if the text would help the model do better on its next attempt, it belongs in a result with isError set. If it would only ever help you, it belongs in a JSON-RPC error and in your stderr log.

What makes a server pleasant to use

  • Return text a model can act on. Not a JSON dump of your ORM. The search tool above returns one line per issue with the id first, because the id is what the next tool call needs.
  • Bound every result. A tool that can return a megabyte will, and it lands in a context window someone is paying for on every subsequent step. Cap it and say in the description that you capped it.
  • Name the empty case. “No matches; this search does not do stemming” prevents the identical-retry loop.
  • Separate read and write servers if you can. Two servers with different credentials lets a host grant read-only access, which is the only kind most users want to approve once.
  • Version your tool names. Renaming a tool silently breaks every saved prompt and every eval that mentions it. Add the new one, deprecate the old one in its description, remove it later.
  • Keep the tools callable without the protocol. Each decorated function above is an ordinary Python function, and your tests should call it directly. A server whose logic can only be exercised through a transport is a server nobody will write tests for.
  • Only advertise listChanged if you send it. Capabilities are a promise the client will act on: declaring list-change notifications and never emitting them leaves clients caching a stale catalogue with no way to know.
Building an MCP Server: A Complete Walkthrough · Multigrid