I2.1 — Protocol and Contract Skeleton for v0.4.0 MCP vertical slice - #77
Conversation
Freezes MCP 2026-07-28 on the official mcp==2.1.1 SDK, generalizes ArchitectureAnswer<T> for a second get_evidence tool without weakening I1's get_service_dependencies contract, and exposes deterministic two-tool discovery via tools/list. Neither tool has a working body yet (get_service_ dependencies lands in I2.2, get_evidence in I2.3) - tools/call stubs raise ToolError so tools/list is complete while dispatch stays out of scope for this sub-increment. A thin ingress guard (app/mcp/guard.py) corrects two gaps confirmed live against the installed SDK: it silently serves pre-2026-07-28/legacy requests instead of rejecting them, and it turns an unknown tool name into a normal isError:true result instead of a JSON-RPC protocol error. Everything else (header/_meta validation, unsupported-version rejection, Origin/Host allow-listing, per-tool argument-schema validation) is the SDK's own verified, spec-conformant behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VLBD6Vm8GrSDZf2Np6SDb
There was a problem hiding this comment.
🟡 Changes recommended
The /mcp mount currently uses MCPConfig() defaults in create_app(), making config.yaml overrides for the Origin/Host allow-list ineffective despite the new MCPConfig wiring and documentation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Implements the v0.4.0 I2.1 MCP “protocol + contract skeleton” vertical slice by adding the MCP server surface (discovery-only), a thin ingress guard to enforce the modern 2026-07-28 protocol behavior, and freezing a second JSON Schema for the new evidence drill-down wire contracts.
Changes:
- Add MCP SDK integration (
mcp==2.1.1) and a minimal MCP server/app layer with deterministic two-tool discovery (tools/list) and stub tool bodies. - Introduce evidence request/response contract types (
EvidenceRequest,EvidenceData/EvidenceRecord/SupportedFact) and freeze the corresponding JSON schema artifacts. - Add guard + unit tests validating protocol/version handling, discovery determinism, and expected error mapping.
File summaries
| File | Description |
|---|---|
| uv.lock | Lockfile update to include mcp and its transitive dependencies. |
| pyproject.toml | Adds pinned mcp==2.1.1 dependency. |
| app/settings.py | Introduces MCPConfig and wires it into AppConfig. |
| app/main.py | Runs MCP session manager in lifespan and mounts the MCP ASGI app. |
| app/mcp/app.py | Builds the MCP ASGI app using SDK streamable HTTP + transport security, wrapped by the guard. |
| app/mcp/guard.py | Middleware enforcing modern protocol header requirements and deterministic protocol errors for unknown tools/unexpected keys. |
| app/mcp/server.py | Defines the singleton MCPServer and cache hints; registers tools. |
| app/mcp/tools.py | Registers get_evidence and get_service_dependencies tools (stubbed). |
| app/mcp/init.py | Package marker for MCP module. |
| app/architecture_intelligence/contracts.py | Adds evidence contracts and tightens the ArchitectureAnswer.tool typing/invariants. |
| app/architecture_intelligence/request.py | Adds EvidenceRequest with closed schema + dedup validation. |
| app/architecture_intelligence/schema_export.py | Exports and writes both dependencies and evidence frozen schemas. |
| schemas/architecture_intelligence/v0.4/architecture-answer.schema.json | Updates tool enum + observation_context invariant keyed on tool. |
| schemas/architecture_intelligence/v0.4/evidence-answer.schema.json | Adds frozen schema for ArchitectureAnswer[EvidenceData]. |
| tests/unit/test_mcp_discovery.py | New protocol/discovery/guard behavior tests using ASGI round-trips. |
| tests/unit/test_architecture_intelligence_schema_frozen.py | Splits frozen-schema assertions into dependencies vs evidence schema. |
| docs/security-model.md | Documents MCP trust boundary and allow-listing behavior. |
Review details
Suppressed comments (1)
app/main.py:116
create_app()mounts the MCP app usingMCPConfig()defaults, so config.yaml overrides formcp.allowed-origins/mcp.allowed-hostsare ignored (even though MCPConfig was added to Settings/AppConfig and docs say deployments must override defaults). This can lead to unexpected 403s and makes the documented configuration ineffective.
# ahead of settings/driver being available (create_app() itself must stay env/Neo4j-free), so
# origin/host allow-listing uses MCPConfig's own defaults rather than the loaded config.yaml -
# revisit if a deployment needs non-default origins (spec §15: local/trusted-network only).
mcp_config = MCPConfig()
- Files reviewed: 15/17 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Reviewed current head Verdict: REQUEST CHANGES. The official SDK choice, two-tool scope, JSON-only transport, deterministic registration order, and raw-
One smaller routing test would also be valuable: because the MCP sub-app is mounted at Nothing here requires expanding I2.1: these are corrections to the protocol and contracts this increment already claims to freeze. |
I2.1 (previous commit) tightened ArchitectureAnswer.tool from str to a closed Literal["get_service_dependencies", "get_evidence"]. This is a deliberate, correct contract change, but it broke test_a_wrong_expected_tool_is_caught_as_a_field_mismatch: an invalid tool value in expected_answer.json now fails Pydantic validation at load time instead of surviving to be caught downstream by the comparator. Renamed and rewritten to assert the (stronger) load-time ScenarioValidationError instead. Caught by running the full local test suite (tests/unit + tests/integration) before pushing, per explicit instruction - the unit suite alone can't see this, since the mutated test lives only in tests/integration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VLBD6Vm8GrSDZf2Np6SDb
|
CI status on
The first push ( |
…P config
Five blockers from the PR review, all fixed:
1. app/mcp/guard.py: rewrote to delegate protocol/header/_meta validation to the SDK's own
mcp.shared.inbound.classify_inbound_request (the same function its "modern" request path uses
internally) instead of a hand-rolled reimplementation. Fixes two bugs: a missing
MCP-Protocol-Version header now returns -32020 HeaderMismatch (was incorrectly -32022, reserved
for a present-but-unsupported version), and the guard's own tool-name/argument checks now run
only after header/body agreement is confirmed, so a header mismatch always takes priority over
an unknown-tool report. Also scoped the guard to MCP_PATH only, so an unrelated path (e.g. a
stray POST the outer app didn't claim) falls through to a normal 404 instead of a synthesized
MCP protocol error.
2. app/mcp/tools.py: the SDK's synthesized outer argument-wrapper schema doesn't declare
additionalProperties: false itself (confirmed live), leaving the advertised tools/list
inputSchema open even though the guard already rejects an unexpected top-level key at runtime.
_close_input_schema mutates each registered Tool's parameters after registration so the
advertised schema matches the enforced behavior.
3. app/architecture_intelligence/contracts.py: ArchitectureAnswer's tool field wasn't actually
coupled to which generic specialization (T) is in use - a ServiceDependenciesData answer could
claim tool="get_evidence" and vice versa, and get_evidence answers could carry non-empty claims/
top-level evidence_refs (spec §12 requires both empty). Added a runtime check (keyed by
__pydantic_generic_metadata__) and a matching `tool: {"const": ...}` in each frozen schema file
(schema_extra now inspects which T generated it, closing this per-specialization). Also added
EvidenceData's records/missing_evidence_refs-partition-requested_evidence_refs invariant and
EvidenceRecord's evidence_type/observation coupling, both with negative tests; the schema-format
limits of the first are documented inline (no standard JSON Schema expresses a 3-array set
partition).
4. app/main.py: create_app() built a bare MCPConfig() instead of reading config.yaml's mcp section,
silently making a deployment's allowed-origins/allowed-hosts override ineffective. Now reads
load_config(CONFIG_PATH).mcp - load_config never touches secrets/Neo4j, so create_app() stays
free of any hard env-var dependency.
5. CI was failing on 8c10acc (see the previous commit's fix, already merged into this branch).
New/extended tests: tests/unit/test_mcp_discovery.py (each missing/mismatched header, a mismatch
combined with an unknown tool proving priority, closed outer schema, unrelated-path 404),
tests/unit/test_architecture_intelligence_contracts.py (tool/T coupling both directions, get_evidence
claims/evidence_refs empty, EvidenceData partition, EvidenceRecord observation coupling - all
"fails both pydantic and schema" where mirrorable), tests/unit/test_mcp_config_wiring.py (a
configured non-default origin/host is honored end-to-end through create_app()).
Verified locally: tests/unit (700 passed) and tests/integration (191 passed), ruff check/format.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VLBD6Vm8GrSDZf2Np6SDb
|
Addressed all 5 blockers in
Routing suggestion: the guard now only inspects Verified locally before pushing: |
|
Re-reviewed current head Verdict: REQUEST CHANGES — one contract blocker remains. The protocol-validation fix, closed advertised input schemas, tool/specialization coupling, evidence partition and observation-metadata invariants, configurable MCP allow-lists, CI regression fix, and unrelated-path routing test are all implemented correctly. Current-head CI, dependency audit, and CodeQL are green. Remaining blocker —
|
…e null
The previous fix enforced empty claims/top-level evidence_refs for get_evidence answers (spec §12)
but never actually forbade a non-null observation_context - only get_service_dependencies's
observation-context-required invariant was enforced, leaving get_evidence's own null-context
requirement purely documentary. ArchitectureAnswer[EvidenceData] with a non-null, otherwise valid
observation_context was accepted by both Pydantic and the frozen evidence schema.
Fixed: _check_envelope_invariants now rejects a non-null observation_context when
tool == "get_evidence", mirrored in the schema_extra's get_evidence conditional
(observation_context: {"type": "null"}). Added the negative test the review requested, proving the
same payload is rejected by both Pydantic and evidence-answer.schema.json.
Verified locally: tests/unit (701 passed) and tests/integration (191 passed), ruff check/format.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VLBD6Vm8GrSDZf2Np6SDb
|
Fixed in Verified locally before pushing: |
|
Re-reviewed current head Verdict: APPROVE — no remaining findings. The final blocker is resolved correctly:
All earlier protocol, schema-closure, semantic-contract, configuration, CI, and routing findings remain resolved. The PR is mergeable, the review thread is closed, and all current-head checks pass: lint/tests, dependency audit, and both CodeQL analyses. PR #77 is ready to merge. |
Summary
Delivers I2.1 of the v0.4.0 MCP Vertical Slice and Evidence Drill-Down spec
(
docs/specifications/0.4.0/i2-mcp-vertical-slice-and-evidence-drill-down.md§19): freeze MCP2026-07-28, add evidence wire contracts, generalize the frozenArchitectureAnswer<T>envelope fora second tool without weakening I1, and expose deterministic two-tool discovery. No tool has a
working body yet —
get_service_dependencies's real dispatch is I2.2,get_evidence's servicelogic is I2.3.
app/architecture_intelligence/contracts.py,request.py): newEvidenceData/EvidenceRecord/SupportedFact/ObservedEvidenceMetadata(reusing the existingapp.provenance.modelSourceType/EvidenceTypeenums, not redefining them) andEvidenceRequest.tooltightened toLiteral["get_service_dependencies", "get_evidence"]; theobservation_context-required invariant is now gated ontool(the only field always present,since
datacan beNonefor either tool in some outcomes) rather than ondata's type. Bothanswer schemas are frozen/committed (
schema_export.pyextended; newschemas/architecture_intelligence/v0.4/evidence-answer.schema.json).app/mcp/): built on the officialmcp==2.1.1SDK (pinned), per direction — thenarrow, stateless 2026-07-28 subset this release needs is real, current SDK behavior, not
functionality worth hand-rolling.
tools.pyregisters both tools (stub bodies raisingToolError— discoverable via
tools/list, not callable) directly against the I1 request/answer types, soin/out schemas come from the real contracts, not a parallel hand-written layer.
app/mcp/guard.py— a thin ingress guard correcting two gaps confirmed live against theinstalled SDK (not assumed from docs): it silently serves pre-2026-07-28/legacy requests instead of
rejecting them (spec §4/§20's "implementation requires initialize while claiming MCP 2026-07-28" is
a named release blocker), and it turns an unknown tool name into a normal
isError: trueresultinstead of a JSON-RPC protocol error (spec §16's determinism/failure-mapping table). Everything else
— header/
_metavalidation, unsupported-version rejection, Origin/Host allow-listing, per-toolargument-schema validation — is the SDK's own verified, spec-conformant behavior and isn't
duplicated.
tools/listin registrationorder, not sorted —
get_evidenceis now registered first to match the spec's requiredlexicographic order — and a routing/mount-order bug where mounting naively would have
307-redirected a bare
POST /mcpor silently shadowed/health.MCPConfig(app/settings.py) adds Origin/Host allow-listing, defaulting to loopback-only (spec§15: local/trusted-network evaluation only, never a production-safe public exposure) —
docs/security-model.mddocuments this trust boundary alongside the existing ones.Test plan
uv run ruff check ./uv run ruff format --check .uv run pytest tests/unit— 690 passed (no integration tests needed: nothing in I2.1 opens aNeo4j session)
tests/unit/test_mcp_discovery.py— spec §17 Protocol-and-Discovery scenarios 1-4, plus thetwo guard corrections and the argument-schema-validation passthrough, via a real
httpx.AsyncClient/ASGI round trip (not the SDK's own client)tests/unit/test_architecture_intelligence_contracts.py/test_architecture_intelligence_schema_frozen.pyextended for
EvidenceData/EvidenceRecord/SupportedFactand the second frozen schemacreate_app()+TestClient):POST /mcptools/listreturns the two tools in order,GET /healthstill works (regression-tested themount-order bug found above)
🤖 Generated with Claude Code
https://claude.ai/code/session_015VLBD6Vm8GrSDZf2Np6SDb