07. stdio — 로컬의 기본
🎯 이 장의 목표
- stdio 전송이 어떻게 동작하는지(자식 프로세스, stdin/stdout) 설명할 수 있다
- stdout은 프로토콜, stderr는 로그라는 분리 규칙을 이해한다
- stdio가 적합한 상황과 그 보안적 함의를 안다
파이프 비유
리눅스에서 cat file.txt | grep hello를 쳐 본 적 있다면, stdio 전송을 이미 절반은 이해한 셈입니다. 한 프로그램의 출력(stdout)이 다른 프로그램의 입력(stdin)으로 파이프를 통해 흘러 들어가죠. stdio 전송은 바로 이 운영체제 파이프를 통신 통로로 씁니다.
MCP가 정의하는 두 표준 전송 중 하나가 stdio(standard input/output, 표준 입출력)입니다. 클라이언트가 MCP 서버를 자식 프로세스로 실행하고, 서버는 stdin에서 JSON-RPC 메시지를 읽고 stdout으로 메시지를 보냅니다. 클라이언트는 가능하면 stdio를 지원해야 합니다.
동작 방식
sequenceDiagram
participant H as Host/Client
participant OS as OS 파이프
participant S as Server (자식 프로세스)
H->>S: 서버를 자식 프로세스로 실행 (spawn)
Note over H,S: stdin/stdout 파이프 연결
H->>OS: JSON-RPC 메시지 (서버 stdin으로)
OS->>S: 전달
S->>OS: JSON-RPC 응답 (stdout으로)
OS->>H: 전달
S-->>H: (stderr로는 로그만)
Note over H,S: 작업 끝 → 클라이언트가 stdin 닫고 프로세스 종료
구체적인 규칙은 다음과 같습니다:
- 클라이언트가 서버 실행 파일(또는 스크립트)을 자식 프로세스로 실행합니다.
- 클라이언트는 서버의 stdin에 JSON-RPC 메시지를 씁니다.
- 서버는 stdout으로 JSON-RPC 메시지를 돌려보냅니다.
- 메시지는 개행(newline)으로 구분되며, 메시지 안에 개행이 들어가면 안 됩니다.
- 서버는 stderr에 정보·디버그·에러 등 로그를 UTF-8 문자열로 쓸 수 있습니다 — 이건 프로토콜 메시지가 아닙니다.
- 끝나면 클라이언트가 stdin을 닫고 자식 프로세스를 종료합니다.
📌 핵심
핵심 규칙 — stdout은 신성하다: stdout으로는 오직 유효한 MCP 메시지만 나가야 합니다. 디버깅하겠다고 print("여기 도착")을 하면, 그 텍스트가 stdout으로 섞여 들어가 JSON-RPC 파싱을 깨뜨립니다. 로그는 반드시 stderr로 보내세요. 클라이언트도 서버의 stdin에 유효한 MCP 메시지가 아닌 것을 써서는 안 됩니다.⚠️ 흔한 실수
흔한 실수 1순위: Python에서 무심코 print(...)를 호출하는 것. print의 기본 목적지가 stdout이라 프로토콜을 오염시킵니다. 로깅은 logging 모듈을 stderr로 설정하거나, MCP의 로깅 기능(13장)을 쓰세요. FastMCP를 쓰면 프레임워크가 stdout을 관리해 주지만, 직접 print하면 여전히 깨집니다.Python에서 stdio 서버 띄우기
공식 mcp SDK 기준입니다. mcp.run()은 기본이 stdio라서, 명시하지 않아도 stdio로 돕니다.
PYTHON
# server.py — 공식 mcp SDK (mcp.server.fastmcp) from mcp.server.fastmcp import FastMCP mcp = FastMCP("Demo") @mcp.tool() def add(a: int, b: int) -> int: """두 정수를 더한다""" return a + b if __name__ == "__main__": mcp.run(transport="stdio") # transport 생략 시에도 stdio가 기본
💡 팁
stdio 서버는 "스스로 실행"하기보다 클라이언트(호스트)가 자식 프로세스로 띄우는 구조입니다. 그래서 python server.py를 직접 치기보다, 클라이언트 설정에 "이 명령으로 서버를 실행하라"고 등록합니다. 개발 중 단독 점검은 MCP Inspector로 합니다(16장).호스트(예: 데스크톱 앱) 설정 파일에는 보통 이런 식으로 등록합니다(형식은 호스트마다 다름):
JSON
{
"mcpServers": {
"demo": {
"command": "python",
"args": ["server.py"]
}
}
}
standalone fastmcp 3.x도 비슷하지만 import와 일부 기본값이 다릅니다:
PYTHON
# server.py — standalone fastmcp (별도 패키지) from fastmcp import FastMCP mcp = FastMCP("Demo") @mcp.tool def add(a: int, b: int) -> int: """두 정수를 더한다""" return a + b if __name__ == "__main__": mcp.run() # 인자 없이 호출하면 stdio가 기본
📌 핵심
두 갈래 차이 재확인: 공식 SDK는 mcp.server.fastmcp에서 임포트하고 데코레이터를 @mcp.tool()처럼 호출형으로 쓰는 예가 많습니다. standalone fastmcp는 from fastmcp import FastMCP이고 @mcp.tool(괄호 없이)도 허용합니다. 사소해 보여도 어느 패키지를 설치했는지에 따라 동작이 갈리니, 프로젝트 시작 시 하나로 통일하세요.stdio의 장점과 한계
stdio가 로컬에서 매력적인 이유는 단순함입니다.
| 측면 | stdio의 특성 |
|---|---|
| 네트워크 | 없음. OS 파이프로만 통신 |
| 성능 | 네트워크 오버헤드가 없어 매우 빠름 (조건에 따라 초당 수천~수만 연산) |
| 배포 | 실행 파일/스크립트와 "어떻게 실행하는지"만 알려주면 끝. 포트·DNS·호스팅 불필요 |
| 인증 | 전송 계층 인증이 없음 (환경 변수 정도) |
| 범위 | 로컬 전용 — 클라이언트와 같은 머신에서만 |
| 동시성 | 클라이언트마다 별도 프로세스(프로세스-당-사용자 모델) |
stdio가 클라이언트에게 주는 대가는 프로세스 격리입니다. 에이전트가 서버의 수명을 소유해서, 에이전트가 종료되면 OS가 그 프로세스를 회수합니다. 네트워크도, 인증 핸드셰이크도, 방화벽 고민도 없습니다. 로컬 개발에는 이것이 바로 원하는 바입니다.
🔒 보안 — "포트가 없다"는 양면: stdio 서버는 네트워크 포트를 열지 않으므로 원격 공격 표면이 없습니다. 프로세스 경계가 보안 경계가 됩니다. 다만 이는 "서버가 안전하다"는 뜻이 아닙니다. 서버는 호스트와 같은 머신에서, 종종 사용자 권한으로 돕니다. 서버가 파일·셸·환경 변수에 접근한다면, 도구 인자를 통한 경로 조작이나 명령 주입은 여전히 위험합니다. 또 stdio는 전송 계층 인증이 없고 비밀값을 보통 환경 변수로 넘기므로, 그 환경 변수가 새지 않게 관리해야 합니다. 입력 검증(15장)은 전송과 무관하게 필요합니다.
이 장에서 배운 것
- stdio는 클라이언트가 서버를 자식 프로세스로 띄우고 stdin/stdout 파이프로 JSON-RPC를 주고받는 전송이다. 메시지는 개행으로 구분.
- stdout은 프로토콜 전용, stderr는 로그 전용.
print오용이 가장 흔한 버그. - 네트워크가 없어 빠르고 단순하며 로컬 전용. 클라이언트마다 별도 프로세스.
- 원격 공격 표면은 없지만, 로컬 권한·입력 검증·환경 변수 관리는 여전히 필요하다.
✍️ 확인 문제
- Python 서버에서
print("debug: tool called")를 넣었더니 클라이언트가 연결을 못 한다. 원인과 올바른 수정은? - stdio가 "원격 공격 표면이 없다"는 말과 "서버가 안전하다"는 말이 같지 않은 이유를 한 가지 들어 보자.
- 같은 stdio 서버에 사용자 50명이 동시에 붙으면 프로세스는 대략 몇 개가 뜨나? 이게 왜 중앙 관리에 부담이 될까?
다음 장: 08. Streamable HTTP — 원격의 표준