Skip to content

Registries

Tool, resource, and prompt lookup, plus session storage and SSE infrastructure.

ToolBinding wraps a ServiceSpec (mutation tools); SelectorToolBinding wraps a SelectorSpec and exposes the read-shaped pipeline knobs — filter_set is read from the spec, and carries ordering with it via an OrderingFilter (a selector declaring its own sort parameter is the other ordering route, and likewise needs no knob); paginate is the one binding-level MCP mechanic. The shared ToolRegistry accepts either kind and is what tools/list and tools/call iterate.

Both bindings carry server_arguments, a ServerArguments naming the declared parameters the server answers for, so no caller does. ToolRegistry.register checks it, whichever registrar built the binding. See Server-supplied arguments.

paginate also decides what a QueryParam shapes. On a paged binding the serializer renders each row and the page envelope (items, page, totalPages, hasNext) is built around the result, so a query param applies to each item in items, never to the envelope — and its description in the inputSchema says so. See Query params on a paged tool.

ToolBinding dataclass

Bases: Generic[InputT, ResultT, ExtraT]

All wiring for a single MCP tool, derived from a ServiceSpec.

A tool is the projection of a service callable plus its declared input and output serializers. The MCP server invokes spec.service directly via resolve_callable_kwargs + run_service — there is no view or viewset in the dispatch path.

annotations and meta are emitted verbatim on this tool's tools/list entry, under annotations and _meta respectively.

The generic parameters mirror ServiceSpec's and are purely informational for type checkers, defaulting to Any when omitted.

display_name class-attribute instance-attribute

display_name: str | None = None

Consumer-only label, never emitted on the MCP wire, so a downstream library can render a richer label than the protocol title.

display_description class-attribute instance-attribute

display_description: str | None = None

Consumer-only blurb, the sibling of display_name and likewise never emitted on the MCP wire.

icons class-attribute instance-attribute

icons: tuple[Icon, ...] = ()

Display icons, emitted in this tool's listing entry. Purely presentational; nothing in dispatch reads them.

include_structured_content class-attribute instance-attribute

include_structured_content: bool | None = None

Whether this tool's tools/call response carries structuredContent. None defers to the INCLUDE_STRUCTURED_CONTENT setting.

include_output_schema class-attribute instance-attribute

include_output_schema: bool | None = None

Whether this tool's tools/list entry carries an outputSchema. None defers to the INCLUDE_OUTPUT_SCHEMA setting.

The MCP spec forbids advertising outputSchema while suppressing structuredContent, so True together with include_structured_content=False is rejected at construction.

max_result_bytes class-attribute instance-attribute

max_result_bytes: int | None | UnsetType = UNSET

Per-tool outbound result ceiling. UNSET defers to the server's MAX_RESULT_BYTES, None disables the check for this tool, an int sets its own. Sentinelled rather than tri-state because None here means "no ceiling", which a deliberately-large export tool genuinely wants.

dispatch_timeout class-attribute instance-attribute

dispatch_timeout: float | None | UnsetType = UNSET

Per-tool dispatch deadline, in seconds. UNSET defers to the server's DISPATCH_TIMEOUT, None disables it here. Async transport only — see dispatch_timeout.

argument_binding class-attribute instance-attribute

argument_binding: ArgumentBinding = BUNDLE

How MCP arguments flow into the kwarg pool. BUNDLE for service tools, because a mutation service typically takes one input_serializer-validated data payload and spreading the dict as top-level kwargs would conflict with that shape.

unknown_arguments class-attribute instance-attribute

unknown_arguments: UnknownArguments = REJECT

How unknown arguments keys are handled relative to the binding's inputSchema. Passed to drf-services' dispatch as registered, with or without an input_serializer.

  • REJECT (default) answers an isError validation_error result naming the unexpected keys, and advertises additionalProperties: false. Without an input_serializer the declared set is the target lookup's keys and, under a SPREAD_* binding, the service's own parameters, which the inputSchema lists. Where that set is open, a lookup or a spread service taking a bare **kwargs, or a lookup carrying a filter_set, nothing is refused and the schema stays open. One whose **kwargs annotation cannot be resolved cannot be enforced, so registration refuses it.
  • PASSTHROUGH is retired in drf-services 0.58.0, where an argument the spec does not declare never reaches a callable, and the registrars refuse it: pass IGNORE or REJECT.
  • IGNORE advertises an open schema and drops them.

always_listed class-attribute instance-attribute

always_listed: bool = False

Keep this binding in tools/list whatever would otherwise leave it out: FILTER_LISTINGS_BY_PERMISSIONS dropping it because its permissions deny the caller, or an operation-scope condition in the spec's affordances that is unmet right now, which leaves a tool out with or without that setting. A discovery aid for tools the caller can see but not invoke — tools/call still 403s, or is refused with the affordance's code. The condition is not asked at list time at all.

query_params class-attribute instance-attribute

query_params: tuple[QueryParam, ...] = ()

Read-shaping params routed to request.query_params at dispatch.

Popped from the caller's arguments like a URL kwarg, but landing in the synthetic request's GET rather than view.kwargs — the channel a serializer reads when it branches on the query string. A filter_set field is not one of these.

url_kwargs class-attribute instance-attribute

url_kwargs: tuple[UrlKwarg, ...] = ()

URL-derived values the model supplies as tool args, seeded into the off-HTTP view's kwargs instead of reaching the service as ordinary params, so a scoping spec.kwargs provider reading view.kwargs sees them. Advertised in the inputSchema and stripped from the dispatched params. See UrlKwarg.

content_kind class-attribute instance-attribute

content_kind: ToolContentKind = TEXT

What this tool's payload becomes in the result's content array. TEXT renders JSON per output_format; the other kinds project it into an image / audio / resource-link block. See ToolContentKind.

content_mime_type class-attribute instance-attribute

content_mime_type: str | None = None

The media type for an IMAGE / AUDIO content_kind. Required for those and meaningless for the rest — a resource link carries its own mimeType per entry.

task_policy class-attribute instance-attribute

task_policy: TaskPolicy = FORBIDDEN

Whether calling this tool hands back a task handle instead of a result. The choice lives on the binding because the extension makes the server the sole decider and gives the client no way to ask. See TaskPolicy.

invalidates class-attribute instance-attribute

invalidates: tuple[str, ...] = ()

URI templates naming the resources a successful call changed.

Published as notifications/resources/updated once the transaction commits, so subscribers re-read. Same {var} syntax as a resource's uri_template, rendered against the result merged with the call's arguments:

invalidates=("invoices://{pk}", "invoices://")

Name the collection too if you want it watched. Topic matching is exact — a prefix rule would match invoices://1 against invoices://11 and miss a tenant-scoped scheme entirely.

Only calls that go through this server fire it. A management command, a Celery job or an admin edit changes the same rows and publishes nothing; MCPServer.notify_resource_updated covers those.

field_audiences class-attribute instance-attribute

field_audiences: Mapping[str, FieldMarking] | None = None

Per-tool overrides layered over the FieldMarking declarations the output serializer carries on its own fields.

The serializer stays authoritative — it is the one declaration the REST API, this transport, and an in-process toolset all read. This exists for the case one tool genuinely needs what a sibling hides: a lookup tool returning the identifier its neighbour drops.

Declared as the spec's own field_audiences, which a registry entry's OfflineContract carries to register_specs, or passed in an agent_contract= at registration, and resolved here, so the field set an agent sees does not depend on which agent transport served it.

server_arguments class-attribute instance-attribute

server_arguments: ServerArguments = field(default_factory=ServerArguments)

Declared parameters the server answers for, so no caller does.

The kernel's ServerArguments, given as server_arguments= at registration. Its values are supplied on every call and outrank the caller's; its withheld names are taken from no one, so the callable's own default answers. tools/list leaves every one of them out of the inputSchema and its required, and each tools/call route drops the caller's value for them before anything reads the arguments (handlers.utils.apply_tool_server_arguments). Static for the binding's lifetime: a value computed per request is not this. Checked against the spec when the binding is registered, so one that cannot mean what it says fails at startup (ToolRegistry.register).

output_serializer property

output_serializer: type | None

The serializer whose rendered output reaches the caller, if any.

rendered_affordances property

rendered_affordances: Mapping[str, AffordanceDeclarer] | None

The affordances answered on every rendered item, if any are declared.

Read off output_selector_spec, the selector spec the result renders through, exactly where drf-services' renderer reads it. The service's own spec.affordances are a different declaration -- the conditions a call is refused against -- and are never rendered, so they are not this. Feeds the advertised outputSchema, so the schema names the key the payload carries.

rendered_kind property

rendered_kind: SelectorKind

Whether a result renders as one object or as a list, for outputSchema.

LIST when output_selector_spec re-fetches a set, which drf-services dispatches to a list result the handler renders many=True, and for a many=True spec, whose result is the service's own list. A service tool never paginates, so a LIST is served as a bare array and the schema has to say so; it once advertised the item alone. See registry.types.utils.rendered_kind.

can_present_nothing property

can_present_nothing: bool

Whether a successful call can present nothing: a single-row result read back through an output re-read selector, which can find no row, or a single-row service declaring ServiceSpec(allow_none=True).

Read by tools/list so the outputSchema admits the {} such a call is served as. A service with no re-read selector and no allow_none answers False and keeps a strict schema (test_a_service_with_no_output_reread_keeps_its_schema_strict), and one declaring it answers True (test_a_service_declaring_allow_none_serves_an_empty_object_its_schema_admits). See registry.types.utils.can_present_nothing.

audience_projection cached property

audience_projection: AudienceProjection

This tool's resolved audience markings, derived once per binding.

Drives both the projected payload and the advertised outputSchema, so the two cannot disagree about which fields a caller will receive.

SelectorToolBinding dataclass

