Most LLM apps are stuck behind a wall. The model can read text and write text, but it cannot touch your database, query your API, or read the file you opened two seconds ago. Every team solves this differently, and every integration is a custom one-off.
Model Context Protocol (MCP) is the bet that this should be a standard. Anthropic open-sourced it in late 2024; the Python and TypeScript SDKs followed. As of mid-2026, MCP servers exist for Postgres, GitHub, Sentry, Linear, Cloudflare, and several hundred other tools. Clients include Claude Code, Claude Desktop, Cursor, Cline, and the OpenAI Agents SDK.
This article builds a working MCP server from scratch. It exposes a tool that queries a SQLite database, a resource that reads a local file, and a prompt template. Then we connect it to Claude Code and watch the model call it.
Prerequisites
- Python 3.10 or newer (
python3 --version) - uv for dependency management
- Claude Code installed (
npm install -g @anthropic-ai/claude-code) or Claude Desktop - A SQLite database, or the patience to create one in step 2
What MCP Actually Is
MCP is a JSON-RPC protocol with three roles:
- Host: the application the user interacts with (Claude Code, Cursor, your own agent)
- Client: lives inside the host, speaks MCP to one server
- Server: exposes tools, resources, and prompts to the client
The host runs many clients. Each client connects to one server. Servers do not see each other. Servers do not see your full conversation. The protocol spec lives at modelcontextprotocol.io/specification and the current stable revision is 2025-06-18.
Three primitives a server can expose:
- Tools: functions the model can call (the equivalent of OpenAI function calling)
- Resources: read-only data the model can fetch, addressed by URI
- Prompts: reusable prompt templates the user can trigger
Step 1: Scaffold the Project
```bash uv init mcp-server-demo cd mcp-server-demo uv add "mcp[cli]" ```
This creates a virtualenv, installs the MCP Python SDK with the CLI extras, and sets up pyproject.toml. The SDK is the official one from github.com/modelcontextprotocol/python-sdk. The v1.x line is the current stable release; v2 is in alpha as of writing and you should pin mcp>=1.27,<2 if you publish this as a library.
Verify the install:
```bash uv run python -c "import mcp; print(mcp.version)"
1.27.x or later
```
Step 2: Create a Sample Database
Skip this step if you already have a database. Otherwise:
```bash sqlite3 data.db <<'SQL' CREATE TABLE customers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT, lifetime_value REAL DEFAULT 0 ); INSERT INTO customers (name, email, lifetime_value) VALUES ('Anya Kusuma', '[email protected]', 1240.50), ('Budi Santoso', '[email protected]', 89.00), ('Citra Wijaya', '[email protected]', 4320.75), ('Dimas Pratama', '[email protected]', 0); SQL ```
Four rows. Enough to test the tool later.
Step 3: Write the Server
Replace the contents of hello.py (or create server.py) with this:
```python """A minimal MCP server with a tool, a resource, and a prompt.""" import json import sqlite3 from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("customer-tools")
DB_PATH = Path(file).parent / "data.db"
@mcp.tool() def search_customers( min_ltv: float = 0, limit: int = 10, ) -> str: """Search customers by minimum lifetime value.
Args:
min_ltv: only return customers with lifetime_value >= this
limit: maximum number of rows to return (1-100)
"""
if not 1 <= limit <= 100:
return json.dumps({"error": "limit must be between 1 and 100"})
conn = sqlite3.connect(DB_PATH)
try:
rows = conn.execute(
"SELECT id, name, email, lifetime_value "
"FROM customers WHERE lifetime_value >= ? "
"ORDER BY lifetime_value DESC LIMIT ?",
(min_ltv, limit),
).fetchall()
finally:
conn.close()
return json.dumps(
[
{
"id": r[0],
"name": r[1],
"email": r[2],
"lifetime_value": r[3],
}
for r in rows
],
indent=2,
)
@mcp.resource("customer://{customer_id}") def get_customer(customer_id: int) -> str: """Fetch a single customer record by ID.""" conn = sqlite3.connect(DB_PATH) try: row = conn.execute( "SELECT id, name, email, lifetime_value FROM customers WHERE id = ?", (customer_id,), ).fetchone() finally: conn.close()
if row is None:
return json.dumps({"error": f"no customer with id {customer_id}"})
return json.dumps(
{
"id": row[0],
"name": row[1],
"email": row[2],
"lifetime_value": row[3],
},
indent=2,
)
@mcp.prompt() def customer_summary(style: str = "concise") -> str: """Generate a prompt for summarising the customer base.""" styles = { "concise": "Summarise the top 5 customers by lifetime value in 3 bullets.", "detailed": "Analyse the customer base, flag accounts with LTV under 100, " "and suggest retention actions for high-value accounts.", } return styles.get(style, styles["concise"])
if name == "main": mcp.run(transport="stdio") ```
A few things worth calling out:
The decorators come from FastMCP, a high-level wrapper around the lower-level Server class. The lower-level API gives you more control over the protocol; FastMCP is what you want for 90% of servers. Type hints on the tool become the JSON schema the model sees. The docstring becomes the tool description the model uses to decide when to call it. Both matter: a tool named search_customers with no docstring is a tool the model cannot find.
We use transport="stdio" for local development. The host spawns the server as a subprocess and talks to it over stdin/stdout. For network access, switch to transport="streamable-http" (added in the 2025-06-18 spec revision; SSE is being deprecated).
Step 4: Connect It to Claude Code
The MCP CLI handles registration. From the project directory:
```bash claude mcp add --transport stdio customer-tools -- uv run python server.py ```
Verify the registration:
```bash claude mcp list ```
You should see customer-tools in the list with a green status. If it shows red, run the same uv run python server.py command yourself to see the error; common problems are a missing dependency or a typo in the DB path.
Step 5: Use It
Open Claude Code in any directory and try:
Show me our top 3 customers by lifetime value.
Claude will call search_customers with limit=3, get the rows back, and format them. The full transcript is in your session log. To see exactly which tools were called, look at the message log under ~/.claude/projects/<project>/<session>.jsonl.
Try the resource next:
What's the email for customer with ID 2?
Claude will fetch customer://2. Same for the prompt: it shows up as a slash command (/customer-summary) you can invoke manually or have the agent use when it needs a starting prompt.
When MCP Is the Wrong Choice
MCP is overkill when:
- You only have one client. Function calling in the OpenAI or Anthropic SDK is simpler and you skip the protocol.
- The data is already exposed over HTTP with a clean OpenAPI spec. An MCP server that just wraps
requests.getis a worse version of the API. - You need real-time streaming. MCP supports notifications but the round-trip model assumes request/response. For push-based data, use a webhook + queue.
- You are building a chat app, not a tool-using agent. The prompt primitive exists, but it overlaps with system prompts and adds a layer without much value.
The honest test: if your tool can be expressed in 50 lines of Python and only Claude Code will ever call it, just write the function. MCP starts paying off when the same tool needs to work across Claude Code, Cursor, and a custom agent you are building.
Debugging Tips
Server crashes silently when Claude calls it. Add a logging handler. FastMCP uses mcp.server.fastmcp.utilities.logging.get_logger(). Anything you log shows up in the host's stderr if the host surfaces it; Claude Code shows it under the server's metadata pane.
Tool is not being called. The model decides to call a tool based on its name and description. If the description is vague, the model will not reach for it. Be specific: "Search customers by minimum lifetime value" is better than "Search."
Schema mismatch errors. The SDK generates the JSON schema from your type hints. int becomes integer, Optional[str] becomes string with no requirement, list[dict] becomes array of object. If you need something the SDK does not infer (enums, defaults the SDK misses), use Annotated[int, Field(description="...")] from Pydantic.
Multiple clients, one DB. SQLite locks on write. If your tool mutates state and the host runs several sessions in parallel, switch to a queue or a database that handles concurrency. The MCP layer does not help with this; it just exposes what you give it.
Where to Go From Here
Once the basics work:
- Add authentication. The 2025-06-18 spec added OAuth 2.1 support; the Python SDK has
mcp.client.authhelpers. For stdio servers, the user is whoever can run the command, which is usually fine. - Switch to HTTP transport if you want the server to be reachable by remote clients. The streamable HTTP transport is the current recommendation; SSE is being phased out.
- Publish to a registry. The official MCP server registry is at registry.modelcontextprotocol.io. Listing a server there is the way other people find it.
- Add sampling. Sampling lets a server ask the host's model to do work (for example, summarise a fetched document). It is the most underused MCP feature, and it is what makes an MCP server a peer of an agent rather than a tool.
The whole demo is around 90 lines of Python. Most of the value is in the design: pick a tight set of tools, write descriptions the model can act on, and keep the server stateless. Everything else is protocol noise.