標準ライブラリだけでMCP stdioサーバーを作る
한국어 원문으로 표시합니다.
목표
json·sys·sqlite3 만으로 MCP 서버를 한 편 씁니다. 클라이언트가 보내는
initialize → notifications/initialized → tools/list → tools/call 을 순서대로
받아 내고, 마지막에는 그 서버를 자식 프로세스로 띄우는 클라이언트까지 씁니다.
왜 중요한가
SDK 를 쓰면 다섯 줄로 끝나는 일입니다. 그런데 사고는 SDK 가 감춰 준 자리에서 납니다 — stdout 에 찍은 디버그 한 줄이 클라이언트의 JSON 파서를 죽이고, 알림에 답을 보내서 짝이 안 맞는 응답이 생기고, 도구가 실패했는데 프로토콜 오류로 돌려보내서 모델이 "서버가 고장났다" 고 판단합니다. 프레이밍(줄 하나 = 메시지 하나), id 로 짝 맞추기, 알림 무시, 오류 두 종류의 구분 — 이 네 가지가 MCP 의 실체이고, 손으로 한 번 짜 보면 SDK 의 로그가 무슨 말을 하는지 읽힙니다.
단계
/root/mcp/seed.sql을 저장하고/root/mcp/shop.db로 적재하세요. customers 5행, orders 8행이어야 합니다./root/mcp/server.py를 만드세요. stdin 에서 한 줄씩 JSON-RPC 요청을 읽고,initialize에protocolVersion이2025-06-18이고capabilities.tools와serverInfo.name이 있는 result 로 답해야 합니다.- 같은 파일에서 알림(id 없는 메시지)에는 답하지 않고, 모르는 메서드는
-32601, 깨진 JSON 은-32700오류(id 는 null)로 답하게 하세요. 서버가 죽으면 안 됩니다. tools/list에 도구 두 개를 돌려주세요 —list_customers와count_orders(inputSchema.properties.status, 문자열, required). 각 도구에description과type: object인inputSchema가 있어야 합니다.tools/call을 구현하세요.count_orders에{"status":"paid"}를 주면 DB 의 paid 건수가content[0].text에 들어 있어야 하고,list_customers는 다섯 고객 이름을 모두 담아야 합니다. DB 경로는 환경변수MCP_DB가 있으면 그것을, 없으면/root/mcp/shop.db를 씁니다.- 오류를 두 종류로 나누세요. 없는 도구 이름은 JSON-RPC 오류
-32602,count_orders에 모르는 상태값(예:banana)을 주면 result 에isError: true와 설명 텍스트를 돌려주어야 합니다. /root/mcp/client.py를 만드세요. 서버를subprocess로 띄워 initialize →notifications/initialized→ tools/list → tools/call(count_orders, paid) 을 보내고, 결과를/root/mcp/session.json에tools(이름 목록)와paid_orders(응답 텍스트)로 적습니다. 환경변수MCP_SESSION_OUT이 있으면 그 경로에 씁니다.- 서버의 모든 로그를 stderr 로 보내세요. 요청마다 메서드 이름이 stderr 에 한 줄씩 찍히고, stdout 에는 JSON 응답 외에 아무것도 나오면 안 됩니다.
참고
- 한 줄씩 읽는 루프는
for line in sys.stdin:이면 됩니다. 응답은sys.stdout.write(json.dumps(...) + "\n")뒤에 반드시flush()하세요 — 파이프는 줄 버퍼링이 아니라서 flush 없이는 클라이언트가 영원히 기다립니다. - 알림인지는
"id" in msg로 판단합니다.msg.get("id")가 None 인지로 보면, id 가 실제로 null 인 (잘못된) 요청과 구분되지 않습니다. json.loads가 던지는 예외를 잡지 않으면 깨진 줄 하나에 서버가 통째로 죽습니다. 클라이언트는 그것을 "서버가 사라졌다" 로 봅니다.- 손으로 시험하려면:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python3 /root/mcp/server.py - 흔한 실수 1:
print()로 디버그 문장을 stdout 에 찍는 것. 8단계 채점이 그것을 잡습니다. - 흔한 실수 2: 없는 도구를
isError: true로 돌려주는 것. 그것은 도구가 실행되다 실패한 것이 아니라 요청 자체가 잘못된 것이라 프로토콜 오류입니다.
가게 DB 를 만든다
/root/mcp/seed.sql 을 저장하고 /root/mcp/shop.db 로 적재하세요. customers 5행, orders 8행이어야 합니다.
sqlite3 는 sqlite3 shop.db < seed.sql 로 파일을 통째로 실행합니다. 파이썬으로 하려면 sqlite3.connect(...).executescript(open(...).read()) 입니다. 이미 있는 DB 에 다시 적재하면 테이블이 있다는 오류가 나니 먼저 지우세요.
initialize 에 답한다
/root/mcp/server.py 를 만드세요. stdin 에서 한 줄씩 JSON-RPC 요청을 읽고, initialize 에 protocolVersion 이 2025-06-18 이고 capabilities.tools 와 serverInfo.name 이 있는 result 로 답해야 합니다.
뼈대 예시의 루프에서 method == "initialize" 일 때 result 딕셔너리를 만들어 reply(msg["id"], result=...) 로 보내면 됩니다. result 에는 protocolVersion, capabilities(tools 키를 가진 객체), serverInfo(name·version) 세 가지가 들어갑니다.
알림에는 답하지 않는다
같은 파일에서 알림(id 없는 메시지)에는 답하지 않고, 모르는 메서드는 -32601, 깨진 JSON 은 -32700 오류(id 는 null)로 답하게 하세요. 서버가 죽으면 안 됩니다.
JSON-RPC 에서 알림은 id 멤버가 없는 요청이고 서버는 답하면 안 됩니다. json.loads 를 try 로 감싸 JSONDecodeError 면 id null 로 -32700 을 보내고 continue 하세요. 나머지 예외도 잡아 -32603 으로 답하면 서버가 살아남습니다.
도구 목록을 낸다
tools/list 에 도구 두 개를 돌려주세요 — list_customers 와 count_orders(inputSchema.properties.status, 문자열, required). 각 도구에 description 과 type: object 인 inputSchema 가 있어야 합니다.
result 는 {"tools": [...]} 이고 도구 하나는 name·description·inputSchema 세 키입니다. inputSchema 는 JSON Schema 객체라 "type": "object" 와 properties 를 갖습니다. 인자가 없는 도구도 properties: {} 는 둡니다.
도구를 실제로 실행한다
tools/call 을 구현하세요. count_orders 에 {"status":"paid"} 를 주면 DB 의 paid 건수가 content[0].text 에 들어 있어야 하고, list_customers 는 다섯 고객 이름을 모두 담아야 합니다. DB 경로는 환경변수 MCP_DB 가 있으면 그것을, 없으면 /root/mcp/shop.db 를 씁니다.
params 는 {"name": 도구이름, "arguments": {...}} 입니다. 결과는 {"content": [{"type": "text", "text": "..."}]} 모양이어야 합니다. os.environ.get("MCP_DB", "/root/mcp/shop.db") 로 경로를 잡고 요청마다 sqlite3.connect 해서 닫으세요.
오류는 두 종류다
오류를 두 종류로 나누세요. 없는 도구 이름은 JSON-RPC 오류 -32602, count_orders 에 모르는 상태값(예: banana)을 주면 result 에 isError: true 와 설명 텍스트를 돌려주어야 합니다.
스펙은 '모르는 도구·잘못된 인자' 를 프로토콜 오류(error 멤버, 예시 코드 -32602)로, '도구가 돌다 실패한 것' 을 도구 실행 오류(result 안의 isError: true)로 구분합니다. 후자는 모델이 읽고 다시 시도할 수 있게 텍스트로 이유를 적습니다.
클라이언트로 한 세션을 돈다
/root/mcp/client.py 를 만드세요. 서버를 subprocess 로 띄워 initialize → notifications/initialized → tools/list → tools/call(count_orders, paid) 을 보내고, 결과를 /root/mcp/session.json 에 tools(이름 목록)와 paid_orders(응답 텍스트)로 적습니다. 환경변수 MCP_SESSION_OUT 이 있으면 그 경로에 씁니다.
subprocess.Popen([...], stdin=PIPE, stdout=PIPE, text=True) 로 띄우고, 보낼 때마다 stdin.flush(), 받을 때는 stdout.readline() 한 줄을 json.loads 합니다. 알림을 보낸 뒤에는 readline 하지 마세요 — 답이 오지 않으니 영원히 기다립니다. 끝낼 때는 stdin 을 닫고 wait() 합니다.
stdout 은 프로토콜 전용이다
서버의 모든 로그를 stderr 로 보내세요. 요청마다 메서드 이름이 stderr 에 한 줄씩 찍히고, stdout 에는 JSON 응답 외에 아무것도 나오면 안 됩니다.
sys.stderr.write(...) 또는 print(..., file=sys.stderr) 입니다. 스펙(stdio 전송)은 서버가 stdout 에 유효한 MCP 메시지 외의 것을 써서는 안 되고, stderr 는 로그 용도로 써도 된다고 정합니다. 채점기는 stdout 의 모든 줄을 JSON 으로 파싱해 봅니다.