Bases: Generic[ResultT, ExtraT]

All wiring for a single MCP read-shaped tool, from a SelectorSpec.

The read-shaped mirror of ToolBinding. Selectors return raw, unscoped data and the tool layer owns every shape decision, chosen by kind.

kind=LIST runs the full pipeline:

arguments → validate(merged inputSchema) → run_selector
          → FilterSet(data=...).qs    (if ``filter_set`` set, and it
                                       orders too when it declares an
                                       ``OrderingFilter``)
          → paginate                  (if ``paginate=True``)
          → output_serializer(many=True)
          → ToolResult

With neither set it behaves as a plain RPC read, rendering the selector's return value verbatim.

kind=RETRIEVE skips pagination but still applies queryset shaping and spec.filter_set before materializing the instance via .first() — so a "stats from a filtered set" retrieve works — then renders output_serializer(many=False). Pairing it with paginate is rejected at construction: that knob only means something on a collection.

paginate=True generates page / limit arguments, slices the queryset and wraps the response with items / page / totalPages / hasNext. Ordering has no binding-level knob at all, and two channels that need none: an OrderingFilter on the spec's FilterSet, or a sort parameter the selector declares for itself. Both are reflected into the inputSchema, so one declaration serves the HTTP transport and every agent transport alike. Prefer the filter where there is one — it validates the value against published choices before it reaches the ORM, while a bare parameter is only as safe as what the selector does with it. On a LIST tool a selector parameter named page or limit is refused at registration: RESERVED_POST_FETCH_KEYS strips both from a LIST selector's arguments on every route, whether or not the tool paginates, so the parameter would never receive the caller's value. A RETRIEVE tool cannot paginate, so nothing strips either name from its selector, which may declare them, as on the Pydantic-AI route. The exception on a LIST tool is a name the input_serializer lays back: a field of that name that is not read_only and is bound to its own name (no source elsewhere, no source="*"), on a plain Serializer, a DataclassSerializer or a bare @dataclass alike. Dispatch overlays the validated values on the stripped arguments, so the selector receives the caller's value under that name, coerced; call_tool, which runs no input_serializer, keeps the name rather than stripping it, so the selector receives it as sent.

annotations and meta are emitted verbatim on this tool's tools/list entry, under annotations and _meta respectively.

The generic parameters mirror SelectorSpec's and are purely informational for type checkers.

display_name class-attribute instance-attribute

display_name: str | None = None

Consumer-only label, never emitted on the MCP wire, so a downstream library can render a richer label than the protocol title.

display_description class-attribute instance-attribute

display_description: str | None = None

Consumer-only blurb, the sibling of display_name and likewise never emitted on the MCP wire.

input_serializer class-attribute instance-attribute

input_serializer: type | None = None

Custom non-filter tool arguments, declared MCP-side.

SelectorSpec carries no input serializer of its own: a selector only describes how to fetch, and the HTTP transport validates the URL and query separately. MCP has no such split — every tool call is one arguments dict — so arguments that are not filter / ordering / pagination knobs are declared here.

icons class-attribute instance-attribute

icons: tuple[Icon, ...] = ()

Display icons, emitted in this tool's listing entry. Purely presentational; nothing in dispatch reads them.

include_structured_content class-attribute instance-attribute

include_structured_content: bool | None = None

Whether this tool's tools/call response carries structuredContent. None defers to the INCLUDE_STRUCTURED_CONTENT setting.

include_output_schema class-attribute instance-attribute

include_output_schema: bool | None = None

Whether this tool's tools/list entry carries an outputSchema. None defers to the INCLUDE_OUTPUT_SCHEMA setting.

The MCP spec forbids advertising outputSchema while suppressing structuredContent, so True together with include_structured_content=False is rejected at construction.

max_result_bytes class-attribute instance-attribute

max_result_bytes: int | None | UnsetType = UNSET

Per-tool outbound result ceiling. UNSET defers to the server's MAX_RESULT_BYTES, None disables it here, an int sets its own.

dispatch_timeout class-attribute instance-attribute

dispatch_timeout: float | None | UnsetType = UNSET

Per-tool dispatch deadline, in seconds. UNSET defers to the server's DISPATCH_TIMEOUT, None disables it here. Async transport only.

max_page_size class-attribute instance-attribute

max_page_size: int | None | UnsetType = UNSET

Per-tool ceiling on the model-supplied limit. UNSET defers to the server's MAX_PAGE_SIZE, None serves any limit the model asks for.

Only meaningful with paginate=True: an unpaginated selector has no limit to clamp, and clamping its result would drop rows with nothing in the payload to say so (see UnboundedListWarning).

argument_binding class-attribute instance-attribute

argument_binding: ArgumentBinding = SPREAD_AUTHOR_WINS

How MCP arguments flow into the kwarg pool. SPREAD_AUTHOR_WINS for selector tools, because a selector typically declares its query parameters as individual function arguments (def list_drafts(*, project_id, status="draft")).

unknown_arguments class-attribute instance-attribute

unknown_arguments: UnknownArguments = REJECT

How unknown arguments keys are handled relative to the merged inputSchema (input_serializer fields, filter_set properties, ordering, pagination). REJECT answers an isError validation_error result naming them, IGNORE drops them. PASSTHROUGH is retired in drf-services 0.58.0, where an argument the spec does not declare never reaches a callable, and the registrars refuse it: pass IGNORE or REJECT.

always_listed class-attribute instance-attribute

always_listed: bool = False

Keep this binding in tools/list even when FILTER_LISTINGS_BY_PERMISSIONS would drop it — same semantics as ToolBinding.always_listed.

query_params class-attribute instance-attribute

query_params: tuple[QueryParam, ...] = ()

Read-shaping params routed to request.query_params at dispatch.

Popped from the caller's arguments like a URL kwarg, but landing in the synthetic request's GET rather than view.kwargs — the channel a serializer reads when it branches on the query string. A filter_set field is not one of these. Nor is a selector parameter: the value is split out of the arguments the selector receives, so registration refuses a selector declaring a parameter of the same name, unless the input_serializer lays the name back, on the terms the class docstring gives for page / limit. The wire and acall_tool then hand the selector the validated value; call_tool runs no input_serializer, so the name reaches only request.query_params there.

url_kwargs class-attribute instance-attribute

url_kwargs: tuple[UrlKwarg, ...] = ()

URL-derived values the model supplies as tool args, seeded into the off-HTTP view's kwargs instead of reaching the selector as ordinary params. Advertised in the inputSchema, exempt from the unknown-argument check, and stripped from the dispatched params. Dispatch spreads view.kwargs into the selector's pool, so one that is required or declares a default is there on every call that dispatches, and registration counts it as the source of a selector parameter of the same name. See UrlKwarg.

spec_kwargs_provides class-attribute instance-attribute

spec_kwargs_provides: tuple[str, ...] = ()

Selector parameters the spec's kwargs= provider fills, as declared at registration. Registration counts each as the parameter's source, the inputSchema does not advertise it, and a call is not refused for leaving it out.

content_kind class-attribute instance-attribute

content_kind: ToolContentKind = TEXT

What this tool's payload becomes in the result's content array. TEXT renders JSON per output_format; the other kinds project it into an image / audio / resource-link block. See ToolContentKind.

content_mime_type class-attribute instance-attribute

content_mime_type: str | None = None

The media type for an IMAGE / AUDIO content_kind. Required for those and meaningless for the rest — a resource link carries its own mimeType per entry.

task_policy class-attribute instance-attribute

task_policy: TaskPolicy = FORBIDDEN

Whether calling this tool hands back a task handle instead of a result. The choice lives on the binding because the extension makes the server the sole decider and gives the client no way to ask. See TaskPolicy.

field_audiences class-attribute instance-attribute

field_audiences: Mapping[str, FieldMarking] | None = None

Per-tool overrides layered over the FieldMarking declarations the output serializer carries on its own fields.

The serializer stays authoritative — it is the one declaration the REST API, this transport, and an in-process toolset all read. This exists for the case one tool genuinely needs what a sibling hides: a lookup tool returning the identifier its neighbour drops.

Declared as the spec's own field_audiences, which a registry entry's OfflineContract carries to register_specs, or passed in an agent_contract= at registration, and resolved here, so the field set an agent sees does not depend on which agent transport served it.

server_arguments class-attribute instance-attribute

server_arguments: ServerArguments = field(default_factory=ServerArguments)

Declared parameters the server answers for, so no caller does.

The kernel's ServerArguments, given as server_arguments= at registration. Its values are supplied on every call and outrank the caller's; its withheld names are taken from no one, so the callable's own default answers. tools/list leaves every one of them out of the inputSchema and its required, and each tools/call route drops the caller's value for them before anything reads the arguments (handlers.utils.apply_tool_server_arguments). Static for the binding's lifetime: a value computed per request is not this. Checked against the spec when the binding is registered, so one that cannot mean what it says fails at startup (ToolRegistry.register).

output_serializer property

output_serializer: type | None

The serializer whose rendered output reaches the caller, if any.

rendered_affordances property

rendered_affordances: Mapping[str, AffordanceDeclarer] | None

The affordances answered on every rendered item, if any are declared.

The spec's own mapping, which drf-services' renderer adds to each item wherever the item sits: the retrieved object, each array element, or each entry in the pagination envelope's items. Feeds the advertised outputSchema, so the schema names the key the payload carries.

rendered_kind property

rendered_kind: SelectorKind

The spec's kind, under the name every tool binding answers it by.

Paired with paginate when the schema is built: a LIST is an array unpaginated and the pagination envelope otherwise, which only a selector tool can produce.

can_present_nothing property

can_present_nothing: bool

Whether a successful call can present nothing: an allow_none RETRIEVE.

Read by tools/list so the outputSchema admits the {} such a call is served as. See registry.types.utils.can_present_nothing.

