Building an MCP Server from Scratch: A Hands-On Guide for Developers
I wrote a piece a while back explaining what MCP is and why it matters. Someone in the comments asked: "OK, I get the concept — but how do I actually build one?" Fair question. Let's do it.
I'll be honest: my first attempt at building an MCP server was a disaster. Not because the protocol is hard, but because the docs are written in that "technically correct but somehow unhelpful" way. Every concept is explained clearly, yet you still don't know where to start. It's like a cookbook that lists twenty ingredients but never tells you whether to heat the pan first.
Spent about two days figuring it out, hit a bunch of walls. Writing this so you don't have to repeat my mistakes.
What We're Building
MCP (Model Context Protocol) is a standardized way for AI models to call external tools. Think of it as USB for the AI world — instead of writing custom integration code for every tool, you implement MCP once and it works with every MCP-compatible client.
What we're doing is straightforward: write an MCP server that exposes some tools, so clients like Claude Code or Hermes Agent can use them.
Sounds fancy? The code is surprisingly minimal. FastMCP, the Python framework, handles most of the heavy lifting. You just need to care about one thing: "What do I want the AI to be able to do?"
Setting Up
I'm using Python 3.11+ and recommend uv for dependency management. If you don't have uv yet:
| 1 | |
Then create the project:
| 1 | |
| 2 | |
| 3 | |
That's it. Three commands. mcp[cli] installs both the MCP Python SDK and the CLI tools.
If you insist on using pip:
| 1 | |
But seriously, use uv. It's fast, handles dependency resolution properly, and you won't waste half an hour debugging version conflicts.
FastMCP: A Working Server in Three Minutes
FastMCP is the high-level wrapper provided by the MCP Python SDK. Writing an MCP server with it is about as complex as writing a Flask route.
Start with the simplest possible example:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
That's really all there is. The @mcp.tool() decorator turns a regular function into an MCP tool. The function's docstring automatically becomes the tool's description — the AI model reads this to decide when to call your tool.
Run it:
| 1 | |
By default it uses stdio transport, meaning it communicates through standard input/output. This works great for local use — no ports to open.
The Three Core Concepts
Before we go further, you need to understand MCP's three building blocks. Get this wrong and you'll be confused about which one to use when.
Tools
Tools are the most common. They let the AI "do things" — call APIs, write files, query databases, send messages. Anything with side effects should be a tool.
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
That Args section in the docstring? It's not decoration. The AI model reads those descriptions to understand what each parameter means. Write them well and the model calls your tool accurately. Write them poorly and the model guesses.
Resources
Resources expose data to the AI. They're like GET endpoints in a REST API — they provide information without side effects.
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
Resources use URI templates for identification, with support for path parameters. AI clients can list available resources and read them on demand.
Prompts
Prompts are predefined prompt templates. Honestly, these come up less often — tools and resources cover most use cases. But they're handy when you want the AI to process something in a specific format:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
Real Example: A Task Manager Server
A calculator is boring. Let's build something actually useful — an MCP server for a simple task management system. Imagine you want the AI to help you manage your TODO list.
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | |
| 22 | |
| 23 | |
| 24 | |
| 25 | |
| 26 | |
| 27 | |
| 28 | |
| 29 | |
| 30 | |
| 31 | |
| 32 | |
| 33 | |
| 34 | |
| 35 | |
| 36 | |
| 37 | |
| 38 | |
| 39 | |
| 40 | |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | |
| 54 | |
| 55 | |
| 56 | |
| 57 | |
| 58 | |
| 59 | |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | |
| 66 | |
| 67 | |
| 68 | |
| 69 | |
| 70 | |
| 71 | |
| 72 | |
| 73 | |
| 74 | |
| 75 | |
| 76 | |
| 77 | |
| 78 | |
| 79 | |
| 80 | |
| 81 | |
| 82 | |
| 83 | |
| 84 | |
| 85 | |
| 86 | |
| 87 | |
| 88 | |
| 89 | |
| 90 | |
| 91 | |
| 92 | |
| 93 | |
| 94 | |
| 95 | |
| 96 | |
| 97 | |
| 98 | |
| 99 | |
| 100 | |
| 101 | |
| 102 | |
| 103 | |
| 104 | |
| 105 | |
| 106 | |
| 107 | |
| 108 | |
| 109 | |
| 110 | |
| 111 | |
| 112 | |
| 113 | |
| 114 | |
This server has four tools and one resource. You can tell the AI something like "add a task: finish the report by tomorrow, high priority" and it'll call add_task for you.
Testing with MCP Inspector
Before hooking up your server to a real client, test it with the MCP Inspector. It's a visual debugging tool that lets you see and call your tools in a browser.
First, switch to streamable-http transport (the Inspector needs HTTP):
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
Then launch the Inspector:
| 1 | |
| 2 | |
A browser UI opens at http://localhost:8000/mcp. You can see all registered tools, resources, and prompts, and call them directly.
I didn't know about this tool the first time I built an MCP server. Went straight to Claude Code, got errors, had no idea what was wrong. Found the Inspector later — debugging speed doubled instantly.
Connecting to Claude Code
Once testing is done, connecting to Claude Code is one command:
| 1 | |
| 2 | |
Restart Claude Code and the task management features are available in your conversations. Say "show me my pending tasks" and Claude Code automatically calls list_tasks.
For stdio transport (no HTTP), edit ~/.claude/claude_code_config.json:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
With this approach, Claude Code launches your server process directly and communicates through stdin/stdout. Good for local tools that don't need networking.
Connecting to Hermes Agent
Hermes Agent supports MCP too. Add to your config:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
Or with stdio:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
Once configured, Hermes Agent can call your tools. Feels pretty great in practice — tell the AI "mark the blog writing task as done" and it just does it. No manually editing JSON files.
Going Further: Database Integration
The examples above use a JSON file for storage. In a real project, you'd probably use a database. MCP servers support lifespan management — connect to the database on startup, disconnect on shutdown.
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | |
| 22 | |
| 23 | |
| 24 | |
| 25 | |
| 26 | |
| 27 | |
| 28 | |
| 29 | |
| 30 | |
| 31 | |
| 32 | |
| 33 | |
| 34 | |
| 35 | |
| 36 | |
| 37 | |
| 38 | |
| 39 | |
| 40 | |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | |
| 54 | |
| 55 | |
| 56 | |
| 57 | |
| 58 | |
| 59 | |
| 60 | |
| 61 | |
Notice the ctx: Context parameter — FastMCP injects it automatically. Access the database through ctx.request_context.lifespan_context.
Streamable HTTP vs SSE vs Stdio
MCP supports three transport options. Which one depends on your use case:
Stdio: Simplest option. The client launches your server process directly. No network config needed. Claude Code defaults to this.
SSE (Server-Sent Events): HTTP-based one-way push. Early MCP versions used this, now superseded by Streamable HTTP. If you see old tutorials using SSE, switch to Streamable HTTP.
Streamable HTTP: Latest transport, supports bidirectional communication. Best for remote deployment and shared servers. If you're deploying an MCP server for your team, use this.
In practice: stdio for local development, Streamable HTTP for server deployment. SSE is basically obsolete.
Debugging Tips
Debugging MCP servers is unavoidable. Here's what I've learned from hitting walls:
Logging matters. Your server runs in the background — you can't see its output. Add logging:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
Strict parameter types. MCP validates parameters against your function's type annotations. If you write def foo(x: int) but pass a string, it errors. Don't assume "Python doesn't enforce types" — MCP does.
Friendly error messages. When a tool fails, return a meaningful error message. Don't let the AI see a raw traceback:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
Test edge cases with Inspector. Pass empty strings, huge numbers, special characters. See if your tools handle them correctly. AI users are more "creative" with input than you'd expect.
A Real Debugging Story
Last week I was building an MCP server to query GitHub repository info. Code was done, Inspector tests all passed, but it flat-out refused to work in Claude Code.
Spent ages debugging. Turns out the problem was my function's return value — I was returning a Python object, not a string. The MCP protocol requires tool return values to be strings (or JSON-serializable things). The Inspector handled it automatically, but Claude Code's MCP client wasn't as forgiving.
Fix was simple — make sure the return value is a string:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
This is documented, but not prominently. I skimmed right past it and wasted two hours.
Security Considerations
MCP servers execute external requests. Security can't be an afterthought.
Don't expose dangerous operations. If your tool can delete files or execute commands, add permission checks. AI models sometimes "creatively" interpret your tool descriptions and do things you didn't expect.
Validate input parameters. AI-provided parameters aren't always reliable:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
Consider rate limiting. If your tool calls external APIs, add rate limiting. Don't let the AI loop through and burn through your API quota.
Deploying to a Server
Local development with stdio is fine, but for team use or multi-client sharing, deploy to a server.
Using Streamable HTTP with uvicorn:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
Then run it with systemd or Docker:
| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
Clients connect using the server address:
| 1 | |
| 2 | |
What Else Can You Build?
MCP goes way beyond task managers. I've seen people build:
- Database query tools: Let AI query SQL databases directly
- API gateways: Wrap multiple third-party APIs as MCP tools for unified AI access
- File management: Let AI read/write local files, manage project structure
- Monitoring and alerts: AI checks server health periodically, notifies on issues
- Code generators: Generate code snippets from templates
The pattern is simple: anything you want AI to help with, wrap it as an MCP tool.
What's Next
I'm planning to build a few more practical MCP servers:
- An Airtable integration — let AI manage my content tables directly
- A system monitoring tool — check CPU, memory, disk usage
- A web scraper tool — fetch and parse web pages on demand
I'll write those up when they're done. Got questions? Drop them in the comments.
Resources: