Build a Runnable MCP Loop in Python (stdio streamable-http LLM tool choice)
Build a Runnable MCP Loop in Python (stdio → streamable-http → LLM tool choice)
Overview
This guide walks through building a complete MCP (Model Context Protocol) loop in Python that transitions from local stdio-based tool discovery to remote HTTP-based invocation, with an LLM orchestrating the tool selection. The pattern bridges "SDK demo" and "service-owned tool sessions," providing a reliable discover → bind → call → feedback loop before choosing the underlying LLM.
Environment Setup
The author used Python 3.13.5 (3.11+ is acceptable). For dependency installation, prefer either uv or pip:
# uv
uv add mcp fastmcp
# pip
python -m pip install mcp fastmcp
Two versions of the FastMCP library exist: the official shipment includes FastMCP v1, while the community version has moved to v2. Both are viable during learning phases.
Write type hints, return types, and docstrings carefully. These become the model-facing tool descriptions later.
Step 1 - Minimal FastMCP Server (stdio)
Concept: Prompts, resources, and tools on one server using transport="stdio".
Illustrative code structure:
from mcp import FastMCP
mcp = FastMCP("custom", host="localhost", port=8001)
if __name__ == "__main__":
mcp.run(transport="stdio")
Key components defined in the server:
greet_user(name: str, style: str = "formal") -> str- Greets a user with a specified style.def greet_user(name: str, style: str = "formal") -> str: """Greet a user with a specified style.""" if style == "friendly": return f"Hey {name}! What's up?" return f"Hello, {name}!"greeting_resource(name: str) -> str- A simple greeting resource returning"Hello, {name}".get_config() -> str- Returns static configuration data ("App configuration here").add(a: int, b: int) -> int- Adds two numbers.get_date() -> str- Returns today's date formatted as%Y-%m-%d.get_weather(city: str) -> str- Returns a fixed response like"It's always sunny in {city}!".
Step 2 - Stdio Client with ClientSession
The client launches the server as a subprocess via StdioServerParameters, which specifies the absolute interpreter, script path, and working directory.
import asyncio
from pathlib import Path
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
server_params = StdioServerParameters(
command=str(Path(__file__).parent / ".venv" / "bin" / "python"),
args=[str(Path(__file__).parent / "demo1-server.py")],
cwd=str(Path(__file__).parent),
)
async def run():
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
prompts = await session.list_prompts()
print([p.name for p in prompts.prompts])
tools = await session.list_tools()
print([t.name for t in tools.tools])
resource_content = await session.read_resource(AnyUrl("greeting://World"))
block = resource_content.contents[0]
if isinstance(block, types.TextResourceContents):
print(block.text)
result = await session.call_tool("add", arguments={"a": 5, "b": 3})
print(result.content[0].text if result.content else result)
print(result.structuredContent)
Key steps:
- Initialize the client with
stdio_client(server_params). - Within the context manager, open a
ClientSession. - Call
session.initialize()to ready the session. - List available prompts and tools using
list_prompts()andlist_tools(). - Read a resource via
read_resource()and extract its text content. - Invoke a tool using
call_tool("add", arguments={...}).
Step 3 - Same Server over Streamable-HTTP
The server remains unchanged but runs on port 8001 instead of 8000. To communicate with it, swap the transport layer:
mcp = FastMCP("custom", host="localhost", port=8001)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Conceptually, replace the stdio client with a streamable HTTP client:
from streamablehttp_client import streamablehttp_client
client = streamablehttp_client("http://localhost:8001/mcp")
Then proceed with the same ClientSession.initialize(), list_prompts(), list_tools(), and call_tool() workflow. The key distinction is that the server now exposes itself over HTTP rather than being accessed via stdin/stdout.
Step 4 - Let the LLM Choose Tools
The server logic stays identical regardless of transport. The client connects (via HTTP in this case) and follows this flow:
- Connect to the tool server.
- Call
list_tools()to retrieve available tools. - Map the LLM's output to tool names and construct arguments accordingly.
- Invoke the selected tool via
call_tool(name, arguments=...). - Feed results back to the model until no further tool calls are requested.
Example interaction outcomes from the original session:
- "What is today's date?" →
get_date - "Weather in Hefei?" →
get_weatherwith{city: "合肥"} - Numeric comparison → custom comparison tool
Logging Pitfalls
When integrating an LLM, avoid mixing debug prints into the stdio server's stdout. Doing so breaks JSON-RPC communication. Keep protocol traffic confined to the MCP pipes and direct human logs elsewhere. This ensures the tool session remains stable even under verbose logging conditions.
Why This Matters for Agents / MCP / RAG Products
Shipping an LLM feature involves more than a single chat completion-it requires a reliable tool session. The core principles are:
- Spawn or connect to servers (local stdio vs. remote HTTP).
- Refresh schemas dynamically.
- Bind the agent loop around these capabilities.
- Keep transports swappable so adding new tools doesn't require rewriting the host.
Once this loop is solidified, every new tool becomes a schema change rather than a full host rewrite.
Compilation
This compilation was performed by YongBo Yu (English translation). See the original Chinese source at [MCP][02]快速入门MCP开发](https://github.com/Cnblogs/MCP-tutorial) for the original tutorial.
Comments
No comments yet. Start the discussion.