audience_projection cached property

audience_projection: AudienceProjection

This tool's resolved audience markings, derived once per binding.

Drives both the projected payload and the advertised outputSchema, so the two cannot disagree about which fields a caller will receive.

kind property

kind: SelectorKind

Shape discriminator, read from the spec's required kind field.

Not stored independently on the binding: a second copy would only be a chance for the two to drift.

filter_set property

filter_set: Any | None

Transport-neutral filtering, read from SelectorSpec.filter_set.

Delegated rather than copied, like kind and selector, so a project declares its filterable shape once on the spec and both the HTTP and MCP transports honour it.

Typed Any because django-filter is optional behind the [filter] extra, and narrowing would force a hard import here.

UrlKwarg dataclass

A URL route capture exposed as a caller-supplied argument off-HTTP.

Over HTTP a nested route's captures (the project_pk of /projects/{project_pk}/widgets/) reach a spec through view.kwargs — directly, or through a spec.kwargs provider that scopes by them. Off-HTTP there is no route, so the caller supplies the value as an ordinary argument: the transport advertises it in the tool / operation schema, pops it out of the arguments, and hands it to build_offline_context(kwargs=…), from where dispatch_spec spreads it into the selector / target pools — authoritative over the spec params, below a spec.kwargs provider. It never reaches the spec as an ordinary input, so the unknown-argument policy never flags it. Adapters import this declaration and pair it with validate_channel_names.

Reach for one when the value is a URL-derived input a spec depends on that is not already an ordinary argument — most commonly a scoping spec.kwargs provider reading view.kwargs (off-HTTP that mapping is otherwise empty, so the provider mis-scopes for every caller), or a closed-surface spec whose route capture must be caller-suppliable. A selector reading the value from its own **extras: Unpack[TypedDict] needs no UrlKwarg: drf-services reflects the key into the schema and params delivers it. A key can be both reflected and registered — the explicit UrlKwarg wins the adapter's schema merge, registration pops the argument into kwargs=, and the authoritative spread still delivers it to the selector pool, so both readers see it.

An explicit null from the caller is not a supplied value. A route capture is a URL segment, so over HTTP there is no value that means null; off-HTTP, {"project_pk": null} is what a model emits for a capture it could not resolve. A transport treats that as an omitted argument — required still refuses it and default still applies — rather than spreading None onto view.kwargs, where a scoping provider would turn it into a silent IS NULL lookup that returns rows and looks successful.

Attributes:

Name Type Description
name str

The argument / view-kwarg key. Must not collide with a reserved transport key; see validate_channel_names.

type str

The JSON-Schema type advertised to the caller — "string" by default, or "integer" / "number" / "boolean" …

description str | None

Optional help text shown to the caller.

default Any

Value seeded when the caller omits the argument; also surfaced as the schema default. Left at UNSET there is no default and the schema carries no default key; default=None is a real declaration ("defaults to null") and is surfaced like any other value. Read it with is not UNSET, never with a truthiness or is not None test.

required bool

Advertise the key in the schema's required list. Use it for a route capture the spec genuinely cannot run without, so a caller is told up front instead of failing mid-dispatch. Setting both required and a default is contradictory and raises in validate_channel_names. It is the registered-declaration counterpart of InputRequired, which does the same job for a key the callable's own TypedDict declares; both end up in the schema's required.

json_schema

json_schema() -> dict[str, Any]

The JSON-Schema property this kwarg contributes to an input schema.

default is emitted whenever one was declared — UNSET is the "no default" sentinel, so an explicit default=None reaches the schema as "default": None instead of vanishing.

QueryParam dataclass

A request-level query param exposed as a caller-supplied argument off-HTTP.

Generalizes the built-in page / limit / order list-selector arguments to any read-shaping param a serializer reads off request.query_params — django-restql field selection (?query= / ?fields=), or a custom serializer that branches on the query string.

Declared in a spec's reads, it is one of the spec's parameters, of its type and with its default: the caller's value is checked as declared, handed by name to a selector, service or target lookup that takes the name, and never an unexpected argument. Over HTTP it is read from the request's query string alone, so a body value of that name is dropped; off HTTP it is the caller's argument, and is put onto the request's query_params where they lack it, so a serializer or provider reading them finds it.

A SelectorSpec filter_set does not need this — its fields are already generated into the schema and flow through as ordinary params.

Declared here rather than in each adapter for the same reason as UrlKwarg: it is the same declaration whichever transport carries it. Pair it with validate_channel_names.

  • name — the argument / query-string key. Must not collide with a reserved transport key; see validate_channel_names.
  • type — the JSON-Schema type advertised to the caller ("string" by default; "integer" / "number" / "boolean" / "array" …).
  • description — optional help text shown to the caller.
  • default — value seeded when the caller omits the argument; also surfaced as the schema default. Left at UNSET there is no default, and the schema carries no default key; default=None is a real declaration ("defaults to null") and is surfaced like any other value. Read it with is not UNSET, never with a truthiness or is not None test.

An explicit null from the caller is not a supplied value. Over HTTP a query param is always a string, so there is no value a caller can send that means null; off-HTTP, {"fields": null} is the shape a model emits for a param it chose not to fill. A transport treats it as an omitted argument — the default still applies — rather than routing None onto request.query_params.

No required flag, deliberately. A query param is read-shaping — omitting one is legitimate by construction, and the spec runs correctly without it. Requiredness belongs to inputs the spec cannot run without, which is UrlKwarg and the InputRequired marker.

json_schema

json_schema() -> dict[str, Any]

The JSON-Schema property this param contributes to an input schema.

default is emitted whenever one was declared — UNSET is the "no default" sentinel, so an explicit default=None reaches the schema as "default": None instead of vanishing.

A UrlKwarg or QueryParam may not take a name the DRF edge fills on every call; registration refuses one that does.

TOOL_EDGE_SEEDS module-attribute

TOOL_EDGE_SEEDS: frozenset[str] = frozenset({'request', 'view', 'serializer'})

Names a tool call fills itself, beyond the kernel's reserved set, that no channel may take.

drf-services' DRF edge seeds request and view on every dispatch_spec call (its EDGE_SEEDS), and its validator binds serializer; a caller's key of the first or third name is dropped (its DROPPED_EDGE_SEEDS). None of the three is in the kernel's RESERVED_POOL_SEEDS, which validate_channel_names always reserves, so a UrlKwarg or QueryParam of one of these names registered, was listed, and never reached what read it. validate_url_kwargs and validate_query_params reserve these beside the post-fetch keys and the server's pool_seeds (test_a_url_kwarg_named_after_an_edge_seed_is_refused, test_a_query_param_named_after_an_edge_seed_is_refused), which is the rule drf-services applies to a spec's own reads when it compiles one.

Written out rather than composed from drf-services' internal module, and pinned equal to DROPPED_EDGE_SEEDS | EDGE_SEEDS by test_the_tool_edge_seeds_are_what_the_drf_edge_fills, so an upstream change fails a test here instead of moving a public constant without a word. A resource read seeds only request (RESOURCE_EDGE_SEEDS), because it runs its selector directly, with no view and no validator.

ToolRegistry

Name to tool binding lookup.

Holds service, selector and chain bindings in one namespace, rejecting duplicates loudly so a misconfigured project surfaces the conflict at registration rather than silently shadowing a tool.

pool_seeds are the owning server's, which MCPServer passes: a binding's server_arguments are checked against them, since dispatch refuses a parameter a registered seed occupies.

register

register(binding: ToolBindingLike) -> None

Add binding, refusing a duplicate name or a binding of server values that cannot hold.

The server_arguments check is made here rather than in the registrars alone, because a binding built by hand and handed straight to server.tools.register would otherwise reach every tools/call unchecked, and fail each one with ImproperlyConfigured where it should have failed the server's startup (test_a_binding_built_by_hand_is_checked_when_it_is_registered). The registrars reach it as their last step, after the spec has compiled.

all

all() -> list[ToolBindingLike]

Every binding, in registration order.

tools/list has to be deterministic so clients can cache the catalog and cursor pagination stays stable, which dict insertion order already gives. Deliberately not sorted by name: registration order is authored order, which is a better first page for a model than an alphabetical one.

ResourceBinding dataclass

Bases: Generic[ResultT]

All wiring for a single MCP resource (or resource template).

A resource is a selector callable plus a URI template. The MCP server invokes the selector directly via resolve_callable_kwargs + run_selector — there is no view or viewset in the dispatch path.

output_serializer is what resources/read renders the selector's return value through, and mime_type advertises the type that body will carry. kwargs_provider mirrors SelectorSpec.kwargs: when set, the handler invokes it once per request and merges the returned dict into the kwarg pool, passing a synthesised OfflineServiceView whose view.kwargs holds the URI-template variables and whose view.action is the binding name. annotations and meta are emitted verbatim on the listing entry, and meta also on the contents block of resources/read.

The generic parameter is purely informational, letting callers pin the selector's return type for type-checker help.

kind instance-attribute

kind: SelectorKind

Pulled out of SelectorSpec.kind by the adapter, so the binding does not carry a reference to the whole spec. LIST invokes the output serializer with many=True, RETRIEVE (the common case for URI-template resources) with many=False. Resources have no post-fetch pipeline, so both kinds are accepted unconditionally.

encoding class-attribute instance-attribute

encoding: ResourceEncoding = JSON

How the selector's value becomes the resources/read body. Declared rather than inferred from mime_type, so advertising a new mime type never silently changes the encoding. See ResourceEncoding.

completions class-attribute instance-attribute

completions: dict[str, Callable[..., Any]] = field(default_factory=dict)

Argument name to completer callable, powering completion/complete.

