From 9e0379304431c809be8503049d229814fbb235fd Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Wed, 26 Aug 2026 09:39:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EC=9E=90=EA=B8=B0=20=EB=B2=84=EC=A0=84=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?SDK=20=EB=B2=84=EC=A0=84=EC=9D=84=20=EB=A7=90=ED=95=98=EA=B3=A0?= =?UTF-8?q?=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastMCP(1.27.2)에는 `version` 인자가 없다. 그래서 `Server.version` 이 None 으로 남고, lowlevel 서버는 `initialize` 응답에 `pkg_version("mcp")` 를 싣는다 — **모든 FastMCP 서버가 SDK 버전을 자기 것이라고 말한다.** 우리 서버는 `1.27.2` 라고 답하고 있었다. 08-24 외부 버그리포트가 윈도우에서 다른 숫자(1.29.0)를 보고 플랫폼 차이로 읽었다. 거기서도 그건 그냥 그 기계의 SDK 버전이었다. 🔴 `importlib.metadata.version("mirror-stack-mcp")` 로는 안 고쳤다. 이 기계의 editable 설치는 dist-info 에 **0.1.0** 이 박혀 있는데 소스는 0.2.11 이다. 그 길로 가면 틀린 숫자를 다른 틀린 숫자로 바꾸고 고쳐진 것처럼 보인다. 기제 확인·판정선은 [인프라] 가 만들었다(mcp_serverinfo_probe.py · selftest 6/6). 이 레포는 공개 패키지라 그쪽이 안 건드리고 넘겼다. 시험 7건 신설. **속성이 아니라 클라이언트가 실제 받는 값** (`create_initialization_options()`)을 본다 — 속성을 대 봐야 배달을 증명 못 한다. 세 후보값이 전부 달라서(소스 0.2.11 · dist-info 0.1.0 · SDK 1.27.2) 시험이 분별력을 가진다. 양성대조도 넣었다: 안 고친 맨 FastMCP 서버가 여전히 SDK 버전을 말하는지 매번 확인한다 — 아니게 되면 이 패치를 재검토하라고 알린다. 실측(왕복 실호출 · [인프라] 판정선 --all): mirror-stack 0.2.11 ⊕ 자기버전. ⚠️ 분모는 .mcp.json 의 4개다. 같은 증상인 [여울] `yeoul-mcp` 는 그쪽 레포다. pytest 59 통과. Co-Authored-By: Claude Opus 5 (1M context) --- mirror_stack_mcp/server.py | 16 ++++++ tests/test_server_version.py | 105 +++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/test_server_version.py diff --git a/mirror_stack_mcp/server.py b/mirror_stack_mcp/server.py index 63d5225..142598a 100644 --- a/mirror_stack_mcp/server.py +++ b/mirror_stack_mcp/server.py @@ -23,6 +23,7 @@ from provmirror import pm from . import ots_anchor +from . import __version__ DISCIPLINE = """\ 🪞🔎🪪 MIRROR STACK — discipline for honest measurement (read on connect). @@ -76,6 +77,21 @@ mcp = FastMCP("mirror-stack", instructions=DISCIPLINE) +# FastMCP (1.27.2) takes no `version`, so `Server.version` stays None and the lowlevel +# server answers `initialize` with `pkg_version("mcp")` — every FastMCP server on earth +# reports the SDK's version as its own. A client asking this server what it is got +# "1.27.2"; an outside bug report on 2026-08-24 read a different number on Windows and +# took it for a platform difference. It was the SDK version there too. +# +# Set after construction on purpose: `initialize` reads this lazily, so assigning here is +# enough and there is no constructor hook to use instead. +# +# 🔴 NOT `importlib.metadata.version("mirror-stack-mcp")`: an editable install on the +# author's machine has 0.1.0 frozen in its dist-info while the source says 0.2.11. That +# route swaps one wrong number for another and looks fixed. The source is the only truth +# the package ships with. +mcp._mcp_server.version = __version__ + def _findings(fs): return [str(f) for f in fs] if isinstance(fs, list) else str(fs) diff --git a/tests/test_server_version.py b/tests/test_server_version.py new file mode 100644 index 0000000..7250a78 --- /dev/null +++ b/tests/test_server_version.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""The server must tell a client ITS version, not the SDK's. + +FastMCP (1.27.2) has no `version` parameter, so `Server.version` stays None and the +lowlevel server answers `initialize` with `pkg_version("mcp")`. The result is that every +FastMCP server reports the SDK's version as its own — ours said `1.27.2`. An outside bug +report on 2026-08-24 read a different number on Windows and took it for a platform +difference; it was the SDK version there too. + +This file tests the value a client actually receives (`create_initialization_options()`), +not the attribute we set — setting an attribute proves nothing about what is served. + +The three candidate values on the author's machine are all different, which is what makes +these assertions able to fail: + + source __version__ 0.2.11 ← correct + importlib.metadata.version("mirror-stack-mcp") 0.1.0 ← stale editable dist-info + importlib.metadata.version("mcp") 1.27.2 ← the SDK, the old bug +""" +import importlib.metadata as md +import re + +import pytest + +from mirror_stack_mcp import __version__ +from mirror_stack_mcp.server import mcp + + +def _served(): + return mcp._mcp_server.create_initialization_options() + + +def test_serves_our_own_version(): + assert _served().server_version == __version__ + + +def test_serves_our_own_name(): + assert _served().server_name == "mirror-stack" + + +def test_not_the_sdk_version(): + """The original bug, stated as its own assertion so a regression names itself.""" + try: + sdk = md.version("mcp") + except md.PackageNotFoundError: + pytest.skip("mcp SDK metadata unavailable — this check needs it to mean anything") + if sdk == __version__: + pytest.skip(f"SDK and package versions coincide at {sdk} — cannot discriminate") + assert _served().server_version != sdk + + +def test_not_the_installed_dist_info(): + """🔴 The tempting fix that only looks like one. + + `importlib.metadata.version()` reads the dist-info recorded at install time. An + editable install here has 0.1.0 frozen while the source says 0.2.11 — using it swaps + one wrong number for another. Skipped rather than passed where they agree: a check + that cannot tell the two apart is not evidence. + """ + try: + dist = md.version("mirror-stack-mcp") + except md.PackageNotFoundError: + pytest.skip("package not installed — nothing to confuse the source with") + if dist == __version__: + pytest.skip(f"dist-info agrees with source at {dist} — cannot discriminate here") + assert _served().server_version != dist + + +def test_version_matches_pyproject(): + """`__version__` is what is served, so it is what must track the release. + + Parsed by regex rather than `tomllib`: this project supports 3.10, where the stdlib + has no TOML reader, and the sibling `test_pins_are_releases.py` reads the same file + the same way. A skip here would silently stop guarding the release version on exactly + the interpreter the tests run under. + """ + import pathlib + root = pathlib.Path(__file__).resolve().parent.parent + text = (root / "pyproject.toml").read_text(encoding="utf-8") + m = re.search(r'(?m)^version\s*=\s*["\']([^"\']+)["\']', text) + assert m, "no `version =` line parsed from pyproject.toml — did the format change?" + assert m.group(1) == __version__ + + +def test_version_looks_like_a_version(): + assert re.fullmatch(r"\d+\.\d+\.\d+([.-].+)?", __version__), __version__ + + +def test_the_bug_is_reproducible_on_a_bare_server(): + """⊕ Discriminating control: a FastMCP server WITHOUT the fix still shows the fault. + + If this ever stops holding, FastMCP has changed its default and the fix above may be + redundant — but the tests would otherwise keep passing without telling anyone. + """ + from mcp.server.fastmcp import FastMCP + bare = FastMCP("bare-control") + assert bare._mcp_server.version is None, ( + "FastMCP now sets a version itself — revisit whether this patch is still needed" + ) + served = bare._mcp_server.create_initialization_options().server_version + assert served != "bare-control" + try: + assert served == md.version("mcp"), "the fallback is no longer the SDK version" + except md.PackageNotFoundError: + pytest.skip("mcp SDK metadata unavailable")