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
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.
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.
| Criteria | Layer | What it is | Analogy |
|---|---|---|---|
| Row | MCP server | The protocol host; exposes the tool catalog; routes calls | Kitchen doorway |
| Row | Tool | The named capability exposed to the agent | A utensil in the kitchen |
| Row | Tool handler | The function bound to that tool; orchestrates the call | Using the frying pan for a specific task |
| Row | Adapter | Translates handler intent into the underlying system call | The recipe/procedure for using the equipment |
| Row | Operation | The real side effect in the target system | The pan actually heating up |
The request flow, in order:
client request → MCP server → tool handler → adapter → real operationThe 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.
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.
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 callsget_repo_status, this function runs. It extracts the argument, makes the underlying API call (the adapter step), and returns a structured response.- The
inputSchemais a JSON Schema object. Therequiredarray 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.
@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:
npx @modelcontextprotocol/inspector python server.pyInspector launches a browser interface. From there:
- The Tools tab shows your registered tools and their schemas
- Select
get_repo_status, entermodelcontextprotocol/python-sdkas the repository, and click Run - Inspector sends the
tools/callrequest 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:
{
"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.
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.
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.

