MCP 서버 Connection closed 오류 해결법

3줄 요약
- stdio로 붙인 MCP 서버가 죽으면 클라이언트에는
MCPError: Connection closed한 줄만 남고, 실제 원인은 서버 프로세스의 stderr에만 기록됩니다. - mcp 2.1.1(PyPI 업로드 2026-08-26 01:13 KST) 기준으로 재현해 보니, 원인 문자열은 서버를 직접 실행해 stderr를 읽으면 6줄 안에 나왔습니다. 도구를 20개 붙이면
tools/list응답만 9,831바이트였습니다. - 서버를 붙이기 전에 아래 30줄짜리 핸드셰이크 진단 스크립트를 먼저 돌려 보시면 됩니다.
터미널에 이것만 찍혔습니다.
mcp.shared.exceptions.MCPError: Connection closed
그 위로는 anyio와 exceptiongroup이 만든 트레이스백이 24줄 붙어 있었는데, 전부 SDK 내부 파일 경로였습니다. 제가 쓴 코드는 클라이언트 스크립트의 await s.initialize() 한 줄뿐이었고, 서버 파일 이름은 어디에도 없었습니다. 서버는 이미 죽어 있었고, 왜 죽었는지는 클라이언트가 알 방법이 없었습니다.
같은 오류를 그날 세 번 봤습니다. 원인은 세 번 다 달랐습니다.
1. Connection closed는 진단 결과가 아니라 "서버가 말없이 사라졌다"는 통보입니다
stdio 트랜스포트에서 클라이언트는 서버를 자식 프로세스로 띄우고 stdin/stdout 파이프로 JSON-RPC를 주고받습니다. 그래서 클라이언트가 관찰할 수 있는 것은 딱 두 가지입니다. 파이프로 유효한 JSON이 오는가, 그리고 파이프가 닫혔는가.
서버가 import 단계에서 죽든, 설정 파일을 못 찾아 죽든, 권한 문제로 죽든 클라이언트 입장에서는 전부 "파이프가 닫혔다"입니다. 파이썬 트레이스백은 서버 프로세스의 stderr로 나가는데, 클라이언트는 그 스트림을 파싱하지 않습니다.
클라이언트가 보는 것과 서버가 아는 것
같은 실패를 두 위치에서 나란히 찍어 봤습니다.
| 관찰 위치 | 실제로 나온 문자열 |
|---|---|
| 클라이언트 예외 | mcp.shared.exceptions.MCPError: Connection closed |
| 서버 프로세스 종료 코드 | 1 |
| 서버 stderr 마지막 줄 | ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer ... |
서버 쪽 메시지에는 원인뿐 아니라 해결 방법과 문서 링크까지 들어 있었습니다. 그 좋은 메시지가 클라이언트에는 한 글자도 전달되지 않습니다.
진단 스크립트 하나로 세 경우가 갈립니다
그래서 클라이언트 라이브러리를 거치지 않고 서버에 initialize 한 통만 직접 밀어 넣는 스크립트를 만들었습니다. 서버가 붙지 않을 때 제일 먼저 돌립니다.
# diagnose.py — stdio MCP 서버가 왜 안 붙는지 서버 stderr를 그대로 보여줍니다
import subprocess, sys, json, time
INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": {"name": "diagnose", "version": "0"}}}
def check(cmd):
t0 = time.time()
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
try:
out, err = p.communicate(json.dumps(INIT) + "\n", timeout=15)
except subprocess.TimeoutExpired: # 응답이 없으면 여기서 걸립니다
p.kill(); out, err = p.communicate()
first = next((l for l in out.splitlines() if l.strip()), "")
ok = first.startswith("{") and '"result"' in first
# 프로토콜 채널에 섞여 나온 비-JSON 줄을 따로 모읍니다
noise = [l for l in out.splitlines() if l.strip() and not l.startswith("{")]
return {"exit": p.returncode, "ms": round((time.time() - t0) * 1000, 1),
"handshake": ok, "stdout_noise": noise[:3],
"stderr_tail": err.strip().splitlines()[-3:]}
if __name__ == "__main__":
print(json.dumps(check([sys.executable] + sys.argv[1:]), ensure_ascii=False, indent=1))
서버 세 개에 돌린 결과입니다. 판정이 한 줄로 갈립니다.
| 서버 상태 | exit | handshake | stdout_noise | 읽어야 할 곳 |
|---|---|---|---|---|
| v1 코드를 v2에서 실행 | 1 | false | 없음 | stderr_tail |
| 정상 | 0 | true | 없음 | — |
| 정상이지만 stdout에 로그 한 줄 | 0 | true | qa-tools 서버 기동 |
stdout_noise |
세 번째 줄이 특히 중요합니다. 핸드셰이크는 성공했는데도 프로토콜 채널에 쓰레기가 섞여 있는 상태라, 클라이언트가 조용히 그 줄을 버리고 넘어갑니다. 나중에 3절에서 다시 다루겠습니다.
붙고 나서도 원인은 계속 서버에 남습니다
연결이 끊기는 경우만 그런 것이 아닙니다. 도구 하나에 일부러 0으로 나누는 코드를 넣고 호출해 봤습니다. 클라이언트가 받은 결과는 이랬습니다.
is_error=True content=[{'type': 'text', 'text': 'Error executing tool boom'}]
ZeroDivisionError: integer division or modulo by zero라는 원래 예외는 서버 stderr에만 남고, 클라이언트에는 도구 이름만 들어간 문장이 갑니다. 도구를 호출하는 주체가 모델이라는 점을 생각하면 이게 무엇을 뜻하는지 분명해집니다. 모델은 무엇이 잘못됐는지 모른 채 같은 호출을 다시 시도합니다. 인자를 고쳐 볼 단서가 없기 때문입니다.
그래서 예외를 그대로 흘려보내지 말고, 도구 안에서 잡아 사람이 읽을 수 있는 문장으로 바꿔 돌려주는 편이 낫습니다. 어차피 stderr는 모델도 사용자도 보지 않습니다. 저는 인자 검증 실패처럼 모델이 고칠 수 있는 종류만 메시지에 담고, 내부 오류는 짧은 식별자만 남기는 쪽으로 정리했습니다.
저수준 Server 클래스를 직접 쓰는 경우라면 v2에서 동작이 달라졌습니다. v1은 핸들러가 던진 예외를 잡아 오류 표시가 붙은 도구 결과로 바꿔 주었지만, v2는 감싸지 않고 최상위 JSON-RPC 오류 응답으로 올려 보냅니다. 클라이언트가 그 지점에서 예외를 던지므로 오류 문구가 아예 모델 눈에 들어가지 않습니다. MCPServer의 @mcp.tool() 데코레이터를 쓰는 동안은 예전처럼 결과로 변환되니, 두 방식을 섞어 쓰는 프로젝트라면 이 차이를 알고 있어야 합니다.
2. 지금 이 오류가 몰리는 이유 — v1 코드가 v2에서 아예 뜨지 않습니다
제 첫 번째 실패 원인이 그것이었습니다. 인터넷에 널려 있는 예제를 그대로 복사했는데, 그 예제들이 전부 v1 문법입니다.
버전 타임라인부터 맞춰 두는 편이 낫습니다
PyPI JSON API의 upload_time_iso_8601을 그대로 읽어 KST로 환산했습니다. HTML 릴리스 목록은 캐시가 오래 남아 못 믿습니다.
| 버전 | 업로드 (KST 환산) | 성격 |
|---|---|---|
| 1.29.0 | 2026-07-28 22:41 | v1 마지막 기능 릴리스 |
| 2.0.0 | 2026-07-28 22:45 | v2 정식. 4분 뒤 |
| 1.29.1 | 2026-08-25 03:30 | v1 유지보수 |
| 2.1.0 | 2026-08-25 04:04 | |
| 2.1.1 | 2026-08-26 01:13 | 이 글의 실측 기준 |
| 2.0.1 | 2026-08-26 19:48 | 2.0 라인 백포트 |
마지막 줄을 보십시오. 2.0.1이 2.1.1보다 나중에 올라왔습니다. 버전 번호만 보고 최신을 고르면 어긋납니다. pip install mcp는 2.1.1을 가져옵니다.
개명된 것은 세 갈래입니다
공식 마이그레이션 가이드가 표로 정리해 두었고, 제가 직접 부딪힌 것은 이 셋이었습니다.
| 항목 | v1 | v2 | 처음 마주치는 증상 |
|---|---|---|---|
| 서버 클래스 | mcp.server.fastmcp.FastMCP |
mcp.server.mcpserver.MCPServer |
ModuleNotFoundError |
| 모델 필드 | inputSchema, isError |
input_schema, is_error |
AttributeError |
| 예외 클래스 | McpError |
MCPError |
ImportError |
mcp.server.fastmcp에는 호환용 껍데기가 남아 있지 않습니다. 가이드는 이렇게 적어 두었습니다. 임포트하면 ModuleNotFoundError가 나고, 그 메시지 안에 이 문서 링크가 들어 있다는 것입니다. 실제로 제가 본 메시지가 그랬습니다. 반면 mcp.types는 영구 별칭으로 남아서 그대로 씁니다. 와이어 타입이 mcp-types라는 별도 배포로 분리됐고 mcp.types가 그것을 그대로 비추는 구조입니다.
데코레이터는 그대로입니다. 서버 쪽 포팅은 사실상 두 줄이었습니다.
# server.py — mcp 2.1.1. v1에서는 위 두 줄만 FastMCP였습니다
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("qa-tools")
@mcp.tool()
def flaky_rate(passed: int, failed: int) -> float:
"""전체 실행 대비 실패 비율을 반환합니다."""
return failed / (passed + failed)
@mcp.tool()
def parse_junit(path: str) -> dict:
"""JUnit XML 경로를 받아 요약을 반환합니다."""
return {"path": path, "tests": 0}
if __name__ == "__main__":
mcp.run()
이렇게 고치자 핸드셰이크가 붙었습니다. initialize부터 tools/list까지 292밀리초, 협상된 프로토콜 버전은 2025-11-25였습니다.
클라이언트 코드도 같이 깨집니다
서버를 고쳤더니 이번에는 클라이언트가 죽었습니다.
AttributeError: 'InitializeResult' object has no attribute 'serverInfo'. Did you mean: 'server_info'?
와이어 포맷은 그대로 camelCase이고 파이썬 속성만 snake_case로 바뀐 것이라, 통신은 멀쩡한데 내 코드만 안 돌아갑니다. 여기서 놓치기 쉬운 함정이 하나 더 있습니다. 가이드가 명시하기를, 직접 model_dump()를 호출해 왔다면 v2에서는 by_alias=True를 붙여야 예전과 같은 camelCase가 나옵니다. 안 붙이면 snake_case 키가 나오는데 예외가 나지 않습니다. 상대 구현이 못 알아듣는 모양으로 조용히 나갑니다.
3. print 한 줄은 대개 조용하다가 서버가 끝나는 순간 튀어나옵니다
세 번째 실패가 제일 오래 걸렸습니다. 서버는 붙었고 도구도 돌았는데, 클라이언트 로그에 Failed to parse JSONRPC message from server가 간헐적으로 찍혔습니다.
세션이 도는 동안에는 fd 1이 stderr를 가리킵니다
도구 안에서 파일 디스크립터 상태를 직접 읽어 봤습니다.
@mcp.tool()
def probe() -> str:
"""세션 중 fd 0/1이 어디를 향하는지 확인합니다."""
import os
return (f"fd0={os.readlink('/proc/self/fd/0')} " # /dev/null
f"fd1_ino={os.fstat(1).st_ino} " # stderr와 같은 inode
f"fd2_ino={os.fstat(2).st_ino}")
반환값은 fd0=/dev/null fd1_ino=38890 fd2_ino=38890이었습니다. 서비스가 도는 동안 표준 입력은 널 디바이스로, 표준 출력은 stderr와 같은 파이프로 돌려집니다. 프로토콜 스트림은 별도의 비공개 디스크립터로 옮겨져 있어서, 핸들러 코드가 실수로 건드릴 수 없습니다. 가이드도 이 동작을 명시하고 있고, 종료할 때 원래대로 복원한다고 되어 있습니다.
복원한다는 그 한 마디가 함정의 정체였습니다.
flush를 했느냐 안 했느냐가 갈랐습니다
print 한 줄만 다른 서버 두 개를 같은 클라이언트로 돌렸습니다.
| 도구 안의 코드 | 마커가 나온 곳 | 클라이언트 파싱 오류 |
|---|---|---|
print("MARKER_LINE") |
프로토콜 채널 | 발생 |
print("MARKER_LINE", flush=True) |
stderr (정상 로그) | 없음 |
파이프에 연결된 stdout은 줄 단위 버퍼링이 아니라 블록 버퍼링입니다. 즉시 flush하면 리다이렉션이 살아 있는 동안 나가서 안전한 로그가 되고, 버퍼에 남으면 프로세스 종료 시점에 밀려 나오는데 그때는 fd 1이 이미 프로토콜 파이프로 되돌아가 있습니다. 그래서 잘 돌던 서버가 종료하는 순간에만 프로토콜을 오염시킵니다.
재현 조건이 "서버가 끝날 때"라서, 짧은 테스트에서는 잡히고 오래 붙여 두면 안 잡힙니다. 반대여야 마음이 편한데 반대가 아닙니다.
그래서 로깅은 stderr로 명시하는 편이 낫습니다
logging 기본 핸들러는 stderr로 나가므로 그대로 쓰면 됩니다. 문제는 디버깅하다 남긴 print입니다. 저는 서버 파일 맨 위에 이 두 줄을 넣어 두기로 했습니다.
import sys, functools
# 습관적으로 남긴 print가 프로토콜 채널로 새지 않도록 목적지를 고정합니다
print = functools.partial(print, file=sys.stderr, flush=True)
가이드에는 클라이언트가 stderr 파이프를 비우지 않으면 서버에 역압이 걸린다는 주의도 함께 적혀 있습니다. 로그를 무한정 쏟아 내는 서버는 그 자체로 위험합니다.
4. 도구를 늘리면 tools/list가 그만큼 컨텍스트를 먹습니다
붙이는 데 성공하고 나니 다음 질문이 생겼습니다. 도구를 몇 개까지 올려도 되는가.
도구당 491바이트가 일정하게 붙었습니다
인자 3개에 한 줄짜리 한글 설명을 가진 도구를 개수만 바꿔 가며 실제 서버를 띄우고, tools/list 응답을 UTF-8 바이트로 쟀습니다.
| 도구 수 | 설명 | tools/list 바이트 | 도구당 |
|---|---|---|---|
| 1 | 짧게 | 502 | 502 |
| 5 | 짧게 | 2,466 | 493 |
| 10 | 짧게 | 4,921 | 492 |
| 20 | 짧게 | 9,831 | 491 |
| 40 | 짧게 | 19,651 | 491 |
| 20 | 없음 | 9,171 | 458 |
| 20 | 두 문장 | 12,831 | 641 |
첫 도구 이후 증가분은 정확히 491바이트로 일정했습니다. 이 페이로드는 모델이 도구를 고를 수 있게 하려고 매 요청에 실려 갑니다. 도구 40개짜리 서버 하나가 2만 바이트 가까이 차지하고, 서버를 셋 붙이면 그만큼 곱해집니다.
설명을 지우는 것은 대책이 아닙니다
docstring을 전부 지우면 20개 기준 660바이트, 6.7퍼센트가 줄었습니다. 대신 모델이 도구를 언제 써야 하는지 알 수 없게 됩니다. 반대로 설명을 두 문장으로 늘리면 30.5퍼센트가 늘었습니다. 줄이려면 설명이 아니라 도구 수를 줄여야 한다는 뜻입니다.
스키마 생성에도 걸리는 데가 있었습니다. 반환 타입을 -> float나 -> str로 적으면 outputSchema가 자동으로 붙는데, -> dict는 붙지 않았습니다. 형태를 특정할 수 없기 때문입니다. 구조화된 출력이 필요하면 반환 타입을 구체적으로 적어야 합니다.
어디서 멈출 것인가
도구를 추가할 때마다 이 세 항목을 봅니다.
| 판단 항목 | 도구 5개 이하 | 도구 20개 안팎 | 도구 40개 이상 |
|---|---|---|---|
| 도입 난이도 | 낮음. 데코레이터 몇 줄 | 낮음. 코드는 그대로 | 낮음 |
| 유지보수 비용 | 낮음 | 중간. 이름 충돌과 설명 중복 관리 | 높음. 어느 도구가 안 쓰이는지 추적 필요 |
| 팀 학습곡선 | 낮음 | 중간 | 높음. 사람이 목록을 다 못 외움 |
| 매 요청 페이로드 | 약 2.5KB | 약 9.8KB | 약 19.7KB |
QA 4~6명 규모에서 제가 잡은 선은 서버당 10개입니다. 근거는 페이로드보다 사람 쪽입니다. 도구가 열 개를 넘어가면 팀원이 무엇이 있는지 몰라서 안 쓰기 시작합니다.
5. 붙이기 전에 30분만 쓰신다면 이 순서로
제가 반나절을 태우고 정리한 순서입니다. 새 MCP 서버를 붙일 때마다 이대로 합니다.
# 1. 설치된 버전을 먼저 확인합니다. 2.x면 v1 예제 코드는 그대로 안 돕니다
pip show mcp | head -2
# 2. 클라이언트에 붙이기 전에 서버 단독으로 핸드셰이크를 확인합니다
python3 diagnose.py server.py
# 3. handshake:false 면 stderr_tail 을, stdout_noise 가 있으면 그 줄을 지웁니다
# 4. 붙은 뒤 tools/list 크기를 한 번 재 두고 도구를 늘릴 때마다 다시 잽니다
점검 항목은 다섯 줄로 줄었습니다.
□ mcp 버전이 2.x인가 (맞다면 FastMCP·camelCase·McpError 세 곳을 고쳤는가)
□ 서버 단독 실행에서 exit=0 이고 handshake=true 인가
□ stdout_noise 가 비어 있는가
□ 도구 안에 남은 print 가 없는가 (있다면 stderr 로 고정했는가)
□ tools/list 바이트를 알고 있는가
이 방법이 맞지 않는 경우
원격 트랜스포트를 쓴다면 3절은 해당이 없습니다. fd 리다이렉션은 stdio 전용 동작입니다. 그리고 진단 스크립트는 initialize 한 통만 보내므로, 도구 실행 중에 죽는 서버는 못 잡습니다. 그 경우는 서버 stderr를 파일로 리다이렉트해 두고 재현하는 편이 빠릅니다.
지금 당장 v2로 옮길 이유는 없습니다
가이드는 v1 유지보수 라인이 중대한 버그 수정과 보안 패치를 계속 받는다고 적고 있습니다. 종료 날짜는 어디에도 없습니다. 돌아가는 서버가 있다면 mcp<2로 상한을 걸어 두고, 새로 만드는 것부터 v2로 쓰는 편이 낫습니다. 저는 그렇게 했습니다.
다만 상한을 걸어 두지 않은 상태에서 누군가 pip install -U를 한 번 돌리면, 그날부터 팀 전체가 Connection closed를 보게 됩니다. 그게 제가 겪은 일이었습니다.
댓글
댓글 쓰기