Skip to content

ci(dependabot): ignore pydantic-core bumps past the pydantic-pinned exact version - #416

Merged
cdeust merged 2 commits into
mainfrom
fix/pr333-pydantic-core-close
Aug 10, 2026
Merged

ci(dependabot): ignore pydantic-core bumps past the pydantic-pinned exact version#416
cdeust merged 2 commits into
mainfrom
fix/pr333-pydantic-core-close

Conversation

@cdeust

@cdeust cdeust commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #333 (pydantic-core 2.46.4 -> 2.47.0) as genuinely unresolvable, not merely unresynced, and adds a dependabot.yml ignore entry so it stops reopening.

Root cause

pydantic-core is never declared directly in pyproject.toml — it arrives transitively through pydantic, which pins its Rust extension exactly per release (ABI lockstep), not by a compatibility range:

  • pydantic 2.13.4 (latest stable, no newer stable release exists) declares pydantic-core==2.46.4.
  • The only releases past 2.13.4 are pre-releases 2.14.0a1 / 2.14.0b1, and both already require pydantic-core==2.48.0.

pydantic-core 2.47.0 sits in the gap between those two pydantic pin generations — no pydantic release, stable or pre-release, declares compatibility with it. This is the same failure class as the existing caio dependabot-ignore entry a few lines above it in dependabot.yml (PR #322): a transitive package whose owner pins it exactly, where the proposed version has no compatible owner release at all.

Verified against PyPI's own requires_dist metadata (not against uv lock's resolver output alone):

$ curl -s https://pypi.org/pypi/pydantic/json | jq '.info.version, (.info.requires_dist[] | select(contains("pydantic-core")))'
"2.13.4"
"pydantic-core==2.46.4"

$ curl -s https://pypi.org/pypi/pydantic/2.14.0b1/json | jq '.info.requires_dist[] | select(contains("pydantic-core"))'
"pydantic-core==2.48.0"

No security advisory affects pydantic-core (gh api "/advisories?ecosystem=pip&affects=pydantic-core" returns [], checked 2026-08-10), so staying on 2.46.4 is not a security hold.

Fix

.github/dependabot.yml: added a pydantic-core ignore entry (versions: [">2.46.4"]) with the reasoning above and its sources inline, mirroring the existing caio entry's format. Remove it once pydantic's stable line moves past 2.13.x and pins a newer pydantic-core.

Verification

  • scripts/check_craftsmanship.py — OK
  • ruff check / ruff format --check — clean
  • YAML parses (yaml.safe_load) and the new ignore entry round-trips correctly

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

…xact version

