Adapters¶
One class per revision, over a normalized core that names no revision at all. This is the whole architecture; everything else follows from it.
Layering¶
flowchart TB
subgraph public["Public API -- revision-free"]
Registry
Specs["ToolSpec / ResourceSpec / PromptSpec"]
Bound["Bound (function + args model + dependency plan)"]
end
subgraph neutral["Normalized core -- revision-free"]
Call
Operation
Outcome
Dispatcher
Exchange
end
subgraph adapters["Adapters -- one per revision"]
Adapter["Adapter (ABC)"]
A26["Adapter2026_07_28"]
A25c["Adapter2025_11_25"]
A25b["Adapter2025_06_18"]
A25a["Adapter2025_03_26"]
end
subgraph transport["Transport"]
Endpoint
AdapterSet
Preamble
end
Endpoint --> AdapterSet
AdapterSet --> Adapter
Endpoint --> Preamble
Adapter --> Call
Dispatcher --> Registry
Dispatcher --> Outcome
Adapter --> Outcome
Exchange --> Adapter
Registry --> Specs
Specs --> Bound
Adapter -.-> A26 & A25c & A25b & A25a
Arrows point downward or inward only. Registry, the specs, Call, Operation
and Outcome never import an adapter, and no adapter imports Endpoint. That
rule is what makes a new revision an additive change.
Design goals¶
Parity is the point. One registration serves every revision, and the same caller code reaches every server. Where revisions differ, the difference is absorbed by the adapter and never by the application.
The public API is revision-free. Application code never names a version, never checks one, and never writes two variants of a handler.
A new revision is a new file. One
Adaptersubclass and one entry in anAdapterSet. No edits toEndpoint,DispatcherorRegistry.Removing a revision is a deletion.
Revision behaviour is testable in isolation. An adapter is a pure-ish object over normalized inputs, so conformance is tested per revision without an HTTP server.
Degradation is explicit. Where a declaration cannot be represented, the adapter decides – visibly, in one place – whether to downgrade it or hide it.
What an adapter is¶
One instance per revision, shared across requests. It holds no per-request
state; everything per-request lives on the Exchange.
from aiohttp_tiny_mcp.protocol.selection import AdapterSet
adapter = AdapterSet.default().by_version["2026-07-28"]
assert adapter.version == "2026-07-28"
The class name carries the revision date – Adapter2026_07_28 – which breaks
the usual naming convention on purpose: the name is greppable against the
specification.
Behavioural flags¶
Everything above the adapter branches on these, never on a version string.
flags = {
adapter.version: (
adapter.can_ask,
adapter.can_push_ask,
adapter.asks_in_arguments,
adapter.has_handshake,
adapter.allows_batch,
)
for adapter in AdapterSet.default().adapters
}
assert flags == {
# can_ask push arguments handshake batch
"2026-07-28": (True, False, False, False, False),
"2025-11-25": (False, True, False, True, False),
"2025-06-18": (False, True, False, True, False),
"2025-03-26": (False, False, True, True, True),
"2024-11-05": (False, False, True, True, False),
}
carries_state is derived: handler state leaves the server and comes back on
whichever revision does not push, so it is can_ask or asks_in_arguments.
carries = {a.version for a in AdapterSet.default().adapters if a.carries_state}
assert carries == {"2026-07-28", "2025-03-26", "2024-11-05"}
The surface¶
Inbound. decode(preamble) turns one message into zero or more Calls, or
DecodeFailures that preserve the id so the error can quote it. The shared
pipeline does the common work and calls per-revision hooks: params_model,
check_message, check_params, client_info_for, answers_for, actions_for,
build_call.
Outbound. encode(call, registry, outcome) renders a final response
identically for JSON, SSE and stdio. encode_value, encode_input_required and
encode_failure are the three shapes.
Projection. describe_tool, describe_resource, describe_prompt and
capabilities project a declaration down to what the revision can express, or
return None to hide it.
Transport. check_http enforces whatever headers the revision requires.
Client role. client_headers, client_handshake_params,
client_decorate_params and client_input_requests – what a client speaking
this revision must send, and how to read a question out of a result.
Failure mapping¶
FailureKind is the normalized vocabulary. Each adapter maps it to the JSON-RPC
code and HTTP status its revision defines, so the same internal failure is
reported the way each client expects.
from aiohttp_tiny_mcp.core import Failure, FailureKind
modern = AdapterSet.default().by_version["2026-07-28"]
legacy = AdapterSet.default().by_version["2025-11-25"]
unknown = Failure(FailureKind.UNKNOWN_METHOD, "no such method")
assert modern.http_status(unknown) == 404
assert legacy.http_status(unknown) == 200
Two codes exist only on 2026-07-28 because only that revision defines them:
-32021 for a missing client capability, -32022 for an unsupported version.
A legacy adapter reports the same internal failure in its own vocabulary.
Argument failures are results, not errors¶
A tool called with arguments that fail validation did not fail to be called. The
model asked for something and deserves to be told what happened, so
INVALID_ARGUMENTS is never encoded as a JSON-RPC error – it becomes a tool
result with isError set. It is in FailureKind so the dispatcher can name it,
not so an adapter can render it.
Adding a revision¶
Write
protocol/vYYYY_MM_DD.pywith oneAdaptersubclass. Subclass the nearest existing revision and override what differs – the legacy trio is three classes deep for exactly this reason.Add it to the
AdapterSet.Set the behavioural flags to the truth.
Test it in isolation:
decode,encode, and the projections.
Nothing else changes. If something else has to change, the core was not neutral enough, and that is the bug.