Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README_PYPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,5 @@ Optional compliance surfaces for regulated deployments cover the EU AI Act, SOX/
---

*Built by Quantamix Solutions B.V. · Patent pending EP26167849.4 · Local by default · Your code never leaves your machine*

<!-- mcp-name: io.github.quantamixsol/graqle -->
9 changes: 7 additions & 2 deletions graqle/release_gate/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ async def gate(
except asyncio.TimeoutError:
return self._fallback_verdict(effective_target, reason="review_timeout")
except Exception as exc: # pylint: disable=broad-except
logger.warning("release_gate review provider failed: %s", type(exc).__name__)
# Log the full exception (type, message, traceback) to the operator
# log. Diagnosing a gate that fails needs the actual error: logging
# only the class name is why the 0.84.0 failures were undiagnosable.
# The verdict object stays redacted — see _fallback_verdict.
logger.exception("release_gate review provider failed: %r", exc)
return self._fallback_verdict(effective_target, reason="review_error")

review = self._normalize_review(review_raw)
Expand All @@ -166,7 +170,8 @@ async def gate(
except asyncio.TimeoutError:
return self._fallback_verdict(effective_target, reason="prediction_timeout")
except Exception as exc: # pylint: disable=broad-except
logger.warning("release_gate prediction provider failed: %s", type(exc).__name__)
# Full exception to the operator log; the verdict stays redacted.
logger.exception("release_gate prediction provider failed: %r", exc)
return self._fallback_verdict(effective_target, reason="prediction_error")

prediction = self._normalize_prediction(prediction_raw)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_packaging/test_mcp_name_marker_in_pypi_readme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""The MCP Registry ownership marker must live in the PyPI long_description.

The defect this pins (0.84.0, 2026-09-15): publishing to the MCP Registry
failed with HTTP 400 —

registry validation failed for package 0 (graqle): PyPI package 'graqle'
ownership validation failed. The server name 'io.github.quantamixsol/graqle'
must appear as 'mcp-name: io.github.quantamixsol/graqle' in the package
README

Root cause: ``<!-- mcp-name: ... -->`` lived in ``README.md``. CR-README-01
repointed ``pyproject.readme`` to ``README_PYPI.md`` for conversion reasons and
did not carry the marker across, so the published ``long_description`` no
longer contained it. The registry reads the **PyPI** README, not the GitHub
one, so ownership validation failed at the first tag push after the change.

0.83.0 published to the registry successfully; 0.84.0 was the first failure —
a clean break introduced by the pointer move.

Why the test is written against ``pyproject.readme`` rather than against a
hard-coded filename: the whole defect was a *pointer change* silently
invalidating an assumption about which file ships. Asserting on the pointer
means any future repoint is caught here rather than at a release.
"""

from __future__ import annotations

import pathlib
import re

import pytest

ROOT = pathlib.Path(__file__).resolve().parents[2]
PYPROJECT = ROOT / "pyproject.toml"

#: The server name registered with the MCP Registry. Must match ``server.json``.
SERVER_NAME = "io.github.quantamixsol/graqle"

#: The exact marker string the registry greps for.
MARKER = f"mcp-name: {SERVER_NAME}"


def _readme_pointer() -> str:
"""Return the filename ``pyproject.toml`` declares as the package readme."""
text = PYPROJECT.read_text(encoding="utf-8")
m = re.search(r'^readme\s*=\s*"([^"]+)"', text, re.M)
assert m, "pyproject.toml has no top-level `readme = \"...\"` declaration"
return m.group(1)


def test_pypi_readme_contains_mcp_name_marker() -> None:
"""Whatever file ships as long_description must carry the marker.

Without it, ``mcp-publisher publish`` fails ownership validation and the
MCP Registry entry silently stops tracking releases.
"""
readme = ROOT / _readme_pointer()
assert readme.is_file(), f"pyproject readme points at missing file: {readme}"

body = readme.read_text(encoding="utf-8")
assert MARKER in body, (
f"{readme.name} does not contain {MARKER!r}. The MCP Registry reads the "
f"PyPI long_description (not README.md) and will reject the publish "
f"with HTTP 400 ownership-validation failure. Add "
f"`<!-- {MARKER} -->` to {readme.name}."
)


def test_marker_server_name_matches_server_json() -> None:
"""The marker must name the same server as ``server.json``.

A mismatch fails validation just as surely as an absent marker.
"""
import json

server_json = ROOT / "server.json"
if not server_json.exists():
pytest.skip("server.json not present in this checkout")

name = json.loads(server_json.read_text(encoding="utf-8-sig")).get("name")
assert name == SERVER_NAME, (
f"server.json declares {name!r} but this test (and the README marker) "
f"expect {SERVER_NAME!r}. Update both together."
)


def test_github_readme_keeps_its_marker_too() -> None:
"""README.md keeps the marker for anyone reading the repo directly.

Not required by the registry once the pointer moved, but removing it would
be a silent regression for GitHub-sourced tooling.
"""
body = (ROOT / "README.md").read_text(encoding="utf-8")
assert MARKER in body, "README.md lost its mcp-name marker"
34 changes: 34 additions & 0 deletions tests/test_release_gate/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,40 @@ def test_gate_handles_review_provider_exception():
assert "review_error" in result.prediction_reasons


def test_provider_exception_text_reaches_the_operator_log(caplog):
"""The real error must be diagnosable from the log, never from the verdict.

Regression for the 0.84.0/0.84.1 release-gate failures: the handler logged
only ``type(exc).__name__``, so every CI failure read as a bare class name
with no message and no traceback, and the cause stayed unknown across
multiple releases.

The two surfaces have opposite requirements and both are asserted here:
the operator log carries the full exception, while the verdict object stays
redacted because it is user-facing (see the module's IP-redaction contract).
"""
import logging

eng = _engine(
review=FakeReviewProvider(raise_exc=RuntimeError("upstream 503 from review api")),
)
with caplog.at_level(logging.ERROR, logger="graqle.release_gate.engine"):
result = asyncio.run(eng.gate(SAMPLE_DIFF, "pypi"))

logged = caplog.text
assert "upstream 503 from review api" in logged, (
"the exception message must reach the operator log — logging only the "
"exception class is what made the 0.84.0 failures undiagnosable"
)
assert "RuntimeError" in logged
assert "Traceback" in logged, "logger.exception must attach the traceback"

# ...and must NOT appear anywhere in the caller-visible verdict.
blob = json.dumps(result.to_dict())
assert "upstream 503" not in blob
assert "RuntimeError" not in blob


# ── 16. Prediction provider exception → WARN ─────────────────────────────

def test_gate_handles_prediction_provider_exception():
Expand Down
Loading