{"slug": "build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice", "title": "Build a Runnable MCP Loop in Python (stdio streamable-http LLM tool choice)", "summary": "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.", "body_md": "**Attributed Chinese → English compile**\n\n**Source:** [\\[MCP\\]\\[02\\]快速入门MCP开发](https://www.cnblogs.com/XY-Heruo/p/19092074)\n\n**Original author:** 花酒锄作田 (Cnblogs) · **Posted:** 2025-09-15\n\nThis 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.\n\nMany 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.\n\nIf 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.\n\nThe author used Python **3.13.5** (3.11+ is fine). Prefer `uv` or pip:\n\n```\n# uv\nuv add mcp fastmcp\n\n# or pip\npython -m pip install mcp fastmcp\n```\n\nNotes from the source:\n\n`mcp` package ships FastMCP v1; community FastMCP has moved to v2—trying both while learning is fine.\nConcept: prompts, resources, and tools on one server with `transport=\"stdio\"`.\n\nIllustrative shape (adapted [fr@mcp.prompt](mailto:fr@mcp.prompt)()def greet_user(name: str, style: str = \"formal\") -> str:\n\n    \"\"\"Greet a user with a specified style.\"\"\"\n\n    if style == \"friendly\":\n\n        return f\"Hey {name}! What's up?\"\n\n    return f\"Hello, {name}!\"\n\n[@mcp](https://dev.to/mcp).resource(\"greeting://{name}\")def greeting_resource(name: str) -> str:\n\n    \"\"\"A simple greeting resource.\"\"\"\n\n    return f\"Hello, {name}!\"\n\n[@mcp](https://dev.to/mcp).resource(\"config://app\")def get_config() -> str:\n\n    \"\"\"Static configuration data\"\"\"\n\n    return \"App configuration here\"\n\n[@mcp](https://dev.to/mcp).tool()def add(a: int, b: int) -> int:\n\n    \"\"\"Add two numbers\"\"\"\n\n    return a + b\n\n[@mcp](https://dev.to/mcp).tool()async def get_date() -> str:\n\n    \"\"\"Get today's date.\"\"\"\n\n    return datetime.now().strftime(\"%Y-%m-%d\")\n\n[@mcp](https://dev.to/mcp).tool()async def get_weather(city: str) -> str:\n\n    \"\"\"Get weather for a given city.\"\"\"\n\n    return f\"It's always sunny in {city}!\"\n\nif **name** == \"**main**\":\n\n    mcp.run(transport=\"stdio\")\n\n```\n**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.\n\n> **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.”\n\n## Step 2 — Stdio Client with `ClientSession`\n\nThe Client launches the Server as a subprocess via `StdioServerParameters` (absolute interpreter, script path, and cwd). Pattern from the source:\nserver_params = StdioServerParameters(\n    command=str(Path(__file__).parent / \".venv\" / \"bin\" / \"python\"),\n    args=[str(Path(__file__).parent / \"demo1-server.py\")],\n    cwd=str(Path(__file__).parent),\n)\n\nasync def run():\n    async with stdio_client(server_params) as (read, write):\n        async with ClientSession(read, write) as session:\n            await session.initialize()\n\n            prompts = await session.list_prompts()\n            print([p.name for p in prompts.prompts])\n\n            tools = await session.list_tools()\n            print([t.name for t in tools.tools])\n\n            resource_content = await session.read_resource(AnyUrl(\"greeting://World\"))\n            block = resource_content.contents[0]\n            if isinstance(block, types.TextResourceContents):\n                print(block.text)\n\n            result = await session.call_tool(\"add\", arguments={\"a\": 5, \"b\": 3})\n            print(result.content[0].text if result.content else result)\n            print(result.structuredContent)\n\nif __name__ == \"__main__\":\n    asyncio.run(run())\n```\n\nServer change:\n\n```\nmcp = FastMCP(\"custom\", host=\"localhost\", port=8001)\n\nif __name__ == \"__main__\":\n    mcp.run(transport=\"streamable-http\")\n```\n\nClient 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.\n\nThis is the fork most product backends care about: **stdio for desktop or host-local tools**, **HTTP for remotely deployed tool servers**.\n\nDocs and ecosystem starting points:\n\nServer stays the same. Client:\n\n`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).\nConfig sketch from the source’s supplementary modules:\n\n```\n{\n  \"llm\": {\n    \"model\": \"qwen-plus\",\n    \"base_url\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n    \"api_key\": \"your token\"\n  },\n  \"server\": {\n    \"host\": \"127.0.0.1\",\n    \"port\": 8000\n  }\n}\n```\n\nExample interactive outcomes from the original session:\n\n`get_date`\n`get_weather` with `{city: \"合肥\"}`\nThat is the whole product loop in miniature: **discover → bind schemas → model proposes → your code executes → feed results back**.\n\nThe 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.\n\nShipping 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.\n\nEnglish compile by YongBo Yu.\n\n[https://yongbo-yu.vercel.app](https://yongbo-yu.vercel.app) · [https://github.com/YongBoYu1](https://github.com/YongBoYu1)\n\nOriginal 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`).  \n\n`tools=...`.\n`session.call_tool(name, args)`, append assistant and tool messages, call the model again.\nExpected behavior (as reported in the original run): prompts listed, resource text returned, `add` yields `8` plus structured content.\n\n**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.\n\n``` python\nimport asyncio\nfrom pathlib import Path\nfrom pydantic import AnyUrl\n\nfrom mcp import ClientSession, StdioServerParameters, types\nfrom mcp.client.stdio import stdio_client\n\nom the original). **Trim any SSH / remote-shell tools** before you run this locally unless you harden them first:\n```\n\npython\n\nfrom datetime import datetime\n\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(\"custom\")", "url": "https://wpnews.pro/news/build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice", "canonical_source": "https://dev.to/yong_yu_f98e15562e9b120a0/build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice-2gdn", "published_at": "2026-09-16 23:21:46+00:00", "updated_at": "2026-09-16 23:53:13.572561+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "FastMCP", "Python", "ClientSession", "StdioServerParameters", "Cnblogs", "花酒锄作田", "Cursor"], "alternates": {"html": "https://wpnews.pro/news/build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice", "markdown": "https://wpnews.pro/news/build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice.md", "text": "https://wpnews.pro/news/build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice.txt", "jsonld": "https://wpnews.pro/news/build-a-runnable-mcp-loop-in-python-stdio-streamable-http-llm-tool-choice.jsonld"}}