Most MCP tutorials fall into one of two failure modes. The first is too abstract: lots of protocol diagrams, no working code. The second is too incomplete: code that runs but stops before the "connect it to Claude" step, which is the part you actually need.

This article gives you a working MCP server in Python, under 100 lines, that exposes real tools and connects to Claude Code. By the end, you will have something you can test, extend, and ship.

We wrote this because we needed it. When building the agentic runtime for Scaletific's IDP, we had to expose the runtime's capabilities through an MCP layer so that AI coding agents could interact with it the same way they interact with any other tool. That meant understanding the full five-layer model (server, tool, handler, adapter, operation) and getting a working server connected to Claude Code. The code below is the pattern we converged on. If you are using a coding agent, you can have a working MCP server within a single session.

What You Will Build

A custom MCP server that exposes a tool for querying the GitHub API. The tool is minimal by design. Once you understand the pattern, adding more tools is repetitive work, not new concepts.

The server will:

  • Expose one tool to a connected client
  • Accept structured input, validated against a schema
  • Return a structured response
  • Run locally and connect to Claude Code via stdio
Concept diagram: the five-layer MCP server model from server to tool handler, adapter, and operation.

Prerequisites

Before writing any code, confirm you have these in place:

  • Python 3.10 or later
  • The MCP Python SDK: pip install mcp
  • Claude Code (or MCP Inspector for standalone testing)

The official MCP Python SDK is at modelcontextprotocol.io/docs/sdk. MCP Inspector, the official dev tool for testing servers during development, is at github.com/modelcontextprotocol/inspector.

Evidence chart: MCP Inspector browser UI showing a successful tool call and response.

The Mental Model You Need First

Most tutorials skip straight to code. That creates brittle builders who can copy an example but struggle to extend it. Before any code: the five-layer model.

Every MCP server has the same internal structure. Understanding the layers tells you where to put code and what each part is responsible for.

CriteriaLayerWhat it isAnalogy
RowMCP serverThe protocol host; exposes the tool catalog; routes callsKitchen doorway
RowToolThe named capability exposed to the agentA utensil in the kitchen
RowTool handlerThe function bound to that tool; orchestrates the callUsing the frying pan for a specific task
RowAdapterTranslates handler intent into the underlying system callThe recipe/procedure for using the equipment
RowOperationThe real side effect in the target systemThe pan actually heating up

The request flow, in order:

request-flow.txt
text
client request → MCP server → tool handler → adapter → real operation

The most common misconception: the adapter does not invoke the tool. The tool handler invokes the adapter. That ordering matters when you are deciding where to put business logic.

One sentence to anchor this: The MCP server exposes tools. Each tool is backed by a handler. That handler uses adapter logic to translate the tool request into a real underlying operation, which is the point where the request becomes tangible.

What this means for builders: Once you see the layers, you know where to put code. Business logic belongs in the handler, not the server registration. Transport logic belongs in the server layer, not the handler. Each layer owns one responsibility.

The Minimal Server Skeleton

This is the smallest possible working MCP server. It does nothing useful yet. That comes next.

server.py
python
from mcp.server import Server
from mcp.server.stdio import stdio_server
import asyncio

app = Server("my-first-server")

async def main():
    async with stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            app.create_initialization_options()
        )

if __name__ == "__main__":
    asyncio.run(main())

What this code does:

  • Creates an MCP server named my-first-server
  • Opens a stdio transport, which is the standard local transport
  • Runs the server and blocks, waiting for client connections

At this point, the server starts, accepts a connection, completes the initialization handshake, and then reports zero available tools, because none have been registered. That is the correct baseline. Run it and confirm it starts without errors before adding anything.

Adding Your First Tool

Now add a tool. This tool queries a GitHub repository for its metadata. The pattern is the same regardless of what the underlying operation is.

server.py
python
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
import asyncio
import httpx

app = Server("my-first-server")

@app.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="get_repo_status",
            description="Get metadata and status for a GitHub repository.",
            inputSchema={
                "type": "object",
                "properties": {
                    "repository": {
                        "type": "string",
                        "description": "The repository in owner/repo format (e.g. modelcontextprotocol/python-sdk)."
                    }
                },
                "required": ["repository"]
            }
        )
    ]

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "get_repo_status":
        repository = arguments["repository"]
        # Adapter: translate handler intent into the underlying call
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"https://api.github.com/repos/{repository}",
                headers={"Accept": "application/vnd.github.v3+json"}
            )
            data = response.json()
        return [types.TextContent(
            type="text",
            text=f"Repo {repository}: {data.get('description', 'No description')} | Stars: {data.get('stargazers_count', 0)} | Open issues: {data.get('open_issues_count', 0)}"
        )]
    raise ValueError(f"Unknown tool: {name}")

