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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [0.2.13] — 2026-08-28

### Fixed
- **`mm_preregister` told clients that pre-seal checks must be strings, while its own lint
told them to send objects.** The published schema carried
`pre_seal_checks: {items: {"type": "string"}}`, derived faithfully from a `list[str]`
type hint that measure-mirror's library had long outgrown. So a client that followed the
⑫h advice — *"Seal each as an object instead: {'name': …, 'result': …}"* — was rejected
by pydantic before the call ever reached the function:
`Input should be a valid string [type=string_type]`.

The obvious workaround clears nothing. Writing the result into the string
(`"neutral-control: not_fired — 30 runs"`) still draws the same WARN — the lint keys on
the entry being a bare string, not on its content — and additionally makes the check name
unrecognised, so no later audit can aggregate that check by name. Every route was a dead
end, and a WARN nobody can act on is a WARN readers learn to skip.

Reported by another lane on 2026-08-26 (which could not test the object form: sealing is
append-only and they would not risk a failed attempt in their ledger). Reproduced and
measured here 2026-08-28. 🔴 The rejection happens at the validation layer, so **nothing
is written** — the ledger file was not even created. That fear was unfounded, and worth
saying out loud, because it is what kept the interface unmeasured for two days.

The hint is now `list[str | dict] | None`, and the tool description states the object form.

### Added
- Three tests covering **the wire, not just the function**. Every existing test called these
tools as plain Python functions, which skips MCP validation entirely — that is precisely
how this shipped: `test_prereg_lint_clean_seal_has_no_warn_or_fail` had been passing
objects to `mm_preregister` and going green while every real client was rejected. The new
tests validate through the server's own arg model and assert the published schema shows
the object form.

🔬 One of them is a **positive control on the instrument**: it asserts the same payload is
REJECTED under the narrow hint this release replaces. Without it the other two would stay
green even if they validated nothing. Both were confirmed to fail on the old hint before
this was committed.

---

## [0.2.12] — 2026-08-28

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion mirror_stack_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""🪞🔎🪪 Mirror Stack unified MCP server."""
__version__ = "0.2.12"
__version__ = "0.2.13"
8 changes: 7 additions & 1 deletion mirror_stack_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,19 @@ def mm_preregister(ledger_path: str, claim_id: str, metric: str, min_n: int = 20
kill_condition: str | None = None, kill_threshold: dict | None = None,
depends_on: list[str] | None = None,
metric_range: list | str | None = None, chance: float | None = None,
pre_seal_checks: list[str] | None = None) -> dict:
pre_seal_checks: list[str | dict] | None = None) -> dict:
"""Seal a claim BEFORE measuring (preregistration). kill_condition/threshold = what falsifies it.

For a non-[0,1] metric, declare metric_range (e.g. [0,100] for a %, or "unbounded" for a
delta/span) + chance (the real chance level, e.g. 1/24≈0.042) so audit doesn't false-FAIL
or assume baseline 0.5. Omit for a plain [0,1] accuracy.

pre_seal_checks entries may be a bare check name, or an object recording what the
check returned — {"name": "neutral-control", "result": "not_fired", "n": 30}. A bare
name declares work without recording it, so the lint WARNs (⑫h) and no later audit can
aggregate its outcome. Writing the result into the string instead clears neither: the
WARN stands, and the check name stops being recognised.

The response carries an automatic seal-quality lint (`lint` key): a FAIL there means
the compute gate will BLOCK this claim — fix and re-seal under a NEW claim_id."""
entry = mm.preregister(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mirror-stack-mcp"
version = "0.2.12"
version = "0.2.13"
description = "Unified MCP server for the Mirror Stack — claims, actions, provenance + verify-all in one server"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
64 changes: 64 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,67 @@ def test_stack_verify_all_on_a_freshly_sealed_ledger(tmp_path):
r = s.stack_verify_all(mm_ledger=str(led))
assert r["ok"] is True and r["verdict"] == "ALL OK"
assert r["passed"] >= 1


# ── the wire schema, not just the function ───────────────────────────────────
# Every test above calls a tool as a plain Python function, which skips the MCP
# validation layer entirely. That is how `pre_seal_checks` could accept objects
# in-process for months while every real client got "Input should be a valid
# string": the type hint said list[str], the library had long outgrown it, and
# nothing here measured the schema a client actually receives.
# Measured 08-28 from [자생]'s report (inbox 0826-145737).

_STRUCTURED = [{"name": "neutral-control", "result": "not_fired", "n": 30},
"positive-control"] # object + bare name, mixed


def _arg_model(name):
tool = [t for t in s.mcp._tool_manager.list_tools() if t.name == name][0]
return tool.fn_metadata.arg_model


def _types_under(node):
"""Every `type` value anywhere in a JSON-schema subtree."""
out = []
if isinstance(node, dict):
t = node.get("type")
out += [t] if isinstance(t, str) else []
for v in node.values():
out += _types_under(v)
elif isinstance(node, list):
for v in node:
out += _types_under(v)
return out


def test_preregister_wire_schema_admits_structured_pre_seal_checks():
# The lint (⑫h) tells the author to seal each check as an object. The wire
# must be able to carry what the lint asks for, or the advice is unfollowable.
_arg_model("mm_preregister").model_validate(
{"ledger_path": "x", "claim_id": "c", "metric": "acc",
"pre_seal_checks": _STRUCTURED})


def test_preregister_published_schema_shows_the_object_form():
# What the client READS, not only what the validator accepts — a client that
# believes the schema never sends the object at all.
tool = [t for t in s.mcp._tool_manager.list_tools()
if t.name == "mm_preregister"][0]
types = _types_under(tool.parameters["properties"]["pre_seal_checks"])
assert "object" in types, tool.parameters["properties"]["pre_seal_checks"]


def test_the_wire_schema_check_can_actually_fail():
# Positive control on the instrument: the same payload must be REJECTED under
# the narrow hint this fix replaced. Without this, the two tests above would
# stay green even if they were validating nothing.
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
from pydantic import ValidationError

def narrow(ledger_path: str, claim_id: str, metric: str,
pre_seal_checks: list[str] | None = None) -> dict: ...

with pytest.raises(ValidationError):
func_metadata(narrow).arg_model.model_validate(
{"ledger_path": "x", "claim_id": "c", "metric": "acc",
"pre_seal_checks": _STRUCTURED})
Loading