에이전트가 내 DB 를 지웠다 · 표준 라이브러리로 짜는 stdio 서버 · 이론
표준 라이브러리로 짜는 stdio 서버
한 줄 요약
MCP stdio 서버는 "stdin 에서 한 줄 읽고, JSON 으로 풀고, id 가 있으면 답하고,
stdout 에 한 줄 쓰고 flush" 하는 루프다. 그 루프를 json·sys·sqlite3 만으로
짜면 SDK 가 감추던 네 가지가 눈에 보인다 — 프레이밍, id 짝 맞추기, 알림 무시,
오류 두 종류.
왜 이게 필요했나
SDK 로 서버를 만들면 데코레이터 하나에 함수 하나다. 그 편리함은 좋지만, 문제가
생겼을 때 로그가 하는 말을 읽지 못한다. "응답을 파싱할 수 없다", "짝이 없는
응답", "initialize 전에 요청을 받았다" 같은 문장은 전부 프로토콜의 층에서 나오는
말이고, 그 층을 한 번도 직접 만져 보지 않은 사람에게는 소음이다.
그리고 사고는 그 층에서 난다. 채용 공고가 MCP 를 적어 놓은 자리의 실제 업무는
"서버를 만드는 것" 보다 "다른 팀이 만든 서버가 왜 우리 앱에서 안 붙는지 찾는 것"
에 가깝다. 그 일을 하려면 스펙이 정한 메시지 모양을 손으로 만들어 보내고 받아
본 경험이 필요하다. 이 모듈은 그 경험을 표준 라이브러리만으로 만든다.
어떻게 동작하나
프레이밍. [전송 스펙](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
의 stdio 절은 메시지가 줄바꿈으로 구분되고 안에 줄바꿈이 없어야 한다고 정한다.
그래서 서버의 뼈대는 for line in sys.stdin: 이다. 응답도 한 줄이어야 하므로json.dumps 의 결과에 \n 을 붙여 쓰고, 반드시 flush() 한다. 파이프는
터미널과 달리 줄 버퍼링이 아니라서, flush 가 없으면 응답이 버퍼에 갇힌 채
클라이언트는 영원히 기다린다. "서버가 멈춘 것 같다" 는 신고의 절반이 이것이다.
id 로 갈라지는 세 갈래. 한 줄을 json.loads 로 풀면 세 경우가 나온다. id
가 있으면 요청이라 반드시 같은 id 로 답한다. id 멤버가 없으면 알림이라 답하지
않는다 — msg.get("id") is None 으로 판단하면 id 가 실제로 null 인 잘못된 요청과
구분되지 않으므로 "id" in msg 로 본다. 그리고 json.loads 자체가 실패하면
[JSON-RPC 2.0](https://www.jsonrpc.org/specification) 5절대로 id: null 에-32700 Parse error 로 답한다. 이 세 갈래를 try 로 감싸지 않으면 깨진 줄 하나에
서버가 죽고, 클라이언트는 그것을 "서버가 사라졌다" 로 본다.
for line in sys.stdin: try: msg = json.loads(line) except json.JSONDecodeError: reply(None, error={"code": -32700, "message": "Parse error"}); continue if "id" not in msg: # 알림 — 답하지 않는다 continue try: reply(msg["id"], result=handle(msg)) except RpcError as e: # 프로토콜 오류 reply(msg["id"], error={"code": e.code, "message": e.message})initialize 는 첫 요청이다. [라이프사이클](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle)
대로 protocolVersion·capabilities·serverInfo 를 담은 result 를 돌려준다.
도구를 낼 서버는 capabilities.tools 를 선언해야 한다(MUST) — 이 객체가 비어
있어도 키는 있어야 한다. 클라이언트는 그 뒤 notifications/initialized 를
보내는데, 이것은 알림이므로 서버는 답하지 않는다. 손으로 짠 서버에서 가장 흔한
실수가 여기서 응답을 내는 것이다.
tools/list 와 tools/call. [도구 스펙](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)
의 형식 그대로다. 목록의 각 도구는 name·description·inputSchema 를 갖고,inputSchema 는 type: object 인 JSON Schema 다. 모델이 이 스키마를 읽고 인자를
만들기 때문에 description 이 곧 모델에게 주는 사용 설명서다. 호출은 params
의 name 과 arguments 를 받아 content 배열로 답한다. 없는 도구 이름은
프로토콜 오류(스펙 예시 -32602), 도구가 돌다 실패한 것은 isError: true 다.
모르는 상태값으로 주문을 세어 달라는 요청은 후자다 — 모델이 텍스트를 읽고 올바른
값으로 다시 부를 수 있어야 한다.
클라이언트도 같은 규칙으로 짠다. subprocess.Popen 으로 서버를 띄우고
stdin 에 쓰고 stdout 에서 readline 한다. 알림을 보낸 뒤에는 readline 하지
않는다 — 답이 오지 않으니 영원히 기다린다. 끝낼 때는 라이프사이클의 stdio 종료
순서대로 stdin 을 먼저 닫고, 서버가 스스로 끝나기를 기다린다.
현장에서 만나는 모습
한 팀이 만든 MCP 서버가 어떤 호스트 앱에서는 붙고 다른 앱에서는 "초기화 실패"
가 났다. 원인은 서버가 시작할 때 stdout 에 배너 한 줄을 찍는 것이었다. 너그러운
호스트는 JSON 아닌 줄을 버렸고, 엄격한 호스트는 첫 줄이 파싱되지 않자 연결을
끊었다. 스펙 문장 하나("stdout 에 MCP 메시지 외의 것을 쓰지 않는다")가 두 호스트의
차이를 설명한다. 이 모듈의 마지막 단계가 그 위생을 채점한다.
또 하나는 initialized 알림에 응답을 보내는 서버다. 호스트가 그 응답을 받고
"짝이 없는 id" 로 경고만 내는 동안 뒤이은 tools/list 응답과 순서가 섞여, 도구
목록이 비어 보이는 증상이 났다. 알림에는 답하지 않는다는 규칙을 어긴 결과가
전혀 다른 곳에서 드러난 것이다.
다음 실습에서 할 것
가게 DB 를 만들고, initialize 부터 tools/call 까지 처리하는 서버를 단계별로
짠 뒤, 그 서버를 자식 프로세스로 띄우는 클라이언트를 써서 한 세션을 끝까지
돈다. 마지막에 stdout 위생을 확인한다.