From c7fc129b58301c8113cb298562d738d7eb3028f2 Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Fri, 28 Aug 2026 16:21:57 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=EA=B0=80=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EB=A1=9C=20=EB=B4=89=EC=9D=B8=ED=95=98=EB=9D=BC?= =?UTF-8?q?=EB=8A=94=EB=8D=B0=20=EC=8A=A4=ED=82=A4=EB=A7=88=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A7=8C=20=EB=B0=9B=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mm_preregister` 가 게시하는 스키마는 `pre_seal_checks: {items:{"type":"string"}}` 였다. `list[str]` 힌트에서 FastMCP 가 성실히 유도한 값인데, measure-mirror 본문은 오래전에 그 힌트를 넘어섰다. 그래서 ⑫h 조언 — *"Seal each as an object instead: {'name':…, 'result':…}"* — 을 따르려는 호출자는 함수에 닿기도 전에 pydantic 에서 튕긴다: `Input should be a valid string [type=string_type]`. 우회도 막혔다. 결과를 문자열에 적으면(`"neutral-control: not_fired — 30 runs"`) ① WARN 은 그대로다 — 린트는 **bare 문자열이라는 사실**에 걸지 내용에 걸지 않는다. ② 게다가 그 이름이 인식 불가가 되어 감사가 이름으로 못 센다. 모든 길이 막다른 길이고, **행동할 수 없는 WARN 은 읽는 쪽이 건너뛰는 법을 배운다** — 그게 진짜 손실이다. 08-26 다른 레인이 신고했다. 그쪽은 객체를 시험하지 못했다 — 봉인이 append-only 라 실패한 시도를 원장에 남기고 싶지 않아서다. 08-28 에 재현·실측했다. 🔴 거절은 검증 층에서 나므로 **아무것도 안 쓰인다** — 원장 파일조차 안 생겼다. 그 두려움은 근거가 없었고, 그 두려움이 이 인터페이스를 이틀간 미측정으로 남겼다. 수리: 힌트를 `list[str | dict] | None` 로. 툴 설명에 객체 형태를 적었다. 시험 3건 신설 — **함수가 아니라 wire 를 잰다**. 기존 시험은 전부 툴을 평범한 파이썬 함수로 호출한다. MCP 검증 층을 통째로 건너뛴다. 🔴 그래서 이게 출하됐다 — `test_prereg_lint_clean_seal_has_no_warn_or_fail` 은 **이미 객체를 넣고 초록이었다**. 실제 클라이언트는 전부 거절당하는 동안에. 새 시험은 서버 자신의 arg model 로 검증하고, 게시된 스키마에 object 가 보이는지 본다. 🔬 그중 하나는 **계기의 양성대조**다 — 같은 payload 가 옛 힌트에선 거절되는지 본다. 없으면 나머지 둘이 아무것도 검증 안 해도 초록으로 남는다. 커밋 전에 옛 힌트로 되돌려 둘 다 실제로 FAIL 하는 것을 확인했다. pytest 62 통과(파이프 없이 · exit 0). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 40 ++++++++++++++++++++++ mirror_stack_mcp/__init__.py | 2 +- mirror_stack_mcp/server.py | 8 ++++- pyproject.toml | 2 +- tests/test_server.py | 64 ++++++++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1dc3f1..5f83d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/mirror_stack_mcp/__init__.py b/mirror_stack_mcp/__init__.py index 7bb327b..178edbb 100644 --- a/mirror_stack_mcp/__init__.py +++ b/mirror_stack_mcp/__init__.py @@ -1,2 +1,2 @@ """🪞🔎🪪 Mirror Stack unified MCP server.""" -__version__ = "0.2.12" +__version__ = "0.2.13" diff --git a/mirror_stack_mcp/server.py b/mirror_stack_mcp/server.py index 142598a..87f4806 100644 --- a/mirror_stack_mcp/server.py +++ b/mirror_stack_mcp/server.py @@ -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( diff --git a/pyproject.toml b/pyproject.toml index 18332da..a185a5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_server.py b/tests/test_server.py index 2a3a804..27e5b26 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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})