Build a Minimal MCP Search Server from Scratch
Build an MCP search server that wraps a search API. Learn the protocol, tool registration, and when to use hosted instead.
MCP (Model Context Protocol) is how AI applications like Claude Desktop, Cursor, and Windsurf connect to external tools. If you want your LLM to search the web, you need an MCP server that exposes a search tool. This tutorial builds one from scratch in Python so you understand exactly what is happening. For production, use a hosted MCP server instead of building your own.
What MCP actually is
MCP is a JSON-RPC protocol over stdin/stdout. The client (Claude Desktop) starts your server as a subprocess, sends JSON-RPC messages to stdin, and reads responses from stdout. The server declares which tools it offers, and the client calls them when the LLM decides it needs a tool. That is the entire protocol.
Be honest: build vs buy
For most use cases, you should use a pre-built MCP server package. Building from scratch only makes sense if you need custom tool logic, want to learn the protocol, or have specific security requirements. A hosted MCP server handles tool registration, error handling, and protocol compliance. Building from scratch means you own all of that complexity. This tutorial is for learning, not for production deployment.
Step 1: The MCP protocol skeleton
An MCP server needs to handle three message types: initialize, tools/list, and tools/call. Here is the minimal skeleton.
import sys, json
def read_message() -> dict:
"""Read a JSON-RPC message from stdin."""
header = ""
while True:
line = sys.stdin.readline()
if line.strip() == "":
break
header += line
length = int(header.split("Content-Length: ")[1].strip())
body = sys.stdin.read(length)
return json.loads(body)
def write_message(msg: dict):
"""Write a JSON-RPC response to stdout."""
body = json.dumps(msg)
header = f"Content-Length: {len(body)}\r\n\r\n"
sys.stdout.write(header + body)
sys.stdout.flush()Step 2: Tool registration
When the client sends a tools/list request, your server returns the list of tools it offers. Each tool has a name, description, and input schema.
TOOLS = [
{
"name": "web_search",
"description": "Search the web using Google, Reddit, YouTube, or Amazon. Returns structured results with titles, URLs, and snippets.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"platform": {
"type": "string",
"enum": ["google", "reddit", "youtube", "amazon", "google_news"],
"default": "google",
"description": "Which platform to search"
}
},
"required": ["query"]
}
}
]
def handle_tools_list() -> dict:
return {"tools": TOOLS}Step 3: Search implementation
When the client calls your tool, execute the search and return results. This is where you wire in the actual search API.
import requests, os
H = {'x-api-key': os.environ.get('SCAVIO_API_KEY', '')}
URL = 'https://api.scavio.dev/api/v1/search'
def handle_tool_call(name: str, arguments: dict) -> dict:
if name != 'web_search':
return {"error": f"Unknown tool: {name}"}
query = arguments.get('query', '')
platform = arguments.get('platform', 'google')
try:
resp = requests.post(URL, headers=H,
json={'platform': platform, 'query': query}, timeout=15)
resp.raise_for_status()
data = resp.json()
results = data.get('organic_results', [])[:5]
formatted = []
for r in results:
formatted.append(
f"Title: {r.get('title', '')}\n"
f"URL: {r.get('link', '')}\n"
f"Snippet: {r.get('snippet', '')}\n"
)
return {"content": [{"type": "text", "text": '\n'.join(formatted)}]}
except Exception as e:
return {"content": [{"type": "text", "text": f"Search failed: {str(e)}"}],
"isError": True}Step 4: The main loop
Tie it all together with a message loop that routes requests to the appropriate handler.
def main():
while True:
try:
msg = read_message()
method = msg.get('method', '')
msg_id = msg.get('id')
if method == 'initialize':
result = {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "minimal-search", "version": "0.1.0"}
}
elif method == 'tools/list':
result = handle_tools_list()
elif method == 'tools/call':
params = msg.get('params', {})
result = handle_tool_call(params.get('name'), params.get('arguments', {}))
else:
result = {}
write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
except Exception:
break
if __name__ == '__main__':
main()Using your custom server
Point Claude Desktop at your server in the MCP config. Save the Python file as search_mcp_server.py and reference it in your config.
{
"mcpServers": {
"custom-search": {
"command": "python3",
"args": ["/path/to/search_mcp_server.py"],
"env": {
"SCAVIO_API_KEY": "your-api-key-here"
}
}
}
}Why you should probably not use this in production
This minimal server skips a lot: no input validation, no rate limiting, no connection management, no proper error recovery, no logging. A hosted MCP server handles all of that. Use this tutorial to understand the protocol. Use a real MCP server package for anything you ship. The learning value is in understanding what happens between your LLM client and the search API. The production value is in letting someone else maintain the protocol plumbing.
Cost
The MCP server itself is free (it is your code). The search API costs $0.005 per search, with 500 free searches per month. For heavier usage, $30/mo gets 7,000 searches. The Python dependencies are just the requests library.