async def main():
    async with stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            app.create_initialization_options()
        )

if __name__ == "__main__":
    asyncio.run(main())

What changed:

  • list_tools() registers the tool with its name, description, and input schema. This is what the client sees during tool discovery.
  • call_tool() is the tool handler. When the AI calls get_repo_status, this function runs. It extracts the argument, makes the underlying API call (the adapter step), and returns a structured response.
  • The inputSchema is a JSON Schema object. The required array tells the client which fields must be provided. The AI will not call the tool without them.

This code hits the GitHub API, which requires no authentication for public repositories. Run it and call the tool with modelcontextprotocol/python-sdk as the repository argument. You will get back real data. The structure stays the same for any API you wrap.

Adding a Resource (Optional)

Resources provide contextual data to the AI without requiring a tool call. Use them for static or slowly-changing information: configuration, lookup tables, reference data.

server.py
python
@app.list_resources()
async def list_resources() -> list[types.Resource]:
    return [
        types.Resource(
            uri="config://project-defaults",
            name="Project Defaults",
            description="Default configuration values for all projects.",
            mimeType="application/json"
        )
    ]

@app.read_resource()
async def read_resource(uri: str) -> str:
    if uri == "config://project-defaults":
        return '{"timeout_seconds": 30, "retry_attempts": 3}'
    raise ValueError(f"Unknown resource: {uri}")

Resources are read by the AI for context, not executed. They appear in resources/list alongside tools, but are accessed via resources/read rather than tools/call.

Testing with MCP Inspector

Before connecting to Claude Code, test the server with MCP Inspector. It provides a visual interface for sending tool calls and inspecting responses.

Install and run:

terminal
bash
npx @modelcontextprotocol/inspector python server.py

Inspector launches a browser interface. From there:

  1. The Tools tab shows your registered tools and their schemas
  2. Select get_repo_status, enter modelcontextprotocol/python-sdk as the repository, and click Run
  3. Inspector sends the tools/call request and displays the response

If the tool returns the expected response, the server is working. If it errors, Inspector shows the full error message and stack trace, which is much easier to debug than reading raw JSON-RPC.

Connecting to Claude Code

Add the server to Claude Code's MCP configuration. Open or create .claude/settings.json in your project root:

.claude/settings.json
json
{
  "mcpServers": {
    "my-first-server": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Replace the path with the absolute path to your server file. Restart Claude Code. Your tool will appear in the tool list available to the agent.

Synthesis visual: progression from local stdio server to multiple tools, Streamable HTTP, OAuth, and production deployment.

To verify it connected, ask Claude Code: "What tools do you have available?" It should list get_repo_status among them.

Where to Go From Here

The server above uses stdio transport, which is correct for local development. For production deployment or sharing a server across a team, you will need Streamable HTTP transport and OAuth authentication.

The path from here:

Add more tools. Each tool follows the same pattern: register it in list_tools(), handle it in call_tool(). The mental model stays the same regardless of how many tools you add.

Remote deployment. Switching from stdio to Streamable HTTP transport makes your server accessible over the network. The protocol is identical; only the transport layer changes.

OAuth. Remote servers require authentication. The MCP protocol supports OAuth 2.0 for remote server connections.

Official reference implementations. github.com/modelcontextprotocol/servers contains a set of reference servers in various languages. Reading the filesystem or fetch server implementations will give you a clear picture of production-grade patterns.

What Comes Next

Your server handles custom logic. What if it also needs to read the web? Firecrawl MCP is a companion server that turns any URL into clean, LLM-optimized markdown. Add it alongside your custom server and your agent has both your internal tools and live web intelligence in the same session.

What Makes Up MCP: Components, Architecture, and How It Works

Understand hosts, clients, servers, tools, resources, and transport before you add more complexity to your server.

To see how purpose-built servers compare to custom builds, and which servers are worth installing before you build anything custom, see Top 10 MCP Servers for Automation Builders in 2026.

Top 10 MCP Servers for Automation Builders in 2026

See which production-ready servers are worth installing before you build your own from scratch.