In this article

🧭 Adding vs building: two different jobs

Before starting, an important clarification: adding an existing MCP server and building your own are two very different jobs. If you just need to connect a ready-made server (Playwright, GitHub, Notion) to your Claude Code or Codex, this is not the right article — I have already covered those in the linked posts below.

Here I talk about the case where you want to expose a system of yours to an AI agent: an internal CRM, a company database, a custom API, a legacy script. The clean way to do it today is to write an MCP server that speaks the Model Context Protocol and lets any compatible client consume it the same way.

Adding vs building an MCP server

Adding (config)

  • A server already exists (npm/pip/binary)
  • You only edit a configuration file
  • Zero code, zero maintenance
  • Covered in: 'Adding MCP to Claude and Codex'

Building (code)

  • You write the tools in Python (or TypeScript)
  • You decide what to expose and what NOT
  • You maintain the code like any service
  • That's what we're talking about here

If no ready server exists for your use case, you have to write one. And it's faster than you think.

🧩 Anatomy: tools, resources, prompts, transport

An MCP server exposes three primitives to the client and lives on a transport. The primitives are: tool (actions the model can invoke), resource (read-only URI-addressed data the model can pull) and prompt (parameterised templates the host can surface as slash commands).

The transport is the channel JSON-RPC messages travel on: stdio when the client spawns the server as a child process (local, minimum latency, zero network) and Streamable HTTP when the server is remote and serves multiple clients. For a first prototype you almost always start with stdio: no ports to open, no auth needed, it all runs on your machine.

  • tool → functions with effects (create, update, query, compute).
  • resource → read-only data with a URI (files, tables, snapshots).
  • prompt → reusable templates with parameters.
  • transport → stdio (local) or Streamable HTTP (remote).
The three MCP primitives in one line
# @mcp.tool     -> the agent CAN invoke it (has effects)
# @mcp.resource -> the agent CAN read it (read-only, by URI)
# @mcp.prompt   -> the host CAN surface it as a slash-command

# Transport is decided at run time: stdio (local) or http (remote)
Three decorators, one transport. Everything else is your application logic.

⚙️ Setup: FastMCP in Python, in 3 commands

In Python the path I recommend in 2026 is FastMCP — the standalone implementation maintained by PrefectHQ that today powers 70% of MCP servers out there. FastMCP 1.0 got folded into the official SDK in 2024, then the project kept its own life and now has the best ergonomics. If you need fine-grained control over the wire format, the official `mcp` SDK is a great alternative; for 90% of use cases, FastMCP is faster to write and read.

Setup: create a venv, install FastMCP, write the first file. I use uv (or `pip`, if you prefer) because it installs in a blink and keeps the project clean.

Terminal · project setup
# 1) Create the folder and the venv
mkdir mcp-orders-demo && cd mcp-orders-demo
uv venv && source .venv/bin/activate

# 2) Install FastMCP (2.x, stable for production)
uv pip install 'fastmcp>=2.11,<3'

# 3) Create the server file
touch server.py

Official docs · FastMCP

Three commands and the project is ready. From here on, only Python.

🔨 The first tool: MCP-style hello world

The first tool is only there to be seen by the client. I define a type-annotated Python function, slap `@mcp.tool` on it, and FastMCP generates on its own the JSON Schema the model sees: the docstring becomes the description, type hints become parameters, the return type becomes the response payload.

The pattern is always the same: a function with types + a clear docstring = a well-documented tool for the LLM. If you botch the docstring or use vague types (`Any`, `dict`), the agent calls you wrong. Being obsessive here is worth it.

server.py · the first tool
from fastmcp import FastMCP

mcp = FastMCP("orders-demo")

@mcp.tool
def ping(name: str) -> str:
    """Reply with a greeting. Useful to check the server responds."""
    return f"pong, {name}"

if __name__ == "__main__":
    mcp.run()  # default: stdio transport
Six lines of Python. The server already responds to tools/list and tools/call.

🏢 Exposing a real internal system (example: orders)

