ADR 0007 — Dual-major MCP SDK compatibility¶
- Date: 2026-08-25 · Status: Accepted
Amends the adapter guidance of ADR-0002 (which stays
in force for its isolation rationale) with the tested two-major reality: the
declared dependency range spans two MCP SDK majors, mcp>=1.28,<3, and
Knovaryn runs its full MCP surface against both.
Context¶
ADR-0002 isolated the then-single high-level SDK server behind an internal
boundary. Since then the upstream SDK published major version 2, which renames
the high-level server class and moves the high-level Context:
| Concern | SDK 1.x | SDK 2.x |
|---|---|---|
| High-level server | mcp.server.fastmcp.FastMCP |
mcp.server.MCPServer |
High-level tool/resource Context |
mcp.server.fastmcp.Context |
mcp.server.mcpserver.context.Context |
Bundled HTTP client used by streamable_http_client |
httpx |
httpx2 |
streamable_http_client(...) yields |
3-tuple (read, write, get_session_id) |
2-tuple (read, write) |
Everything else Knovaryn uses is API-identical across both majors. Supporting
only one major would either pin users to a frozen line or break every install
the day their environment resolves mcp 2.x, and declaring an unbounded range
without testing both sides would be a claim without evidence.
Decision¶
The single compatibility-adapter boundary¶
src/knovaryn/interfaces/mcp/_compat.py is the only module that knows the
SDK split. It exposes:
build_mcp_server(name, *, instructions, lifespan)— constructs the high-level server under either name;_context_class()— resolves the correctContextclass for the installed major;open_streamable_http(url, headers=...)— client-side Streamable HTTP connection that hides thehttpx/httpx2rename and normalizes the yielded streams to exactly(read, write);SUPPORTED_MCP_MAJORS = (1, 2),MIN_SUPPORTED_MCP_VERSION = "1.28",SERVER_CLS_NAME_1X = "FastMCP",SERVER_CLS_NAME_2X = "MCPServer".
server.py, the knovaryn-mcp entry point, and the tests import the unified
names from this boundary. The declared range in pyproject.toml
(mcp>=1.28,<3) must stay in lockstep with SUPPORTED_MCP_MAJORS.
Why application logic must not branch on the MCP major¶
Tool handlers, resources, and the workspace below them are transport- and
SDK-version-free by construction. If any handler tested
mcp_sdk_major(), each new SDK point release could silently fork product
behavior, and the acceptance matrix would have to cover a behavioral product
matrix instead of a compatibility matrix. The adapter exists precisely so the
answer to "which SDK am I running on?" appears in exactly one file; a second
place is a defect.
Context injection behavior¶
Both majors inspect registered tool/resource signatures and inject the context
object into the parameter annotated with their Context class. Because the SDK
evaluates annotations with eval_str against the module globals of
server.py, the module binds Context = _context_class() at import time — a
TYPE_CHECKING-only alias is deliberately not enough. Handlers receive the
context positionally as their first annotated parameter; nothing else about the
signature contract differs between majors.
One 1.x-only wrinkle handled inside the adapter: 1.x's Settings model carries
an unresolved pydantic forward reference that emits
IncompleteFieldDefinitionWarning on every server construction. _compat.py
calls Settings.model_rebuild() once before construction — fixing the warning
at its source rather than filtering it (warnings-as-errors policy).
Lifespan behavior¶
Identical semantics on both majors: build_mcp_server receives an async
context-manager factory; its yield value (the opened Workspace) becomes
reachable in every handler as ctx.request_context.lifespan_context. On
shutdown the lifespan's finally closes the workspace. Combined with the v0.2.1
shutdown-hygiene work (NullPool SQLite connections and shielded engine
disposal), teardown produces no post-loop Event loop is closed noise even when
the lifespan unwinds inside a cancelled task scope.
stdio behavior¶
knovaryn mcp / knovaryn-mcp defaults to --transport stdio, served via
server.run(transport="stdio") on both majors. This is the canonical local-host
transport: no network listener, inherits the parent process's stdio, no
authentication material involved.
Authenticated Streamable HTTP behavior¶
For --transport streamable-http the entry point hosts
server.streamable_http_app() under uvicorn, wrapped by bearer_guard: when
server.api_token is configured, every request without
Authorization: Bearer <token> is refused with 401 and
WWW-Authenticate: Bearer before reaching the MCP app — fail closed. An
empty token keeps the guard as a pass-through, which is only ever legitimate on
a loopback bind (see next section). Clients connect through
streamable_http_client(url, http_client=...), passing custom headers via the
provided HTTP client because both majors route headers that way.
Non-loopback refusal¶
mcp_bind_checked mirrors the REST J4 policy: binding to anything other than
127.0.0.1 / ::1 / localhost without a configured server.api_token raises
ConfigurationError at startup — before any socket opens. Explicitly setting
server.allow_insecure_nonloopback=true documents a deliberate, unauthenticated,
network-exposed dev server; production exposure goes through the token guard.
Host/Origin and bearer-token expectations¶
Stated precisely, because this is where MCP servers most often get oversold:
- The MCP HTTP endpoint performs no Host-header or Origin allowlisting of its
own and sets no cookies; authentication is exclusively the explicit
Authorization: Bearerheader, which browser pages cannot attach cross-origin. - Its safe default is therefore loopback bind + optional token: a hostile web page cannot reach a loopback-bound socket's responses cross-origin, and any wider exposure requires the token guard to be active (enforced by the refusal above).
- Operators who reverse-proxy the streamable-HTTP endpoint beyond a trusted boundary own the edge policy there — Host allowlist and Origin checks belong at that proxy, alongside TLS termination. This matches the deployment guidance in security hardening.
- MCP clients are expected to send the bearer header explicitly (as
streamable_http_clientdoes through its supplied HTTP client). Cookie-based or redirect-following auth is neither offered nor accepted.
Client compatibility assumptions¶
The acceptance matrix drives Knovaryn with the MCP client stack bundled in
the same pinned SDK major as the server under test (ClientSession +
stdio_client / streamable_http_client from that install). That is the
compatibility we claim: an MCP host on SDK 1.x can talk to Knovaryn served by
SDK 1.x, and likewise for 2.x. Cross-major client↔server combinations ride the
wire protocol's own stability and are additionally exercised implicitly — every
cell speaks the same MCP protocol version negotiated at initialize. The driver
normalizes the one structural difference clients see (3-tuple vs 2-tuple stream
yield) in a single helper, mirroring the server-side adapter.
Clean shutdown guarantees¶
Every acceptance-matrix stdio run ends with an explicit shutdown check: after
the client session closes, the driver polls up to 10 s and asserts that no
non-daemon threads leaked relative to the process baseline. The underlying
guarantees are: the lifespan always closes the workspace (finally), engine
disposal is shielded, and SQLite uses NullPool — so neither SDK major leaves
dangling event loops, connections, or worker threads behind a normal exit.
Dependency bounds¶
- Declared range:
mcp>=1.28,<3. 1.28is the floor because it is the oldest line actually exercised end to end by the matrix; older 1.x releases are neither claimed nor recommended.<3is the current ceiling. Widening past it requires adding a major-version acceptance cell first (recorded next toMATRIXinscripts/mcp_acceptance_matrix.py), then extending the adapter if — and only if — the new major renamed something this boundary cares about.
Acceptance-matrix evidence¶
The executable proof is scripts/mcp_acceptance_matrix.py (+ driver
scripts/mcp_acceptance_driver.py, wired into CI as the required release-tier
job via tests/release/test_mcp_acceptance_matrix.py). Per cell it builds the
wheel once, creates a clean virtualenv, installs the wheel plus the exact pin,
records the exact environment (Python + knovaryn versions, phase 0), verifies
the installed SDK matches the pin, and runs four mandatory phases: full stdio
lifecycle (tool/resource discovery, project → source → pipeline → bounded
preview → lineage, thread-leak check), authenticated Streamable HTTP round trip
plus 401 refusal, and the non-loopback bind refusal.
Matrix executed locally on 2026-08-25 from commit 2bad356 — all cells PASS:
| Pin (role) | Python | knovaryn | Tools | Resource templates | Examples | Leaked threads | stdio | HTTP (401 + round trip) | Bind refusal |
|---|---|---|---|---|---|---|---|---|---|
1.28.0 (lower supported boundary) |
3.13.7 | 0.2.1 | 23 | 6 | 3 | none | ok | ok | ok |
1.29.1 (latest supported 1.x) |
3.13.7 | 0.2.1 | 23 | 6 | 3 | none | ok | ok | ok |
2.0.0 (representative 2.x) |
3.13.7 | 0.2.1 | 23 | 6 | 3 | none | ok | ok | ok |
2.1.0 (latest supported 2.x) |
3.13.7 | 0.2.1 | 23 | 6 | 3 | none | ok | ok | ok |
Reproduce with:
uv run python scripts/mcp_acceptance_matrix.py # full 4-cell matrix
uv run python scripts/mcp_acceptance_matrix.py --matrix 2.0.0 # one cell
(The script needs network access to resolve the pinned SDK versions into fresh virtualenvs, which is why the CI job owns the canonical run.)
Deprecation and future-removal policy for 1.x support¶
- 1.x support is claimed and tested while it remains inside the declared range; removal follows the changelog, never a silent range bump.
- Dropping 1.x means, in order: (1) a changelog entry under
[Unreleased]announcing the intended removal and the replacement floor, (2) narrowingpyproject.tomltogether withSUPPORTED_MCP_MAJORS/MIN_SUPPORTED_MCP_VERSIONin the same change, (3) removing the corresponding matrix cell and the 1.x branches in_compat.py, (4) noting the removal in the MCP guides. - Until then, a 1.x-only regression blocks release exactly like a 2.x one: the acceptance matrix is a required CI job, so a failing cell fails the release.
- If upstream ships a breaking 3.x, the same record governs: acceptance cell first, adapter extension second, range widening third.
Consequences¶
- Users on either SDK major get the identical tool surface (23 tools,
knovaryn://resource templates) and identical security posture; upgrades of the SDK underneath an install are non-events for application code. - The cost is confined to one small adapter module plus per-cell CI minutes; the alternative — branching outside the adapter — was rejected because it makes every future handler version-aware.
- Claims stay honest by construction: whatever the matrix does not execute
(older 1.x floors, hypothetical 3.x) is outside the declared bounds, and the
claim matrix marks the MCP surfaces
experimentaluntil 1.0 freezes the tool contract.