Build a Runnable MCP Loop in Python (stdio streamable-http LLM tool choice) A developer has published a Python walkthrough for building a runnable MCP client-host loop, covering stdio transport, streamable HTTP, and letting an LLM choose which tool to invoke. The tutorial, adapted from a Chinese-language Cnblogs post by 花酒锄作田, demonstrates listing prompts, resources, and tools via ClientSession, calling them over stdio, and warns against exposing arbitrary remote SSH command execution to a model without allowlists, authentication, and human confirmation. Attributed Chinese → English compile Source: \ MCP\ \ 02\ 快速入门MCP开发 https://www.cnblogs.com/XY-Heruo/p/19092074 Original author: 花酒锄作田 Cnblogs · Posted: 2025-09-15 This is an English rewrite of the original tutorial’s ideas and code patterns. It is not original work by the compiler. Always link the Chinese source; do not present this compile as the original. Many MCP write-ups only show how to register a Server and paste it into Cursor. The Cnblogs post by 花酒锄作田 is useful for product engineers because it also builds the Client / Host side : list prompts, resources, and tools; call them over stdio; switch to streamable HTTP; then let an LLM decide which tool to invoke. If you are shipping agents into a backend, that Client loop is the missing middle between “SDK demo” and “our service owns the tool session.” You need a reliable discover → bind → call → feed-back loop before you care which model sits on top. The author used Python 3.13.5 3.11+ is fine . Prefer uv or pip: uv uv add mcp fastmcp or pip python -m pip install mcp fastmcp Notes from the source: mcp package ships FastMCP v1; community FastMCP has moved to v2—trying both while learning is fine. Concept: prompts, resources, and tools on one server with transport="stdio" . Illustrative shape adapted fr@mcp.prompt mailto:fr@mcp.prompt 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} " @mcp https://dev.to/mcp .resource "greeting://{name}" def greeting resource name: str - str: """A simple greeting resource.""" return f"Hello, {name} " @mcp https://dev.to/mcp .resource "config://app" def get config - str: """Static configuration data""" return "App configuration here" @mcp https://dev.to/mcp .tool def add a: int, b: int - int: """Add two numbers""" return a + b @mcp https://dev.to/mcp .tool async def get date - str: """Get today's date.""" return datetime.now .strftime "%Y-%m-%d" @mcp https://dev.to/mcp .tool async def get weather city: str - str: """Get weather for a given city.""" return f"It's always sunny in {city} " if name == " main ": mcp.run transport="stdio" Preflight: run the server script alone once. If imports fail, the Client will fail in a confusing way when it tries to spawn the child process. Production caution — SSH / god-mode shell: the original also demonstrates a remote SSH tool. Treat that as high-risk . Do not expose arbitrary remote command execution to a model without allowlists, authentication, and human confirmation. Prefer narrow, typed tools over “run anything on this host.” Step 2 — Stdio Client with ClientSession The Client launches the Server as a subprocess via StdioServerParameters absolute interpreter, script path, and cwd . Pattern from the source: 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 if name == " main ": asyncio.run run Server change: mcp = FastMCP "custom", host="localhost", port=8001 if name == " main ": mcp.run transport="streamable-http" Client change conceptually : use streamablehttp client "http://localhost:8001/mcp" , then the same ClientSession.initialize / list / call tool flow. The source notes a third return value, get session id , for session management—usually unused in hello-worlds. This is the fork most product backends care about: stdio for desktop or host-local tools , HTTP for remotely deployed tool servers . Docs and ecosystem starting points: Server stays the same. Client: list tools and mapThe original uses an OpenAI-compatible client pointed at qwen-plus , compatible-mode/v1 . Any OpenAI-tools-compatible endpoint works the same way DeepSeek, OpenAI, and similar . Config sketch from the source’s supplementary modules: { "llm": { "model": "qwen-plus", "base url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api key": "your token" }, "server": { "host": "127.0.0.1", "port": 8000 } } Example interactive outcomes from the original session: get date get weather with {city: "合肥"} That is the whole product loop in miniature: discover → bind schemas → model proposes → your code executes → feed results back . The author’s sample logger can write to a file; if you stream-log, keep protocol traffic on the MCP pipes and human logs elsewhere. Mixing debug prints into a stdio Server’s stdout will break JSON-RPC—the same lesson every serious MCP guide repeats. Shipping an LLM feature is less about a single chat completion and more about a reliable tool session : spawn or connect to servers, refresh schemas, bound the agent loop, and keep transports swappable local stdio versus remote HTTP . The same Client you use for MCP tools is where you later hang RAG retrieval as a resource or tool—without rewriting the host when you add the next capability. Get this loop solid once, and every new tool becomes a schema change instead of a host rewrite. English compile by YongBo Yu. https://yongbo-yu.vercel.app https://yongbo-yu.vercel.app · https://github.com/YongBoYu1 https://github.com/YongBoYu1 Original Chinese article © 花酒锄作田 / Cnblogs. Always link the source; do not present this compile as the original. each tool to an OpenAI-compatible function schema name , description , parameters from inputSchema . tools=... . session.call tool name, args , append assistant and tool messages, call the model again. Expected behavior as reported in the original run : prompts listed, resource text returned, add yields 8 plus structured content. Failure mode to remember: starting the Client also starts the Server. Server syntax or import errors look like Client connection failures—debug the Server first. python import asyncio from pathlib import Path from pydantic import AnyUrl from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio client om the original . Trim any SSH / remote-shell tools before you run this locally unless you harden them first: python from datetime import datetime from mcp.server.fastmcp import FastMCP mcp = FastMCP "custom"