pydantic-core is transitive (hypermnesia-mcp -> pydantic -> pydantic-core)
and pydantic pins it EXACTLY per release rather than by range: pydantic
2.13.4 (latest stable) declares `pydantic-core==2.46.4`. Verified against
PyPI's own metadata that no pydantic release accepts 2.47.0 at any
maturity — the only releases past 2.13.4 are pre-releases 2.14.0a1/b1,
and both already require `pydantic-core==2.48.0`. 2.47.0 sits in a gap
between the two pydantic pin generations with no compatible pydantic
release, so the bump PR #333 proposed is unresolvable, not merely
unresynced (same class of failure as the existing caio ignore entry
above it, PR #322). No advisory affects pydantic-core, so this is not a
security hold.

PR #333 is closed with this same reasoning in its body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cdeust added a commit that referenced this pull request Aug 10, 2026
…migration

Two follow-ups the CI run on PR #331 caught immediately, both fixed at
the source rather than baselined/skipped:

- tests_py/infrastructure/test_stdio_late_response.py's test method
  exceeded the 40-line/method cap after the first extraction pass
  (the extracted helper itself was still over). Split further: a
  _RaceFixtures dataclass + _build_race_fixtures() groups the six-value
  setup into one call, leaving _drive_late_request_scenario() and the
  test method both under the cap.
- pyproject.toml gained exceptiongroup>=1.2.0 in a later edit than the
  `uv lock` run in the prior commit — CI's `uv lock --check` correctly
  caught the resulting drift. Re-locked (uv.lock unchanged in content,
  since exceptiongroup was already pinned transitively at the same
  version — only the marker condition and provenance changed) and
  regenerated the 5 requirements/*.txt files whose extras include the
  dev group.

Also adds .craftsmanship.conf (same fix as PR #332/#416, not yet on
this branch since it was cut before those merged): the local
zetetic-marketplace pre-commit hook has no auto-generated-file
detection and flags requirements/*.txt against the generic §4.1
500-line cap. Scoped skip for requirements/, matching the documented
auto-generated exception. Distinct from this repo's own CI gate
(scripts/check_craftsmanship.py), unaffected by this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cdeust

cdeust commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: REQUEST_CHANGES

Reviewed at head 1f980c7ae81b609ea07bf2699586bf6eec870947. Single-file dependabot.yml change; no benchmark run (not applicable, no code path touched).

Move 0 — ledger / seen-defect check

No un-issued "pre-existing/unrelated" dismissal — this PR is the resolution of a previously-deferred issue (#333), with the issue closed and its number cited. Passes.

Independent PyPI verification (per review instruction — not reusing the PR's own claim)

I queried PyPI's JSON API myself, not the PR's curl/jq transcript:

$ curl -s https://pypi.org/pypi/pydantic/json | jq '.info.version, (.info.requires_dist[]|select(contains("pydantic-core")))'
"2.13.4"
"pydantic-core==2.46.4"

$ curl -s https://pypi.org/pypi/pydantic/2.14.0a1/json | jq '.info.requires_dist[]|select(contains("pydantic-core"))'
"pydantic-core==2.47.0"          # <-- NOT 2.48.0

$ curl -s https://pypi.org/pypi/pydantic/2.14.0b1/json | jq '.info.requires_dist[]|select(contains("pydantic-core"))'
"pydantic-core==2.48.0"

The PR's central factual claim is false. Both the PR body and the dependabot.yml comment state: "the only releases past 2.13.4 are pre-releases 2.14.0a1/2.14.0b1, and both already require pydantic-core==2.48.0" and conclude "2.47.0 sits in a gap between the two pydantic pin generations with no compatible pydantic release at any maturity." This is wrong: pydantic==2.14.0a1 declares pydantic-core==2.47.0 exactly — a compatible pre-release does exist. The PR's own "Verified against PyPI's own requires_dist metadata" transcript in the description only queries 2.14.0b1, never 2.14.0a1; the verification was incomplete, and the incomplete check produced a false conclusion that is now embedded permanently as a source-cited comment in dependabot.yml.

This matters beyond pedantry: coding-standards.md §8 requires every claim to trace to a verified source, and this repo's own review instruction for this PR was explicitly "vérifie les métadonnées toi-même... plutôt que de reprendre l'affirmation" — running that check is what surfaced the error. A future maintainer reading this comment will be told "no compatible pydantic exists at any maturity," which will misdirect any future investigation (e.g., "should we track pydantic pre-releases to unblock pydantic-core" is a live, valid option this comment forecloses on false grounds).

Does the practical conclusion still hold despite the wrong evidence?

Yes, narrowly. pydantic stable (2.13.4, the only stable line this repo tracks) pins pydantic-core==2.46.4 exactly — this repo does not run a pre-release pydantic, so a standalone pydantic-core bump to 2.47.0 is genuinely unresolvable while staying on stable pydantic, independent of whether some pre-release happens to want it. The dependabot ignore entry's practical effect (stop proposing pydantic-core-only bumps above 2.46.4 until pydantic's stable line moves) is defensible — but the comment must say that, not the falsified "no compatible release exists" claim.

Form of the exclusion (versions: [">2.46.4"])

Open-ended above, same shape as the existing caio precedent it cites. This is a real, if secondary, risk given the corrected facts: when pydantic 2.14.0 eventually stabilizes and pins pydantic-core==2.48.0 (or whatever its stable pin turns out to be), Dependabot will need to bump pydantic-core as a companion to the pydantic bump — and this open-ended >2.46.4 rule will keep blocking that legitimate, compatible bump too, silently, until a human remembers to delete the entry (the comment's "Remove this entry once..." is a manual TODO with no tracking mechanism). Given the corrected evidence shows the compatible window is already partially known (a pre-release pins exactly 2.47.0, another exactly 2.48.0), consider either: (a) accept the open range but file a tracking issue for "remove pydantic-core dependabot ignore when pydantic 2.14 stabilizes" instead of relying on a comment, or (b) leave as-is if the team accepts the caio-precedent risk profile — but that's a judgment call, not blocking by itself.

Verdict rationale

The exclusion mechanism itself (dependabot ignore, mirroring caio) is a reasonable and narrow tool for this failure class, and no security advisory holds it back (confirmed independently: gh api "/advisories?ecosystem=pip&affects=pydantic-core"[]). But the PR ships a source-cited comment asserting a fact that is directly contradicted by the same source it cites, once queried completely. That's a correctness defect in the artifact being merged, not a style nit — required to fix before merge.

Required to unblock

  • Correct the dependabot.yml comment (and ideally the PR description, for the record): pydantic==2.14.0a1 requires pydantic-core==2.47.0, not 2.48.0; only 2.14.0b1 requires 2.48.0. Restate the actual root cause as "pydantic's stable line pins pydantic-core exactly, and this repo does not track pydantic pre-releases," not "no compatible pydantic release exists at any maturity."
  • Non-blocking: consider a tracking issue instead of a comment-only reminder for removing the ignore entry once pydantic 2.14 stabilizes, given the open-ended >2.46.4 range will also suppress the eventual legitimate companion bump.

🤖 Reviewed by code-reviewer (Sonnet 5), no benchmark run (not applicable).

…und it

Review finding on PR #416: the prior comment claimed no pydantic
release at any maturity accepts pydantic-core 2.47.0. That was checked
against only one of the two pre-releases past 2.13.4 (2.14.0b1, which
requires pydantic-core==2.48.0) and wrongly generalized to the other.
PyPI's own metadata for 2.14.0a1 (not previously queried) shows it
requires pydantic-core==2.47.0 exactly -- a real match exists, just on
an alpha this repo does not track.

The practical conclusion is unchanged (2.13.4, the latest STABLE
pydantic, pins pydantic-core==2.46.4 exactly, so the bump to 2.47.0
does not resolve against what this repo actually installs) but the
comment now states that reason instead of the false "no match at any
maturity" one -- a wrong # source: comment is worse than no comment,
since the next person to read it takes it as fact.

Also narrows the exclusion from the open `>2.46.4` to `>2.46.4,<2.48.0`
(PEP 440 comma-AND syntax, verified against `packaging.specifiers
.SpecifierSet`): the open form would also have silently blocked the
legitimate 2.48.0 bump the day pydantic's stable line reaches 2.14.0
(matching the beta's pin) -- an exclusion should be no wider than the
problem it routes around.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cdeust

cdeust commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Summary

Second-round review of 3a61eba1. The first round's blocking finding is fixed: the false claim ("no pydantic release at any maturity accepts pydantic-core 2.47.0") is replaced by the accurate one — 2.14.0a1 does declare pydantic-core==2.47.0, this repo simply doesn't track pydantic alphas, so the practical rejection stands for the correct reason. The exclusion range is now bounded (>2.46.4,<2.48.0) instead of open-ended.

Verification performed this round (all against primary sources, no benchmark/test run)

  • PyPI JSON metadata, fetched directly for all three releases named: pydantic==2.13.4 (latest stable) → pydantic-core==2.46.4; pydantic==2.14.0a1pydantic-core==2.47.0; pydantic==2.14.0b1pydantic-core==2.48.0. Exact match to the table now embedded in dependabot.yml. pydantic's PyPI releases list confirms no stable release exists past 2.13.4 — the "no newer stable exists" claim holds.
  • Security-hold claim: re-ran gh api "/advisories?ecosystem=pip&affects=pydantic-core" myself — empty list, confirming "not a security hold" independently rather than trusting the report.
  • Provenance of the closed issue: gh pr view 333 confirms PR deps: bump pydantic-core from 2.46.4 to 2.47.0 #333 ("bump pydantic-core from 2.46.4 to 2.47.0") is CLOSED — the report's stated cause-and-effect is real, not invented.
  • Range correctness, tested against real version strings, not eyeballed: built packaging.specifiers.SpecifierSet(">2.46.4,<2.48.0") and checked membership — 2.46.4 excluded (equality boundary correct), 2.47.0/2.47.9 excluded (the actual problem), 2.48.0/2.48.1 included (i.e. NOT excluded by the ignore rule) — confirms the tightened range unblocks the legitimate future bump to 2.48.0 while still blocking the 2.47.x alpha-only line. This is the load-bearing claim of the PR and it holds under direct test, not just visual inspection.
  • File integrity: fetched the actual .github/dependabot.yml blob at the PR head and parsed it with yaml.safe_load — valid YAML, the new pydantic-core ignore entry sits correctly alongside the existing caio entry in the same ignore: list, same structural pattern.
  • Comment accuracy: the new comment explicitly states its own prior error ("an earlier version of this comment claimed... wrongly generalized") rather than silently rewriting history — this is the honest correction the task asked for, not a smoothed-over reformulation.

Rules compliance (coding-standards.md)

Rule Status Evidence Action
§8 Source discipline pass dependabot.yml — 3 # source: PyPI JSON citations, each independently re-fetched and matched above none
§9 anti-pattern (invented constants) pass <2.48.0 bound is not invented — traced to 2.14.0b1's actual declared pin none
§4 size limits n/a config file, no function/class

Stakes

Medium — dependency-update governance for a transitive package with no direct import in this repo (root cause section confirms pydantic-core isn't imported directly). Not High: no security advisory, no auth/billing/crypto/schema surface.

Issues

Blocking

None.

Non-blocking

None — round-1 finding (false universal claim + open-ended exclusion) is fully resolved and independently re-verified against PyPI, not just re-asserted by the author.

Verdict

APPROVE.

@cdeust
cdeust merged commit e88e4e2 into main Aug 10, 2026
26 checks passed
@cdeust
cdeust deleted the fix/pr333-pydantic-core-close branch August 10, 2026 10:27
cdeust added a commit that referenced this pull request Aug 10, 2026
…migration

Two follow-ups the CI run on PR #331 caught immediately, both fixed at
the source rather than baselined/skipped:

- tests_py/infrastructure/test_stdio_late_response.py's test method
  exceeded the 40-line/method cap after the first extraction pass
  (the extracted helper itself was still over). Split further: a
  _RaceFixtures dataclass + _build_race_fixtures() groups the six-value
  setup into one call, leaving _drive_late_request_scenario() and the
  test method both under the cap.
- pyproject.toml gained exceptiongroup>=1.2.0 in a later edit than the
  `uv lock` run in the prior commit — CI's `uv lock --check` correctly
  caught the resulting drift. Re-locked (uv.lock unchanged in content,
  since exceptiongroup was already pinned transitively at the same
  version — only the marker condition and provenance changed) and
  regenerated the 5 requirements/*.txt files whose extras include the
  dev group.

Also adds .craftsmanship.conf (same fix as PR #332/#416, not yet on
this branch since it was cut before those merged): the local
zetetic-marketplace pre-commit hook has no auto-generated-file
detection and flags requirements/*.txt against the generic §4.1
500-line cap. Scoped skip for requirements/, matching the documented
auto-generated exception. Distinct from this repo's own CI gate
(scripts/check_craftsmanship.py), unaffected by this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cdeust added a commit that referenced this pull request Aug 10, 2026
… real

Addresses REQUEST_CHANGES on #419.

1. BLOCK - _check_plugin_pin recognized only two of the marketplace
   schema's five source shapes (github, local-path) and silently
   returned ([], [], None) for the other three (npm, url, git-subdir),
   all schema-legal (verified live against
   https://json.schemastore.org/claude-code-marketplace.json), all
   currently unused by this repo's manifest (verified by reading every
   plugins[].source in it) but not by the dispatcher's own logic. That
   silence is exactly the defect class this gate exists to close, in
   code this same PR introduced. Fixed: a dict source with a recognized-
   but-unchecked type (npm/url/git-subdir) now fails loudly
   (UNVERIFIED_SOURCE_TYPE, naming the type and pointing at where to add
   a checker); any other shape (wrong type, unknown source key) fails
   loudly too (UNRECOGNIZED_SOURCE). 7 new tests in
   tests_py/scripts/test_check_marketplace_pins_dispatch.py replay the
   reviewer's exact npm/url probe plus git-subdir, an unknown dict, and
   two malformed non-dict/non-str shapes - every one now produces a
   failure, none pass silently.

2. BLOCK - rebased onto origin/main (e88e4e2). The branch was stale
   behind #414/#416/#417; check_craftsmanship.py --base origin/main (the
   exact invocation CI runs) is clean post-rebase.

3. Non-blocking, fixed anyway - the incident-replay tests now execute a
   frozen, verbatim copy of the pre-fix check_github_pin/check_self_pin
   logic (git blame: pre-e0661ad9) against the identical historical
   inputs, asserting it returns the old silent (None, None)/[] BEFORE
   asserting the current code returns the failure AFTER (frozen copies
   live in tests_py/scripts/_marketplace_pins_legacy_replay.py). The
   commit message on e0661ad asserted this replay already happened; it
   did not - only the new code was exercised, and the "before" was
   prose. This is what the prose should have described from the start.

Incidental fix required to keep this PR's own CI green: bumped
zetetic-team-subagents 2.36.0 -> 2.37.0 (a real v2.37.0 tag landed on
cdeust/zetetic-team-subagents at 2026-08-10T10:32Z, mid-session, from
unrelated work - confirmed via `gh release view`, not assumed). Unrelated
to items 1-3 and to this PR's actual subject; flagged here rather than
silently folded in.

Re-measured after, not before: check_craftsmanship.py --base origin/main
clean; check_marketplace_pins.py exits 0 live (one NOTICE, the already-
disclosed pending registry entry); ruff check/format clean; pyright 0
diagnostics on every touched file; tests_py/scripts/ 771 passed (up from
764 pre-review), 5 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cdeust added a commit that referenced this pull request Aug 10, 2026
* feat(mcp2): migrate off fastmcp onto the native mcp 2.0.0 SDK server

mcp 2.0.0 folds FastMCP's decorator API directly into the SDK
(mcp.server.mcpserver.MCPServer, the documented successor to
fastmcp.FastMCP) and fastmcp-slim (latest, 3.4.5) still declares
mcp<2.0, so the two cannot coexist in one lock. Adopting MCPServer
resolves the conflict and removes ~24 transitive packages from the
lock (keyring, secretstorage, jeepney, jaraco-*, openapi-pydantic,
websockets, watchfiles, email-validator, ...).

Mechanical surface (7 tool_registry_*.py files, mcp_progress.py,
mcp_prompts.py, tool_error_handler.py, handlers/_tool_meta.py):
FastMCP -> MCPServer in type annotations and imports; Context/ToolError
imports moved to mcp.server.mcpserver / mcp.server.mcpserver.exceptions.

Two architectural pieces mcp 2.0.0 has no drop-in equivalent for,
each resolved by direct empirical verification against the installed
package (not assumed from type signatures):

1. tool_profile_middleware.py — FastMCP's 4-hook Middleware
   (on_list_tools/on_call_tool/on_list_prompts/on_get_prompt) becomes
   ONE mcp.server.context.ServerMiddleware.__call__(ctx, call_next),
   dispatching on ctx.method. Rejecting a tools/call by raising before
   call_next (the naive port) loses the classified message — it skips
   MCPServer._handle_call_tool's own isError=True conversion and
   surfaces as a bare "MCPError: Internal server error". Fixed by
   constructing and returning a CallToolResult(is_error=True, ...)
   directly instead — the graceful, model-visible shape the original
   design intended (tool_error_handler.py's whole idiom). prompts/get
   has no in-band error shape, so that path still raises, but as
   mcp.shared.exceptions.MCPError specifically (the one class the
   dispatcher's error mapper recognizes and preserves the message for).
   Registered via MCPServer(middleware=[...]) at construction — no
   post-construction add_middleware() exists anymore.

2. stdio_transport.py (295 lines, the FastMCP-race workaround) —
   DELETED, not ported. Reproduced the exact scenario from its own
   removed regression test (deterministic anyio.Event synchronization,
   no sleeps) directly against bare mcp==2.0.0 in an isolated venv:
   the rewritten dispatcher (mcp.shared.jsonrpc_dispatcher
   .JSONRPCDispatcher.run) explicitly wraps the write-stream close
   OUTSIDE the task-group join, so a request still in flight at EOF
   always gets an explicit answer now -- the original silent-drop
   defect is fixed upstream. What is NOT reproduced: in-flight work is
   cancelled on EOF (not drained to completion), so the answer is a
   graceful CONNECTION_CLOSED error, not the handler's real result --
   a narrower but still-correct guarantee (no silent drop, ever) this
   session initially over-claimed as full parity before checking the
   actual response payload, not just its presence. Replaced with
   test_stdio_late_response.py, a positive regression pin for the
   verified (not assumed) contract. __main__.py's main() is now
   anyio.run(mcp.run_stdio_async) -- the SDK also owns lifespan entry
   internally now, and there is no more banner/PyPI-update-check
   ceremony to preserve (mcp 2.0.0 does neither).

Two further defects found only by full empirical round-trips, not by
reading signatures:

- mcp 2.0.0's MCPServer.tool() has no output_schema parameter at all --
  structured output is derived exclusively from the wrapped function's
  return TYPE ANNOTATION. Every tool_registry_*.py inner function
  returned bare `-> dict`, which mcp 2.0.0 does not structure (no
  Tool.output_schema, no structuredContent) -- a universal regression
  across all 43 tools, not just the 9 with a hand-authored outputSchema.
  Fixed at the root: `-> dict` -> `-> dict[str, Any]` (mechanical,
  43 occurrences) restores auto-derived structuredContent; a new
  apply_output_schemas() (handlers/_tool_meta.py) then overrides the
  auto-derived generic schema with each handler's hand-authored
  outputSchema by assigning tool.output_schema directly on the
  persistent internal Tool object (a cached_property, overridable via
  normal instance-__dict__ precedence) -- verified this persists across
  every subsequent tools/list call, unlike mutating the per-call
  wire-level mcp.types.Tool the old apply_param_docs() read from
  (also fixed to read the same persistent internal registry).
- mcp 2.0.0's func_metadata no longer parses Google-style docstring
  Args: sections for prompt-argument descriptions (verified: no such
  logic anywhere in mcp.server.mcpserver.utilities.func_metadata).
  mcp_prompts.py's three prompt functions now declare descriptions via
  Annotated[str, Field(description=...)] instead.

Test suite: 7046 of 7046 non-PG-gated tests pass (verified in an
isolated venv with the full stack including flashrank installed);
264 skip (PostgreSQL-gated, expected without a live PG). mcp.Client's
in-process transport wraps server errors in ExceptionGroup (anyio
task-group teardown) where fastmcp.Client raised directly -- test
assertions updated to unwrap; tests_py/test_mcp_prompts.py's helper
needs ExceptionGroup/BaseExceptionGroup, 3.11+ builtins, so
exceptiongroup>=1.2.0 (already a transitive anyio dependency) is now
declared directly in the dev extras rather than relied on by luck.

uv.lock + requirements/*.txt regenerated via `uv lock` +
scripts/generate_pip_constraints.py. .craftsmanship-baseline.json
surgically pruned (3 entries) for the deleted stdio_transport.py test
files only -- not regenerated wholesale, to avoid grandfathering
unrelated pre-existing debt the full-tree --write-baseline scan would
otherwise have swept in from files this change never touches.

Builds on investigation from an abandoned 2026-08-01 session (commits
ba0821b8/86158b0d on the local feat/mcp-2.0 branch, never merged) that
correctly identified both hard problems but did not verify its own
tool_profile_middleware.py port against a real mcp.Client round-trip
(it raises MCPError for tools/call too, which IS recognized and DOES
preserve the message -- but changes tools/call rejections from a
graceful isError=True result to a protocol-level error, a real,
unverified behavior change) and did not reach stdio_transport.py at
all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp2): keep the craftsmanship gate and lockfile in sync with the migration

Two follow-ups the CI run on PR #331 caught immediately, both fixed at
the source rather than baselined/skipped:

- tests_py/infrastructure/test_stdio_late_response.py's test method
  exceeded the 40-line/method cap after the first extraction pass
  (the extracted helper itself was still over). Split further: a
  _RaceFixtures dataclass + _build_race_fixtures() groups the six-value
  setup into one call, leaving _drive_late_request_scenario() and the
  test method both under the cap.
- pyproject.toml gained exceptiongroup>=1.2.0 in a later edit than the
  `uv lock` run in the prior commit — CI's `uv lock --check` correctly
  caught the resulting drift. Re-locked (uv.lock unchanged in content,
  since exceptiongroup was already pinned transitively at the same
  version — only the marker condition and provenance changed) and
  regenerated the 5 requirements/*.txt files whose extras include the
  dev group.

Also adds .craftsmanship.conf (same fix as PR #332/#416, not yet on
this branch since it was cut before those merged): the local
zetetic-marketplace pre-commit hook has no auto-generated-file
detection and flags requirements/*.txt against the generic §4.1
500-line cap. Scoped skip for requirements/, matching the documented
auto-generated exception. Distinct from this repo's own CI gate
(scripts/check_craftsmanship.py), unaffected by this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(mcp2): update the remaining hand-maintained fastmcp references

Real bugs surfaced by CI running against the mcp 2.0.0 migration, not
speculative cleanup:

- scripts/launcher_pins.py: BASE_PACKAGES still pinned fastmcp==3.4.5
  -- the plugin-bootstrap package list has no resolver available to
  catch drift itself, which is exactly why
  tests_py/scripts/test_launcher_pins_match_lock.py exists (compares
  this list against requirements/setup.txt on every PR) and is what
  caught this: `_BASE_PACKAGES disagrees with setup.txt`, failing
  every Test (Python 3.1x) leg on PR #331. Fixed to mcp==2.0.0.
- scripts/setup.py: install_deps()'s hand-written package list had the
  same fastmcp>=2.0.0 entry, unguarded by a test (no equivalent to the
  launcher_pins check exists for this file). Fixed to mcp>=2.0.0 --
  otherwise a fresh `setup.sh` run would install a package this repo
  no longer imports.
- scripts/verify_mcp_hosts.py: refactored main() (Fowler 2018 Ch. 6,
  Extract Function) into _build_parser/_resolved_command/_case_command/
  _run_one_case/_run_all_cases while touching this file for the
  PYTHONPATH fix -- the previous single function was already at the
  file's craftsmanship baseline (76 lines, nesting depth 5, both over
  CLAUDE.md's 40-line/3-level caps) and this PR's own edit was the
  trigger to fix it rather than add to it further. Both baseline
  entries pruned since neither violation reproduces anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(mcp2): shrink verify_mcp_hosts.py functions back under the size caps

The prior commit's refactor and PYTHONPATH-fix comment pushed two
functions over CLAUDE.md's 40-line/method cap again (caught by CI's
Craftsmanship Gate on push, not locally -- the pre-commit hook and the
CI gate agree, this was a same-session miss, not a policy gap):

- _environment(): the PYTHONPATH rationale moved from a 17-line inline
  comment to the module docstring's new "Environment isolation"
  section, leaving a two-line pointer at the call site.
- _build_parser(): split into _add_selection_arguments (which host
  identities/profiles a run exercises) and _add_runtime_arguments (how
  each case is driven) -- the same split the module's own two concerns
  already implied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(mcp2): drain before signalling shutdown in the stdio host harness

PR #331's red CI was not a Cortex regression and not a race the mcp 2.0.0
SDK gets wrong: it was our harness asking for a guarantee the MCP protocol
never grants.

Model. Order the events of one batch: arrive(r) -> accept(r) -> begin(r)
-> produce(r) -> enqueue(r) -> emit(r), against EOF -> readclose ->
cancel -> join -> wclose. Serial reads over rendezvous streams force
accept(r) -> EOF -> cancel for every request in the batch, and enqueue(r)
-> emit(r) holds because stdout_writer lives outside the dispatcher's task
group. Nothing orders enqueue(r) before cancel. The guarantee that is
therefore nowhere established -- and that the harness assumed -- is the
ANSWER OBLIGATION FOR AN ACCEPTED REQUEST: for every r the dispatcher takes
off the read stream, exactly one frame carrying r's id reaches the wire.

Mechanism, read from the pinned wheel (sha256 1cb4c75d...49d6, the hash
uv.lock pins), not from its docs: JSONRPCDispatcher._handle_request sets
answer_write_started on the line BEFORE awaiting the response write, and
the shutdown arm declines to send CONNECTION_CLOSED whenever that flag is
set ("prefer possibly-zero answers over possibly-two"). A write cancelled
at MemoryObjectSendStream.send's entry checkpoint provably never delivered,
yet counts as possibly sent -- so the request settles with no frame at all.
Instrumented trace: begin(5) write_result-enter(5)
send-cancelled-at-entry-checkpoint(5,CancelledError), then silence. This
corrects the prior hypothesis (a handler task cancelled before its first
scheduling turn); the handler ran to completion.

Verdict. The SDK promises nothing here, and MCP 2025-06-18 §Lifecycle >
Shutdown > stdio makes closing stdin the shutdown signal with no drain
phase defined, so an accepted request is owed nothing once EOF lands. The
defect is in the client: subprocess.run(input=...) signals shutdown before
reading a single response. Reproduced against a BARE mcp 2.0.0 server with
zero Cortex code -- ids 4 and 5 of a six-frame batch got no frame, 5/5 runs;
the same batch drained first, 5/5 runs, loses nothing.

Change. scripts/mcp_host_client.py owns the exchange and keeps stdin open
until every expected id has arrived, closing it only then; synchronisation
is by event (a response line, or stdout EOF), never by elapsed time -- the
caller's --timeout survives only as a watchdog that kills a wedged child
and never decides a verdict. stderr goes to a file, not an undrained pipe.
verify_mcp_hosts.py drops to 276 lines, so its baselined file-size entry is
pruned. mcp_server/__main__.py's main() comment claimed mcp 2.0.0 had the
drain invariant built in; it does not, and the comment now says why the
workaround is still not restored (a drain in our transport would let a
wedged handler hold shutdown hostage, and real hosts never need it).

Tests. test_stdio_eof_drain.py forces both orderings deterministically over
one in-memory pair -- park the response write, then EOF: no frame; drain,
then EOF: every real result. test_mcp_host_client.py pins that stdin is
provably still open at each response read. test_stdio_late_response.py's
docstring generalised one green interleaving into "no silent drop, ever";
that inference is retracted, with the scope it actually covers spelled out.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp2): follow the harness split through its callers, and type the gate

Two CI failures on the previous commit, both real:

- tests_py/scripts/test_verify_mcp_hosts.py still imported `_environment`
  and `_responses` from scripts/verify_mcp_hosts.py, which no longer owns
  them. Retargeted at scripts/mcp_host_client.py; `_responses(stdout: str)`
  became `absorb(line, responses)` because the exchange reads one line at a
  time -- it must decide the batch is answered before it may close stdin,
  so it cannot wait for the whole stream. Added the case that change
  introduces: a blank line (the trailing newline whole-stream `splitlines()`
  never yielded) is not a malformed frame.
- The sibling import is now `scripts.mcp_host_client` under one canonical
  name, with the repo root put on sys.path when this module runs as a
  script. A bare `import mcp_host_client` would give the executed script and
  an importing test two distinct module objects -- and two distinct
  `ContractError` classes, so `pytest.raises` would miss the real one. That
  dual-identity trap is already documented in
  tests_py/scripts/_craftsmanship_support.py for its own siblings.

Also fixed, pre-existing on this branch and blocking the Type Check gate:
mcp_server/tool_profile_middleware.py read `params["name"]` as `Any | None`
and handed it to `allows()`/`is_available()`, both typed `str`. `_requested_name`
now collapses a missing or non-string name to `""` -- which no profile lists,
so both gates refuse it exactly as they already refused an unknown name.
Behaviour is unchanged on every input; only the type is now honest.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mcp2): pin the drain rule's degenerate cases

The exchange's termination argument has to hold at the corners, not only on
the happy batch: a server that answers nothing (EOF before any frame) must
report nothing and close, never block; a frame arriving after the last
awaited id must still be parsed and contract-checked rather than discarded
by the loop's exit; and ids are awaited as a SET, since mcp 2.0.0 runs
handlers concurrently and does not answer in request order.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: cdeust <cdeust@icloud.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
cdeust added a commit that referenced this pull request Aug 10, 2026
…rsions ship silently (#419)

* fix(marketplace): close the pin-verification gap that let dangling versions ship silently

The hypermnesia-mcp-viz marketplace pin read version "3.0.0" for six days
with no matching cortex-viz tag (v2.8.0 was, and remained, the latest real
one). check_marketplace_pins.py never caught it: PIN_BEHIND_RELEASE only
ever asked "is the pin behind the latest known tag?" — a pin sitting AHEAD
of every real release read as current and passed silently.

Root cause, fixed at the source: add PIN_VERSION_UNPUBLISHED (existence,
not staleness) for both github-source and self-source pins, and the same
principle for a third version surface this incident exposed — the public
MCP registry (io.github.cdeust/hypermnesia-mcp was published at 4.17.1
while the tag/server.json/PyPI were already at 4.17.2, invisible until
queried directly): REGISTRY_VERSION_STALE, exact-equality against
server.json's own declared version.

- scripts/check_marketplace_pins.py split into a thin composition-root
  facade + 5 single-purpose modules (marketplace_pins_{http,semver,github,
  self,manifests,registry}.py) — the single file crossed the 300-line
  §4.1 cap once REGISTRY_VERSION_STALE was added. AP's byte-identical
  mirror needs the same split; its weekly diff job will show that until
  synced, which is the intended signal, not a regression.
- PENDING_PINS / PENDING_REGISTRY: a real, correctly-flagged finding whose
  fix is genuinely in flight (a real open PR, or a workflow that only
  fires on the next tag) degrades to a named, non-silent NOTICE instead
  of a red run — never a placeholder, always naming the tracking
  reference, dead code once the real state catches up.
- Corrected an unverified hypothesis relayed from another session: the
  marketplace schema does NOT accept only a raw sha. Checked against
  https://json.schemastore.org/claude-code-marketplace.json (2026-08-10):
  a github-source pin's `ref` field ("Git branch or tag to use") is
  schema-legal. The hypermnesia-mcp-viz pin now carries `ref: "v3.1.0"`
  alongside `sha` (kept for immutability — a tag ref alone can be
  force-moved after the fact; PIN_SHA_UNREACHABLE verifies it
  independently) so the pin self-documents its target.
- Bumped the pin to the real cortex-viz v3.1.0 release
  (052e4a40d3e6bddaeb1cec6662e23b451575c481, cdeust/cortex-viz#130):
  merged, tagged, PyPI-published (verified live on pypi.org 2026-08-10).
  Supersedes Cortex#418, whose sha (064e6d1) was a provisional PR-head
  value that changed on squash-merge, per that PR's own note.
- Corrected three places in this repo that repeated the same "renamed in
  v3.0.0" claim cortex-viz's own CHANGELOG made (README.md,
  marketplace.json's cortex-viz shim description,
  plugins/cortex-viz-deprecated/hooks/hooks.json's live session-start
  notice, docs/codex-plugin.md) plus this repo's own CHANGELOG.md
  Unreleased entry (still in-progress prose, not settled history, so
  corrected in place rather than annotated). tests_py/scripts/
  test_codex_plugin_contract.py had hardcoded the dangling "3.0.0"/sha as
  its expected contract — fixed to the real v3.1.0/sha.
- Adds .github/workflows/release.yml::publish-mcp-registry: auto-publish
  server.json to registry.modelcontextprotocol.io on every v* tag via
  `mcp-publisher login github-oidc` (GitHub OIDC, no stored secret —
  checked against modelcontextprotocol/registry's own docs, not assumed
  to mirror PyPI Trusted Publishing). Checksum-pinned mcp-publisher v1.8.1
  binary. Uncovered and fixed a real blocker while wiring this:
  server.json's description was 113 chars against the registry's
  100-char schema limit (`mcp-publisher validate` 422) — shortened to 98.
  The actual registry publish of 4.17.2 is NOT done by this PR: it
  requires either the next v* tag (this job) or a maintainer with real
  mcp-publisher access — an interactive OAuth/PAT login attempt was
  correctly refused by this agent's own permission classifier, which is
  the right outcome for a sensitive, irreversible action taken by an
  autonomous agent.

Test: a dedicated regression replays the incident's exact historical
values (repo tags topping out at v2.8.0, pin "3.0.0") through both the
pre-fix and post-fix check_github_pin — silently (None, None) before,
PIN_VERSION_UNPUBLISHED after. 38 tests across 3 new + 1 modified test
file; full tests_py/scripts/ suite green (764 passed, 5 skipped).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(marketplace): close dispatch silence, rebase, and make the replay real

Addresses REQUEST_CHANGES on #419.

1. BLOCK - _check_plugin_pin recognized only two of the marketplace
   schema's five source shapes (github, local-path) and silently
   returned ([], [], None) for the other three (npm, url, git-subdir),
   all schema-legal (verified live against
   https://json.schemastore.org/claude-code-marketplace.json), all
   currently unused by this repo's manifest (verified by reading every
   plugins[].source in it) but not by the dispatcher's own logic. That
   silence is exactly the defect class this gate exists to close, in
   code this same PR introduced. Fixed: a dict source with a recognized-
   but-unchecked type (npm/url/git-subdir) now fails loudly
   (UNVERIFIED_SOURCE_TYPE, naming the type and pointing at where to add
   a checker); any other shape (wrong type, unknown source key) fails
   loudly too (UNRECOGNIZED_SOURCE). 7 new tests in
   tests_py/scripts/test_check_marketplace_pins_dispatch.py replay the
   reviewer's exact npm/url probe plus git-subdir, an unknown dict, and
   two malformed non-dict/non-str shapes - every one now produces a
   failure, none pass silently.

2. BLOCK - rebased onto origin/main (e88e4e2). The branch was stale
   behind #414/#416/#417; check_craftsmanship.py --base origin/main (the
   exact invocation CI runs) is clean post-rebase.

3. Non-blocking, fixed anyway - the incident-replay tests now execute a
   frozen, verbatim copy of the pre-fix check_github_pin/check_self_pin
   logic (git blame: pre-e0661ad9) against the identical historical
   inputs, asserting it returns the old silent (None, None)/[] BEFORE
   asserting the current code returns the failure AFTER (frozen copies
   live in tests_py/scripts/_marketplace_pins_legacy_replay.py). The
   commit message on e0661ad asserted this replay already happened; it
   did not - only the new code was exercised, and the "before" was
   prose. This is what the prose should have described from the start.

Incidental fix required to keep this PR's own CI green: bumped
zetetic-team-subagents 2.36.0 -> 2.37.0 (a real v2.37.0 tag landed on
cdeust/zetetic-team-subagents at 2026-08-10T10:32Z, mid-session, from
unrelated work - confirmed via `gh release view`, not assumed). Unrelated
to items 1-3 and to this PR's actual subject; flagged here rather than
silently folded in.

Re-measured after, not before: check_craftsmanship.py --base origin/main
clean; check_marketplace_pins.py exits 0 live (one NOTICE, the already-
disclosed pending registry entry); ruff check/format clean; pyright 0
diagnostics on every touched file; tests_py/scripts/ 771 passed (up from
764 pre-review), 5 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(marketplace): sequence registry publish after PyPI; pin zetetic-team-subagents sha

Three more items from review on #419.

1. REFUSED, with evidence — the request was to replace "ai-architect-mcp-
   codebase" with "automatised-pipeline" in check_marketplace_pins.py's
   docstring (now marketplace_pins_manifests.py), citing an AP canonical-
   drift job failure. Direct verification (curl raw.githubusercontent.com/
   cdeust/Cortex/main/... + gh api against ai-architect-mcp-codebase's
   copy, both fetched and diffed this session) shows the OPPOSITE of the
   claim: Cortex main already reads "ai-architect-mcp-codebase" (the
   current name); AP's own copy still reads "automatised-pipeline" (the
   old one). Making the requested edit would reintroduce the exact half-
   rename the instruction itself warned against. AP's repo needs the fix,
   not Cortex's — out of scope for this PR. No change made here.

2. zetetic-team-subagents pin hardened the same way hypermnesia-mcp-viz
   already was: added `ref: "v2.37.0"` + `sha` (57a5723d..., verified via
   `gh api .../git/refs/tags/v2.37.0` and confirmed `identical` to `main`
   via the compare endpoint — not a dangling PR-head).

3. `publish-mcp-registry` re-sequenced: `needs: publish-pypi` (was
   `needs: build`, parallel to `publish-pypi` rather than after it). A
   registry entry naming a version before the PyPI package exists is the
   same PIN_VERSION_UNPUBLISHED-shaped defect this PR spends most of its
   diff closing, just pointed the other direction — nearly reintroduced
   it in the very job meant to fix the analogous drift. Verified against
   cortex-viz's own Release.yaml (`needs: [test, release]` on its
   publish-registry job, fetched and read this session), same rationale.

Re-measured after: check_craftsmanship.py --base origin/main clean;
check_marketplace_pins.py exits 0 live; actionlint clean on release.yml;
ruff check/format clean repo-wide; tests_py/scripts/ 771 passed, 5 skipped
(unchanged — no test asserts the two JSON/YAML-only edits' exact values,
appropriately, since neither introduces new logic).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant