Lesson 7 - Step 5 of 5

Complete STDIO Server

Build a fully functional MCP server using STDIO transport.

Building on Previous Lessons

In this step, we'll create stdio_transport.py — a reusable STDIO transport. Then we'll create an example that uses all our previous modules.

Building on Lessons 3-6

This lesson imports from jsonrpc.py, mcp_core.py, tools.py, and resources.py. Make sure all previous modules are in your project directory.

Your Project Structure

text
your_project/
    jsonrpc.py          # Lesson 3
    mcp_core.py         # Lesson 4
    tools.py            # Lesson 5
    resources.py        # Lesson 6
    stdio_transport.py  # NEW - We'll create this
    stdio_example.py    # NEW - Example using transport
How to Read Code Blocks

In this lesson:

  • Filename in header = Write this code to that file
  • "Add after Section X" = Append to your existing file
  • bash blocks = Commands to run in your terminal
  • "Expected Output" = What you should see (don't write this)

Section 1: Create the STDIO Transport

Create: stdio_transport.py

Create a new file with the STDIO transport:

python stdio_transport.py
"""
stdio_transport.py - STDIO transport for MCP servers.

This module builds on mcp_core.py from Lesson 4.
"""

import sys
import json
import logging

# Import from Lesson 4
from mcp_core import handle_request


# =============================================================================
# Section 1: Logging Setup (stderr only!)
# =============================================================================

logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[logging.StreamHandler(sys.stderr)]  # CRITICAL: stderr only!
)
logger = logging.getLogger("mcp-stdio")

Try It

bash
python -c "from stdio_transport import logger; logger.info('Test')"

You should see a log message to stderr.

Section 2: STDIO Transport Class

Edit: stdio_transport.py

Add after Section 1:

python stdio_transport.py - Add after Section 1
# =============================================================================
# Section 2: STDIO Transport
# =============================================================================

def write_response(response: dict) -> None:
    """Write a JSON-RPC response to stdout."""
    # Use compact JSON - no pretty printing for STDIO!
    json_str = json.dumps(response, separators=(',', ':'))
    sys.stdout.write(json_str + "\n")
    sys.stdout.flush()  # CRITICAL: Always flush!
    logger.debug(f"Sent response for id={response.get('id')}")


def run_stdio_server() -> None:
    """
    Run MCP server using STDIO transport.

    Reads JSON-RPC messages from stdin (one per line).
    Writes responses to stdout.
    Logs to stderr.
    """
    logger.info("STDIO MCP Server starting...")

    while True:
        try:
            # Read one line from stdin
            line = sys.stdin.readline()

            # EOF - client disconnected
            if not line:
                logger.info("EOF received, shutting down")
                break

            # Skip empty lines
            line = line.strip()
            if not line:
                continue

            # Parse JSON
            try:
                request = json.loads(line)
            except json.JSONDecodeError as e:
                logger.error(f"JSON parse error: {e}")
                continue

            # Use shared handler from mcp_core
            response = handle_request(request)

            # Send response (None for notifications)
            if response is not None:
                write_response(response)

        except KeyboardInterrupt:
            logger.info("Interrupted, shutting down")
            break
        except Exception as e:
            logger.error(f"Unexpected error: {e}", exc_info=True)

    logger.info("Server shutdown complete")

Add a test block at the end:

python stdio_transport.py - Add at the end
# =============================================================================
# Run if executed directly
# =============================================================================

if __name__ == "__main__":
    run_stdio_server()

Section 3: Example with Tools

Create: stdio_example.py

Create a server that uses STDIO transport with tools:

python stdio_example.py
"""
stdio_example.py - STDIO server using tools.py

Run: python stdio_example.py
Test: echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python stdio_example.py
"""

from datetime import datetime

# Import from our modules
from mcp_core import router
from tools import ToolRegistry, register_tool_handlers, text_result
from stdio_transport import run_stdio_server

# =============================================================================
# Register tools
# =============================================================================

tool_registry = ToolRegistry()


@tool_registry.register(
    name="echo",
    description="Echo back a message",
    input_schema={
        "type": "object",
        "properties": {"message": {"type": "string", "description": "Message to echo"}},
        "required": ["message"]
    }
)
def echo(args: dict) -> dict:
    return text_result(args.get("message", ""))


@tool_registry.register(
    name="get_time",
    description="Get current server time",
    input_schema={"type": "object", "properties": {}, "required": []}
)
def get_time(args: dict) -> dict:
    return text_result(f"Current time: {datetime.now().isoformat()}")


@tool_registry.register(
    name="add_numbers",
    description="Add two numbers",
    input_schema={
        "type": "object",
        "properties": {
            "a": {"type": "number", "description": "First number"},
            "b": {"type": "number", "description": "Second number"}
        },
        "required": ["a", "b"]
    }
)
def add_numbers(args: dict) -> dict:
    a = args.get("a", 0)
    b = args.get("b", 0)
    return text_result(f"{a} + {b} = {a + b}")


# Register with the MCP router
register_tool_handlers(router, tool_registry)


# =============================================================================
# Run the server
# =============================================================================

if __name__ == "__main__":
    run_stdio_server()

Section 4: Test Your Server

Test 1: Initialize Handshake

bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' | python stdio_example.py
text Expected Output (stdout)
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"mcp-server","version":"1.0.0"}}}

Test 2: List Tools

bash
(
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}'
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
) | python stdio_example.py 2>/dev/null

Expected: Two JSON responses - initialize result and tools list with echo, get_time, add_numbers.

Test 3: Call a Tool

bash
(
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}'
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"Hello, STDIO!"}}}'
) | python stdio_example.py 2>/dev/null

Expected: Response with "text": "Hello, STDIO!"

Claude Desktop Configuration

To use this server with Claude Desktop, add it to your configuration file:

json
{
    "mcpServers": {
        "stdio-demo": {
            "command": "python",
            "args": ["/absolute/path/to/stdio_server.py"]
        }
    }
}
Configuration File Location

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
Linux: ~/.config/Claude/claude_desktop_config.json

What You've Built

You've created a reusable STDIO transport:

File Purpose
stdio_transport.py Reusable STDIO transport using mcp_core.py
stdio_example.py Example server combining STDIO + tools

The key insight: the same MCP router handles requests regardless of transport!

  • mcp_core.py handles MCP protocol logic
  • tools.py registers tools
  • stdio_transport.py handles I/O over stdin/stdout
STDIO Rules
  • Never use print() (it goes to stdout!)
  • Always call flush() after writing
  • Never pretty-print JSON (breaks line-based protocol)
  • Always log to stderr only

What's Next?

In the next lesson, we'll explore HTTP/SSE transport, which enables remote MCP servers and web-based clients.