"""Protocol adapters and structural interfaces for requests and registries."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Any, ClassVar, Protocol, cast
from pydantic import BaseModel, ValidationError
from .core import (
AnswerAction,
Call,
ClientInfo,
ClientProfile,
DecodeFailure,
Failure,
FailureKind,
InputRequest,
NeedsInput,
Operation,
Preamble,
Rejected,
Value,
answer_actions,
decode_failure_target,
)
from .hub import Hub
from .models import (
CallToolParams,
CompleteParams,
ErrorBody,
ErrorResponse,
GetPromptParams,
Implementation,
Incoming,
ListParams,
Params,
PromptDef,
ReadResourceParams,
ResourceDef,
ResourceTemplateDef,
ToolDef,
)
from .specs import Bound, PromptSpec, ResourceSpec, ToolSpec
class RequestLike(Protocol):
"""Request interface for AppKey-based dependency injection, including non-HTTP transports."""
@property
def app(self) -> Any: ...
class RegistryProtocol(Protocol):
"""Read-only properties allow registries with concrete dict collections to conform."""
@property
def info(self) -> Implementation: ...
@property
def instructions(self) -> str | None: ...
@property
def hub(self) -> Hub: ...
@property
def tools(self) -> Mapping[str, ToolSpec]: ...
@property
def resources_fixed(self) -> Mapping[str, ResourceSpec]: ...
@property
def resources_templated(self) -> Sequence[ResourceSpec]: ...
@property
def prompts(self) -> Mapping[str, PromptSpec]: ...
@property
def completer(self) -> Bound | None: ...
def match_resource(self, uri: str) -> tuple[ResourceSpec, dict[str, str]] | None: ...
[docs]
class Adapter(ABC):
"""One instance per revision, stateless and shared across requests."""
version: ClassVar[str]
supersedes: ClassVar[tuple[str, ...]] = ()
BASE_FAILURE_MAP: ClassVar[Mapping[FailureKind, tuple[int, int]]] = MappingProxyType(
{
FailureKind.PARSE: (-32700, 400),
FailureKind.MALFORMED: (-32600, 400),
FailureKind.INVALID_PARAMS: (-32602, 200),
FailureKind.ORIGIN_REJECTED: (-32600, 403),
FailureKind.INTERNAL: (-32603, 200),
FailureKind.INVALID_ARGUMENTS: (-32603, 200),
FailureKind.UNKNOWN_METHOD: (-32601, 200),
FailureKind.UNKNOWN_TARGET: (-32601, 200),
FailureKind.RESOURCE_NOT_FOUND: (-32002, 200),
FailureKind.HEADER_MISMATCH: (-32603, 200),
FailureKind.UNSUPPORTED_VERSION: (-32600, 400),
FailureKind.INPUT_UNSUPPORTED: (-32603, 200),
FailureKind.MISSING_REQUIRED_CAPABILITY: (-32603, 200),
}
)
FAILURE_MAP: ClassVar[Mapping[FailureKind, tuple[int, int]]] = BASE_FAILURE_MAP
def bind(self, versions: tuple[str, ...]) -> None:
"""Receive the served revisions once at AdapterSet construction, for discovery."""
return None # noqa: B027 -- concrete default, most revisions need nothing
def check_http(
self,
pre: Preamble,
headers: Mapping[str, str],
registry: RegistryProtocol | None = None,
) -> None:
"""Raise `Rejected` on a revision-specific HTTP binding violation."""
return None # noqa: B027 -- concrete default, most revisions check nothing
[docs]
def decode(self, pre: Preamble) -> Sequence[Call | DecodeFailure]:
"""Decode messages independently; one invalid item does not abort the batch."""
if pre.parse_error:
raise Rejected(Failure(FailureKind.PARSE, "invalid JSON body"))
if pre.is_batch:
if not self.allows_batch:
raise Rejected(Failure(FailureKind.MALFORMED, "batching not supported"))
if not pre.body: # `is_batch` already means the body is a list
raise Rejected(Failure(FailureKind.MALFORMED, "empty batch"))
return [self.decode_item(item) for item in pre.body]
return [self.decode_item(pre.body)]
def decode_item(self, item: object) -> Call | DecodeFailure:
"""Preserve the request id on failure without aborting sibling batch items."""
if not isinstance(item, dict):
return DecodeFailure(
id=None,
failure=Failure(FailureKind.MALFORMED, "request must be an object"),
must_respond=True,
)
body = cast(dict[str, Any], item)
try:
return self.decode_one(body)
except Rejected as e:
call_id, must_respond = decode_failure_target(body)
return DecodeFailure(id=call_id, failure=e.failure, must_respond=must_respond)
def decode_one(self, body: dict[str, Any]) -> Call:
if "id" in body and body["id"] is None:
raise Rejected(Failure(FailureKind.MALFORMED, "id must not be null"))
try:
msg = Incoming.model_validate(body)
except ValidationError as e:
raise Rejected(Failure(FailureKind.MALFORMED, str(e))) from None
operation = self.operation_for(msg.method)
if operation is None:
raise Rejected(Failure(FailureKind.UNKNOWN_METHOD, f"unknown method: {msg.method}"))
self.check_message(operation, msg)
try:
params = self.params_model(operation).model_validate(msg.params)
except ValidationError as e:
raise Rejected(Failure(FailureKind.INVALID_PARAMS, str(e))) from None
self.check_params(params)
return self.build_call(operation, msg, params)
def build_call(self, operation: Operation, msg: Incoming, params: Params) -> Call:
target: str | None = None
arguments: dict[str, Any] = {}
match params:
case CallToolParams() | GetPromptParams():
target, arguments = params.name, dict(params.arguments)
case ReadResourceParams():
target = params.uri
case CompleteParams():
arguments = params.model_dump(
by_alias=True, exclude={"meta", "input_responses", "request_state"}
)
case ListParams():
arguments = {"cursor": params.cursor} if params.cursor else {}
return Call(
operation=operation,
id=msg.id,
target=target,
arguments=arguments,
params=params,
client=self.client_info_for(params),
progress_token=params.meta.progress_token,
log_level=params.meta.log_level,
answers=self.answers_for(params),
actions=self.actions_for(params),
state=params.request_state,
raw=msg.params,
is_notification=msg.is_notification,
)
def params_model(self, operation: Operation) -> type[Params]:
"""The params model this revision validates `operation` against."""
return Params
def check_message(self, operation: Operation, msg: Incoming) -> None:
"""Reject an envelope this revision does not allow for `operation`."""
return None # noqa: B027 -- concrete default, most revisions check nothing
def check_params(self, params: Params) -> None:
"""Reject params this revision requires more of (2026-07-28 `_meta`)."""
return None # noqa: B027 -- concrete default, most revisions check nothing
def client_info_for(self, params: Params) -> ClientInfo:
"""Client identity, if the revision supplies it."""
return ClientInfo()
def answers_for(self, params: Params) -> Mapping[str, Any]:
"""MRTR answers, as this revision carries them."""
return params.input_responses or {}
def actions_for(self, params: Params) -> Mapping[str, AnswerAction]:
"""Whether each answer accepted, declined, or cancelled its request."""
return answer_actions(params.input_responses)
@abstractmethod
def operation_for(self, method: str) -> Operation | None: ...
@abstractmethod
def method_for(self, operation: Operation) -> str | None: ...
[docs]
def encode(
self, call: Call, registry: RegistryProtocol, outcome: Value | NeedsInput | Failure
) -> Mapping[str, Any]:
"""Encode a final response, identically for JSON, SSE, and stdio."""
match outcome:
case Value():
return self.encode_value(call, registry, outcome.result)
case NeedsInput():
return self.encode_input_required(call, registry, outcome)
case Failure():
return self.encode_failure(call.id, outcome)
@abstractmethod
def encode_value(
self, call: Call, registry: RegistryProtocol, result: BaseModel
) -> Mapping[str, Any]: ...
def encode_input_required(
self, call: Call, registry: RegistryProtocol, out: NeedsInput
) -> Mapping[str, Any]:
raise NotImplementedError(f"{self.version} cannot carry MRTR")
def encode_failure(self, call_id: Any, failure: Failure) -> Mapping[str, Any]:
code, _ = self.FAILURE_MAP[failure.kind]
return ErrorResponse(
id=call_id, error=ErrorBody(code=code, message=failure.message, data=failure.data)
).wire()
@property
def carries_state(self) -> bool:
"""Client-held state must be sealed on output and verified on return."""
return self.can_ask or self.asks_in_arguments
def http_status(self, failure: Failure) -> int:
return self.FAILURE_MAP[failure.kind][1]
def client_headers(
self,
method: str,
name: str | None = None,
params: Mapping[str, Any] | None = None,
tool: ToolDef | None = None,
) -> Mapping[str, str]:
"""Required HTTP headers; `name` identifies the tool, resource, or prompt."""
return {}
def client_handshake_params(self, client: ClientProfile) -> Params:
"""Params for `initialize` or `server/discover`."""
return Params()
def client_decorate_params(self, params: Params, client: ClientProfile) -> Params:
"""Apply revision-specific metadata before sending a request."""
return params
def client_input_requests(
self, result: Mapping[str, Any]
) -> tuple[Mapping[str, InputRequest], Any] | None:
"""Return input requests and retry state, or None. Stream-pushed requests bypass this."""
return None
[docs]
def capabilities(self, registry: RegistryProtocol) -> Mapping[str, Any]:
"""Capabilities shared by all supported revisions."""
caps: dict[str, Any] = {}
if registry.tools:
caps["tools"] = {"listChanged": True}
if registry.resources_fixed or registry.resources_templated:
caps["resources"] = {"listChanged": True, "subscribe": True}
if registry.prompts:
caps["prompts"] = {"listChanged": True}
if registry.completer is not None:
caps["completions"] = {}
caps["logging"] = {}
return caps
@abstractmethod
def describe_server(self, registry: RegistryProtocol, call: Call) -> BaseModel:
"""Negotiate the requested legacy revision from `call.params.protocol_version`."""
@abstractmethod
def describe_tool(self, spec: ToolSpec) -> ToolDef | None: ...
def describe_resource(self, spec: ResourceSpec) -> ResourceDef | ResourceTemplateDef | None:
"""Resource definitions are shared across revisions."""
return spec.definition if spec.definition is not None else spec.template
def describe_prompt(self, spec: PromptSpec) -> PromptDef | None:
"""Prompt definitions are shared across revisions."""
return spec.definition
#: MRTR: return questions, then retry with answers.
can_ask: ClassVar[bool] = False
#: Push questions on the active stream; receive answers in a separate POST.
can_push_ask: ClassVar[bool] = False
#: Pre-elicitation fallback: exchange questions and answers through tool calls.
asks_in_arguments: ClassVar[bool] = False
#: Whether a progress notification may carry a human-readable `message`.
#: It arrived in 2025-03-26.
progress_message: ClassVar[bool] = True
allows_batch: ClassVar[bool] = False
#: Negotiate once via initialize; retain the revision and capabilities in a session.
has_handshake: ClassVar[bool] = False