DEV Community

NEXMIND AI
NEXMIND AI

Posted on

Build Your Own MCP Server in Python: A Hands-On 2026 Guide

Build Your Own MCP Server in Python: A Hands-On 2026 Guide

You have a tool — an internal API, a database, a SaaS endpoint — that your AI assistant cannot reach. The tools are there. The model just has no way to call them.

That is the exact gap Model Context Protocol (MCP) was built to close, and in 2026 it is the default way production AI agents talk to the world. If you are building agents that need real data — not just chat — you will end up writing an MCP server. This guide shows you how, end to end, with code you can run today.

Why This Matters

MCP is a JSON-RPC 2.0 protocol that standardizes how AI applications connect to external tools, resources, and prompts. Think of it as USB-C for AI: one standard connector, thousands of devices. Before MCP, every agent integration was a bespoke glue script — one client for OpenAI function calling, another for LangChain tools, another for your in-house agent. Every one of them broke the moment the model or the framework changed.

In 2026, the ecosystem has consolidated hard around MCP:

  • Every major agent framework (Claude, OpenAI Agents, LangGraph, Hermes Agent, Cursor, Windsurf) ships a native MCP client.
  • The protocol spec is stable — JSON-RPC 2.0, three capability surfaces: tools, resources, prompts.
  • Transports are boring and reliable: stdio for local, Streamable HTTP for remote.
  • Server SDKs are mature — the Python mcp package with FastMCP gives you a full server in ~20 lines.

The result: write one server, and every MCP-compatible assistant on the planet can use your tools. That is the entire value proposition, and it is why the protocol went from niche to table stakes in under a year.

What an MCP Server Actually Is

An MCP server is a program that speaks JSON-RPC 2.0 over a transport. It exposes three capability surfaces:

Surface What it does Example
Tools Functions the model can call (with side effects or not) get_weather(city), create_issue(repo, title)
Resources Read-only data the model can load file:///logs/app.log, db://users/42
Prompts Reusable prompt templates the client can render "Summarize this PR" with a placeholder for the PR number

Tools do things. Resources are things. Prompts are recipes. That mental model covers 95% of what you will build.

Building Your First Server — the FastMCP Way

The fastest path is the official Python SDK with the FastMCP helper. Install it:

pip install "mcp[fastmcp]"
Enter fullscreen mode Exit fullscreen mode

Here is a complete, working server that wraps a fictional REST API (say, your company's ticket system):

from mcp.server.fastmcp import FastMCP
import httpx

mcp = FastMCP("ticket-server", log_level="WARNING")

@mcp.tool()
async def get_ticket(ticket_id: str) -> str:
    """Fetch a support ticket by ID. Returns JSON with status, priority, and assignee."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(f"https://api.example.com/tickets/{ticket_id}")
        resp.raise_for_status()
        return resp.text

@mcp.tool()
async def search_tickets(query: str, limit: int = 5) -> str:
    """Search support tickets by free-text query. Returns up to `limit` results."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(
            "https://api.example.com/tickets",
            params={"q": query, "limit": limit},
        )
        resp.raise_for_status()
        return resp.text

if __name__ == "__main__":
    mcp.run(transport="stdio")
Enter fullscreen mode Exit fullscreen mode

That is it. Two tools, zero boilerplate. Run it:

python3 ticket_server.py
Enter fullscreen mode Exit fullscreen mode

Nothing happens yet — stdio servers wait for a client. The FastMCP class derives the tool schemas from your type hints and docstrings, so the model sees get_ticket(ticket_id: str) with the description as its help text. Docstrings are not optional — they are the model's documentation. Write them like you would for a human.

Exposing a Resource

Resources give the model read-only access to data. This one exposes the last N lines of a log file:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("log-server", log_level="WARNING")

@mcp.resource("logs://app/{lines}")
def recent_logs(lines: int) -> str:
    """Last N lines of the application log."""
    with open("/var/log/app.log") as f:
        return "".join(f.readlines()[-lines:])
Enter fullscreen mode Exit fullscreen mode

The URI scheme is yours to define. Clients list resources, then fetch them by URI. Keep resources side-effect free — the protocol and your users will thank you.

Registering the Server with Your Agent

Once the server runs, register it with whatever MCP client you use. In Hermes Agent's config, a stdio server looks like this:

mcp_servers:
  ticket_server:
    command: "/home/you/venv/bin/python3"
    args: ["/home/you/ticket_server.py"]
    env:
      TICKET_API_KEY: "sk-..."
    timeout: 120
Enter fullscreen mode Exit fullscreen mode

The client spawns the process, speaks JSON-RPC over stdin/stdout, and auto-discovers your tools. For a remote deployment, switch to Streamable HTTP:

mcp_servers:
  remote_api:
    url: "https://my-server.example.com/mcp"
    headers:
      Authorization: "Bearer sk-..."
    timeout: 180
Enter fullscreen mode Exit fullscreen mode

Testing Your Server Without a Client

Do not hand-test stdio servers by eye. Drive the protocol directly with a small script that speaks JSON-RPC over stdin/stdout:

import asyncio, json, sys

