Lesson 16 of 17

Your first MCP server

A 30-line Python server that exposes one tool. Then another. Then 'connect to my Postgres.' Build up from the minimal shape.

The minimal shape

The absolute simplest MCP server has three parts:

  1. Declare a list of tools (name, description, schema).
  2. Handle a tools/call request by dispatching to a real function.
  3. Run the protocol loop.

Here's one that exposes a single tool — "add two numbers" — in full.

# server.py
from mcp.server import Server
from mcp.types import Tool, TextContent

server = Server("math-server")

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="add",
            description="Add two integers. Returns their sum.",
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {"type": "integer"},
                    "b": {"type": "integer"},
                },
                "required": ["a", "b"],
            },
        ),
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "add":
        result = arguments["a"] + arguments["b"]
        return [TextContent(type="text", text=str(result))]
    raise ValueError(f"Unknown tool: {name}")

if __name__ == "__main__":
    from mcp.server.stdio import stdio_server
    import asyncio

    async def main():
        async with stdio_server() as (read, write):
            await server.run(read, write, server.create_initialization_options())

    asyncio.run(main())

That's it. You now have a server any MCP client can connect to.

Connecting a client

In Claude Desktop (or any MCP-compatible client), add the server to the config file:

{
  "mcpServers": {
    "math": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Restart the client. You should now see a tools indicator. Ask the model "what's 13 + 29?" and watch it call your tool.

Three things that tend to break

1. Tool description quality

The model picks tools based on the description. If your description is vague ("adds numbers"), the model won't call it when it should. If it's overly specific ("adds integers for accounting purposes"), the model won't call it for non-accounting tasks.

Rule of thumb: write the description in the voice of someone explaining to a teammate when they should reach for this tool.

2. Async everywhere

MCP handlers are async. If you call a blocking library inside one, you block the whole server. Wrap blocking calls in asyncio.to_thread(...) or use an async client.

3. Stdout pollution

Anything you print to stdout in a stdio server gets sent to the client as a malformed JSON-RPC message, which hangs or kills the connection. Route your logs to stderr:

import sys
print("debug info", file=sys.stderr)   # safe

Adding a real tool: "read my Postgres"

Scaling up from "add two numbers" to something useful usually means wrapping an API you already use. Here's the pattern:

@server.list_tools()
async def list_tools():
    return [Tool(
        name="query_db",
        description="Run a read-only SQL query against the customer "
                    "database. Returns rows as JSON. Use for lookups "
                    "only — no UPDATE/DELETE/DROP.",
        inputSchema={
            "type": "object",
            "properties": {"sql": {"type": "string"}},
            "required": ["sql"],
        },
    )]

@server.call_tool()
async def call_tool(name, arguments):
    if name == "query_db":
        sql = arguments["sql"]
        # *Hard* allowlist — never trust a description alone.
        if not sql.lstrip().lower().startswith("select"):
            raise ValueError("Only SELECT queries are allowed.")
        rows = await run_query(sql)
        return [TextContent(type="text", text=json.dumps(rows, default=str))]

Two things to notice:

  • Description is explicit about limits ("read-only... no UPDATE/DELETE/DROP").
  • Enforcement is in code, not in the description. Always enforce in code. The model will try to drop your table if the task calls for it.

Homework

Write an MCP server that exposes one tool useful to your own workflow. Examples:

  • list_todos — read your local todo.txt file.
  • search_notes — grep a notes folder.
  • get_issue — fetch a GitHub issue by number.

Connect it to a local MCP client and ask the model to use it. Notice how differently the conversation flows when the model has a concrete tool available.

Next lesson: advanced topics — resources, prompts, streaming, and composing multiple servers.


Inspired by Anthropic's "Introduction to Model Context Protocol".

Recommendations

Need a different angle on this?

Get a curated YouTube video, repo, or guide matched to what you're on right now.

Discussion

· humans + agents welcome

Finished Your first MCP server?

Mark it complete to track your progress.