Building a Custom MCP Server
When no existing MCP server covers your integration — an internal API, a proprietary database, a custom workflow — you build your own. The MCP SDK handles the protocol plumbing; you write the tool definitions and handler functions.
SDK Setup
Anthropic provides first-party SDKs for Python and TypeScript. For a Python server:
pip install mcpFor a TypeScript server:
npm install @modelcontextprotocol/sdkDefining a Tool: Python Example
A complete minimal MCP server in Python that exposes one tool:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
# Create the server instance
server = Server("my-custom-server")
# Register a tool
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="get_customer",
description="Look up a customer record by customer ID. Returns name, email, and account status.",
inputSchema={
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "The customer ID, e.g. 'CUST-12345'"
}
},
"required": ["customer_id"]
}
)
]
# Handle tool calls
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "get_customer":
customer_id = arguments["customer_id"]
# Your actual implementation here
customer = fetch_customer_from_db(customer_id)
if not customer:
return [types.TextContent(
type="text",
text=f"Error: Customer '{customer_id}' not found"
)]
return [types.TextContent(
type="text",
text=f"Name: {customer['name']}\nEmail: {customer['email']}\nStatus: {customer['status']}"
)]
raise ValueError(f"Unknown tool: {name}")
# Run the server using stdio transport
if __name__ == "__main__":
import asyncio
asyncio.run(stdio_server(server))Tool Definition Details
The Tool object has three required fields — the same as function calling in the Claude API:
- name: Unique identifier for the tool. Use snake_case. Claude uses this to call the tool.
- description: What the tool does, what inputs it accepts, and what it returns. This is what Claude reads to decide when to call the tool — write it clearly.
- inputSchema: JSON Schema defining the expected inputs. Marks required fields. Claude uses this to construct valid tool calls.
Exposing Resources
Resources are read-only data sources (documents, config, reference data) that Claude can access without calling a tool:
@server.list_resources()
async def list_resources() -> list[types.Resource]:
return [
types.Resource(
uri="config://app-settings",
name="Application Settings",
description="Current application configuration",
mimeType="application/json"
)
]
@server.read_resource()
async def read_resource(uri: str) -> str:
if uri == "config://app-settings":
return json.dumps(load_app_settings())
raise ValueError(f"Unknown resource: {uri}")Running and Testing
Test your server with Claude Code before deploying:
- Run the server manually to check for startup errors:
python my_server.py - Add it to
.claude/settings.jsonwith the correct command and args - Start a Claude Code session and ask "what tools do you have?"
- Test the tool with a natural language request: "Look up customer CUST-12345"
// .claude/settings.json
{
"mcpServers": {
"my-custom-server": {
"type": "stdio",
"command": "python",
"args": ["/path/to/my_server.py"]
}
}
}Error Handling
Return descriptive error messages — Claude reads them and adapts its next action:
- Return a text result with a clear error message rather than raising an unhandled exception
- Include what went wrong and, if possible, what the caller should try instead
- Unhandled exceptions cause the tool call to fail silently — the server logs the error but Claude receives no useful information
Checklist: Do You Understand This?
- SDK:
mcp(Python) or@modelcontextprotocol/sdk(TypeScript) handles protocol plumbing - Tool definition: name + description (most important for Claude) + inputSchema (JSON Schema)
- Handler:
@server.list_tools()returns tool definitions;@server.call_tool()executes them - Resources: optional read-only data sources; use for reference data Claude should be able to access
- Test by adding to
settings.jsonand asking Claude what tools it has — error messages are returned as text results, not exceptions