Now the piece that really matters: connecting a system of yours. In the repo example I expose a mini in-memory store of orders (in production you replace it with your DB call or your internal API) with two tools — `list_orders` and `get_order` — and a resource `orders://all` that returns the full snapshot, so the model can pull it without explicitly invoking anything.

Notice the pattern: tools do actions, resources provide context. And above all: I expose nothing the model shouldn't be allowed to do. No `execute_sql`, no `delete_all`, no shortcuts: if a tool is admin-only, it doesn't belong in the MCP server.

  • Discrete, single-purpose tools: `list_orders(status)` yes, `run_query(sql)` no.
  • Resources for context: dashboards, summaries, readable snapshots.
  • Output filtering: return only the fields the model needs to see.
server.py · tools and resource on an internal system
from fastmcp import FastMCP
from typing import Literal

mcp = FastMCP("orders-demo")

# In production: call your DB / your internal API
ORDERS = [
    {"id": "A-001", "customer": "Rossi Srl", "total": 1200, "status": "paid"},
    {"id": "A-002", "customer": "Bianchi SpA", "total": 340,  "status": "pending"},
]

@mcp.tool
def list_orders(status: Literal["paid", "pending"] | None = None) -> list[dict]:
    """List orders, optionally filtered by status."""
    return [o for o in ORDERS if status is None or o["status"] == status]

@mcp.tool
def get_order(order_id: str) -> dict | None:
    """Return the single order by id, or None if missing."""
    return next((o for o in ORDERS if o["id"] == order_id), None)

@mcp.resource("orders://all")
def all_orders() -> list[dict]:
    """Full snapshot of orders (read-only)."""
    return ORDERS
Two single-purpose tools + one snapshot resource. No generic APIs.

🛡️ Security: what NOT to expose (and how auth works)

This is the part where I see the most mistakes. As of February 2026, security researchers were reporting that 41% of MCP servers in the wild had no authentication at all, and between January and February 2026 over 30 CVEs had already been filed against the MCP ecosystem alone. The message is clear: an MCP server should be treated like an untrusted dependency with root-equivalent permissions.

Rule number one: on stdio you don't need OAuth, because the server is local and trusted; but any credentials (API keys, DB tokens) go through environment variables, never hardcoded. On HTTP, the current spec (2025-11-25) mandates OAuth 2.1 with PKCE, and operational recommendations are to keep the authorization server separate from the resource server, define per-tool scopes (`orders:read`, `orders:write:paid`) instead of blanket agent access, and use short-lived tokens with rotating refresh.

  • Never expose `run_sql`, `exec_shell`, `read_file(path)` without an allow-list.
  • Always validate input and constrain output (allow-list of fields).
  • HTTP: OAuth 2.1 + PKCE, per-tool scopes, short-lived tokens.
  • stdio: credentials via env, never in cleartext in code.
  • Logs and rate-limits on every tool: if an agent goes rogue you see it fast.
Anti-pattern vs safe pattern
# ❌ NO: giving the agent a universal service door
@mcp.tool
def run_sql(query: str) -> list[dict]:
    return db.execute(query)  # SQL injection + data exfiltration

# ✅ YES: discrete tools, tight types, output filters
@mcp.tool
def get_paid_orders(limit: int = 20) -> list[dict]:
    """Latest paid orders (max 100). Returns only public fields."""
    limit = max(1, min(limit, 100))
    rows = db.orders.find(status="paid", limit=limit)
    return [{"id": r.id, "total": r.total} for r in rows]
If a tool is generic enough to do anything, it will do anything. Including harm.

🐛 Common mistakes and debugging

The bugs I see most often when someone writes their first MCP server are almost always the same. I list them in frequency order, so if it happens to you, you spot it fast.

The most effective debugger is still the official MCP Inspector (`npx @modelcontextprotocol/inspector`): you point it at the server, see the tool list, invoke whichever tool with arbitrary params and read the response. Before wiring it into Claude Code or Codex, always take it for a spin in Inspector: if it doesn't work there, it won't work with the agent either.

  • Empty or vague docstring → the agent doesn't know when to call it.
  • `Any` or `dict` type hints → useless JSON Schema, wrong calls.
  • print() on stdio → you break JSON-RPC. Use `logging` on stderr.
  • Tools too generic → the agent gets it wrong every time. Prefer 5 specific tools over 1 do-it-all.
  • Non-serializable errors → always wrap in a dict with `error` and `detail`.
