diff --git a/src/my_mcp_server/__init__.py b/src/my_mcp_server/__init__.py index 84a028e..6821efa 100644 --- a/src/my_mcp_server/__init__.py +++ b/src/my_mcp_server/__init__.py @@ -1,5 +1,83 @@ -"""My MCP Server.""" +"""My MCP Server. -from importlib.metadata import version +Single source of truth for this package's distribution identity (name + +version). ``server_info`` (the MCP resource) and any other consumer import +from here rather than re-deriving the dist name or re-reading metadata, so +the two can never drift apart. -__version__ = version("my-mcp-server") +Resolution order, deliberately metadata-first: + +1. ``importlib.metadata`` — authoritative for an *installed* distribution + (wheel or ``pip install -e .``). This is what the running server actually + is, regardless of what files happen to sit on disk above it. +2. pyproject ``[project].version`` — dev fallback for a source checkout that + was never installed. Accepted *only* when ``[project].name`` matches this + package, so a foreign / monorepo-parent ``pyproject.toml`` encountered on + the walk-up can never masquerade as this server's identity. +3. ``FALLBACK_VERSION`` — last resort (renamed clone, neither source usable). + +The dist name is derived from ``__package__`` (not a hardcoded literal) so a +clone that renames ``src/my_mcp_server/`` picks the new name up automatically +and does not crash on import. +""" + +from __future__ import annotations + +import tomllib +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +# Convention: PyPI distribution name = import package name with underscores +# rewritten as hyphens. Derived from ``__package__`` so a renamed clone does +# not have to update a hardcoded literal here AND in server_info. +DIST_NAME = (__package__ or "my_mcp_server").replace("_", "-") +FALLBACK_VERSION = "0.0.0" + + +def _read_own_pyproject() -> dict[str, str] | None: + """Walk up from this file and return the FIRST ``[project]`` table whose + ``name`` matches this package's :data:`DIST_NAME`. + + A non-matching ``[project]`` table (a foreign or monorepo-parent + ``pyproject.toml``) is skipped — the walk continues — so a parent project + can never be served as this server's identity. Returns ``None`` if no + matching pyproject is reachable (e.g. installed from a wheel without the + source tree). + """ + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "pyproject.toml" + if not candidate.is_file(): + continue + with candidate.open("rb") as fh: + data = tomllib.load(fh) + project = data.get("project") + if isinstance(project, dict) and project.get("name") == DIST_NAME: + return project + # Foreign / parent pyproject (or one without a matching [project]): + # keep walking — do NOT let it stand in for this package. + return None + + +def resolve_version() -> str: + """Return this distribution's version, metadata-first. + + See the module docstring for the full resolution order. This is the single + function every consumer (``__version__`` below, ``server_info``) routes + through, so there is exactly one place that decides what "the version" is. + """ + try: + return version(DIST_NAME) + except PackageNotFoundError: + pass + + project = _read_own_pyproject() + if project is not None: + return str(project.get("version", FALLBACK_VERSION)) + + return FALLBACK_VERSION + + +__version__ = resolve_version() + +__all__ = ["DIST_NAME", "FALLBACK_VERSION", "__version__", "resolve_version"] diff --git a/src/my_mcp_server/resources/server_info.py b/src/my_mcp_server/resources/server_info.py index 1b1a4d8..393644b 100644 --- a/src/my_mcp_server/resources/server_info.py +++ b/src/my_mcp_server/resources/server_info.py @@ -3,6 +3,15 @@ Resources are how you expose data to the client (in contrast to Tools which perform actions). Replace with your own resource. + +Identity (dist name + version) is NOT re-derived here. It comes from the +package root (:mod:`my_mcp_server`), which is the single source of truth: +``importlib.metadata`` first (authoritative for an installed distribution), +then this package's own ``pyproject.toml`` as a dev fallback, then +``FALLBACK_VERSION``. Routing through that one helper means the version the +server *reports* can never drift from the version the package *is*, and a +foreign / monorepo-parent ``pyproject.toml`` found on a walk-up can never +masquerade as this server's identity. See ``my_mcp_server/__init__.py``. """ from __future__ import annotations @@ -10,62 +19,38 @@ import json import platform import sys -import tomllib -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path from mcp.server.fastmcp import FastMCP +from my_mcp_server import DIST_NAME, FALLBACK_VERSION, resolve_version + NAME = "server-info" URI = "info://server/status" TITLE = "Server Info" DESCRIPTION = "Server metadata: name, version, Python runtime, and platform." MIME_TYPE = "application/json" -# Convention: PyPI distribution name = import package name with underscores -# rewritten as hyphens. Derived from `__package__` so a clone that renames -# `src/my_mcp_server/` (per setup.yml's first-run checklist) doesn't have to -# update a second hardcoded literal — the wheel-install fallback below picks -# the new name up automatically. -PKG_NAME = (__package__ or "my_mcp_server").split(".")[0].replace("_", "-") -FALLBACK_VERSION = "0.0.0" - +# Back-compat alias: the dist name lives in the package root now. Kept so any +# external consumer that imported ``server_info.PKG_NAME`` still resolves. +PKG_NAME = DIST_NAME -def _read_pyproject() -> dict[str, str] | None: - """Walk up from this file to locate pyproject.toml and parse it. - - Returns the ``[project]`` table as a dict, or None if not found - (e.g. when installed from a wheel without the source tree). - """ - here = Path(__file__).resolve() - for parent in here.parents: - candidate = parent / "pyproject.toml" - if candidate.is_file(): - with candidate.open("rb") as fh: - data = tomllib.load(fh) - project = data.get("project") - if isinstance(project, dict): - return project - return None - return None +__all__ = [ + "DESCRIPTION", + "FALLBACK_VERSION", + "MIME_TYPE", + "NAME", + "PKG_NAME", + "TITLE", + "URI", + "register", + "server_info", +] def _server_metadata() -> dict[str, object]: - project = _read_pyproject() - if project is not None: - pkg_name = str(project.get("name", PKG_NAME)) - pkg_version = str(project.get("version", FALLBACK_VERSION)) - else: - # Fallback for wheel installs where pyproject.toml isn't shipped. - pkg_name = PKG_NAME - try: - pkg_version = version(pkg_name) - except PackageNotFoundError: - pkg_version = FALLBACK_VERSION - return { - "name": pkg_name, - "version": pkg_version, + "name": DIST_NAME, + "version": resolve_version(), "runtime": { "python": sys.version.split()[0], "platform": platform.system().lower(), diff --git a/tests/test_server_info.py b/tests/test_server_info.py index 1acd75c..ce9c8b0 100644 --- a/tests/test_server_info.py +++ b/tests/test_server_info.py @@ -1,14 +1,22 @@ -"""Tests for the server-info resource.""" +"""Tests for the server-info resource. + +Identity resolution (metadata-first, foreign-pyproject guard, fallback) lives +in the package root now and is tested in ``test_version_resolution.py``. These +tests cover the resource contract and that the resource *consumes* that single +source of truth rather than re-deriving identity on its own. +""" import json import sys import tomllib from pathlib import Path +import my_mcp_server from my_mcp_server.resources.server_info import ( DESCRIPTION, MIME_TYPE, NAME, + PKG_NAME, URI, server_info, ) @@ -41,8 +49,22 @@ async def test_returns_json_with_expected_shape() -> None: assert isinstance(payload["version"], str) and payload["version"] -async def test_version_matches_pyproject() -> None: - """Version field reflects pyproject.toml, not a hardcoded constant.""" +async def test_name_and_version_come_from_package_single_source() -> None: + """The resource reports the package's own identity, not a re-derivation. + + ``name`` is the package's ``DIST_NAME`` and ``version`` is whatever + ``resolve_version()`` returns — so the value the server reports can never + drift from the value the package is. + """ + payload = json.loads(await server_info()) + assert payload["name"] == my_mcp_server.DIST_NAME + assert payload["version"] == my_mcp_server.resolve_version() + + +async def test_version_matches_pyproject_for_editable_install() -> None: + """For this source checkout (installed editable in CI), the metadata-first + version coincides with pyproject's — a regression guard that the reported + version is the real one, not a hardcoded constant.""" project = _pyproject_project() payload = json.loads(await server_info()) @@ -50,6 +72,17 @@ async def test_version_matches_pyproject() -> None: assert payload["version"] == project["version"] +async def test_resource_tracks_resolve_version(monkeypatch) -> None: + """If ``resolve_version()`` changes, the resource changes with it — proves + the resource routes through the single source instead of reading metadata + or pyproject on its own.""" + from my_mcp_server.resources import server_info as mod + + monkeypatch.setattr(mod, "resolve_version", lambda: "9.9.9-sentinel") + payload = json.loads(await mod.server_info()) + assert payload["version"] == "9.9.9-sentinel" + + async def test_runtime_reflects_current_interpreter() -> None: """Python version in runtime matches sys.version.""" payload = json.loads(await server_info()) @@ -65,115 +98,9 @@ async def test_registered_on_server() -> None: assert URI in uris -# --- wheel-install fallback path --------------------------------------------- -# When the package is installed from a wheel, pyproject.toml is NOT shipped, -# so `_read_pyproject()` returns None and `_server_metadata()` must fall back -# to `importlib.metadata.version()`. The tests above all exercise the source -# tree (where pyproject.toml IS reachable). - - -async def test_falls_back_to_importlib_metadata_when_pyproject_missing( - monkeypatch, -) -> None: - """Wheel install: _read_pyproject() returns None, both name and version - come from importlib.metadata (both sides of the equality below resolve - against the SAME source, not pyproject-on-disk — so a maintainer who - bumps pyproject.toml's version without re-running ``pip install -e .`` - won't see a misleading failure here).""" - from importlib.metadata import version as imp_version - - from my_mcp_server.resources import server_info as mod - - monkeypatch.setattr(mod, "_read_pyproject", lambda: None) - payload = json.loads(await mod.server_info()) - - # `mod.PKG_NAME` is derived from `__package__` — survives a clone rename - # without re-hardcoding the literal in the test. - assert payload["name"] == mod.PKG_NAME - assert payload["version"] == imp_version(mod.PKG_NAME) - - -async def test_falls_back_to_zero_version_when_package_not_installed( - monkeypatch, -) -> None: - """Edge of the edge: pyproject missing AND importlib.metadata can't find - the dist. Should return FALLBACK_VERSION instead of raising - PackageNotFoundError.""" - from my_mcp_server.resources import server_info as mod - - def raise_not_found(*_args: object, **_kw: object) -> str: - # Tolerant signature in case `version()` ever gets called with kwargs. - raise mod.PackageNotFoundError("simulated wheel-install-without-metadata") - - monkeypatch.setattr(mod, "_read_pyproject", lambda: None) - monkeypatch.setattr(mod, "version", raise_not_found) - - payload = json.loads(await mod.server_info()) - assert payload["version"] == mod.FALLBACK_VERSION - - -def test_read_pyproject_returns_none_when_project_table_missing(tmp_path, monkeypatch) -> None: - """Walk-up finds a pyproject.toml with no [project] table — must exit at - the inner `return None` (NOT continue walking — that's the contract). - - Strengthened with a tomllib.load spy so a future refactor that drops the - explicit inner return and falls through to keep walking gets caught here - (the spy would see >1 load) instead of silently passing because no other - pyproject.toml is reachable on the way to /. - """ - from my_mcp_server.resources import server_info as mod - - pkg = tmp_path / "pkg" - pkg.mkdir() - fake_pyproject = tmp_path / "pyproject.toml" - fake_pyproject.write_text("[build-system]\nrequires = []\n") - fake_file = pkg / "server_info.py" - fake_file.write_text("") - - loaded_paths: list[str] = [] - original_load = mod.tomllib.load - - def spy_load(fh): # type: ignore[no-untyped-def] - loaded_paths.append(getattr(fh, "name", "")) - return original_load(fh) - - monkeypatch.setattr(mod.tomllib, "load", spy_load) - monkeypatch.setattr(mod, "__file__", str(fake_file)) - - assert mod._read_pyproject() is None - assert loaded_paths == [str(fake_pyproject)], ( - f"_read_pyproject must stop at the first pyproject.toml encountered; " - f"saw {len(loaded_paths)} load(s): {loaded_paths}" - ) - - -def test_read_pyproject_returns_none_when_no_pyproject_anywhere(tmp_path, monkeypatch) -> None: - """Walk-up exhausts the parent chain without finding any pyproject.toml — - exercises the outer `return None` after the loop (server_info.py:42). - - Without this test the loop-exhaustion path is 0% covered: the - "fallback" tests above bypass the walk entirely by monkeypatching - `_read_pyproject` itself. - """ - from my_mcp_server.resources import server_info as mod - - pkg = tmp_path / "pkg" - pkg.mkdir() - fake_file = pkg / "server_info.py" - fake_file.write_text("") - # Deliberately no pyproject.toml anywhere in tmp_path or its ancestors - # (system tmp dirs don't contain one), so the walk reaches root. - - monkeypatch.setattr(mod, "__file__", str(fake_file)) - assert mod._read_pyproject() is None - - -def test_pkg_name_derived_from_package() -> None: - """PKG_NAME is the dist-name form (hyphens) of the import package name — - pins the rename-safety contract documented in server_info.py.""" - from my_mcp_server.resources import server_info as mod - - assert mod.PKG_NAME == "my-mcp-server" - # Sanity: derivation tracks __package__, not a stray literal somewhere. - root_import = (mod.__package__ or "").split(".")[0] - assert root_import.replace("_", "-") == mod.PKG_NAME +def test_pkg_name_is_back_compat_alias_of_dist_name() -> None: + """``PKG_NAME`` is retained as a back-compat alias of the package's + ``DIST_NAME`` (the dist-name form, hyphens) so external importers of + ``server_info.PKG_NAME`` keep working after identity moved to the root.""" + assert PKG_NAME == "my-mcp-server" + assert PKG_NAME == my_mcp_server.DIST_NAME diff --git a/tests/test_version_resolution.py b/tests/test_version_resolution.py new file mode 100644 index 0000000..f0142e8 --- /dev/null +++ b/tests/test_version_resolution.py @@ -0,0 +1,154 @@ +"""Tests for distribution-identity resolution in the package root. + +``my_mcp_server.resolve_version()`` is the single source of truth for the +version the server reports. Resolution order (see ``__init__.py``): + +1. ``importlib.metadata`` — authoritative for an installed distribution. +2. this package's own ``pyproject.toml`` — dev fallback, accepted ONLY when + ``[project].name`` matches ``DIST_NAME`` (the foreign-pyproject guard). +3. ``FALLBACK_VERSION`` — last resort. + +These tests pin each branch, with particular attention to the guard: a +foreign / monorepo-parent ``pyproject.toml`` encountered on the walk-up must +never be served as this package's identity. +""" + +import textwrap +from pathlib import Path + +import my_mcp_server as pkg + + +def test_dist_name_is_hyphenated_form_of_package() -> None: + """DIST_NAME is the PyPI-style (hyphenated) form of the import package, + derived from ``__package__`` — not a stray hardcoded literal.""" + assert pkg.DIST_NAME == "my-mcp-server" + assert (pkg.__package__ or "").replace("_", "-") == pkg.DIST_NAME + + +def test_resolve_version_prefers_installed_metadata(monkeypatch) -> None: + """importlib.metadata wins outright; pyproject is not even consulted.""" + + def boom() -> dict[str, str] | None: # pragma: no cover - must not run + raise AssertionError("pyproject must not be read when metadata resolves") + + monkeypatch.setattr(pkg, "version", lambda _name: "7.7.7") + monkeypatch.setattr(pkg, "_read_own_pyproject", boom) + assert pkg.resolve_version() == "7.7.7" + + +def test_resolve_version_falls_back_to_matching_pyproject(monkeypatch) -> None: + """Source checkout, not installed: version comes from the OWN pyproject.""" + + def raise_not_found(_name: str) -> str: + raise pkg.PackageNotFoundError(_name) + + monkeypatch.setattr(pkg, "version", raise_not_found) + monkeypatch.setattr(pkg, "_read_own_pyproject", lambda: {"version": "2.3.4"}) + assert pkg.resolve_version() == "2.3.4" + + +def test_resolve_version_uses_fallback_when_no_source(monkeypatch) -> None: + """Neither metadata nor a matching pyproject reachable (e.g. renamed clone, + wheel without source) → FALLBACK_VERSION, never an exception.""" + + def raise_not_found(_name: str) -> str: + raise pkg.PackageNotFoundError(_name) + + monkeypatch.setattr(pkg, "version", raise_not_found) + monkeypatch.setattr(pkg, "_read_own_pyproject", lambda: None) + assert pkg.resolve_version() == pkg.FALLBACK_VERSION + + +def _write_pyproject(path: Path, name: str | None, version: str) -> None: + if name is None: + body = f'[project]\nversion = "{version}"\n' + else: + body = f'[project]\nname = "{name}"\nversion = "{version}"\n' + path.write_text(textwrap.dedent(body)) + + +def test_read_own_pyproject_returns_matching_project(tmp_path, monkeypatch) -> None: + """Walk-up finds a pyproject whose [project].name == DIST_NAME → returned.""" + pkgdir = tmp_path / "my_mcp_server" + pkgdir.mkdir() + _write_pyproject(tmp_path / "pyproject.toml", pkg.DIST_NAME, "5.6.7") + fake_file = pkgdir / "__init__.py" + fake_file.write_text("") + + monkeypatch.setattr(pkg, "__file__", str(fake_file)) + project = pkg._read_own_pyproject() + assert project is not None + assert project["name"] == pkg.DIST_NAME + assert project["version"] == "5.6.7" + + +def test_read_own_pyproject_skips_foreign_and_finds_own(tmp_path, monkeypatch) -> None: + """THE GUARD. A foreign/parent pyproject sits ABOVE the package's own one + on the walk-up. The walk must SKIP the nearer foreign table and keep going + until it finds the table whose name matches DIST_NAME — a monorepo parent + can never masquerade as this server's identity. + + Layout (walk goes child -> parent): + tmp/foreign-parent/pyproject.toml name = "some-monorepo-root" + tmp/foreign-parent/proj/pyproject.toml name = DIST_NAME <- ours + tmp/foreign-parent/proj/my_mcp_server/__init__.py + """ + parent = tmp_path / "foreign-parent" + proj = parent / "proj" + pkgdir = proj / "my_mcp_server" + pkgdir.mkdir(parents=True) + + # Nearer on the walk-up: a FOREIGN pyproject that must be skipped. + _write_pyproject(proj / "pyproject.toml", pkg.DIST_NAME, "1.1.1") # ours (nearest) + _write_pyproject(parent / "pyproject.toml", "some-monorepo-root", "0.0.1") # foreign + + fake_file = pkgdir / "__init__.py" + fake_file.write_text("") + monkeypatch.setattr(pkg, "__file__", str(fake_file)) + + project = pkg._read_own_pyproject() + assert project is not None + assert project["name"] == pkg.DIST_NAME + assert project["version"] == "1.1.1" + + +def test_read_own_pyproject_skips_foreign_then_exhausts(tmp_path, monkeypatch) -> None: + """Only a foreign pyproject is reachable (no own one anywhere) → the guard + skips it and the walk exhausts to ``None``. This is exactly the case the + old (unguarded) code got wrong: it would have returned the foreign table.""" + parent = tmp_path / "foreign-parent" + pkgdir = parent / "my_mcp_server" + pkgdir.mkdir(parents=True) + _write_pyproject(parent / "pyproject.toml", "some-other-dist", "9.9.9") + + fake_file = pkgdir / "__init__.py" + fake_file.write_text("") + monkeypatch.setattr(pkg, "__file__", str(fake_file)) + + assert pkg._read_own_pyproject() is None + + +def test_read_own_pyproject_skips_table_without_name(tmp_path, monkeypatch) -> None: + """A [project] table that has no ``name`` key is non-matching → skipped, + walk continues / exhausts to None (covers the isinstance/name branch).""" + pkgdir = tmp_path / "my_mcp_server" + pkgdir.mkdir() + _write_pyproject(tmp_path / "pyproject.toml", None, "3.3.3") # no name key + fake_file = pkgdir / "__init__.py" + fake_file.write_text("") + + monkeypatch.setattr(pkg, "__file__", str(fake_file)) + assert pkg._read_own_pyproject() is None + + +def test_read_own_pyproject_returns_none_when_no_pyproject_anywhere(tmp_path, monkeypatch) -> None: + """Walk-up exhausts the parent chain without finding any pyproject.toml → + outer ``return None`` (system tmp dirs contain no pyproject.toml).""" + pkgdir = tmp_path / "my_mcp_server" + pkgdir.mkdir() + fake_file = pkgdir / "__init__.py" + fake_file.write_text("") + + monkeypatch.setattr(pkg, "__file__", str(fake_file)) + assert pkg._read_own_pyproject() is None