MCP servers are the connective tissue of modern AI development. Every agent, every workflow, every tool-using LLM needs them. And yet most tutorials stop at "hello world."
We've shipped four production MCP servers at Like One — brain context, HIG auditing, code signing diagnostics, and security scanning. This guide covers everything we learned, from first line of code to production deployment.
No hand-waving. No pseudo-code. Real patterns from real servers.
What Is an MCP Server (And Why Build One)?
Model Context Protocol is a standard for connecting AI models to external tools and data. Instead of hardcoding API calls into prompts, MCP gives models a structured way to discover and invoke tools at runtime.
Think of it like USB for AI. Your server exposes capabilities. Any MCP-compatible client — Claude Code, custom agents, IDE integrations — can discover and use them without custom integration code.
Why build your own instead of using existing servers?
- Domain-specific tools. Generic servers can't know your codebase, your data model, or your business logic. Custom servers encode institutional knowledge into tool definitions.
- Security control. Third-party servers are security liabilities unless audited. Building your own means you control every permission boundary.
- Competitive advantage. The teams that build great MCP tools will ship faster than teams that don't. This is the new developer tooling frontier.
Architecture: How MCP Servers Work
Before writing code, understand the protocol. MCP uses JSON-RPC 2.0 over stdio or HTTP with Server-Sent Events. The lifecycle looks like this:
- Initialize. Client connects, sends capabilities. Server responds with its capabilities, tool list, and resource list.
- Discover. Client calls
tools/listto get available tools with their JSON Schema parameter definitions. - Invoke. Client calls
tools/callwith a tool name and arguments. Server validates, executes, returns results. - Stream (optional). For long-running operations, the server can send progress notifications.
The critical insight: tool descriptions are part of the model's prompt. They directly influence how the AI decides to use your tools. Write bad descriptions and the model will misuse your tools — no matter how solid the implementation.
Choose Your Stack
MCP servers can be built in any language. The two best-supported options in 2026:
Python (mcp SDK)
Best for: data tools, research pipelines, rapid prototyping. The official mcp Python package gives you decorators and type inference. Most servers in the ecosystem use Python.
pip install mcp
TypeScript (@modelcontextprotocol/sdk)
Best for: web integrations, teams already in the Node ecosystem, servers that need to run in containers alongside web services.
npm install @modelcontextprotocol/sdk
We'll use Python for this guide because the ergonomics are better for tool-heavy servers. Every concept translates directly to TypeScript.
Step 1: Project Structure
Start with a clean layout. Resist the urge to build a monolith.
my-mcp-server/
├── server.py # Entry point + tool definitions
├── tools/
│ ├── __init__.py
│ ├── search.py # Individual tool implementations
│ └── analyze.py
├── tests/
│ ├── test_search.py
│ └── test_analyze.py
├── pyproject.toml
└── README.md
Separate tool logic from protocol handling. Your server.py should be thin — just tool registration and the MCP boilerplate. Actual logic lives in the tools/ directory where it's independently testable.
Step 2: Your First MCP Server
Here's a complete, working MCP server in under 30 lines:
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.server.stdio
server = Server("my-first-server")
@server.list_tools()
async def list_tools():
return [
Tool(
name="greet",
description="Generate a greeting for the given name",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name to greet"
}
},
"required": ["name"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "greet":
return [TextContent(
type="text",
text=f"Hello, {arguments['name']}!"
)]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with mcp.server.stdio.stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())
That's it. This server is functional. You can register it in Claude Code's .mcp.json right now and it works.
But a production server needs more. Let's build it up.
Step 3: Write Tool Descriptions That Actually Work
This is where most MCP tutorials fail. They show you the plumbing and ignore the part that matters most: how the AI interprets your tools.
Tool descriptions are prompt engineering. The model reads them to decide when and how to use each tool. Bad descriptions cause three problems:
- Misuse. The model calls your tool when it shouldn't, or with wrong arguments.
- Underuse. The model doesn't realize your tool solves its current problem.
- Confusion. Multiple tools with overlapping descriptions cause the model to pick randomly.
Rules for great tool descriptions:
- Lead with when to use it. "Search the codebase for files matching a glob pattern" beats "File search tool."
- Specify what it returns. "Returns a list of file paths sorted by modification time" tells the model what to expect.
- Include constraints. "Maximum 100 results. Use specific patterns for large codebases" prevents misuse.
- Describe each parameter. Don't just name them. Explain format, defaults, and edge cases.
- Differentiate from similar tools. If you have both
search_filesandsearch_content, explain when to use each.
Here's what production-quality tool metadata looks like:
Tool(
name="brain_search",
description=(
"Search brain context by keyword across keys, "
"descriptions, and values. Use this when you need "
"to find specific information stored in previous "
"sessions. Returns top matches ranked by relevance. "
"Prefer this over brain_read when you don't know "
"the exact key name."
),
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keywords. Can be partial. Matches against key names, descriptions, and stored values."
},
"limit": {
"type": "integer",
"description": "Maximum results to return (1-20). Default: 5. Use higher values for broad searches.",
"default": 5,
"minimum": 1,
"maximum": 20
}
},
"required": ["query"]
}
)
Step 4: Input Validation That Survives AI
JSON Schema in your tool definition is a suggestion to the model. It is not enforcement. The model can and will send malformed inputs. Your server must validate everything at runtime.
This isn't optional. Agentic loops amplify errors. One bad input in a chain of ten tool calls corrupts the entire workflow.
from pathlib import Path
def validate_file_path(path_str: str, allowed_root: str) -> Path:
"""Validate and resolve a file path safely."""
path = Path(path_str).resolve()
root = Path(allowed_root).resolve()
# Block path traversal
if not str(path).startswith(str(root)):
raise ValueError(
f"Path {path_str} is outside allowed directory"
)
# Block null bytes
if "\\x00" in path_str:
raise ValueError("Null bytes in path")
# Block symlink escape
if path.is_symlink():
real = path.resolve()
if not str(real).startswith(str(root)):
raise ValueError("Symlink points outside allowed directory")
return path
Do this for every parameter type. Strings get length limits. Numbers get range checks. Arrays get size limits. File paths get containment checks. No exceptions.
Step 5: Error Handling That Helps the Model
When a tool fails, the error message becomes part of the model's context. Generic errors like "Something went wrong" waste the model's reasoning capacity. Specific errors help it self-correct.
@server.call_tool()
async def call_tool(name: str, arguments: dict):
try:
if name == "search":
query = arguments.get("query", "").strip()
if not query:
return [TextContent(
type="text",
text="Error: 'query' parameter is required and cannot be empty. Provide a search term."
)]
if len(query) > 500:
return [TextContent(
type="text",
text=f"Error: query is {len(query)} chars (max 500). Shorten your search term."
)]
results = await perform_search(query)
if not results:
return [TextContent(
type="text",
text=f"No results for '{query}'. Try broader keywords or check spelling."
)]
return [TextContent(type="text", text=json.dumps(results))]
except Exception as e:
return [TextContent(
type="text",
text=f"Error in {name}: {type(e).__name__}: {str(e)}"
)]
Three rules for MCP error messages: say what went wrong, say why, and suggest what to do differently. The model will use all three.
Step 6: Testing MCP Servers
You cannot manually test MCP servers effectively. The input space is too large — AI models will call your tools in ways you never imagined.
We use a three-layer testing strategy:
Unit Tests: Test Tool Logic Directly
import pytest
from tools.search import perform_search
@pytest.mark.asyncio
async def test_search_returns_results():
results = await perform_search("authentication")
assert len(results) > 0
assert all("score" in r for r in results)
@pytest.mark.asyncio
async def test_search_empty_query():
with pytest.raises(ValueError, match="empty"):
await perform_search("")
Integration Tests: Test the MCP Protocol
from mcp.client import ClientSession
from mcp.client.stdio import stdio_client
@pytest.mark.asyncio
async def test_tool_list():
async with stdio_client(["python", "server.py"]) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [t.name for t in tools.tools]
assert "search" in names
@pytest.mark.asyncio
async def test_search_via_protocol():
async with stdio_client(["python", "server.py"]) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("search", {"query": "test"})
assert result.content[0].text
Adversarial Tests: Test What AI Will Actually Send
@pytest.mark.asyncio
async def test_path_traversal_blocked():
result = await call_tool("read_file", {"path": "../../etc/passwd"})
assert "outside allowed directory" in result[0].text
@pytest.mark.asyncio
async def test_massive_input_handled():
result = await call_tool("search", {"query": "a" * 10000})
assert "max" in result[0].text.lower()
@pytest.mark.asyncio
async def test_wrong_type_handled():
result = await call_tool("search", {"query": 12345})
# Should coerce or error gracefully, not crash
assert result[0].text
Run adversarial tests on every build. If you skip them, the first user who connects your server to a production agent will find the bugs for you — the hard way.
Step 7: Add Resources and Prompts
Tools are the headline feature, but MCP servers can also expose resources (read-only data the model can pull) and prompts (reusable prompt templates).
Resources are perfect for giving agents context without tool calls:
@server.list_resources()
async def list_resources():
return [
Resource(
uri="config://server-status",
name="Server Status",
description="Current server health and configuration",
mimeType="application/json"
)
]
@server.read_resource()
async def read_resource(uri: str):
if uri == "config://server-status":
status = get_server_health()
return json.dumps(status)
Prompts let you ship reusable workflows:
@server.list_prompts()
async def list_prompts():
return [
Prompt(
name="code-review",
description="Review code for security and performance issues",
arguments=[
PromptArgument(
name="file_path",
description="Path to the file to review",
required=True
)
]
)
]
Use resources for data that changes slowly. Use tools for actions and queries. Use prompts for multi-step workflows the user triggers explicitly.
Step 8: Configuration and Registration
To use your server with Claude Code, register it in your project's .mcp.json:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"MY_API_KEY": "your-key-here"
}
}
}
}
For HTTP transport (remote servers), the configuration changes to a URL endpoint. But start with stdio — it's simpler, faster, and avoids network auth complexity.
Pro tips for configuration:
- Use absolute paths. Relative paths break when the working directory changes.
- Pass secrets via env vars. Never hardcode API keys in server code.
- Set timeouts. A hung tool call blocks the entire agent. Default timeout of 60 seconds is reasonable for most tools.
Step 9: Performance Patterns
MCP tools run in the critical path of AI reasoning. A slow tool doesn't just waste time — it degrades the quality of the agent's work by fragmenting its context.
Patterns that matter:
Need Help Building?
Building MCP servers for your team or product? Our consulting services handle the full lifecycle — from protocol design to production deployment.
- Cache aggressively. If a tool reads from a database or API, cache results with a TTL. The model often calls the same tool multiple times with similar queries.
- Return minimal data. The model has a finite context window. Returning a 50KB JSON blob when 2KB would suffice wastes capacity the model needs for reasoning.
- Use async everywhere. MCP servers are async by design. Blocking calls (synchronous file reads, network requests without async) stall the entire server.
- Paginate large results. Return the first N results with a count of total matches. Let the model request more if needed.
# Bad: returns everything
@server.call_tool()
async def call_tool(name, args):
if name == "list_files":
return all_10000_files()
# Good: paginated with count
@server.call_tool()
async def call_tool(name, args):
if name == "list_files":
limit = min(args.get("limit", 50), 200)
offset = args.get("offset", 0)
files = get_files(limit=limit, offset=offset)
total = get_file_count()
return [{
"files": files,
"total": total,
"showing": f"{offset+1}-{offset+len(files)}"
}]
Step 10: Deploy and Distribute
Local servers are fine for personal use. But if you want others to use your server, you need packaging.
Python: Publish to PyPI
# pyproject.toml
[project]
name = "my-mcp-server"
version = "1.0.0"
[project.scripts]
my-mcp-server = "my_mcp_server.server:main"
Users install with pip install my-mcp-server and register the command name in their MCP config.
TypeScript: Publish to npm
// package.json
{
"name": "my-mcp-server",
"bin": { "my-mcp-server": "./dist/server.js" }
}
Users install with npx my-mcp-server or npm install -g my-mcp-server.
Docker (Advanced)
For servers with heavy dependencies or system requirements, ship a Docker image. The MCP client connects via stdio to the container process.
Whichever distribution method you choose, include a server.json manifest. This emerging convention helps registries and tooling discover your server's capabilities automatically.
Lessons from Four Production Servers
After shipping MCP Shield (security scanning), Claude Brain (context persistence), Orchard HIG (design auditing), and Orchard Sign (code signing diagnostics), here's what we know:
- Fewer tools, better descriptions. Our first server had 15 tools. We cut it to 4. Usage quality doubled. Models struggle with large tool catalogs — they get choice paralysis just like humans.
- Test with real agents, not mocks. Unit tests catch bugs. Agent testing catches design flaws. The model will use your tools in combinations you never considered.
- Version your tool schemas. When you change a tool's parameters, existing clients break. Treat tool schemas like API contracts.
- Log everything. Every tool call, every parameter, every response time. You need this data to understand how models actually use your tools — and where they struggle.
- Security is not optional. An MCP server runs with your permissions. One path traversal bug and an agent can read any file on your system. Audit ruthlessly. We built an entire tool for this.
What's Next for MCP
The protocol is evolving fast. Streamable HTTP transport is replacing SSE. Authentication standards are solidifying. Registries are emerging so models can discover servers dynamically.
The builders who invest in MCP tooling now are building the infrastructure layer of the agent-first future. Every custom server you ship is a competitive moat — institutional knowledge encoded as tool definitions that make AI work better for your specific domain.
Start with one server. One tool. Get it working in Claude Code. Then iterate.
The best MCP servers aren't the most complex. They're the ones that solve a real problem with clean tool definitions, solid validation, and thoughtful error messages. Ship that, and both humans and AI will thank you.
For building MCP servers that expose knowledge bases and vector search, see our RAG tutorial. Once your server is built, see how to add MCP servers to Claude Code to get it running in Claude Code.