Terminal · debug with MCP Inspector
# Start the Inspector pointing at your stdio server
npx @modelcontextprotocol/inspector uv run server.py

# Then open http://localhost:6274 and:
# - check the tools appear in 'Tools'
# - invoke list_orders with status=paid
# - read the response and the JSON-RPC log

Official docs · MCP Inspector

Inspector is the first thing to learn. It saves you hours of guesswork.

📦 GitHub repo

All the code from this article — working FastMCP server, mini orders store, HTTP-with-Bearer example and a `docker-compose.yml` so you can try it without installing Python locally — sits in the public repo cool-solution-org/mcp-server-orders-demo. Clone, `docker compose up -d` and in thirty seconds you have the MCP server answering Inspector.

The README also has the step to hook it into Claude Code (`claude mcp add`) and a line-by-line explanation of the `.env.example`. Every snippet in this article is pulled verbatim from that repo, so you can start from there and adapt it to your internal system.

Terminal · try the repo in one minute
git clone https://github.com/cool-solution-org/mcp-server-orders-demo
cd mcp-server-orders-demo
docker compose up -d

# Then the Inspector, to verify the tools are really there
npx @modelcontextprotocol/inspector docker compose exec server uv run server.py

GitHub repo · mcp-server-orders-demo

Full code in the repo: https://github.com/cool-solution-org/mcp-server-orders-demo

✅ Final checklist

If you are about to ship your first MCP server, before feeding it to an agent, go through this checklist. These are the questions I refuse to skip myself, because every time I did, I ended up chasing a bug in production.

  • Every tool has a clear docstring and tight types (no `Any`, no loose `dict`)?
  • No generic tools like `run_sql`, `exec_shell`, `read_file(path)`?
  • Credentials from env only, never committed?
  • If HTTP: OAuth 2.1 + PKCE, per-tool scopes, short-lived tokens?
  • Logs and rate-limits on every tool?
  • Tested with the Inspector before wiring it to the agent?

Frequently asked questions about build custom MCP server

Python or TypeScript for writing an MCP server?

Both work: there are official SDKs for both. I recommend Python with FastMCP for 90% of cases because it's the fastest to write and read, and it has the largest community (it powers ~70% of MCP servers). TypeScript makes sense if your system is already in Node and you want to share types and libraries.

What's the difference between adding an MCP server and building one?

Adding means wiring a ready-made server (Playwright, GitHub, Notion) into your Claude Code or Codex by editing only a config file. Building means writing the server code yourself to expose a system of yours (CRM, internal DB, custom API) to an agent. Two different jobs: if the first is enough, you don't need this article.

How do I secure an MCP server?

Ground rules: avoid generic tools (`run_sql`, `exec_shell`); use tight type hints and validate inputs; pass credentials through environment variables, never hardcode them. If the server is HTTP (remote), the 2025-11-25 spec mandates OAuth 2.1 with PKCE, separate authorization and resource servers, per-tool scopes, and short-lived tokens. Add logs and rate-limits on every tool.

Local (stdio) or remote (HTTP): which do I pick?

Always start with stdio for the prototype: minimum latency, no auth, no network. Move to Streamable HTTP only when the server needs to live on another machine or serve multiple users at once — then you'll have to do OAuth 2.1 for real. Tool logic doesn't change: only how you launch them.

How do I debug before wiring the server to the agent?

Use the official MCP Inspector (`npx @modelcontextprotocol/inspector`): it's a UI that connects to your server, shows exposed tools, lets you invoke them with arbitrary params and shows JSON-RPC messages. If it doesn't work in Inspector, it won't work in Claude Code or Codex either — so starting there saves you hours.

Let's talk

If this topic is relevant to you, write to me: comparing notes on code and AI is always time well spent.

All articles