MCP 서버 만들기, 파이썬으로 테스트 실패만 읽어 주는 도구 붙이기

3줄 요약
- 튜토리얼의 FastMCP 코드는 mcp 2.3.0에서 import부터 막힙니다.
- MCPServer로 바꾸고 stdio로 부르면 도구 하나가 바로 돌아갑니다.
- 서버 쪽 print 한 줄이 연결을 끊으니 로그는 stderr로 보냅니다.
pip install mcp를 하고 FastMCP를 쓰는 튜토리얼 코드를 돌려 봤더니 import 줄에서 바로 멈췄습니다. 한국어 튜토리얼 중에는 위키독스 「나만의 MCP 서버 만들기」처럼 이 이름을 쓰는 글이 있습니다.
에러 문구에 원인이 그대로 적혀 있었는데요, 2.x부터 FastMCP가 MCPServer로 이름이 바뀌었다는 내용입니다. 새 이름으로 서버를 만들고 도구를 하나 붙여 호출까지 해 봤습니다. 도구는 JUnit 결과 파일에서 실패한 테스트만 골라 줍니다.
실행은 Linux 컨테이너(Python 3.13.16)에서 했고, 예제 결과 파일은 테스트 5개짜리로 제가 직접 만들었습니다. Claude Code에 붙이는 마지막 부분은 직접 돌리지 않고 공식 문서의 명령을 옮겼습니다.
pip install mcp 다음에 만나는 에러
설치된 버전부터 봅니다
PyPI의 mcp 최신 버전은 2.3.0이고, 한국 시간 10월 3일 오전 7시에 올라왔습니다. 가상환경을 새로 만들어 설치하면 이 버전을 받게 됩니다.
# 가상환경을 만들고 mcp 를 설치합니다
python3 -m venv v && . v/bin/activate
pip install mcp
pip list | grep -E '^mcp ' # mcp 2.3.0
옛 튜토리얼의 import는 에러가 납니다
1.x 시절 코드는 from mcp.server.fastmcp import FastMCP로 시작합니다. 2.3.0에서 이 줄을 실행하면 아래 에러가 나옵니다.
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.
해결 방법은 두 가지입니다. 기존 코드를 그대로 두려면 pip install "mcp<2"로 버전을 고정하고, 새로 시작한다면 이름을 바꿉니다. 여기서는 이름을 바꾸는 쪽으로 갑니다. 이름 변경 내용은 공식 마이그레이션 가이드에 있습니다.
이 에러가 클라이언트에는 Connection closed 한 줄로만 보일 때가 있습니다. 원인은 서버의 stderr에 남아 있고, 읽는 법은 MCP 서버가 Connection closed로만 보일 때 stderr 읽는 법에 적어 두었습니다.
JUnit 실패 목록을 돌려주는 서버
예제 결과 파일
pytest라면 --junitxml=results.xml로 이 형식의 파일이 만들어집니다. 예제는 로그인 테스트 5개 중 2개가 실패한 파일입니다.
<testsuite name="login-suite" tests="5" failures="2" errors="0" time="12.4">
<testcase classname="login" name="test_valid_login" time="1.2"/>
<testcase classname="login" name="test_lockout_after_5" time="3.1">
<failure message="expected 423 but got 401">AssertionError: expected 423 but got 401</failure>
</testcase>
<testcase classname="login" name="test_session_timeout" time="5.8">
<failure message="TimeoutError: element #logout not found">TimeoutError: element #logout not found</failure>
</testcase>
</testsuite>
위 예시는 일부만 줄여 실었고, 실제 파일에는 통과한 테스트가 2개 더 있습니다.
서버 코드
서버는 함수 하나에 데코레이터 하나입니다. 타입 힌트로 입력 스키마가 만들어지고, docstring은 도구 설명으로 쓰입니다.
# server.py — JUnit XML 에서 실패한 테스트만 돌려주는 MCP 서버
import xml.etree.ElementTree as ET
from mcp.server import MCPServer
mcp = MCPServer("junit-reader")
@mcp.tool()
def failed_tests(path: str) -> list[dict]:
"""JUnit XML 파일에서 실패한 테스트의 이름과 메시지를 돌려줍니다."""
root = ET.parse(path).getroot()
out = []
for tc in root.iter("testcase"):
f = tc.find("failure") # failure 태그가 있으면 실패한 테스트
if f is not None:
out.append({"test": f"{tc.get('classname')}.{tc.get('name')}",
"message": f.get("message")})
return out
if __name__ == "__main__":
mcp.run() # 전송 방식을 안 적으면 stdio
코드에서는 from mcp.server import MCPServer를 썼습니다. 마이그레이션 가이드에는 mcp.server.mcpserver에서 가져오라고 되어 있지만, 두 경로 모두 2.3.0에서 돌았습니다.
클라이언트로 도구를 불러 보기
문자열을 넘기면 URL로 처리합니다
Claude Code에 붙이기 전에 같은 패키지의 Client로 서버가 도는지부터 확인했습니다. 처음에는 서버 실행 명령을 문자열 그대로 넘겼는데요, 바로 막혔습니다.
async with Client("python server.py") as c: # 이렇게 하면 실패합니다
결과는 httpx2.UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol.이었습니다. Client에 문자열을 넘기면 Streamable HTTP 주소로 처리합니다. 설치된 버전의 도움말에도 URL 문자열은 Streamable HTTP용이고, 서브프로세스로 띄울 때는 StdioServerParameters를 쓰라고 되어 있습니다.
stdio로 띄워서 호출합니다
# client.py — 서버를 서브프로세스로 띄워 도구 목록 조회와 호출을 한 번씩 합니다
import asyncio, sys
from mcp import Client, StdioServerParameters
async def main():
params = StdioServerParameters(command=sys.executable, args=["server.py"])
async with Client(params) as c:
tools = await c.list_tools()
for t in tools.tools:
print("도구:", t.name, "|", t.description)
print("스키마:", t.input_schema)
r = await c.call_tool("failed_tests", {"path": "results.xml"})
print("structured:", r.structured_content)
print("is_error:", r.is_error)
asyncio.run(main())
도구: failed_tests | JUnit XML 파일에서 실패한 테스트의 이름과 메시지를 돌려줍니다.
스키마: {'type': 'object', 'properties': {'path': {'title': 'Path', 'type': 'string'}}, 'required': ['path'], 'title': 'failed_testsArguments'}
structured: {'result': [{'test': 'login.test_lockout_after_5', 'message': 'expected 423 but got 401'}, {'test': 'login.test_session_timeout', 'message': 'TimeoutError: element #logout not found'}]}
is_error: False
실패 2건이 이름과 메시지까지 그대로 출력됐습니다. 반환 타입을 list[dict]로 적었더니 결과가 result 키 안에 들어가므로, 꺼낼 때는 r.structured_content["result"]를 씁니다.
서버가 조용히 실패하는 경우
도구를 붙인 뒤 손본 부분은 세 가지였습니다. 모두 같은 파일로 재현했습니다.
| 증상 | 원인 | 고치는 법 |
|---|---|---|
클라이언트가 Invalid JSON ... input_value='디버그 출력'으로 죽음 |
서버가 stdout에 print를 함 | print(..., file=sys.stderr) |
도구 실패가 Error executing tool failed_tests로만 보임 |
일반 예외는 원인 문구가 클라이언트에 전달되지 않음 | ToolError로 이유를 적어 던짐 |
../../../etc/passwd 같은 경로도 받을 수 있음 |
경로 인자를 검사하지 않음 | 기준 폴더 밖이면 거부 |
stdout으로 print 하면 연결이 끊깁니다
stdio 서버는 표준 출력으로 클라이언트와 JSON 메시지를 주고받습니다. 그 출력 사이에 디버그용 print가 섞이면 클라이언트가 JSON으로 읽지 못합니다. 서버 파일 맨 위에 print("서버 시작합니다")를 넣어도, 도구 함수 안에 print("디버그 출력")을 넣어도 같은 ValidationError가 났습니다. 로그가 필요하면 file=sys.stderr로 보내면 되고, 그렇게 바꾸니 호출 결과는 정상이었고, 로그는 따로 출력됐습니다.
예외는 이유를 적어서 던집니다
없는 파일 nope.xml을 주면 is_error: True는 오지만 내용은 Error executing tool failed_tests가 전부였습니다. 호출하는 쪽이 모델이면 이유를 알 수 없어 같은 호출을 되풀이할 수 있습니다. mcp.server.mcpserver.exceptions의 ToolError를 던지도록 바꾸면 문구가 그대로 전달됩니다.
경로 인자는 기준 폴더 안으로 제한합니다
path를 문자열 그대로 받으면 서버 프로세스가 읽을 수 있는 파일은 무엇이든 열게 됩니다. 아래처럼 기준 폴더 밖이면 거부하게 바꿨습니다.
# server2.py 의 도구 부분 — 기준 폴더 밖과 없는 파일을 거부하고 이유를 알려 줍니다
from pathlib import Path
from mcp.server.mcpserver.exceptions import ToolError
BASE = Path(__file__).parent.resolve()
@mcp.tool()
def failed_tests(path: str) -> list[dict]:
"""BASE 폴더 안의 JUnit XML 에서 실패한 테스트를 돌려줍니다."""
p = (BASE / path).resolve()
if BASE not in p.parents:
raise ToolError(f"허용 폴더 밖입니다: {path}")
if not p.exists():
raise ToolError(f"파일이 없습니다: {path}")
... # 이후는 앞의 server.py 와 같습니다
results.xml | is_error: False
nope.xml | is_error: True | Error executing tool failed_tests: 파일이 없습니다: nope.xml
../../../etc/passwd | is_error: True | Error executing tool failed_tests: 허용 폴더 밖입니다: ../../../etc/passwd
Claude Code에 붙이기 전, 팀에 쓰기 전에 확인할 점
등록 명령
로컬 stdio 서버는 --transport 없이 -- 뒤에 실행 명령을 적습니다. 공식 빠른 시작의 Playwright 예제를 이 서버에 맞게 바꾼 명령입니다. 경로는 예시이고, 제 컨테이너에서는 이 명령을 실행하지 않았습니다.
# 서버 이름 junit, 실행 명령은 -- 뒤에 적습니다 (경로는 본인 환경에 맞게 바꿉니다)
claude mcp add junit -- python /path/to/server2.py
claude mcp list # ✔ Connected 가 나오면 붙은 것입니다
공식 문서에 따르면 claude mcp add는 설정만 저장하고 서버가 실제로 뜨는지는 알려 주지 않으므로, 상태는 claude mcp list로 확인합니다. 기본 범위는 현재 프로젝트이고, 팀원과 같이 쓰려면 --scope project로 .mcp.json에 기록하고 저장소에 올립니다.
pytest 실행이 느려서 이런 도구를 고민한다면 변경한 코드에 걸린 테스트만 다시 돌리는 방법도 같이 보시면 좋습니다.
팀에 넣을지 가늠하는 표
| 항목 | 이 서버 기준 |
|---|---|
| 도입 난이도 | 낮음. 파일 하나와 의존성 하나이고, 등록은 명령 한 줄 |
| 유지보수 비용 | 보통. SDK가 1.x에서 2.x로 올라가며 import가 안 돼서 버전을 고정해야 함 |
| 팀 학습곡선 | 낮음. 팀원이 읽는 것은 데코레이터 함수 하나 |
| 먼저 막을 것 | 경로 인자 검사, stdout 출력 금지, 에러 문구 |
도구를 만드는 것보다 도구가 읽을 수 있는 범위를 정하는 쪽이 손이 더 갔습니다. 처음에는 path를 그대로 받았고, 경로 검사를 넣은 뒤에야 팀 저장소에 올려도 되겠다 싶었습니다. 다만 팀에서 실제로 써 본 것은 아니어서요, 표의 평가는 이 예제를 만들어 본 느낌입니다.
따라 해 볼 순서
- 가상환경에서 pip install mcp 를 실행하고 버전이 2.x인지 확인
- server.py 를 만들고 from mcp.server import MCPServer 로 시작
- client.py 로 list_tools / call_tool 을 한 번 호출
- 서버 안의 print 를 전부 stderr 로 바꾸거나 지움
- 경로 인자는 기준 폴더 안으로 제한하고 ToolError 로 이유를 적음
- claude mcp add ... -- python server.py 로 등록하고 claude mcp list 확인
댓글
댓글 쓰기