mcptutorialarchitecture

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.

7 min read

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.

Python
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.

Python
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.

Python
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.

Python
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.

JSON
{
  "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.