Skip to content

Commit 8e39dd6

Browse files
authored
fix(server-info): trust installed metadata over foreign pyproject + version guard (#42)
1 parent 88b8df4 commit 8e39dd6

4 files changed

Lines changed: 304 additions & 160 deletions

File tree

src/my_mcp_server/__init__.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,83 @@
1-
"""My MCP Server."""
1+
"""My MCP Server.
22
3-
from importlib.metadata import version
3+
Single source of truth for this package's distribution identity (name +
4+
version). ``server_info`` (the MCP resource) and any other consumer import
5+
from here rather than re-deriving the dist name or re-reading metadata, so
6+
the two can never drift apart.
47
5-
__version__ = version("my-mcp-server")
8+
Resolution order, deliberately metadata-first:
9+
10+
1. ``importlib.metadata`` — authoritative for an *installed* distribution
11+
(wheel or ``pip install -e .``). This is what the running server actually
12+
is, regardless of what files happen to sit on disk above it.
13+
2. pyproject ``[project].version`` — dev fallback for a source checkout that
14+
was never installed. Accepted *only* when ``[project].name`` matches this
15+
package, so a foreign / monorepo-parent ``pyproject.toml`` encountered on
16+
the walk-up can never masquerade as this server's identity.
17+
3. ``FALLBACK_VERSION`` — last resort (renamed clone, neither source usable).
18+
19+
The dist name is derived from ``__package__`` (not a hardcoded literal) so a
20+
clone that renames ``src/my_mcp_server/`` picks the new name up automatically
21+
and does not crash on import.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import tomllib
27+
from importlib.metadata import PackageNotFoundError, version
28+
from pathlib import Path
29+
30+
# Convention: PyPI distribution name = import package name with underscores
31+
# rewritten as hyphens. Derived from ``__package__`` so a renamed clone does
32+
# not have to update a hardcoded literal here AND in server_info.
33+
DIST_NAME = (__package__ or "my_mcp_server").replace("_", "-")
34+
FALLBACK_VERSION = "0.0.0"
35+
36+
37+
def _read_own_pyproject() -> dict[str, str] | None:
38+
"""Walk up from this file and return the FIRST ``[project]`` table whose
39+
``name`` matches this package's :data:`DIST_NAME`.
40+
41+
A non-matching ``[project]`` table (a foreign or monorepo-parent
42+
``pyproject.toml``) is skipped — the walk continues — so a parent project
43+
can never be served as this server's identity. Returns ``None`` if no
44+
matching pyproject is reachable (e.g. installed from a wheel without the
45+
source tree).
46+
"""
47+
here = Path(__file__).resolve()
48+
for parent in here.parents:
49+
candidate = parent / "pyproject.toml"
50+
if not candidate.is_file():
51+
continue
52+
with candidate.open("rb") as fh:
53+
data = tomllib.load(fh)
54+
project = data.get("project")
55+
if isinstance(project, dict) and project.get("name") == DIST_NAME:
56+
return project
57+
# Foreign / parent pyproject (or one without a matching [project]):
58+
# keep walking — do NOT let it stand in for this package.
59+
return None
60+
61+
62+
def resolve_version() -> str:
63+
"""Return this distribution's version, metadata-first.
64+
65+
See the module docstring for the full resolution order. This is the single
66+
function every consumer (``__version__`` below, ``server_info``) routes
67+
through, so there is exactly one place that decides what "the version" is.
68+
"""
69+
try:
70+
return version(DIST_NAME)
71+
except PackageNotFoundError:
72+
pass
73+
74+
project = _read_own_pyproject()
75+
if project is not None:
76+
return str(project.get("version", FALLBACK_VERSION))
77+
78+
return FALLBACK_VERSION
79+
80+
81+
__version__ = resolve_version()
82+
83+
__all__ = ["DIST_NAME", "FALLBACK_VERSION", "__version__", "resolve_version"]

src/my_mcp_server/resources/server_info.py

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,69 +3,54 @@
33
44
Resources are how you expose data to the client (in contrast to Tools which
55
perform actions). Replace with your own resource.
6+
7+
Identity (dist name + version) is NOT re-derived here. It comes from the
8+
package root (:mod:`my_mcp_server`), which is the single source of truth:
9+
``importlib.metadata`` first (authoritative for an installed distribution),
10+
then this package's own ``pyproject.toml`` as a dev fallback, then
11+
``FALLBACK_VERSION``. Routing through that one helper means the version the
12+
server *reports* can never drift from the version the package *is*, and a
13+
foreign / monorepo-parent ``pyproject.toml`` found on a walk-up can never
14+
masquerade as this server's identity. See ``my_mcp_server/__init__.py``.
615
"""
716

817
from __future__ import annotations
918

1019
import json
1120
import platform
1221
import sys
13-
import tomllib
14-
from importlib.metadata import PackageNotFoundError, version
15-
from pathlib import Path
1622

1723
from mcp.server.fastmcp import FastMCP
1824

25+
from my_mcp_server import DIST_NAME, FALLBACK_VERSION, resolve_version
26+
1927
NAME = "server-info"
2028
URI = "info://server/status"
2129
TITLE = "Server Info"
2230
DESCRIPTION = "Server metadata: name, version, Python runtime, and platform."
2331
MIME_TYPE = "application/json"
2432

25-
# Convention: PyPI distribution name = import package name with underscores
26-
# rewritten as hyphens. Derived from `__package__` so a clone that renames
27-
# `src/my_mcp_server/` (per setup.yml's first-run checklist) doesn't have to
28-
# update a second hardcoded literal — the wheel-install fallback below picks
29-
# the new name up automatically.
30-
PKG_NAME = (__package__ or "my_mcp_server").split(".")[0].replace("_", "-")
31-
FALLBACK_VERSION = "0.0.0"
32-
33+
# Back-compat alias: the dist name lives in the package root now. Kept so any
34+
# external consumer that imported ``server_info.PKG_NAME`` still resolves.
35+
PKG_NAME = DIST_NAME
3336

34-
def _read_pyproject() -> dict[str, str] | None:
35-
"""Walk up from this file to locate pyproject.toml and parse it.
36-
37-
Returns the ``[project]`` table as a dict, or None if not found
38-
(e.g. when installed from a wheel without the source tree).
39-
"""
40-
here = Path(__file__).resolve()
41-
for parent in here.parents:
42-
candidate = parent / "pyproject.toml"
43-
if candidate.is_file():
44-
with candidate.open("rb") as fh:
45-
data = tomllib.load(fh)
46-
project = data.get("project")
47-
if isinstance(project, dict):
48-
return project
49-
return None
50-
return None
37+
__all__ = [
38+
"DESCRIPTION",
39+
"FALLBACK_VERSION",
40+
"MIME_TYPE",
41+
"NAME",
42+
"PKG_NAME",
43+
"TITLE",
44+
"URI",
45+
"register",
46+
"server_info",
47+
]
5148

5249

5350
def _server_metadata() -> dict[str, object]:
54-
project = _read_pyproject()
55-
if project is not None:
56-
pkg_name = str(project.get("name", PKG_NAME))
57-
pkg_version = str(project.get("version", FALLBACK_VERSION))
58-
else:
59-
# Fallback for wheel installs where pyproject.toml isn't shipped.
60-
pkg_name = PKG_NAME
61-
try:
62-
pkg_version = version(pkg_name)
63-
except PackageNotFoundError:
64-
pkg_version = FALLBACK_VERSION
65-
6651
return {
67-
"name": pkg_name,
68-
"version": pkg_version,
52+
"name": DIST_NAME,
53+
"version": resolve_version(),
6954
"runtime": {
7055
"python": sys.version.split()[0],
7156
"platform": platform.system().lower(),

tests/test_server_info.py

Lines changed: 42 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
"""Tests for the server-info resource."""
1+
"""Tests for the server-info resource.
2+
3+
Identity resolution (metadata-first, foreign-pyproject guard, fallback) lives
4+
in the package root now and is tested in ``test_version_resolution.py``. These
5+
tests cover the resource contract and that the resource *consumes* that single
6+
source of truth rather than re-deriving identity on its own.
7+
"""
28

39
import json
410
import sys
511
import tomllib
612
from pathlib import Path
713

14+
import my_mcp_server
815
from my_mcp_server.resources.server_info import (
916
DESCRIPTION,
1017
MIME_TYPE,
1118
NAME,
19+
PKG_NAME,
1220
URI,
1321
server_info,
1422
)
@@ -41,15 +49,40 @@ async def test_returns_json_with_expected_shape() -> None:
4149
assert isinstance(payload["version"], str) and payload["version"]
4250

4351

44-
async def test_version_matches_pyproject() -> None:
45-
"""Version field reflects pyproject.toml, not a hardcoded constant."""
52+
async def test_name_and_version_come_from_package_single_source() -> None:
53+
"""The resource reports the package's own identity, not a re-derivation.
54+
55+
``name`` is the package's ``DIST_NAME`` and ``version`` is whatever
56+
``resolve_version()`` returns — so the value the server reports can never
57+
drift from the value the package is.
58+
"""
59+
payload = json.loads(await server_info())
60+
assert payload["name"] == my_mcp_server.DIST_NAME
61+
assert payload["version"] == my_mcp_server.resolve_version()
62+
63+
64+
async def test_version_matches_pyproject_for_editable_install() -> None:
65+
"""For this source checkout (installed editable in CI), the metadata-first
66+
version coincides with pyproject's — a regression guard that the reported
67+
version is the real one, not a hardcoded constant."""
4668
project = _pyproject_project()
4769
payload = json.loads(await server_info())
4870

4971
assert payload["name"] == project["name"]
5072
assert payload["version"] == project["version"]
5173

5274

75+
async def test_resource_tracks_resolve_version(monkeypatch) -> None:
76+
"""If ``resolve_version()`` changes, the resource changes with it — proves
77+
the resource routes through the single source instead of reading metadata
78+
or pyproject on its own."""
79+
from my_mcp_server.resources import server_info as mod
80+
81+
monkeypatch.setattr(mod, "resolve_version", lambda: "9.9.9-sentinel")
82+
payload = json.loads(await mod.server_info())
83+
assert payload["version"] == "9.9.9-sentinel"
84+
85+
5386
async def test_runtime_reflects_current_interpreter() -> None:
5487
"""Python version in runtime matches sys.version."""
5588
payload = json.loads(await server_info())
@@ -65,115 +98,9 @@ async def test_registered_on_server() -> None:
6598
assert URI in uris
6699

67100

68-
# --- wheel-install fallback path ---------------------------------------------
69-
# When the package is installed from a wheel, pyproject.toml is NOT shipped,
70-
# so `_read_pyproject()` returns None and `_server_metadata()` must fall back
71-
# to `importlib.metadata.version()`. The tests above all exercise the source
72-
# tree (where pyproject.toml IS reachable).
73-
74-
75-
async def test_falls_back_to_importlib_metadata_when_pyproject_missing(
76-
monkeypatch,
77-
) -> None:
78-
"""Wheel install: _read_pyproject() returns None, both name and version
79-
come from importlib.metadata (both sides of the equality below resolve
80-
against the SAME source, not pyproject-on-disk — so a maintainer who
81-
bumps pyproject.toml's version without re-running ``pip install -e .``
82-
won't see a misleading failure here)."""
83-
from importlib.metadata import version as imp_version
84-
85-
from my_mcp_server.resources import server_info as mod
86-
87-
monkeypatch.setattr(mod, "_read_pyproject", lambda: None)
88-
payload = json.loads(await mod.server_info())
89-
90-
# `mod.PKG_NAME` is derived from `__package__` — survives a clone rename
91-
# without re-hardcoding the literal in the test.
92-
assert payload["name"] == mod.PKG_NAME
93-
assert payload["version"] == imp_version(mod.PKG_NAME)
94-
95-
96-
async def test_falls_back_to_zero_version_when_package_not_installed(
97-
monkeypatch,
98-
) -> None:
99-
"""Edge of the edge: pyproject missing AND importlib.metadata can't find
100-
the dist. Should return FALLBACK_VERSION instead of raising
101-
PackageNotFoundError."""
102-
from my_mcp_server.resources import server_info as mod
103-
104-
def raise_not_found(*_args: object, **_kw: object) -> str:
105-
# Tolerant signature in case `version()` ever gets called with kwargs.
106-
raise mod.PackageNotFoundError("simulated wheel-install-without-metadata")
107-
108-
monkeypatch.setattr(mod, "_read_pyproject", lambda: None)
109-
monkeypatch.setattr(mod, "version", raise_not_found)
110-
111-
payload = json.loads(await mod.server_info())
112-
assert payload["version"] == mod.FALLBACK_VERSION
113-
114-
115-
def test_read_pyproject_returns_none_when_project_table_missing(tmp_path, monkeypatch) -> None:
116-
"""Walk-up finds a pyproject.toml with no [project] table — must exit at
117-
the inner `return None` (NOT continue walking — that's the contract).
118-
119-
Strengthened with a tomllib.load spy so a future refactor that drops the
120-
explicit inner return and falls through to keep walking gets caught here
121-
(the spy would see >1 load) instead of silently passing because no other
122-
pyproject.toml is reachable on the way to /.
123-
"""
124-
from my_mcp_server.resources import server_info as mod
125-
126-
pkg = tmp_path / "pkg"
127-
pkg.mkdir()
128-
fake_pyproject = tmp_path / "pyproject.toml"
129-
fake_pyproject.write_text("[build-system]\nrequires = []\n")
130-
fake_file = pkg / "server_info.py"
131-
fake_file.write_text("")
132-
133-
loaded_paths: list[str] = []
134-
original_load = mod.tomllib.load
135-
136-
def spy_load(fh): # type: ignore[no-untyped-def]
137-
loaded_paths.append(getattr(fh, "name", ""))
138-
return original_load(fh)
139-
140-
monkeypatch.setattr(mod.tomllib, "load", spy_load)
141-
monkeypatch.setattr(mod, "__file__", str(fake_file))
142-
143-
assert mod._read_pyproject() is None
144-
assert loaded_paths == [str(fake_pyproject)], (
145-
f"_read_pyproject must stop at the first pyproject.toml encountered; "
146-
f"saw {len(loaded_paths)} load(s): {loaded_paths}"
147-
)
148-
149-
150-
def test_read_pyproject_returns_none_when_no_pyproject_anywhere(tmp_path, monkeypatch) -> None:
151-
"""Walk-up exhausts the parent chain without finding any pyproject.toml —
152-
exercises the outer `return None` after the loop (server_info.py:42).
153-
154-
Without this test the loop-exhaustion path is 0% covered: the
155-
"fallback" tests above bypass the walk entirely by monkeypatching
156-
`_read_pyproject` itself.
157-
"""
158-
from my_mcp_server.resources import server_info as mod
159-
160-
pkg = tmp_path / "pkg"
161-
pkg.mkdir()
162-
fake_file = pkg / "server_info.py"
163-
fake_file.write_text("")
164-
# Deliberately no pyproject.toml anywhere in tmp_path or its ancestors
165-
# (system tmp dirs don't contain one), so the walk reaches root.
166-
167-
monkeypatch.setattr(mod, "__file__", str(fake_file))
168-
assert mod._read_pyproject() is None
169-
170-
171-
def test_pkg_name_derived_from_package() -> None:
172-
"""PKG_NAME is the dist-name form (hyphens) of the import package name —
173-
pins the rename-safety contract documented in server_info.py."""
174-
from my_mcp_server.resources import server_info as mod
175-
176-
assert mod.PKG_NAME == "my-mcp-server"
177-
# Sanity: derivation tracks __package__, not a stray literal somewhere.
178-
root_import = (mod.__package__ or "").split(".")[0]
179-
assert root_import.replace("_", "-") == mod.PKG_NAME
101+
def test_pkg_name_is_back_compat_alias_of_dist_name() -> None:
102+
"""``PKG_NAME`` is retained as a back-compat alias of the package's
103+
``DIST_NAME`` (the dist-name form, hyphens) so external importers of
104+
``server_info.PKG_NAME`` keep working after identity moved to the root."""
105+
assert PKG_NAME == "my-mcp-server"
106+
assert PKG_NAME == my_mcp_server.DIST_NAME

0 commit comments

Comments
 (0)