Build a Runnable MCP Loop in Python (stdio streamable-http LLM tool choice)
Python으로 실행 가능한 MCP 루프 만들기 — stdio, streamable-http, LLM tool choice까지
대부분의 MCP 자료가 Server 등록과 Cursor 연동만 다루는 반면, 이 글은 Client/Host 측까지 직접 구축해 prompts·resources·tools를 열거하고, stdio와 streamable-http 두 전송 방식으로 호출한 뒤 LLM이 어떤 tool을 쓸지 선택하게 하는 전체 루프를 다룹니다. Agent를 백엔드에 배포하려는 개발자에게 SDK 데모와 실제 서비스 사이의 빈 구간을 채워주는 튜토리얼입니다.
- 주제
본문
이 글은 중국 Cnblogs의 블로거 花酒锄作田의 MCP 튜토리얼을 영어로 재작성한 컴파일 글입니다. 원저자는 Python 3.13.5(3.11 이상이면 충분) 환경에서 uv 또는 pip으로 mcp와 fastmcp를 설치하는 것으로 시작합니다. 참고로 공식 mcp 패키지에는 FastMCP v1이 포함되어 있고, 커뮤니티 FastMCP는 v2로 넘어간 상태라 두 가지를 함께 시도해보는 것도 학습에 도움이 된다고 전합니다. 중요한 조언은 type hint, return type, docstring을 정성스럽게 작성하라는 것입니다 — 이들이 나중에 모델이 읽는 tool description이 되기 때문입니다.
Server — 한 프로세스에 prompt, resource, tool 모두 올리기
FastMCP 인스턴스 하나에 세 가지 프리미티브를 함께 등록하는 구조를 보여줍니다. @mcp.prompt()로 스타일을 지정할 수 있는 인사 프롬프트(greet_user), @mcp.resource()로 greeting://{name} 같은 동적 리소스와 config://app 같은 정적 리소스, 그리고 @mcp.tool()로 add(a, b), get_date(), get_weather(city) 같은 도구를 데코레이터로 붙입니다. 마지막에 mcp.run(transport="stdio")로 실행합니다. 여기서 중요한 사전 점검(preflight)이 있습니다 — Server 스크립트를 단독으로 먼저 실행해보라는 것입니다. import 에러가 있으면 Client가 자식 프로세스를 spawn하다 알기 어려운 방식으로 실패하기 때문입니다. 원문에는 원격 SSH tool 시연도 포함되어 있는데, 컴파일러는 이를 고위험 요소로 명시합니다. allowlist, 인증, 사람 확인 없이 임의 원격 명령 실행을 모델에 노출하지 말고, '무엇이든 실행'보다 좁고 타입이 정해진 tool을 권합니다.
stdio Client — ClientSession으로 discover → call
Client 쪽은 StdioServerParameters로 Server를 자식 프로세스로 띄웁니다. 절대 경로의 인터프리터(venv 안 python), 스크립트 경로, cwd를 지정하고, stdio_client로 read/write 스트림을 얻은 뒤 ClientSession을 열어 initialize()를 호출합니다. 이후 session.list_prompts()와 session.list_tools()로 등록된 이름들을 열거하고, read_resource(AnyUrl("greeting://World"))로 리소스를 읽고(TextResourceContents 타입을 확인해 text를 꺼냄), call_tool("add", {"a": 5, "b": 3})을 호출하면 content[0].text로 '8'과 함께 structuredContent도 얻는 흐름입니다. 원문 실행 결과로 prompts 목록, resource 텍스트, add 결과 8과 구조화된 content가 확인됐다고 합니다.
streamable-http로 전환 — 배포형 tool server의 분기점
Server는 FastMCP("custom", host="localhost", port=8001)로 바꾸고 mcp.run(transport="streamable-http")로 실행하고, Client는 streamablehttp_client("http://localhost:8001/mcp")로 연결하면 나머지 initialize/list/call 흐름은 동일합니다. streamablehttp_client는 get_session_id라는 세 번째 반환값도 주지만 hello-world 단계에서는 보통 쓰지 않는다고 합니다. 이 지점이 실제 제품 백엔드가 가장 신경 쓰는 분기입니다 — stdio는 데스크톱이나 호스트 로컬 도구용, HTTP는 원격 배포된 tool server용입니다.
LLM tool choice — 모델이 tool을 고르게 하는 루프
마지막 단계에서 Server는 그대로 두고 Client에 LLM을 붙입니다. list_tools() 결과를 OpenAI 호환 function schema(name, description, parameters는 inputSchema에서)로 매핑하고, chat completions에 tools=로 전달합니다. 모델이 tool call을 내놓는 동안 session.call_tool(name, args)로 실행하고, assistant 메시지와 tool 메시지를 대화에 추가해 다시 모델을 호출하는 루프를 돌다가 tool call이 없어지면 멈춥니다. 원문은 Qwen/DashScope의 OpenAI 호환 엔드포인트(compatible-mode/v1, qwen-plus)를 썼지만, DeepSeek이나 OpenAI 등 어떤 tools 호환 엔드포인트도 같은 방식으로 동작한다고 합니다. 실제 세션 예시로 '오늘 날짜가 뭐야?' → get_date, '허페이 날씨?' → get_weather({city: "合肥"})가 보고됐습니다.
기억할 실패 모드와 운영 포인트
Client를 시작하면 Server도 함께 시작되므로 Server의 문법·import 에러가 Client 연결 실패처럼 보입니다 — 디버깅은 항상 Server부터입니다. 또한 stdio Server의 stdout에 디버그 print를 섞으면 JSON-RPC가 깨지므로, 로깅은 파일 등 별도 채널로 보내야 합니다. 글은 LLM 기능 배포가 단일 chat completion이 아니라 신뢰할 수 있는 tool session — server spawn/연결, schema 갱신, agent loop 한정, 전송 방식 교체 가능성 — 의 문제라고 정리합니다. 이 루프를 한 번 탄탄히 만들어두면, 나중에 RAG 검색을 resource나 tool로 붙일 때도 host를 재작성하지 않고 schema 변경만으로 확장할 수 있습니다.
원문: dev.to / 번역·요약: Trawling