A completer is dispatched through resolve_callable_kwargs against a pool of value (the text typed so far), arguments (siblings the client has already resolved, also spread by name), request and user. It returns an iterable of suggestions — a list, a generator or a queryset — which the handler slices to the spec's cap rather than draining.

cache_ttl_ms class-attribute instance-attribute

cache_ttl_ms: int | UnsetType = UNSET

How long a client may cache this resource's body, in milliseconds. UNSET takes the server's RESOURCE_CACHE_TTL_MS, 0 by default because a resource body is live data. Worth setting on anything genuinely static: hosts prefetch interactive views before any tool call, so a zero TTL means fetching the same HTML repeatedly.

icons class-attribute instance-attribute

icons: tuple[Icon, ...] = ()

Display icons, emitted in this resource's listing entry. Purely presentational; nothing in dispatch reads them.

always_listed class-attribute instance-attribute

always_listed: bool = False

Keep this resource in resources/list (or resources/templates/list) even when FILTER_LISTINGS_BY_PERMISSIONS would drop it — same semantics as ToolBinding.always_listed.

ResourceRegistry

URI or URI-template to ResourceBinding lookup.

Concrete resources are matched by exact URI, templates by a regex derived from the template. resolve returns the binding plus the variables extracted from the URI.

Specificity, not registration order. A template's {var} matches any single segment, so reports://{report_id} also matches reports://all-tenants-summary. Resolving in registration order would make which permission stack guards a URI a function of the order the two were registered in — and the wrong answer is the permissive one, since the template is the general case. Concrete URIs are therefore tried first, and only then templates.

by_uri_template

by_uri_template(uri_template: str) -> ResourceBinding | None

Exact lookup on the registered template string.

A caller holding the template itself — the completion API's ref/resource, say — must use this rather than resolve: things://{pk} satisfies its own pattern with pk="{pk}", so resolve would answer, plausibly and wrongly.

A URI-template variable may not take a name resources/read seeds itself; registration refuses one that does.

RESOURCE_EDGE_SEEDS module-attribute

RESOURCE_EDGE_SEEDS: frozenset[str] = frozenset({'request'})

Pool keys resources/read seeds itself, beyond the kernel's reserved set.

RESERVED_POOL_SEEDS is the kernel's, and since drf-services 0.58.0 it no longer lists request: off HTTP there is none, so the kernel leaves it to an HTTP adapter to seed. resources/read does, laying the synthesised request over the URI-template variables, so a variable named request would never reach the selector. register_resource reserves these beside the kernel's set and the server's pool_seeds so such a template is refused rather than silently shadowed (test_a_template_variable_named_after_a_pool_seed_is_refused[request]).

ResourceEncoding

Bases: str, Enum

How a resource's selector return value becomes the resources/read body.

Declared separately from the binding's mimeType rather than sniffed from it — sniffing would silently change behaviour for anyone already advertising a non-JSON type.

Attributes:

Name Type Description
JSON

Pretty-print the value as JSON. The default, and what every selector-backed data resource wants.

TEXT

The value is already the body; the selector must return a str. For HTML, Markdown, CSV or plain text, where JSON-encoding would wrap the payload in a quoted string literal.

BLOB

The value is binary. The selector returns bytes and the body is base64-encoded into the spec's blob field instead of text — the two are mutually exclusive on a contents entry.

PromptBinding dataclass

All wiring for a single MCP prompt.

A prompt is a server-defined message template the client invokes by name. The render callable receives the client-supplied arguments as kwargs and returns a list of PromptMessage instances, a list of strings (each becoming a user text message), a single string, or a coroutine yielding any of those. The handler normalises whichever shape arrives into the spec's messages list at dispatch time.

annotations and meta are emitted verbatim on this prompt's prompts/list entry, under annotations and _meta respectively.

completions class-attribute instance-attribute

completions: dict[str, Callable[..., Any]] = field(default_factory=dict)

Argument name to completer callable, powering completion/complete.

A completer is dispatched through resolve_callable_kwargs against a pool of value (the text typed so far), arguments (siblings the client has already resolved, also spread by name), request and user. It returns an iterable of suggestions — a list, a generator or a queryset — which the handler slices to the spec's cap rather than draining.

icons class-attribute instance-attribute

icons: tuple[Icon, ...] = ()

Display icons, emitted in this prompt's listing entry. Purely presentational; nothing in dispatch reads them.

always_listed class-attribute instance-attribute

always_listed: bool = False

Keep this prompt in prompts/list even when FILTER_LISTINGS_BY_PERMISSIONS would hide it — same semantics as ToolBinding.always_listed.

PromptRegistry

Name to PromptBinding lookup.

Mirrors ToolRegistry: names are unique and a duplicate raises at registration.

Interactive views (MCP Apps)

MCPServer.register_ui_resource(...) declares an HTML view for an MCP host to render inline in the chat. The view is an ordinary ResourceBinding with the Apps mime type, TEXT encoding, and a _meta bundle built from UIResourceMeta; UIToolMeta then links a tool to it, so the host renders that tool's result inside the view. See Interactive views for the host/server split, the three refused-link cases, and the keep-tenant-data-out rule.

UIResourceMeta dataclass

What a host needs to know to render an interactive view.

Serialises into the resource's _meta under the Apps extension's key. Typed at the registration parameter rather than in the wire types, because _meta is an open namespace shared by every extension: it stays a free-form dict at the boundary while each extension keeps its own closed shape on the way in.

Attributes:

Name Type Description
csp UICsp | None

Origins the view needs; see UICsp.

permissions Sequence[UIPermission]

Browser capabilities the view would use. The host decides whether to grant them.

domain str | None

A stable identity for the view's origin, letting a host group views from one publisher — for a single consent prompt, say — rather than treating every URI as unrelated.

prefers_border bool | None

Rendering hint that the view looks better with the host's chrome around it. A hint, not a requirement.

to_dict

to_dict() -> dict[str, Any]

Serialise to the extension's camelCase wire shape, omitting empties.

UICsp dataclass

The network origins an interactive view needs, declared to the host.

The server declares; the host enforces. A host builds the iframe's Content-Security-Policy from this, so an origin the view talks to but does not declare here is blocked at runtime, with nothing in the server logs to say so.

Each field is a sequence of origins ("https://api.example.com") mapping onto one CSP directive. An empty one is omitted from the payload, so declaring nothing declares nothing — which is not the same as declaring "deny all", the host's default anyway.

Attributes:

Name Type Description
connect_domains Sequence[str]

fetch / XMLHttpRequest / WebSocket targets.

resource_domains Sequence[str]

Images, stylesheets, scripts, fonts. A view loading Django {% static %} assets must list the static origin here; a self-contained single-file template needs nothing.

frame_domains Sequence[str]

Origins the view may itself embed in an iframe.

base_uri_domains Sequence[str]

Permitted values for the document's <base>.

to_dict

to_dict() -> dict[str, Any]

Serialise to the extension's camelCase wire shape, omitting empties.

UIPermission

Bases: str, Enum

A browser capability an interactive view asks the host to grant.

The host decides; this only declares what the view would use, in the resource's _meta.ui.permissions. Anything not declared is denied by the iframe sandbox the host builds.

UIToolMeta dataclass

Links a tool to the interactive view that renders its result.

Serialises into the tool's _meta under the Apps extension's key, so a host reading tools/list knows which ui:// resource to fetch and which surfaces may call the tool. The view renders from the tool's structuredContent, so a linked tool must emit it: register_service_tool and friends refuse a link when it is switched off.

Attributes:

Name Type Description
resource_uri str

The ui:// URI of a view registered on this same server with register_ui_resource. Concrete, never a template — the spec defines no expansion mechanism, because the host fetches a view once and then pushes each result into it by notification.

visibility Sequence[UIVisibility]

Who may call the tool. Empty is "unsaid", which hosts read as the ordinary model-callable default. Host-enforced: this server declares it and does not filter tools/list on it.

to_dict

to_dict() -> dict[str, Any]

Serialise to the extension's camelCase wire shape, omitting empties.

UIVisibility

Bases: str, Enum

Who may call a tool that is linked to an interactive view.

Declared per tool in _meta.ui.visibility and enforced by the host, which the spec requires not to offer the model a tool whose visibility omits MODEL. This server only declares it — nothing here filters tools/list on it, because a client that does not implement the extension would not honour the rule anyway.

Attributes:

Name Type Description
MODEL

The agent may call it — ordinary tool behaviour.

APP

The view may call it. An APP-only tool is a fine-grained operation that serves the view rather than the conversation.

The view document

