MCP SDK 2.3.0 FastMCP ModuleNotFoundError와 MCPServer 전환
mcp 2.3.0에서 from mcp.server.fastmcp import FastMCP를 실행하자마자 첫 임포트에서 ModuleNotFoundError가 났습니다. 패키지를 올렸더니 서버가 시작부터 멈추면 도구 등록이나 인자 스키마부터 의심하기 쉽지만, 이번에 멈춘 곳은 그보다 앞의 임포트 줄입니다. 이 실패를 배포 전에 잡아 두고, MCP 파이썬 SDK를 운영용 도구 서버에 들이면서 MCPServer로 옮길지 기존 코드를 지킬지 판단할 근거를 모으는 것이 이번 테스트의 목적입니다. Debian 13의 Python 3.12 가상환경에서 기존 예제가 내는 임포트 오류와 고친 뒤의 실제 호출 결과를 stdio 클라이언트로 실행해 기록했습니다.
패키지 이름은 mcp, 공식 저장소는 modelcontextprotocol/python-sdk입니다. 여기서 MCP라고 하면 그 파이썬 SDK로 작은 도구 서버를 띄우고, 언어 모델 없이 stdio 클라이언트가 함수를 직접 부르는 범위를 말합니다. 저장소, PyPI, 마이그레이션 가이드의 HTTP 상태는 아래 출력 블록에 있습니다.
MCP filesystem Access denied: 허용 폴더 밖 읽기는 막힙니다 글은 허용 폴더 밖 경로가 거부되는 원인과 경로 제한 설정을 설명합니다.
Debian 13의 Python 3.12 가상환경에서 그 오류를 재현한 뒤, 같은 도구 두 개를 부르는 길 두 가지를 비교했습니다. 하나는 mcp 2.3.0을 유지하면서 MCPServer로 바꾸는 길이고, 다른 하나는 오류 문장이 안내하는 대로 mcp<2를 설치해 FastMCP 코드를 그대로 두는 길입니다. 도구는 정수 두 개를 더하는 add와 한글 음절을 세는 count_hangul입니다.
패키지가 깔렸다는 것과 도구가 불린다는 것은 서로 다른 이야기입니다. 설치가 끝나 있어도 임포트 경로가 바뀌면 서버는 요청을 받기도 전에 종료합니다. 그래서 설치된 버전, 서버 초기화 정보, 도구 목록, 반환값을 순서대로 이어 가며 어디서 끊기는지를 찾았습니다.
환경 표는 서로 다른 SDK를 어느 가상환경에서 돌렸는지 보여 줍니다. Python 3.12.15는 같고, MCPServer 경로는 mcp 2.3.0, FastMCP 유지 경로는 mcp 1.30.0입니다. 운영에서도 실행 파일과 의존성 묶음을 같이 적어 두지 않으면, 같은 서버 소스가 어디서는 되고 어디서는 안 되는 이유를 설명할 수 없습니다.
| 항목 | 값 |
|---|---|
| OS | Debian GNU/Linux 13 (trixie) |
| Python | 3.12.15 |
| MCPServer 경로 | mcp 2.3.0 (venv23) |
| FastMCP 유지 경로 | mcp 1.30.0 (venv1, 설치 조건 mcp<2) |
| 실행일 | 2026-10-14 |
mcp 2.3.0에서 FastMCP 임포트가 첫 줄에서 멈추는 이유
재현용 서버는 v1 예제처럼 FastMCP를 임포트하고 파일 끝에서 mcp.run()을 부릅니다. 서버 이름은 ainewsnote-demo이고, add는 정수 a와 b의 합을 돌려줍니다. count_hangul은 문자가 가 이상 힣 이하일 때만 세기 때문에 입력 문자열과 결과를 맞춰 보기 쉽습니다. mcp 2.3.0에서는 첫 줄에서 예외가 나서 도구 본문까지 가지도 못합니다.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ainewsnote-demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 정수를 더한다"""
return a + b
@mcp.tool()
def count_hangul(text: str) -> int:
"""문자열의 한글 음절 수를 센다"""
return sum("가" <= c <= "힣" for c in text)
if __name__ == "__main__":
mcp.run()
가상환경은 venv23, 인터프리터는 Python 3.12입니다. 패키지를 mcp==2.3.0으로 지정해 임포트 오류가 나던 설치 상태를 그대로 만듭니다. OS, 파이썬, 패키지 버전을 찍은 다음 빈 표준입력으로 서버 파일을 실행합니다. 연결 테스트보다 앞선 서버 시작 단계에서 실패 지점을 떼어 내려는 명령입니다.
uv venv -q venv23 -p 3.12
uv pip install -p venv23 'mcp==2.3.0'
grep ^PRETTY_NAME= /etc/os-release
venv23/bin/python -V
venv23/bin/python -c 'import importlib.metadata as m; print("mcp", m.version("mcp"))'
date -I
timeout 20 venv23/bin/python src/server_v1.py < /dev/null || echo "exit:$?"
출력을 보면 패키지 설치가 성공한 뒤 서버 실행이 실패합니다. 설치 버전은 mcp 2.3.0인데 트레이스백 마지막은 ModuleNotFoundError이고, 종료 표시는 exit:1입니다. 예외 문장이 MCPServer 전환과 mcp<2 고정을 함께 제시하니, 설치를 되풀이할 것이 아니라 코드와 패키지 계열을 맞춰야 합니다.
$ rm -rf venv23 src work && mkdir -p src
$ uv venv -q venv23 -p 3.12
$ uv pip install -p venv23 mcp==2.3.0
Using Python 3.12.15 environment at: venv23
⠋ Resolving dependencies... ⠙ Resolving dependencies... ⠋ Resolving dependencies... ⠙ Resolving dependencies... ⠙ mcp==2.3.0 ⠙ mcp-types==2.3.0 ⠙ anyio==4.15.1 ⠙ anyio==4.15.1 ⠙ httpx2==2.13.1 ⠙ anyio==4.15.1 ⠹ anyio==4.15.1 ⠹ httpcore2==2.13.1 ⠹ httpcore2==2.13.1 ⠹ jsonschema==4.26.0 ⠹ opentelemetry-api==1.45.1 ⠹ pydantic==2.14.1 ⠹ pydantic-core==2.50.1 ⠹ pyjwt==2.15.1 ⠹ pyjwt==2.15.1 ⠹ python-multipart==0.0.32 ⠹ sse-starlette==3.5.0 ⠹ starlette==1.8.0 ⠹ starlette==1.8.0 ⠹ typing-extensions==4.16.0 ⠹ typing-extensions==4.16.0 ⠹ typing-extensions==4.16.0 ⠹ typing-inspection==0.4.4 Resolved 28 packages in 263ms
░░░░░░░░░░░░░░░░░░░░ [0/0] Installing wheels... ░░░░░░░░░░░░░░░░░░░░ [0/28] Installing wheels... ░░░░░░░░░░░░░░░░░░░░ [0/28] click==8.5.0 ░░░░░░░░░░░░░░░░░░░░ [1/28] click==8.5.0 ░░░░░░░░░░░░░░░░░░░░ [1/28] annotated-types==0.8.0 █░░░░░░░░░░░░░░░░░░░ [2/28] annotated-types==0.8.0 █░░░░░░░░░░░░░░░░░░░ [2/28] typing-inspection==0.4.4 ██░░░░░░░░░░░░░░░░░░ [3/28] typing-inspection==0.4.4 ██░░░░░░░░░░░░░░░░░░ [3/28] attrs==26.1.0 ██░░░░░░░░░░░░░░░░░░ [4/28] attrs==26.1.0 ██░░░░░░░░░░░░░░░░░░ [4/28] anyio==4.15.1 ███░░░░░░░░░░░░░░░░░ [5/28] anyio==4.15.1 ███░░░░░░░░░░░░░░░░░ [5/28] rpds-py==2026.9.1 ████░░░░░░░░░░░░░░░░ [6/28] rpds-py==2026.9.1 ████░░░░░░░░░░░░░░░░ [6/28] cffi==2.1.1 █████░░░░░░░░░░░░░░░ [7/28] cffi==2.1.1 █████░░░░░░░░░░░░░░░ [7/28] pyjwt==2.15.1 █████░░░░░░░░░░░░░░░ [8/28] pyjwt==2.15.1 █████░░░░░░░░░░░░░░░ [8/28] truststore==0.10.5 ██████░░░░░░░░░░░░░░ [9/28] truststore==0.10.5 Installed 28 packages in 10ms
+ annotated-types==0.8.0
+ anyio==4.15.1
+ attrs==26.1.0
+ cffi==2.1.1
+ click==8.5.0
+ cryptography==50.0.2
+ h11==0.16.0
+ httpcore2==2.13.1
+ httpx2==2.13.1
+ idna==3.20
+ jsonschema==4.26.0
+ jsonschema-specifications==2025.9.1
+ mcp==2.3.0
+ mcp-types==2.3.0
+ opentelemetry-api==1.45.1
+ pycparser==3.11
+ pydantic==2.14.1
+ pydantic-core==2.50.1
+ pyjwt==2.15.1
+ python-multipart==0.0.32
+ referencing==0.37.0
+ rpds-py==2026.9.1
+ sse-starlette==3.5.0
+ starlette==1.8.0
+ truststore==0.10.5
+ typing-extensions==4.16.0
+ typing-inspection==0.4.4
+ uvicorn==0.54.0
$ grep ^PRETTY_NAME= /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
$ venv23/bin/python -V
Python 3.12.15
$ venv23/bin/python -c "import importlib.metadata as m; print(\"mcp\", m.version(\"mcp\"))"
mcp 2.3.0
$ date -I
2026-10-14
$ cat > src/server_v1.py << 'PY'
$ timeout 20 venv23/bin/python src/server_v1.py
Traceback (most recent call last):
File "/home/user/what/run/src/server_v1.py", line 1, in <module>
from mcp.server.fastmcp import FastMCP
File "/home/user/what/run/venv23/lib/python3.12/site-packages/mcp/server/fastmcp.py", line 16, in <module>
raise ModuleNotFoundError(_MESSAGE, name=__name__)
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.
exit:1
$ curl -sI -o /dev/null -w "%{http_code} %{url_effective}\n" --max-time 15 https://py.sdk.modelcontextprotocol.io/v2/migration/
200 https://py.sdk.modelcontextprotocol.io/v2/migration/
$ curl -sI -o /dev/null -w "%{http_code} %{url_effective}\n" --max-time 15 https://py.sdk.modelcontextprotocol.io/v1/
200 https://py.sdk.modelcontextprotocol.io/v1/
$ curl -sI -o /dev/null -w "%{http_code} %{url_effective}\n" --max-time 15 https://pypi.org/project/mcp/2.3.0/
200 https://pypi.org/project/mcp/2.3.0/
$ curl -sI -o /dev/null -w "%{http_code} %{url_effective}\n" --max-time 15 https://github.com/modelcontextprotocol/python-sdk
200 https://github.com/modelcontextprotocol/python-sdk

캡처 속 트레이스백에는 작업용 상위 폴더 이름까지 찍혀 있고, 본문 출력은 그 앞부분만 /home/user/what으로 바꿔 적었습니다. 파일 이름과 줄 번호, 오류 내용은 그대로입니다.
같은 실행에서 FastMCP 임포트가 막힌 화면입니다. 예외는 No module named 'mcp.server.fastmcp'로 시작하고, mcp 2.x에서 FastMCP가 MCPServer로 바뀌었다고 알려 줍니다. 새 임포트로는 from mcp.server.mcpserver import MCPServer를, 기존 코드를 지키려면 mcp<2를 쓰라고 적혀 있습니다. 화면의 exit:1은 도구 실행 중 오류가 아니라 시작 단계에서 실패했다는 뜻으로 읽었습니다.
트레이스백을 따라가면 예외는 사용자 파일의 첫 임포트에서 났고, 설치된 패키지의 mcp/server/fastmcp.py가 ModuleNotFoundError를 일으켰습니다. 모듈 파일이 없어서 경로 자체가 비어 있는 경우와는 출력 모양이 다릅니다. 패키지가 그 자리에 이전 안내를 담은 예외를 심어 둔 것입니다.
공식 v1 문서는 v1을 유지보수 라인으로 안내하면서, v1을 계속 쓸 때는 의존성 상한을 mcp<2로 두라고 설명합니다. 이번 실행에서 아래 주소는 모두 HTTP 200이었습니다.
가이드는 https://py.sdk.modelcontextprotocol.io/v2/migration/, v1 문서는 https://py.sdk.modelcontextprotocol.io/v1/, 패키지 2.3.0 페이지는 https://pypi.org/project/mcp/2.3.0/, 저장소는 https://github.com/modelcontextprotocol/python-sdk입니다. 상태 코드는 같은 출력의 curl 행에 있고, 확인에 쓴 명령은 다음과 같습니다.
curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' --max-time 15 https://py.sdk.modelcontextprotocol.io/v2/migration/
curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' --max-time 15 https://py.sdk.modelcontextprotocol.io/v1/
curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' --max-time 15 https://pypi.org/project/mcp/2.3.0/
curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' --max-time 15 https://github.com/modelcontextprotocol/python-sdk
MCPServer로 바꾸고 stdio로 add·count_hangul 부르기
2.3.0을 그대로 두면서 고칠 곳은 임포트와 생성자 두 군데입니다. FastMCP 자리에 MCPServer를 넣고, 이름 문자열 ainewsnote-demo만 위치 인자로 넘깁니다. @mcp.tool()과 두 함수의 인자는 v1 파일과 똑같이 둡니다. 아래 서버는 그렇게 등록해서 실제 호출까지 끝났습니다.
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("ainewsnote-demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 정수를 더한다"""
return a + b
@mcp.tool()
def count_hangul(text: str) -> int:
"""문자열의 한글 음절 수를 센다"""
return sum("가" <= c <= "힣" for c in text)
if __name__ == "__main__":
mcp.run()
클라이언트는 mcp.Client에 StdioServerParameters를 넘깁니다. command는 지금 돌고 있는 파이썬, args는 같은 디렉터리의 server.py이고, mode는 지정하지 않았습니다. 접속한 뒤 서버 이름, 서버 버전, 협상된 protocol을 찍고, list_tools로 이름과 설명, input_schema의 속성 이름을 찍습니다. 그다음 add에 a 2와 b 40을, count_hangul에 텍스트 MCP 서버 테스트를 넣습니다.
import asyncio
import sys
from pathlib import Path
from mcp import Client
from mcp.client.stdio import StdioServerParameters
SERVER = Path(__file__).with_name("server.py")
async def main() -> None:
params = StdioServerParameters(command=sys.executable, args=[str(SERVER)])
async with Client(params) as client:
info = client.session.server_info
print(
f"server_name={info.name!r} server_version={info.version!r} "
f"protocol={client.session.protocol_version}"
)
listed = await client.list_tools()
for tool in listed.tools:
props = list(tool.input_schema.get("properties", {}))
print(f"tool name={tool.name} desc={tool.description} inputs={props}")
calls = [
("add", {"a": 2, "b": 40}),
("count_hangul", {"text": "MCP 서버 테스트"}),
]
for name, args in calls:
result = await client.call_tool(name, args)
text = "".join(getattr(block, "text", "") for block in result.content)
print(
f"call name={name} args={args} structured={result.structured_content} "
f"is_error={result.is_error} text={text}"
)
if __name__ == "__main__":
asyncio.run(main())
클라이언트는 앞에서 만든 venv23의 파이썬으로 실행합니다. 클라이언트가 같은 인터프리터로 서버를 띄우니, 설치해 둔 mcp 2.3.0 환경과 호출 환경이 자연스럽게 맞춰집니다. 이 코드의 stdio 연결은 서버 프로세스를 직접 띄우는 방식이라 포트나 호스트 인자가 따로 필요 없고, 아래 명령 하나로 호출 결과까지 나옵니다.
venv23/bin/python -c 'import importlib.metadata as m; print("mcp", m.version("mcp"))'
timeout 30 venv23/bin/python -u src/client.py
MCPServer로 바꾼 서버의 초기화 정보와 도구 호출 결과입니다. add는 42, count_hangul은 5를 돌려줬고 두 호출 모두 is_error=False입니다. 임포트를 넘긴 데서 그치지 않고 도구 목록과 반환값까지 나왔으니, 클라이언트와 서버가 실제로 요청을 주고받았다고 봐도 됩니다.
$ test -x venv23/bin/python
$ venv23/bin/python -c "import importlib.metadata as m; print(\"mcp\", m.version(\"mcp\"))"
mcp 2.3.0
$ cat > src/server.py << 'PY'
$ cat > src/client.py << 'PY'
$ timeout 30 venv23/bin/python -u src/client.py
server_name='ainewsnote-demo' server_version='' protocol=2026-07-28
tool name=add desc=두 정수를 더한다 inputs=['a', 'b']
tool name=count_hangul desc=문자열의 한글 음절 수를 센다 inputs=['text']
call name=add args={'a': 2, 'b': 40} structured={'result': 42} is_error=False text=42
call name=count_hangul args={'text': 'MCP 서버 테스트'} structured={'result': 5} is_error=False text=5

