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.

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.

  • REJECT (default) answers -32602 and advertises additionalProperties: false — but only with an input_serializer to validate against. A serializer-less binding has no declared field set, so REJECT cannot fire and its schema stays open.
  • PASSTHROUGH advertises an open schema and merges unknown keys into the validated payload.
  • IGNORE advertises an open schema and drops them.

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 because its permissions deny the caller. A discovery aid for tools the caller can see but not invoke — tools/call still 403s.

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 on the registry entry's OfflineContract and resolved here, so the field set an agent sees does not depend on which agent transport served it.

output_serializer property

output_serializer: type | None

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

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 — and do not name that parameter ordering / page / limit, which RESERVED_POST_FETCH_KEYS strips from the selector's pool.

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, page=1, limit=10)).

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 -32602, PASSTHROUGH merges them into the validated payload, IGNORE drops them.

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.

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. 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.

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 on the registry entry's OfflineContract and resolved here, so the field set an agent sees does not depend on which agent transport served it.

output_serializer property

output_serializer: type | None

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

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. The transport advertises it, pops it from the arguments, and hands it to build_offline_context(query_params=…); it never reaches the spec as an input, so the unknown-argument policy never flags 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.

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.

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.

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.

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.

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,
) -> 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,
) -> 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 and UnknownArguments are re-exported from djangorestframework-services (the transport-neutral dispatch_spec owns these dispatch policies); import them from rest_framework_mcp.constants.

UnknownArguments

Bases: Enum

How dispatch_spec treats params keys outside a spec's declared set.

The declared set is derived from the spec without any transport knowledge: a ServiceSpec's input_serializer fields plus the keys its nested target selectors consume; a SelectorSpec's selector parameters. When the set cannot be enumerated — a callable that declares **kwargs, or a duck-typed filter_set whose fields are opaque to the core — the spec is treated as open and this policy is a no-op (there is nothing to call "unknown").

Members (internal knob — the value never appears on a wire):

  • IGNORE — undeclared keys are dropped (DRF serializers already do this to a mutation's payload, and a selector simply never receives kwargs it doesn't declare). The default, reproducing the pre-policy behaviour.
  • REJECT — an undeclared key raises ValidationError, the same surface a strict serializer produces. Useful when the caller wants a clean correction signal (e.g. a model calling a tool with a mistyped argument).
  • PASSTHROUGH — undeclared keys survive: they are merged onto the mutation's validated_data before the keyword pool is built, so a callable that declares them (or **kwargs) receives them. The one policy that must live inside dispatch_spec — it needs the seam between validation and pool construction that a wrapper cannot reach.

Callers strip their own transport-only keys (pagination, ordering, output format) before calling, so the declared-set check sees only spec inputs.

ArgumentBinding

Bases: Enum

How dispatch_spec turns the flat params into a callable's kwargs.

Every dispatched callable's keyword pool always carries the request / user seeds (and, for a mutation, data / serializer / instance / collection). This enum controls the one remaining question a caller answers about its wire: can client-supplied input land as individual keyword arguments, and do those override the spec author's kwargs(...) invariants? It is a trust-boundary decision, not plumbing — which is why it belongs to the caller rather than the spec. Pass the member directly; the value never appears on a wire. Those reserved seeds are always stripped from the spread in the SPREAD_* modes, so a client cannot poison transport-controlled state by naming an argument after one of them.

Attributes:

Name Type Description
AUTO

The default — resolve per spec type: ServiceSpecBUNDLE (a mutation takes its validated payload as one data bundle), SelectorSpecSPREAD_AUTHOR_WINS (a read spreads its params so the selector can declare them as parameters).

BUNDLE

Only the validated payload reaches the callable, as data=; individual client fields are not spread as kwargs. The safe choice for a mutation whose kwargs scopes the write — the client cannot inject arbitrary keyword arguments.

SPREAD_AUTHOR_WINS

Client fields are spread into the pool as individual kwargs, but spec.kwargs(...) overrides on conflict — an author-scoped tenant_id cannot be reset by the client.

SPREAD_CALLER_WINS

Like SPREAD_AUTHOR_WINS, but the client wins on conflict: spec.kwargs(...) supplies defaults it may override.

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.

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.

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.

Declared on the registry entry's OfflineContract 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.

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.

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
) -> dict[str, Any]

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

Merges four 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, its required keys populating required, the request / user / view transport seeds skipped) plus the filter_set fields, via drf-services' spec_to_json_schema. This is the same reflection the Pydantic-AI SpecToolset consumes, 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.

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

Returns:

Type Description
dict[str, Any]

An object schema carrying properties, and required only when at

dict[str, Any]

least one required field exists.

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 when convenient, which is fine here.

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.