register_ui_resource(body_template_name=...) composes the document through this, so most projects never call it directly. It is exported for the case that assembles the markup some other way and still wants the packaged shell and the ui/* bridge — a bridge whose failure modes are all silent, and which Writing the bridge yourself describes for anyone declining it.

build_app_document

build_app_document(body: str, *, title: str, diagnostics: bool | None = None) -> str

Wrap a view's markup in a complete MCP Apps document.

body is a fragment -- the view's own markup, styles and scripts, with no <html> around it. What comes back is the whole document: the element structure a sandbox expects, a minimal theme-inheriting stylesheet, and the ui/* postMessage bridge inlined ahead of the fragment so the fragment can assign mcpApp.onToolResult while the parser is still running.

Reached most easily through register_ui_resource(body_template_name=...), which renders a Django template into body. It is public because the other content sources deserve the same shell: a project assembling its markup some other way can wrap the result and pass it as html= or return it from a selector=.

Three properties are structural rather than advisory, because each of them cost a consumer at least one debugging round:

  • <html>, <head> and <body> are written out. HTML5 infers all three and browsers do not care, but the sandbox loads this document as raw HTML and applies a CSP to it, and a sandbox injecting anything into <head> has nowhere to put it when the element is implied.
  • Nothing is fetched. No CDN, no module import, no external stylesheet -- which is the extension's own advice, and means a view needs no resource_domains in its CSP just to boot.
  • The bridge always completes its handshake, so the frame is always revealed and a broken view can say what is wrong -- to the console always, and into the document when diagnostics is on. See bridge.js.

Parameters:

Name Type Description Default
body str

The view's markup. Inserted verbatim -- it is HTML by definition, and it is the project's own template output, not caller input.

required
title str

The document title. Escaped.

required
diagnostics bool | None

Whether a protocol failure is written into the document as well as logged to the console. None follows settings.DEBUG.

The text is written for whoever wrote the view, and the audience of a rendered view is whoever is using the product -- so it is off where that audience is real. A failed handshake is not necessarily a failed view either: a host that errors on ui/initialize may still deliver the tool result, which would leave a working view under a banner of raw protocol text. The bridge cannot tell those apart, so the default resolves toward saying nothing to end users and logging in both cases.

None

Returns:

Type Description
str

A complete text/html document.

Bulk registration

register_tools(server, definitions, *, selector_defaults=None, service_defaults=None) is an additive entry point for registering many tools in one call. Pass a list of ToolDefinition.service(...) / ToolDefinition.selector(...) instances plus per-kind ServiceDefaults / SelectorDefaults that fill in fields each definition leaves as None. Returns the resulting bindings in input order.

register_tools

register_tools(
    server: MCPServer,
    definitions: Iterable[ToolDefinition],
    *,
    selector_defaults: SelectorDefaults | None = None,
    service_defaults: ServiceDefaults | None = None,
) -> list[ToolBinding | SelectorToolBinding]

Register every ToolDefinition against server.

Parameters:

Name Type Description Default
server MCPServer

The server to register against.

required
definitions Iterable[ToolDefinition]

The definitions to register, in order.

required
selector_defaults SelectorDefaults | None

Per-kind defaults merged underneath each selector definition's own values. Any field the definition sets to something other than None counts as authored and wins.

None
service_defaults ServiceDefaults | None

The same, for service definitions.

None

Returns:

Type Description
list[ToolBinding | SelectorToolBinding]

The resulting bindings, in the order of definitions, so test

list[ToolBinding | SelectorToolBinding]

harnesses and observability code can introspect what landed.

Raises:

Type Description
TypeError

The definition's kind is unrecognised. The discriminator is internal, so this needs direct ToolDefinition construction with an unsupported value.

ToolDefinition dataclass

Declarative description of a single tool, fed to register_tools.

A transport-agnostic container for the kwargs that would otherwise be passed to MCPServer.register_service_tool or MCPServer.register_selector_tool, plus a ToolKind discriminator selecting between them at dispatch time.

Construct via service / selector, which enforce the per-kind kwarg surface; direct construction is available for tests and tooling but bypasses that. Filtering is declared on the spec (SelectorSpec.filter_set), so neither kind carries a filter_set kwarg.

Every per-call kwarg defaults to None, which register_tools reads as "no override" — letting a SelectorDefaults / ServiceDefaults supply the value, and falling back to the registration method's own default when neither does.

display_name class-attribute instance-attribute

display_name: str | None = None

Consumer-only label, never emitted on the MCP wire. Carried onto the resulting binding so a downstream library can render a richer label than the protocol title.

display_description class-attribute instance-attribute

display_description: str | None = None

Consumer-only blurb, the sibling of display_name and likewise never emitted on the MCP wire.

always_listed class-attribute instance-attribute

always_listed: bool | None = None

Keep this binding in tools/list when FILTER_LISTINGS_BY_PERMISSIONS would otherwise hide it. None takes the registration default (False).

spec_kwargs_provides class-attribute instance-attribute

spec_kwargs_provides: Sequence[str] | None = None

Explicit opt-in declaring that spec.kwargs(view, request) supplies these required callable parameters at dispatch time.

Trust has to be declared per transport, because spec.kwargs is a runtime callable whose output depends on the view context — URL path params under DRF, URI template vars for MCP resources, neither for MCP tools. Supply a sequence to acknowledge that the provider is the static source for those names.

url_kwargs class-attribute instance-attribute

url_kwargs: Sequence[UrlKwarg] | None = None

URL-derived values the model supplies as tool args, seeded into the off-HTTP view.kwargs at dispatch. See UrlKwarg. Deprecated, and warns at registration: declare it in the spec's reads instead.

query_params class-attribute instance-attribute

query_params: Sequence[QueryParam] | None = None

Read-shaping values the model supplies as tool args, seeded into the off-HTTP request.query_params at dispatch. See QueryParam. Deprecated, and warns at registration: declare it in the spec's reads instead.

server_arguments class-attribute instance-attribute

server_arguments: ServerArguments | None = None

Declared arguments the server answers for, as on register_service_tool: a bound value the caller cannot replace, and names withheld from the caller. Per tool, so neither SelectorDefaults nor ServiceDefaults carries it.

service classmethod

service(
    *,
    name: str,
    spec: ServiceSpec,
    description: str | None = None,
    title: str | None = None,
    display_name: str | None = None,
    display_description: str | None = None,
    output_format: OutputFormat | None = None,
    permissions: Sequence[Any] | None = None,
    rate_limits: Sequence[Any] | None = None,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
    include_structured_content: bool | None = None,
    include_output_schema: bool | None = None,
    argument_binding: ArgumentBinding | None = None,
    unknown_arguments: UnknownArguments | None = None,
    always_listed: bool | None = None,
    spec_kwargs_provides: Sequence[str] | None = None,
    url_kwargs: Sequence[UrlKwarg] | None = None,
    query_params: Sequence[QueryParam] | None = None,
    server_arguments: ServerArguments | None = None,
) -> ToolDefinition

Typed entry point for service-tool definitions.

selector classmethod

selector(
    *,
    name: str,
    spec: SelectorSpec,
    description: str | None = None,
    title: str | None = None,
    display_name: str | None = None,
    display_description: str | None = None,
    input_serializer: type | None = None,
    output_format: OutputFormat | None = None,
    permissions: Sequence[Any] | None = None,
    rate_limits: Sequence[Any] | None = None,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
    paginate: bool | None = None,
    include_structured_content: bool | None = None,
    include_output_schema: bool | None = None,
    argument_binding: ArgumentBinding | None = None,
    unknown_arguments: UnknownArguments | None = None,
    always_listed: bool | None = None,
    spec_kwargs_provides: Sequence[str] | None = None,
    url_kwargs: Sequence[UrlKwarg] | None = None,
    query_params: Sequence[QueryParam] | None = None,
    server_arguments: ServerArguments | None = None,
) -> ToolDefinition

Typed entry point for selector-tool definitions.

The LIST / RETRIEVE shape lives on the spec (SelectorSpec.kind), not here — the bulk registration loop reads it from there.

ServiceDefaults dataclass

Per-kind defaults for register_tools over service definitions.

None is the "no override" sentinel: only non-None values are applied as defaults to the matching MCPServer.register_service_tool call, and a per-definition value always wins.

That includes the tri-state fields, where None on the registration method means "inherit the global setting": passing include_structured_content=None here is "no override", not a request to inherit. Leave it unset for the global, or pass True / False.

SelectorDefaults dataclass

Per-kind defaults for register_tools over selector definitions.

Sister of ServiceDefaults, with the same convention: None is "no override", deferring to the per-definition value or to MCPServer.register_selector_tool's own default, and a per-definition kwarg always wins on conflict.

The selector-only knobs live here too, so a project wanting every selector tool to paginate by default says so once. Filtering is not among them — filter_set is declared on each SelectorSpec, never as a registration default, and it carries ordering with it.

ToolKind

Bases: Enum

Discriminator for ToolDefinition and the register_tools dispatch table.

Internal-only — never appears on the wire. SERVICE maps to MCPServer.register_service_tool, SELECTOR to MCPServer.register_selector_tool. Prefer ToolDefinition.service / ToolDefinition.selector over passing this kwarg by hand — those are the typed entry points.

ArgumentBinding is re-exported from djangorestframework-services, whose dispatch_spec owns it, and UnknownArguments from django-service-specs, the kernel that drf-services dispatches through and that owns it; import both from rest_framework_mcp.constants. ArgumentBinding.SPREAD_CALLER_WINS and UnknownArguments.PASSTHROUGH are retired: registering a tool with either, directly, through a ToolDefinition or through ServiceDefaults / SelectorDefaults, raises ImproperlyConfigured naming the tool.

UnknownArguments

Bases: str, Enum

The closed argument set's policy, applied at every level of nesting.

REJECT is the default everywhere. An untrusted caller's undeclared key is refused before it costs a query, and a trusted one's typo - a command's misspelt option, a task enqueued with last month's argument name - fails where the caller can see it rather than being dropped on the floor.

There is no pass-through policy. An undeclared argument reaching a callable is exactly what the closed set exists to prevent: on a selector, with no Validator in front of it, it would let a caller supply a keyword the operation's author never offered. PASSTHROUGH is a member and not a policy, and every function taking one refuses it.

REJECT class-attribute instance-attribute

REJECT = 'reject'

Refuse the undeclared key with InvalidArguments, at the level it appeared.

IGNORE class-attribute instance-attribute

IGNORE = 'ignore'

Drop the undeclared key and carry on.

PASSTHROUGH class-attribute instance-attribute

PASSTHROUGH = 'passthrough'

Kept as a name, and refused as a policy.

It is here for the current release of djangorestframework-services, whose published transports compare a policy against it by identity: with the member, that comparison is false for either policy, where its absence would raise AttributeError. It is never run. check_arguments and parameters_schema raise ValueError for it where they read the policy, so dispatch, adispatch, check_call, bind_arguments and spec_input_schema refuse it too, rather than running it as IGNORE.

ArgumentBinding

Bases: Enum

Whether a caller's values may fill a service's own parameters.

A call has one pool, and every dispatched callable takes its keyword arguments from it by name: the seeds (user, progress, request, view, a project's registered ones and, for a mutation, data, serializer, instance / collection), the arguments the spec declares - its reads, its filter's names, its input serializer's fields - and the author's values from a view's hooks and the spec's kwargs provider. An author's value for a declared argument replaces the caller's, so the author wins a conflict, and a key a typed provider is annotated to fill is never the caller's at all. A seed's name is never an argument: a caller's value named like one is dropped (view aside, which UnknownArguments.REJECT refuses), and an input serializer's field of that name is validated into data while the seed keeps its own value, so a client cannot poison transport-controlled state by naming an argument after one. A name any callable of the call marks NotClientInput is the server's: the client's value for an input serializer's field of that name reaches the service only inside data, while an author's value for it (a view hook's, the spec's kwargs provider's or input_data's) still reaches the parameter.

What is left for this enum is one question, about a service spec with no input_serializer and about a selector spec: whether the callable's own parameters are caller input. A selector spec's declared reads are the caller's under every member. It is a trust-boundary decision, which is why it belongs to the caller rather than the spec. Pass the member directly; the value never appears on a wire.

Attributes:

Name Type Description
AUTO

The default, resolved per spec type: ServiceSpec → BUNDLE, SelectorSpec → SPREAD_AUTHOR_WINS, so a selector takes the caller's value for each parameter its signature names.

BUNDLE

A service's own parameters are never the caller's: the validated payload reaches it as data=, and each validated value by its key as well (one a callable of the call marks NotClientInput only inside data), beside its declared reads by name; nothing else the client sends reaches it. A service with no input_serializer takes data={}. The safe choice for a mutation whose kwargs scopes the write. A selector takes none of the caller's values for its parameters either: only a route capture, an author's value, a seed and its declared reads fill them, the filter still reads the caller's values, and a parameter nothing fills is the callable's own TypeError. A selector view dispatches this member.

SPREAD_AUTHOR_WINS

A service with no input_serializer declares its own parameters as the caller's input, and the author's values still replace the caller's on a conflict, so an author-scoped tenant_id cannot be reset by the client. A service with an input serializer keeps the serializer as its input.

SPREAD_CALLER_WINS

Retired. dispatch_spec refuses it with ValueError: one pool has no place for a caller's value to beat the author's. The member stays importable.

Chain tools

ChainStep is one step of a register_chain_tool sequence — an alias, a ServiceSpec / SelectorSpec, and an inputs callable. That callable receives a ChainContext, which exposes the validated tool arguments as ctx.args and any prior step's output as ctx[alias]. See Chain specs into one tool.

ChainStep dataclass

One step in a ChainToolBinding.

A step wraps a single ServiceSpec (a write) or SelectorSpec (a read) and binds its output to alias so later steps can read it via ctx[alias].

What is stored under alias is the final value: for a ServiceSpec with an output_selector_spec.selector that means the re-fetched value, so a downstream step reads what the response would serialize.

Attributes:

Name Type Description
alias str

The name this step's result is stored under in the ChainContext. Must be unique within the chain.

spec ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]

The ServiceSpec or SelectorSpec this step runs.

inputs Callable[[ChainContext], Mapping[str, Any]] | None

(ctx) -> Mapping returning the kwargs merged into the step's call pool alongside request / user; resolve_callable_kwargs then filters them to the callable's signature. None gives the step only request / user / data (the validated chain args), which is what a first step taking the chain input as data wants. Any step needing a prior output or a reshaped payload supplies this explicitly.

ChainContext dataclass

The accumulating context a chain tool threads through its steps.

Passed to each ChainStep's inputs callable so a step can build its call kwargs from the validated tool arguments and any prior step's output:

inputs=lambda ctx: {"account_id": ctx["acct"].id, **ctx.args}

ctx[alias] is the post-output-selector result a prior step stored, and raises KeyError for an alias that has not run — only possible when a step references a later one, a wiring bug worth surfacing loudly. Mutable by design, and built fresh per tool call, so there is no cross-request shared state.

Attributes:

Name Type Description
args Any

The validated chain input — a dataclass instance, a dict, or the raw arguments mapping when no input serializer is resolved.

request Any

The synthesised DRF request.

user Any

The authenticated user.

outputs dict[str, Any]

Alias to step result, filled in as the chain runs.

ChainToolBinding dataclass

All wiring for a single MCP tool that runs a sequence of specs.

A chain tool threads a ChainContext through its ordered steps — each step's result is stored under its alias and readable by later steps — so one tool call can express retrieve x → write y → write z with z derived from both x and y. Sequencing is a transport concern owned by the MCP layer; the steps themselves are ordinary ServiceSpec / SelectorSpec units of API behaviour.

Fields not listed below mirror ToolBinding.

Attributes:

Name Type Description
steps tuple[ChainStep, ...]

The ordered steps, run front to back. Non-empty, and aliases must be unique.

input_serializer type | None

The chain's input schema and validation. None falls back to the first step's ServiceSpec.input_serializer; a first selector step has none, so the chain then validates nothing and ctx.args is the raw arguments mapping.

atomic bool

Run the whole sequence inside one transaction.atomic(), so any step raising rolls back every prior write. Per-step spec.atomic is subordinate — steps run with atomic=False under the chain transaction.

output_alias str | None

Which step's result is rendered as the tool response. None renders the last step. Mutually exclusive with output_all.

output_all bool

Render {alias: rendered} for every step that declares an output serializer.

permissions tuple[Any, ...]

Each step's spec.permission_classes, each wrapped in a DRFPermissionAdapter, in step order, then the chain's own. Position is what tells a step's class, judged under the step's alias, from the chain's, judged under the tool's name, so a binding laid out otherwise is refused with ImproperlyConfigured. MCPServer.register_chain_tool lays it out so.

display_name class-attribute instance-attribute

display_name: str | None = None

Consumer-only label, never emitted on the MCP wire, so a downstream library can render a richer label than the protocol title.

display_description class-attribute instance-attribute

display_description: str | None = None

Consumer-only blurb, the sibling of display_name and likewise never emitted on the MCP wire.

icons class-attribute instance-attribute

icons: tuple[Icon, ...] = ()

Display icons, emitted in this tool's listing entry. Purely presentational; nothing in dispatch reads them.

always_listed class-attribute instance-attribute

always_listed: bool = False

Keep this chain in tools/list even when FILTER_LISTINGS_BY_PERMISSIONS would drop it, or a service step's operation-scope affordances condition is unmet right now — same semantics as ToolBinding.always_listed.

content_kind class-attribute instance-attribute

content_kind: ToolContentKind = TEXT

What this tool's payload becomes in the result's content array. TEXT renders JSON per output_format; the other kinds project it into an image / audio / resource-link block. See ToolContentKind.

content_mime_type class-attribute instance-attribute

content_mime_type: str | None = None

The media type for an IMAGE / AUDIO content_kind. Required for those and meaningless for the rest — a resource link carries its own mimeType per entry.

task_policy class-attribute instance-attribute

task_policy: TaskPolicy = FORBIDDEN

Whether calling this tool hands back a task handle instead of a result. The choice lives on the binding because the extension makes the server the sole decider and gives the client no way to ask. See TaskPolicy.

invalidates class-attribute instance-attribute

invalidates: tuple[str, ...] = ()

URI templates naming the resources a successful call changed.

Same contract as invalidates: published as notifications/resources/updated once the transaction commits, rendered against the result merged with the call's arguments, and matched exactly — so name the collection too if you want it watched:

invalidates=("invoices://{pk}", "invoices://")

field_audiences class-attribute instance-attribute

field_audiences: Mapping[str, FieldMarking] | None = None

Per-tool overrides layered over the FieldMarking declarations the output serializer carries on its own fields.

The serializer stays authoritative — it is the one declaration the REST API, this transport, and an in-process toolset all read. This exists for the case one tool genuinely needs what a sibling hides: a lookup tool returning the identifier its neighbour drops.

Passed in the OfflineContract given to register_chain_tool(agent_contract=...), since a chain has no registry entry, and resolved here, so the field set an agent sees does not depend on which agent transport served it.

audience_projection cached property

audience_projection: AudienceProjection

This tool's resolved audience markings, derived once per binding.

Drives both the projected payload and the advertised outputSchema, so the two cannot disagree about which fields a caller will receive.

output_step property

output_step: ChainStep

The step whose result is rendered (output_alias or the last).

resolved_input_serializer property

resolved_input_serializer: type | None

The serializer used to validate the chain's arguments.

input_serializer when set, else the first step's ServiceSpec.input_serializer. Shared by the tools/list schema builder and the dispatcher, so the advertised schema and the validation cannot drift. A many=True first step's serializer describes one item of its list, so __post_init__ refuses inheriting it.

output_serializer property

output_serializer: type | None

The serializer the rendered output goes through, for outputSchema.

The output step's own (ServiceSpec.output_selector_spec. output_serializer or SelectorSpec.output_serializer). None under output_all, where the response is a multi-key object with no single schema, or when the output step declares no serializer.

rendered_affordances property

rendered_affordances: Mapping[str, AffordanceDeclarer] | None

The affordances answered on the rendered output, for outputSchema.

Read from the output step's spec the way drf-services' renderer reads it, which dispatches on the class: a SelectorSpec's own mapping, a ServiceSpec's output_selector_spec mapping -- never the service's own affordances, which are the conditions a call is refused against and are not rendered. None under output_all, for the reason output_serializer is: that response has no single schema.

On a registered chain this can only be a declaration that asks no condition: one that does is refused in __post_init__, because a chain step computes no answers for the renderer to read. Kept general rather than hard-wired to None, so the schema still describes the {"available": true} object such a declaration does render.

rendered_kind property

rendered_kind: SelectorKind | None

Whether the output step renders as one object or a list, for outputSchema.

The output step's answer from registry.types.utils.rendered_kind, the same one the chain renderer picks many by. A chain never paginates, so a LIST is served as a bare array. None under output_all, whose {alias: rendered} object has no single kind.

can_present_nothing property

can_present_nothing: bool

Whether a successful call can present nothing, judged on the output step.

False under output_all, whose {alias: rendered} object is never None. Otherwise the output step's spec answers, exactly as it would registered as a tool of its own. See registry.types.utils.can_present_nothing.

Selector-tool schema

Builds the merged inputSchema for selector tools — exposed for projects that want to introspect property generation outside of the registration flow. The selector's own signature (its declared parameters and an **extras: Unpack[TypedDict], plus the FilterSet fields) is reflected via djangorestframework-services' spec_to_json_schema — the same reflection the Pydantic-AI toolset consumes — with ordering / pagination knobs and any explicit input_serializer / UrlKwarg layered on top, so the shape is described the same way across transports.

build_selector_tool_input_schema

build_selector_tool_input_schema(
    binding: SelectorToolBinding,
    *,
    max_page_size: int | None = None,
    pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
    query_param_on_pages: str | None = PAGED_QUERY_PARAM_SCOPE,
) -> dict[str, Any]

Build the JSON Schema for a selector tool's inputSchema.

Merges five sources, in order of precedence (later sources override earlier ones on key collision):

  1. Reflected spec shape — the selector callable's own parameters (an **extras: Unpack[TypedDict] expanded into one property per key, the request / user / view transport seeds skipped) plus the filter_set fields, via drf-services' spec_to_json_schema, told which names this server fills (schema.utils.selector_inputs). A filled name -- a registered seed, a UrlKwarg with a default, a key a TypedDict-annotated kwargs= provider returns, a spec_kwargs_provides= name -- is not advertised, and every other parameter without a default is required. This is the same reflection the Pydantic-AI SpecToolset consumes, under the same rule, so both transports advertise the same shape: a nested route's parent_pk read from extras is discoverable without an explicit UrlKwarg, and a FilterSet's OrderingFilter advertises ordering with nothing else declared.
  2. spec.input_serializer — tool-specific args that aren't reflected selector params. A SelectorSpec carries no input serializer, so this is MCP-only; its curated fields win over a reflected param of the same name, and required-marked fields stay required.
  3. paginate=True — adds optional page and limit positive integers. limit carries a maximum when max_page_size is supplied, so the model sees the ceiling dispatch will clamp to.
  4. url_kwargs — each registered UrlKwarg's advertised schema, winning over a reflected key of the same name.
  5. query_params — each registered QueryParam's advertised schema. On a paged tool its description also says that the param applies to each item, never to the envelope.

Last, the binding's server_arguments names leave properties and required, whichever source listed them: the server answers each of them, so the caller is never asked (schema.utils.without_server_arguments).

Parameters:

Name Type Description Default
binding SelectorToolBinding

The selector tool binding to describe.

required
max_page_size int | None

The effective page ceiling — the binding's override, else the server's. None advertises no maximum.

None
pool_seeds PoolSeeds

The server's registered seeds, which fill a selector parameter of the same name, so it is not asked of the client.

DEFAULT_POOL_SEEDS
query_param_on_pages str | None

What a QueryParam applies to on a paged tool, the server's AgentConventions.query_param_on_pages already rendered, so it is written as given. None advertises each param exactly as declared.

PAGED_QUERY_PARAM_SCOPE

Returns:

Type Description
dict[str, Any]

An object schema carrying properties, and required only when at

dict[str, Any]

least one required field exists.

Output schema

Builds a tool's outputSchema from its output serializer, kind-aware like the input side. may_be_empty=True is what tools/list passes for a tool that can present nothing (each binding answers it as can_present_nothing): the root stays an object schema and admits the {} such a call is served as (Concepts).

build_output_schema

build_output_schema(
    output_serializer: type | None,
    *,
    kind: SelectorKind | None = None,
    paginate: bool = False,
    projection: AudienceProjection | None = None,
    affordances: Mapping[str, AffordanceDeclarer] | None = None,
    may_be_empty: bool = False,
    handle_description: str | None = HANDLE_DESCRIPTION,
) -> dict[str, Any] | None

Build a JSON Schema for a tool's output, or None if not declared.

MCP-named wrapper over drf-services' output_to_json_schema. None when there is no output_serializer; otherwise the shape matches what the dispatch pipeline returns:

  • kind=None / RETRIEVE — the bare item schema.
  • kind=LIST, paginate=False — {type: array, items: <item>}.
  • kind=LIST, paginate=True — {items, page, totalPages, hasNext}.

A read-only BaseSerializer subclass has no fields to describe and also answers None. Anything that is neither a BaseSerializer subclass nor a dataclass type raises TypeError, as it would fail to render; registration refuses those shapes first (adapters.utils.validate_serializer_shapes), so one never reaches the per-request tools/list build.

projection applies the output serializer's agent markings, so the advertised schema describes what a caller actually receives rather than what the serializer renders in full. It is generated from the same declaration the dispatch path projects the payload through, which is what stops a tool advertising a field its results no longer carry.

affordances is the affordances mapping on the selector spec the payload renders through, and declares the affordances object drf-services adds to every rendered item, with each name's refusal code enumerated. It is the other key the schema would otherwise miss: no serializer declares it, so the serializer-derived item says nothing about it, and a schema that does not forbid extra keys lets every result conform anyway. Pass the same mapping the render path reads, which each binding exposes as rendered_affordances, so the schema and the payload come from one declaration.

The wording for an unlabelled handle is supplied here rather than upstream: it is a sentence written for a model, and drf-services does not know that a model is what is reading. handle_description is that wording, the server's AgentConventions.handle_field_description already rendered, so it is written as given; None leaves such a handle undescribed, and a handle declaring its own wording keeps it either way.

may_be_empty says a successful call can present nothing, which is served as structuredContent: {} (each binding answers it as can_present_nothing). MCP needs the schema's root to stay an object and the served {} to conform, so a null cannot be added the way drf-services' allow_none= adds one, and this never passes that keyword. Instead the root keeps its type and properties, and its required list moves into "anyOf": [{"required": [...]}, {"maxProperties": 0}]. A full row satisfies the first branch, {} the second, and a non-empty row missing a required field neither. An item schema with nothing required already admits {} and is returned as derived. So is a LIST schema, whatever may_be_empty says: a list result is a list, empty at worst, and the paginated envelope's four keys stay required.

The guard is one or-chain, so branch coverage cannot see a deleted condition. Each one is held by a test that fails without it: test_a_retrieve_that_cannot_present_nothing_keeps_its_schema_strict (not may_be_empty), test_may_be_empty_returns_a_list_schema_as_derived (kind is LIST, its envelope case), test_may_be_empty_with_no_output_serializer_is_still_no_schema (schema is None) and test_may_be_empty_leaves_a_schema_with_nothing_required_as_derived (not schema.get("required")).

Session stores

SessionStore

Bases: Protocol

Pluggable persistence for MCP-Session-Id lifecycle.

The transport calls create after a successful initialize, binding the new session to the authenticated principal, and owner on every subsequent request — which is what enforces both that clients re-initialize after a server restart and that a session minted under one principal cannot be presented by another. destroy runs on HTTP DELETE, after the same ownership check.

principal_id is an opaque string the transport derives from the authenticated token (see rest_framework_mcp.auth.principal_for_token.principal_for_token); stores persist and return it verbatim.

InMemorySessionStore

Process-local session store. Useful for tests and single-process dev servers.

State lives on the instance, so each store is isolated. Multi-process deployments should use DjangoCacheSessionStore instead — this class will not see sessions created in another process.

DjangoCacheSessionStore

Session store backed by django.core.cache.

Works across processes — the production-suitable default.

Two windows, both configurable (SESSION_TTL_SECONDS / SESSION_MAX_AGE_SECONDS, or the constructor arguments here). The TTL is an idle window that restarts on every successful owner read, so a session in continuous use never lapses; the max age is the absolute ceiling that stops a sliding window outliving a revoked principal.

Neither window can promise more than the cache underneath. An eviction policy like Redis's allkeys-lru drops session keys well before any timeout, and the client cannot tell that apart from expiry. If sessions vanish early, check the eviction policy before these settings.

A value this store cannot read fails the ownership comparison and the client transparently re-initializes. A bare principal string — the shape older versions wrote — is honoured and rewritten in the current shape, so an upgrade does not log every current holder out.

Namespacing. An instance built by MCPServer keys its entries under the server's name, so two servers in one project cannot see each other's sessions. Without it they share one flat key space over the same Django cache: a session minted at one satisfies the other's ownership check, and a DELETE against either destroys the other's session. The namespace is hashed into the key (drf-mcp:session:<digest>:<token>) because name is free-form while cache keys must survive backends like memcached, which reject spaces and control characters and cap length at 250.

Constructing the store yourself means you own the namespace:

MCPServer(session_store=DjangoCacheSessionStore(namespace="internal"))

exists

exists(session_id: str) -> bool

Whether a key is present — deliberately not owner() is not None.

A value this store cannot read is present but ownerless, and the gate keys on owner. Reading through owner here would also refresh the idle window, making a liveness probe extend what it probes.

owner

owner(session_id: str) -> str | None

Resolve the owning principal, refreshing the idle window on the way.

Idle, not fixed. The window restarts on every successful read, so only a genuinely idle session lapses.

Bounded by an absolute maximum age. The principal binding is checked once, at initialize, so an unbounded sliding window would keep a revoked principal alive for as long as it kept talking.

Server-initiated push

SSEBroker

Bases: Protocol

Pluggable pub/sub for server-pushed MCP messages.

The transport calls subscribe when a client opens GET /mcp/, publish from app code pushing a payload to a specific session, and unsubscribe when the streaming generator unwinds.

Two implementations ship: InMemorySSEBroker (single-process, no infra) and RedisSSEBroker (the [redis] extra), required for multi-worker deployments where any worker can serve the streaming GET.

The contract is deliberately narrow: a session has at most one live subscriber, and publish returns True if a delivery was attempted, False if no subscriber was attached. Whether publish is fire-and-forget or awaits confirmation is the implementation's choice; the transport treats it as best-effort either way.

Implementations must also bound what an undrained subscriber can accumulate. A stream the client stops reading is not an error, and there is no backpressure channel to the publisher, so an unbounded queue turns one paused consumer into unbounded resident memory.

active_streams property

active_streams: int

How many session streams this worker is currently serving.

What MAX_CONCURRENT_SSE_STREAMS is measured against, so it is a per-worker count of local subscribers rather than a cluster-wide one: the resource being protected is this process's task pool.

InMemorySSEBroker

In-process per-session pub/sub for server-pushed MCP messages.

Each subscribed session gets a private asyncio.Queue. App code in the same process publishes to it via publish; the streaming GET generator pulls off the queue and emits SSE frames.

State is instance-scoped, so multiple servers in one process share none of it. Multi-process deployments need an out-of-process backend — see RedisSSEBroker (the [redis] extra).

One subscriber per session: re-subscribing replaces the previous queue, and the old generator errors out on its next await. There is no replay; clients needing durability call tools/call rather than relying on SSE.

Each queue is bounded, and a full one drops its oldest payload. A client that opens the stream and stops reading it drains nothing, while notify keeps enqueueing — a paused consumer would otherwise pin one payload of memory per notification for as long as it holds the connection. Dropping rather than blocking is the only option that keeps the publisher honest: publish is called from request handling, so waiting on a reader that may never return would park the writer too. Dropping is also already the contract — delivery is best-effort, and a client that missed a notification re-reads — and the drop is reported, as the False that publish already uses for "nobody got this".

unsubscribe

unsubscribe(session_id: str, queue: Queue[Any]) -> None

Remove queue from the registry if it's still the live subscriber.

Compares by identity so a re-subscribed session doesn't accidentally unregister the new queue when the old generator shuts down.

publish async

publish(session_id: str, payload: Any) -> bool

Enqueue payload for session_id if a subscriber exists.

Returns True if delivery was attempted, False if the session had no subscriber or if the queue was full and the oldest payload was dropped to make room. A miss is the caller's to react to, and most ignore it: the client catches up on a fresh tools/call round-trip.

RedisSSEBroker

Cross-process SSE broker backed by Redis pub/sub.

Drop-in replacement for InMemorySSEBroker when running multiple ASGI workers behind a load balancer. The streaming GET handler can land on any worker, and await server.notify(...) from a different worker still reaches the right session because every worker subscribes to the same Redis channel (<prefix>:<session_id>). JSON encode/decode happens at the broker boundary, so app code pushes Python dicts and the streaming generator sees dicts too.

Wire it into MCPServer:

from redis.asyncio import Redis
from rest_framework_mcp import MCPServer
from rest_framework_mcp.transport.redis_sse_broker import RedisSSEBroker

broker = RedisSSEBroker(Redis.from_url("redis://localhost:6379/0"))
server = MCPServer(name="my-app", sse_broker=broker)

Caveats:

  • Same single-subscriber-per-session contract as the in-memory broker: re-subscribing replaces the old subscriber's queue.
  • Same bounded per-session queue, for the same reason: the listener task pumps Redis into a local queue, so a client that stops reading the socket would otherwise accumulate every published payload in this worker's memory. Past max_queued_events the oldest is dropped.
  • Replay is a separate, opt-in collaborator — pair this with RedisSSEReplayBuffer for cross-worker Last-Event-ID resume.
  • The Redis client's lifecycle is the consumer's: close it during ASGI lifespan shutdown.

active_streams property

active_streams: int

Local subscribers only, matching what the cap protects.

The ceiling exists to stop one worker's task pool being exhausted, and a cluster-wide count would neither measure that nor be worth a Redis round-trip per GET.

publish async

publish(session_id: str, payload: Any) -> bool

Publish to the session's channel and report whether anyone received it.

True when at least one listener was attached. False — zero subscribers — can also mean the streaming task has not connected yet, so a caller needing at-least-once delivery layers its own retry.

has_subscriber

has_subscriber(session_id: str) -> bool

Local-only check: whether this worker has an active subscriber.

Cross-process visibility would cost an extra Redis round-trip and buy nothing — the streaming generator only cares about its own queue.

SSE replay (resume)

SSEReplayBuffer

Bases: Protocol

Pluggable per-session ring buffer for SSE event replay.

Pair this with an SSEBroker to support Last-Event-ID resume: when a client reconnects with that header, the SSE response generator drains every event past the supplied ID before entering live mode, so the client sees no gap.

The buffer is the single source of truth for event IDs: record assigns a new monotonic ID per session, so the live frame and any replayed frame agree. The transport wraps that ID into the broker payload as {"_mcp_event_id", "_mcp_payload"} and the response generator unwraps it to emit id: lines.

Implementations must bound their per-session storage — an uncapped buffer leaks when clients never reconnect.

Resume is opt-in: pass sse_replay_buffer=... to MCPServer. When omitted there are no id: lines and Last-Event-ID is ignored.

record async

record(session_id: str, payload: Any) -> str

Persist payload for session_id and return its event ID.

The ID is what the response emits as the id: line and what the client echoes back via Last-Event-ID. IDs must be monotonic within a session; cross-session ordering is not required.

replay

replay(session_id: str, after_id: str | None) -> AsyncIterator[tuple[str, Any]]

Yield (event_id, payload) pairs strictly after after_id.

after_id=None yields nothing — a fresh subscribe is the no-replay path. An after_id older than the oldest retained event yields whatever is still held, best-effort: the client can tell it lost events only by counting. One newer than the latest recorded event yields nothing, the client being up to date.

forget async

forget(session_id: str) -> None

Drop all retained events for session_id.

Called when a session is explicitly destroyed, so dead sessions do not accumulate buffer state. Implementations relying on TTL eviction can no-op this.

InMemorySSEReplayBuffer

In-process bounded replay buffer for SSE event resume.

Each session holds its own collections.deque capped at max_events, evicting the oldest event when a new one arrives. Event IDs are zero-padded monotonic integers per session, string-valued because the SSE wire format is string-only and clients echo them back verbatim via Last-Event-ID.

Bounded in both directions. max_events caps one session's history; max_sessions caps how many sessions are retained at all, dropping the least recently written when a new one arrives. The second bound is what stops the buffer growing for the life of the process: forget is called only when a client explicitly DELETEs its session, while sessions ordinarily end by expiring in the session store — which notifies nothing — or by a client simply dropping the connection. Without the cap every session that ever recorded an event keeps its ring and its counter forever.

An evicted session that is somehow still live restarts its numbering, and a reconnect carrying the old Last-Event-ID then replays nothing rather than replaying the wrong events. That is the same silent gap a client already accepts when its ring overflows, which is why the default is generous enough that reaching it means far more open sessions than one worker can serve streams for.

Single-process ASGI deployments only: a resume can land on a different worker than the one that recorded the events, so multi-worker deployments need RedisSSEReplayBuffer. State is instance-scoped, so multiple servers in one process share no replay history.

RedisSSEReplayBuffer

Cross-process replay buffer backed by Redis Streams.

Drop-in replacement for InMemorySSEReplayBuffer when running multiple ASGI workers: a reconnect can land on any worker, and a shared Redis Stream replays the same events whichever worker recorded them.

Stream IDs are auto-assigned by Redis and monotonic within a session, so they double as the SSE event IDs the client echoes back via Last-Event-ID. MAXLEN ~ N caps the retained history per session, approximately: Redis trims a stream only by dropping whole internal nodes, so each session keeps at least its newest max_events events and less than one node more. A node closes at stream-node-max-entries (100 by default) or stream-node-max-bytes (4096 by default), whichever comes first, so the margin is under 100 events and under about 4 KB, and none at all once events pass about 2 KB. The lower bound is the one replay needs. One trim also removes at most 10,000 entries (100 times stream-node-max-entries), so after max_events is lowered on a long stream the first few writes leave it above the bound before it catches up.

Wire it into MCPServer:

from redis.asyncio import Redis
from rest_framework_mcp import MCPServer
from rest_framework_mcp.transport.redis_sse_replay_buffer import (
    RedisSSEReplayBuffer,
)

client = Redis.from_url("redis://localhost:6379/0")
buffer = RedisSSEReplayBuffer(client, max_events=2048, namespace="my-app")
server = MCPServer(name="my-app", sse_broker=..., sse_replay_buffer=buffer)

Every stream carries a TTL. forget runs only on an explicit DELETE, while sessions ordinarily end by expiring or by a client simply dropping the connection, so without an expiry each such session leaves its stream in Redis for good. ttl_seconds is refreshed on every write and defaults to a day, matching the session store's idle window: past it the session the stream belongs to could not reconnect anyway.

Pass namespace when one Redis serves more than one server, as the cache-backed stores do with the server's name. Keys here are addressed by session id, so a collision needs an id minted by the other server, but the separation keeps a shared Redis inspectable and matches the subscription broker, where topics genuinely do collide.

The Redis client is the consumer's responsibility — close it during ASGI lifespan shutdown.

record async

record(session_id: str, payload: Any) -> str

Append payload to the session's stream and return the assigned ID.

XADD <key> MAXLEN ~ N * data <json>: the * lets Redis choose a monotonic ID, and ~ trims at internal node boundaries, which bounds memory in the same shape as exact trimming and is faster.