캡처에서 서버 이름은 ainewsnote-demo, server_version은 빈 문자열, protocol은 2026-07-28입니다. add의 입력 이름은 a, b로, count_hangul의 입력 이름은 text로 나옵니다. 호출 결과는 각각 {'result': 42}와 {'result': 5}이고 텍스트에도 같은 값이 실렸습니다. 두 호출의 is_error가 False라는 것까지 같이 보면, 서버 버전 칸이 비어 있다고 호출이 실패한 것으로 오해할 일은 없습니다.
add는 a + b를 돌려주니 2와 40의 합이 42이고, 출력의 42가 그 반환입니다. count_hangul은 한글 음절 범위만 셉니다. 인자 MCP 서버 테스트에서 그 범위에 드는 글자는 서, 버, 테, 스, 트로 호출 결과 5와 맞고, 라틴 문자와 공백은 범위 밖입니다. 정수 반환은 텍스트 42, 5와 structured의 result에 함께 실렸습니다.
이번 생성자에는 이름만 넘겼기 때문에 출력의 server_version은 빈 문자열입니다. 설치된 SDK 버전은 앞의 패키지 버전 행에서 봐야 합니다.
이 길의 좋은 점은 mcp 2.3.0을 유지한 채 stdio 호출이 끝났다는 것입니다. add는 42, count_hangul은 5, is_error는 False였고, 도구 함수와 @mcp.tool()은 v1 파일 그대로였습니다. 바꾼 줄은 임포트와 생성자 클래스뿐입니다. 걸리는 점도 있습니다. 같은 가상환경에서 FastMCP 임포트는 앞 절의 ModuleNotFoundError로 끝나니, 예제 코드를 붙여 넣기 전에 임포트부터 바꿔야 합니다. 결과 필드는 is_error, 입력 스키마는 input_schema라서 v1 이름인 isError와 inputSchema를 그대로 읽으면 속성 오류가 나고, 그 출력은 다음 절에 있습니다. 버전을 넘기지 않으면 server_version이 빈 문자열로 남아, 패키지 버전 2.3.0이 서버 버전 칸에 저절로 찍히지도 않았습니다.
v1 코드를 그대로 두는 길, 그리고 2.3.0에서 걸리는 필드·인자 오류
오류 문장이 알려 준 다른 길은 mcp<2로 고정하는 것입니다. 공식 v1 문서도 v1을 계속 쓸 때 그 상한을 걸라고 안내합니다. venv1을 2.3.0 환경과 떼어 만들고, 앞의 server_v1.py를 손대지 않은 채 불렀습니다. 클라이언트는 v1에서 동작하던 stdio_client와 ClientSession.initialize를 쓰고, 도구 속성은 inputSchema, 결과는 isError로 읽습니다.
import asyncio
import sys
from pathlib import Path
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
SERVER = Path(__file__).with_name("server_v1.py")
async def main() -> None:
params = StdioServerParameters(command=sys.executable, args=[str(SERVER)])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
init = await session.initialize()
print(
f"server_name={init.serverInfo.name!r} "
f"server_version={init.serverInfo.version!r} "
f"protocol={init.protocolVersion}"
)
listed = await session.list_tools()
for tool in listed.tools:
props = list((tool.inputSchema or {}).get("properties", {}))
print(f"tool name={tool.name} desc={tool.description} inputs={props}")
calls = [
("add", {"a": 2, "b": 40}),
("count_hangul", {"text": "MCP 서버 테스트"}),
]
for name, args in calls:
result = await session.call_tool(name, args)
text = "".join(getattr(block, "text", "") for block in result.content)
print(
f"call name={name} args={args} isError={result.isError} "
f"text={text} structured={result.structuredContent}"
)
if __name__ == "__main__":
asyncio.run(main())
기존 서버 파일을 지키는 경로에서는 설치 조건을 mcp<2로 둡니다. 아래 명령은 venv1을 따로 만들고 설치된 버전을 찍은 뒤 v1 클라이언트를 돌립니다. 이번에 실제로 깔린 것은 mcp 1.30.0이었으니, 기록에는 의존성 상한과 실제 설치 버전을 따로 남겨야 합니다.
uv venv -q venv1 -p 3.12
uv pip install -p venv1 'mcp<2'
venv1/bin/python -V
venv1/bin/python -c 'import importlib.metadata as m; print("mcp", m.version("mcp"))'
timeout 30 venv1/bin/python -u src/client_v1.py
2.3.0 쪽에서는 v1 필드 이름과 잘못된 호출을 이어서 시험합니다. add 도구의 input_schema 속성을 찍고 나서 inputSchema를 읽고, 정상 인자로 is_error를 찍고 나서 isError를 읽습니다. 그다음 a에 문자열 둘을 넣은 add와, 등록하지 않은 delete_files를 부릅니다.
import asyncio
import sys
from pathlib import Path
from mcp import Client
from mcp.client.stdio import StdioServerParameters
SERVER = Path(__file__).with_name("server.py")
async def main() -> None:
params = StdioServerParameters(command=sys.executable, args=[str(SERVER)])
async with Client(params) as client:
listed = await client.list_tools()
tool = next(item for item in listed.tools if item.name == "add")
print(f"input_schema_props={list(tool.input_schema.get('properties', {}))}")
try:
print(f"inputSchema={tool.inputSchema}")
except AttributeError as exc:
print(f"inputSchema AttributeError: {exc}")
result = await client.call_tool("add", {"a": 2, "b": 40})
print(f"is_error={result.is_error}")
try:
print(f"isError={result.isError}")
except AttributeError as exc:
print(f"isError AttributeError: {exc}")
bad = [
("add", {"a": "둘", "b": 40}),
("delete_files", {}),
]
for name, args in bad:
result = await client.call_tool(name, args)
text = "".join(getattr(block, "text", "") for block in result.content)
print(f"call name={name} args={args} is_error={result.is_error} text={text}")
if __name__ == "__main__":
asyncio.run(main())
probe는 venv23에서 돌려 v2 결과 객체의 필드와 오류 반환을 봅니다. 정상 호출 뒤 옛 필드 이름을 읽는 단계와 잘못된 인자를 보내는 단계를 나눠 두었기 때문에, 클라이언트 쪽 속성 오류와 서버의 검증 실패가 섞이지 않습니다. 이어지는 명령은 mcp 1.30.0의 PyPI 페이지 상태를 확인할 뿐이고, 도구 호출이 됐는지는 앞의 probe 출력으로 판단합니다.
timeout 30 venv23/bin/python -u src/probe_v2.py
curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' --max-time 15 https://pypi.org/project/mcp/1.30.0/
v1 호출 성공과 v2 호환성 점검이 한 흐름에 찍혀 있습니다. mcp 1.30.0에서 반환값은 42와 5이고 isError=False입니다. 반면 v2에서 옛 필드를 읽으면 AttributeError가 납니다. 문자열 둘을 정수 인자로 보낸 경우와 등록하지 않은 delete_files를 부른 경우는 is_error=True로 돌아오니, 속성 접근 실패와는 성격이 다른 결과입니다.
$ rm -rf venv1
$ uv venv -q venv1 -p 3.12
$ uv pip install -p venv1 mcp<2
Using Python 3.12.15 environment at: venv1
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/0⠙ Resolving dependencies... ⠋ Resolving dependencies... ⠙ Resolving dependencies... ⠙ mcp==1.30.0 ⠙ anyio==4.15.1 ⠙ httpx-sse==0.4.3 ⠙ httpx==0.28.1 ⠙ jsonschema==4.26.0 ⠙ pydantic-settings==2.15.0 ⠙ pydantic==2.14.1 ⠙ pydantic==2.14.1 ⠙ pydantic-core==2.50.1 ⠙ pyjwt==2.15.1 ⠙ pyjwt==2.15.1 ⠙ python-multipart==0.0.32 ⠙ sse-starlette==3.5.0 ⠙ starlette==1.8.0 ⠙ starlette==1.8.0 ⠙ typing-extensions==4.16.0 ⠙ typing-extensions==4.16.0 ⠙ typing-extensions==4.16.0 Resolved 29 packages in 48ms
░░░░░░░░░░░░░░░░░░░░ [0/0] Installing wheels... ░░░░░░░░░░░░░░░░░░░░ [0/29] Installing wheels... ░░░░░░░░░░░░░░░░░░░░ [0/29] typing-extensions==4.16.0 ░░░░░░░░░░░░░░░░░░░░ [1/29] typing-extensions==4.16.0 ░░░░░░░░░░░░░░░░░░░░ [1/29] typing-inspection==0.4.4 █░░░░░░░░░░░░░░░░░░░ [2/29] typing-inspection==0.4.4 █░░░░░░░░░░░░░░░░░░░ [2/29] h11==0.16.0 ██░░░░░░░░░░░░░░░░░░ [3/29] h11==0.16.0 ██░░░░░░░░░░░░░░░░░░ [3/29] pycparser==3.11 ██░░░░░░░░░░░░░░░░░░ [4/29] pycparser==3.11 ██░░░░░░░░░░░░░░░░░░ [4/29] idna==3.20 ███░░░░░░░░░░░░░░░░░ [5/29] idna==3.20 ███░░░░░░░░░░░░░░░░░ [5/29] python-multipart==0.0.32 ████░░░░░░░░░░░░░░░░ [6/29] python-multipart==0.0.32 ████░░░░░░░░░░░░░░░░ [6/29] httpx==0.28.1 ████░░░░░░░░░░░░░░░░ [7/29] httpx==0.28.1 ████░░░░░░░░░░░░░░░░ [7/29] anyio==4.15.1 █████░░░░░░░░░░░░░░░ [8/29] anyio==4.15.1 █████░░░░░░░░░░░░░░░ [8/29] mcp==1.30.0 ██████░░░░░░░░░░░░░░ [9/29] mcp==1.30.0 Installed 29 packages in 29ms
+ annotated-types==0.8.0
+ anyio==4.15.1
+ attrs==26.1.0
+ certifi==2026.7.22
+ cffi==2.1.1
+ click==8.5.0
+ cryptography==50.0.2
+ h11==0.16.0
+ httpcore==1.0.9
+ httpx==0.28.1
+ httpx-sse==0.4.3
+ idna==3.20
+ jsonschema==4.26.0
+ jsonschema-specifications==2025.9.1
+ mcp==1.30.0
+ pycparser==3.11
+ pydantic==2.14.1
+ pydantic-core==2.50.1
+ pydantic-settings==2.15.0
+ pyjwt==2.15.1
+ python-dotenv==1.2.4
+ python-multipart==0.0.32
+ referencing==0.37.0
+ rpds-py==2026.9.1
+ sse-starlette==3.5.0
+ starlette==1.8.0
+ typing-extensions==4.16.0
+ typing-inspection==0.4.4
+ uvicorn==0.54.0
$ venv1/bin/python -V
Python 3.12.15
$ venv1/bin/python -c "import importlib.metadata as m; print(\"mcp\", m.version(\"mcp\"))"
mcp 1.30.0
$ cat > src/client_v1.py << 'PY'
$ timeout 30 venv1/bin/python -u src/client_v1.py
server_name='ainewsnote-demo' server_version='1.30.0' protocol=2025-11-25
Processing request of type ListToolsRequest
tool name=add desc=두 정수를 더한다 inputs=['a', 'b']
tool name=count_hangul desc=문자열의 한글 음절 수를 센다 inputs=['text']
Processing request of type CallToolRequest
call name=add args={'a': 2, 'b': 40} isError=False text=42 structured={'result': 42}
Processing request of type CallToolRequest
call name=count_hangul args={'text': 'MCP 서버 테스트'} isError=False text=5 structured={'result': 5}
$ cat > src/probe_v2.py << 'PY'
$ timeout 30 venv23/bin/python -u src/probe_v2.py
input_schema_props=['a', 'b']
inputSchema AttributeError: 'Tool' object has no attribute 'inputSchema'
is_error=False
isError AttributeError: 'CallToolResult' object has no attribute 'isError'
Tool 'add' rejected arguments: ['a']
call name=add args={'a': '둘', 'b': 40} is_error=True text=Error executing tool add: 1 validation error for addArguments
a
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='둘', input_type=str]
For further information visit https://errors.pydantic.dev/2.14/v/int_parsing
Tool 'delete_files' failed: 'Unknown tool: delete_files'
call name=delete_files args={} is_error=True text=Unknown tool: delete_files
$ curl -sI -o /dev/null -w "%{http_code} %{url_effective}\n" --max-time 15 https://pypi.org/project/mcp/1.30.0/
200 https://pypi.org/project/mcp/1.30.0/

