← Back to Blog

Build an MCP Server from Scratch (2026)

Step-by-step guide to building MCP servers for Claude. Protocol design, tool implementation, security, and deployment.


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:

  1. Initialize. Client connects, sends capabilities. Server responds with its capabilities, tool list, and resource list.
  2. Discover. Client calls tools/list to get available tools with their JSON Schema parameter definitions.
  3. Invoke. Client calls tools/call with a tool name and arguments. Server validates, executes, returns results.
  4. 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:

  1. Lead with when to use it. "Search the codebase for files matching a glob pattern" beats "File search tool."
  2. Specify what it returns. "Returns a list of file paths sorted by modification time" tells the model what to expect.
  3. Include constraints. "Maximum 100 results. Use specific patterns for large codebases" prevents misuse.
  4. Describe each parameter. Don't just name them. Explain format, defaults, and edge cases.
  5. Differentiate from similar tools. If you have both search_files and search_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:

  1. 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.
  2. 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.
  3. Version your tool schemas. When you change a tool's parameters, existing clients break. Treat tool schemas like API contracts.
  4. 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.
  5. 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.


Free: Claude custom instructions template pack

Eight copy-paste templates — developer, writer, analyst, CLAUDE.md starter, and more. Plus new guides in your inbox. No spam, unsubscribe anytime.

Or grab the templates directly — no email needed

Keep learning — for free

50+ AI courses. 590+ lessons. No paywall for starters.

Need help building this?

We build MCP servers, Claude workflows, and AI agents for teams. Strategy calls start at $150/hr.