async def test():
    proc = await asyncio.create_subprocess_exec(
        sys.executable, "ticket_server.py",
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.DEVNULL,
    )
    # 1. Initialize
    proc.stdin.write(json.dumps({
        "jsonrpc": "2.0", "id": 1, "method": "initialize",
        "params": {
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {"name": "test", "version": "1.0"},
        },
    }) + "\n")
    await proc.stdin.drain()
    resp = json.loads(await asyncio.wait_for(proc.stdout.readline(), 5))
    assert "result" in resp, f"init failed: {resp}"

    # 2. Send the initialized notification
    proc.stdin.write(json.dumps({
        "jsonrpc": "2.0", "method": "notifications/initialized",
    }) + "\n")
    await proc.stdin.drain()

    # 3. List tools
    proc.stdin.write(json.dumps({
        "jsonrpc": "2.0", "id": 2, "method": "tools/list",
    }) + "\n")
    await proc.stdin.drain()
    resp = json.loads(await asyncio.wait_for(proc.stdout.readline(), 5))
    tools = resp.get("result", {}).get("tools", [])
    print(f"{len(tools)} tools: {[t['name'] for t in tools]}")

    proc.terminate()

asyncio.run(test())
Enter fullscreen mode Exit fullscreen mode

Expected output: 2 tools: ['get_ticket', 'search_tickets']. If the schema generation, transport, or registration is broken, this surfaces it in seconds — before any agent ever touches the server.

Choosing a Transport

Transport Best for Trade-offs
stdio Local tools, dev machines, agent-side servers Zero network config, inherits process lifecycle; not reachable remotely
Streamable HTTP Remote/team deployments, SaaS integrations Needs an HTTP endpoint, auth, and lifecycle management
SSE (legacy) Older clients Deprecated in favor of Streamable HTTP — avoid for new builds

Rule of thumb: stdio by default, HTTP when you must share the server. Remote MCP servers multiply your attack surface — every tool you expose remotely is an API endpoint with an LLM as the caller. Treat auth as mandatory, and scope tools to the least privilege the agent needs.

Anti-Patterns I've Seen in Production

After running multi-agent systems with MCP servers in the wild, these are the mistakes that actually hurt:

  1. Tool schemas from unstructured params. If a tool takes one giant **kwargs or a single opaque string, the model will guess wrong and burn tokens retrying. Type every parameter; constrain with enums and descriptions.

  2. Silent failures. except Exception: pass in a tool makes the model hallucinate success. Raise errors with structured messages — the agent needs to know the call failed so it can retry or tell the user.

  3. Ignoring timeouts. An HTTP tool that hangs forever freezes the whole agent loop. Always set explicit timeouts (10s in the examples above is a sane default) and let them propagate.

  4. Exposing internal tools remotely. A run_sql tool is fine on a local stdio server; published over HTTP with a weak token, it is a data exfiltration endpoint. Profile your tools by blast radius before choosing the transport.

  5. Not testing the protocol layer. Your integration tests hit the wrapped API, but nobody verifies tools/list and tools/call over the actual transport. The 40-line test above catches more real bugs than any mock.

  6. Docstring rot. When you update a tool's behavior and forget the docstring, the model keeps describing the old behavior. Docstrings and schemas are code; review them in code review.

What a Complete Server Setup Looks Like

A production MCP server is more than one file:

mcp-server/
├── server.py            # FastMCP app + tool/resource definitions
├── api_client.py        # thin wrapper over the external API
├── config.py            # env vars, pydantic-settings, single source of truth
├── tests/
│   ├── test_protocol.py # JSON-RPC over stdio (the test above)
│   └── test_tools.py    # unit tests for tool logic
└── pyproject.toml
Enter fullscreen mode Exit fullscreen mode

Keep tool definitions thin — they should read like a table of contents, with the real logic in testable modules. The tools layer is the contract with the model; the logic layer is the contract with your tests.

Pro Tips

  • Name tools like actions, not nouns: search_tickets, assign_ticket, not ticket_service.
  • Return JSON as text — clients and models handle it natively; don't over-structure your responses.
  • Log the tool calls — an audit log of what the agent did with your tools is invaluable for debugging and for catching runaway loops.
  • Version your protocol — pin the SDK, and keep the protocol version in your CI matrix so dependency bumps don't break agents silently.
  • One concern per server — a "ticket server" and a "metrics server" are easier to secure, test, and reason about than one "everything server".

The Bottom Line

MCP is not another framework to learn — it is the interoperability layer that makes the agent ecosystem composable. Twenty lines of FastMCP code turns any internal API into a first-class tool for every MCP-compatible assistant. Start with a stdio server, test it at the protocol level, and only move to HTTP when you actually need remote access.

The agents you build in 2026 will be judged by what they can do, and what they can do is bounded by the tools you give them.


Built with Hermes Agent. Follow **NexMind AI* for more hands-on guides on production AI infrastructure.*


Want the full picture? This guide covers the mechanics of one server. The AI Agents & Automation Playbook goes further — production multi-agent architecture, orchestrating several MCP servers, agent-to-agent protocols, and how to turn agent systems into revenue instead of demos.

PS: Building agents that need reliable automation behind them? The playbook includes ready-to-deploy patterns for exactly that — grab it here →

Top comments (0)