캡처의 v1 실행은 mcp<2 조건으로 깔린 mcp 1.30.0이 기존 서버 파일을 부른 결과입니다. Python 3.12.15에서 서버 이름은 ainewsnote-demo, server_version은 1.30.0, protocol은 2025-11-25로 나옵니다. add와 count_hangul은 각각 42와 5를 돌려줬고 둘 다 isError=False입니다. 뒤에 이어지는 v2 속성 오류와 정수 검증 실패는 별도 점검의 결과라서, 앞의 v1 호출이 실패한 것과는 상관이 없습니다.
v1 서버는 표준에러에 Processing request of type ListToolsRequest와 Processing request of type CallToolRequest를 남기고, 출력에서는 이 줄이 클라이언트 행 사이에 끼어 있습니다. 도구 오류가 아니라 요청 종류를 알리는 로그이니, isError가 False인 두 호출과 함께 보면 됩니다.
2.3.0 probe에서 input_schema의 속성은 a, b입니다. inputSchema를 읽으면 AttributeError: 'Tool' object has no attribute 'inputSchema'가 납니다. 정상 인자 2와 40의 is_error는 False이고, isError를 읽으면 AttributeError: 'CallToolResult' object has no attribute 'isError'가 납니다. v1 클라이언트를 2.3.0 결과 객체에 그대로 붙이면 바로 이 예외에서 멈춥니다.
인자 타입이 틀린 호출은 결과가 다릅니다. a가 문자열 둘, b가 40이면 is_error는 True입니다. 텍스트는 Error executing tool add로 시작하고 Input should be a valid integer, unable to parse string as an integer, type=int_parsing, input_value='둘'을 담고 있습니다. 서버 로그는 Tool 'add' rejected arguments: ['a']입니다. 성공한 호출은 a에 정수 2를 넣었을 때입니다.
등록되지 않은 이름은 또 다르게 실패합니다. delete_files를 빈 인자로 부르면 is_error는 True, 텍스트는 Unknown tool: delete_files, 서버 로그는 Tool 'delete_files' failed: 'Unknown tool: delete_files'입니다. 부를 수 있는 이름은 list_tools에 나온 add와 count_hangul뿐입니다. 두 경우 모두 is_error가 True이지만, 하나는 인자 a의 정수 검증에서, 다른 하나는 도구 이름에서 걸렸습니다.
가상환경을 나눠야 하는 이유는 설치 목록에서도 보입니다. 2.3.0 목록에는 httpx2, httpcore2, mcp-types가, 1.30.0 목록에는 httpx와 httpx-sse가 있습니다. 그래서 venv23과 venv1을 따로 두었습니다. PyPI의 1.30.0 페이지는 https://pypi.org/project/mcp/1.30.0/이고, 출력의 curl 행은 HTTP 200입니다.
버전을 고정하면 server_v1.py를 고치지 않고도 add 42와 count_hangul 5를 isError=False로 받습니다. inputSchema와 isError 이름도 그대로이고, 서버 버전이 1.30.0으로 찍혀 어떤 패키지로 띄웠는지 출력만 보고 알 수 있습니다. 그 대신 이 경로의 protocol은 2025-11-25라서 2.3.0 클라이언트가 찍은 2026-07-28은 나오지 않고, 의존성 이름도 2.3.0의 httpx2, mcp-types와 다릅니다. 무엇보다 공식 v1 문서가 말하는 유지보수 라인에 머무르는 길이니, 2.3.0의 MCPServer 동작을 이 환경의 결과로 짐작하면 안 됩니다.
헬스체크와 배포 설정도 같이 바뀌어야 하는 부분
이번 출력으로 보면 옮기는 데 드는 품은 서버 클래스 이름에서 끝나지 않습니다. mcp 2.3.0에서는 input_schema와 is_error를 읽어야 하고, v1 이름인 inputSchema와 isError를 그대로 쓰면 AttributeError가 났습니다. 헬스체크 코드도 서버 임포트를 바꿀 때 같이 고치고, 도구 목록을 읽은 다음 정상 인자의 반환값까지 확인하도록 짜기를 권합니다. 그래야 옛 필드에 접근하다 점검 코드가 멈춘 상황을 서버 자체의 장애와 헷갈리지 않습니다.
배포 설정에서는 서버를 띄우는 인터프리터와 스크립트 경로를 한 묶음으로 관리합니다. 이 글의 venv23과 venv1처럼 실행 환경을 갈라 두면, 전환하는 동안에도 어느 SDK와 클라이언트 조합을 점검했는지 따라가기 쉽습니다. Kubernetes에서 probe를 붙일 때도 stdio 호출을 맡은 애플리케이션의 준비 상태 검사에 맞춰 설정을 갱신하고, 초기화와 정상 도구 호출을 기준으로 삼아야 합니다. 외부 문서 주소의 HTTP 200이나 빈 server_version만 보고 도구 서버가 준비됐다고 판정하면 이번 호출 결과가 보여 준 것을 놓칩니다.
새 서버는 MCPServer로, 당장 못 고치는 v1 서비스는 버전 고정으로
비교표에는 이번 실행에서 도구 호출까지 성공한 선택지만 넣었습니다.
| 구분 | 실행한 내용 | 결과 |
|---|---|---|
| MCPServer add, mcp 2.3.0 | a=2, b=40 | structured result 42, text 42, is_error False. protocol 2026-07-28. server_version은 빈 문자열 |
| MCPServer count_hangul, mcp 2.3.0 | text가 MCP 서버 테스트 | structured result 5, text 5, is_error False. 입력 이름은 text |
| FastMCP, mcp 1.30.0 | 같은 server_v1.py를 mcp<2 환경에서 호출 | add 42, count_hangul 5, isError False. protocol 2025-11-25. server_version 1.30.0 |
mcp 2.3.0으로 새 도구 서버를 세우거나 이미 2.x가 깔린 환경이라면 MCPServer로 바꾸는 쪽을 권장합니다. 데코레이터와 함수는 그대로 두면 되고, 이번 stdio 호출에서 add와 count_hangul이 42와 5를 돌려줬습니다. 클라이언트는 input_schema와 is_error를 읽습니다. 이름만 넘긴 이번 실행의 server_version은 빈 문자열이었습니다.
이미 배포된 v1 코드를 당장 고칠 수 없다면 mcp<2 고정을 선택하면 됩니다. 이번 실행에서는 mcp 1.30.0이 깔렸고, 손대지 않은 server_v1.py가 같은 도구를 isError=False로 돌려줬습니다. MCPServer로 옮길 때는 FastMCP 임포트와 생성자, 클라이언트 필드 이름을 한 작업으로 묶어 바꿔야 빠뜨리는 곳이 없습니다. 복구가 먼저인 배포라면 저는 기존 환경을 따로 살려 두고, 전환할 환경에서 정상 호출을 통과시킨 다음에 실행 경로를 바꾸겠습니다.
호출이 거절될 때는 is_error와 서버 로그를 같이 보시면 됩니다. 정수 자리에 문자열 둘을 넣은 경우는 인자 a의 검증에서, delete_files는 목록에 없는 이름이라서 걸렸습니다. 둘 다 True이지만 고칠 곳은 다릅니다. 정상 결과와 나란히 놓으면 임포트 오류, 인자 오류, 이름 오류를 출력만으로 가를 수 있습니다.
세 실행의 출력을 놓고 보면 이번 문제는 도구 함수가 아니라 SDK 경계의 이름과 실행 환경을 맞추는 일이었습니다. 새 서버는 MCPServer와 v2 클라이언트를 짝지어 두고, 기존 FastMCP 서비스는 의존성 상한을 건 별도 환경으로 유지하는 구성을 추천합니다. 실시간 서비스에 붙일 때는 stdio 도구 호출을 맡는 계층을 모델 처리 계층과 떼어 놓고, 배포 전에 초기화와 대표 호출을 준비 상태 검사에 넣습니다. 응답 지연이나 처리량에 맞춘 자원 사양은 실제 서비스 부하로 정해야 하고, 여기서 성공한 반환값을 서버 용량의 근거로 삼지는 않습니다. 운영 로그에는 설치 버전과 호출 오류를 함께 남겨, 패키지 전환 문제와 사용자 입력 문제에 서로 다른 대응을 붙이겠습니다.







One Comment