LabHub
배우기 러닝패스 코스

The agent dropped my database

Build an MCP stdio server with the standard library only

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

json·sys·sqlite3 만으로 MCP 서버를 한 편 씁니다. 클라이언트가 보내는 initializenotifications/initializedtools/listtools/call 을 순서대로 받아 내고, 마지막에는 그 서버를 자식 프로세스로 띄우는 클라이언트까지 씁니다.

왜 중요한가

SDK 를 쓰면 다섯 줄로 끝나는 일입니다. 그런데 사고는 SDK 가 감춰 준 자리에서 납니다 — stdout 에 찍은 디버그 한 줄이 클라이언트의 JSON 파서를 죽이고, 알림에 답을 보내서 짝이 안 맞는 응답이 생기고, 도구가 실패했는데 프로토콜 오류로 돌려보내서 모델이 "서버가 고장났다" 고 판단합니다. 프레이밍(줄 하나 = 메시지 하나), id 로 짝 맞추기, 알림 무시, 오류 두 종류의 구분 — 이 네 가지가 MCP 의 실체이고, 손으로 한 번 짜 보면 SDK 의 로그가 무슨 말을 하는지 읽힙니다.

단계

  1. /root/mcp/seed.sql 을 저장하고 /root/mcp/shop.db 로 적재하세요. customers 5행, orders 8행이어야 합니다.
  2. /root/mcp/server.py 를 만드세요. stdin 에서 한 줄씩 JSON-RPC 요청을 읽고, initializeprotocolVersion2025-06-18 이고 capabilities.toolsserverInfo.name 이 있는 result 로 답해야 합니다.
  3. 같은 파일에서 알림(id 없는 메시지)에는 답하지 않고, 모르는 메서드는 -32601, 깨진 JSON 은 -32700 오류(id 는 null)로 답하게 하세요. 서버가 죽으면 안 됩니다.
  4. tools/list 에 도구 두 개를 돌려주세요 — list_customerscount_orders(inputSchema.properties.status, 문자열, required). 각 도구에 descriptiontype: objectinputSchema 가 있어야 합니다.
  5. tools/call 을 구현하세요. count_orders{"status":"paid"} 를 주면 DB 의 paid 건수가 content[0].text 에 들어 있어야 하고, list_customers 는 다섯 고객 이름을 모두 담아야 합니다. DB 경로는 환경변수 MCP_DB 가 있으면 그것을, 없으면 /root/mcp/shop.db 를 씁니다.
  6. 오류를 두 종류로 나누세요. 없는 도구 이름은 JSON-RPC 오류 -32602, count_orders 에 모르는 상태값(예: banana)을 주면 result 에 isError: true 와 설명 텍스트를 돌려주어야 합니다.
  7. /root/mcp/client.py 를 만드세요. 서버를 subprocess 로 띄워 initialize → notifications/initialized → tools/list → tools/call(count_orders, paid) 을 보내고, 결과를 /root/mcp/session.jsontools(이름 목록)와 paid_orders(응답 텍스트)로 적습니다. 환경변수 MCP_SESSION_OUT 이 있으면 그 경로에 씁니다.
  8. 서버의 모든 로그를 stderr 로 보내세요. 요청마다 메서드 이름이 stderr 에 한 줄씩 찍히고, stdout 에는 JSON 응답 외에 아무것도 나오면 안 됩니다.

참고

가게 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 요청을 읽고, initializeprotocolVersion2025-06-18 이고 capabilities.toolsserverInfo.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_customerscount_orders(inputSchema.properties.status, 문자열, required). 각 도구에 descriptiontype: objectinputSchema 가 있어야 합니다.

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.jsontools(이름 목록)와 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 으로 파싱해 봅니다.