Skip to content

Registries

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

register_tool holds a kernel spec in a KernelToolBinding: a ServiceSpec for a mutation tool, a SelectorSpec for a read. The spec carries what the operation takes, who may run it and what it returns; the binding carries the MCP knobs, the facts of the binding rather than of the operation. A selector tool's read-shaped pipeline is the spec's: filter_set is read from it, and carries ordering with it via an OrderingFilter (a sort read the spec declares is the other ordering route, and likewise needs no knob); paginate is the one binding-level MCP mechanic. register_resource holds a kernel RETRIEVE SelectorSpec in a KernelResourceBinding. See Kernel specs.

A tool binding carries server_arguments, a ServerArguments naming the declared parameters the server answers for, so no caller does. ToolRegistry.register checks it. See Server-supplied arguments.

paginate also decides what a spec's shapes apply to. On a paged binding the presenter renders each row and the page envelope (items, page, totalPages, hasNext) is built around the result, so a shape 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.

KernelToolBinding dataclass

All wiring for one MCP tool whose operation is a kernel spec.

spec is a django_service_specs ServiceSpec or SelectorSpec, and its class says which kind of tool this is: a service tool runs a mutation, a selector tool reads. The call goes through the kernel's dispatch / adispatch, and the advertised schemas are the kernel's own spec_input_schema / spec_output_schema, so the arguments a service tool takes are the spec's parameters, spread at the top level of arguments rather than nested under a data key.

Every other field is a fact of the MCP binding rather than of the operation: the scope gate (scopes), rate_limits, server_arguments, task_policy, invalidates, dispatch_timeout and max_result_bytes are read by the tools/call shell around the dispatch, never by the spec.

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.

invalidates class-attribute instance-attribute

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

Service tools only: 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.

paginate class-attribute instance-attribute

paginate: bool = False

LIST selector tools only: serve the result a page at a time, under the model-supplied page and limit.

max_page_size class-attribute instance-attribute

max_page_size: int | None | UnsetType = UNSET

LIST selector tools only: the 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).

always_listed class-attribute instance-attribute

always_listed: bool = False

Keep this tool in tools/list whatever would otherwise leave it out: FILTER_LISTINGS_BY_PERMISSIONS dropping it because its spec's checks refuse 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 (test_always_listed_keeps_an_unavailable_tool). A discovery aid for a tool the caller can see but not run: the condition is not asked at list time at all, and tools/call still refuses the call (test_an_always_listed_tool_still_refuses_a_call_failing_its_shape).

scopes class-attribute instance-attribute

scopes: tuple[str, ...] | None = None

The OAuth scopes a caller's token must carry, every one of them.

None is no scope gate. Judged by handlers.utils.scope_verdict, so a token missing one is answered -32006 with every declared scope in requiredScopes, not only the missing ones, as the scope gate is answered on every other binding. An empty tuple is refused when the binding is built, by hand or by the registrar: it would read as a gate and admit every caller.

unknown_arguments class-attribute instance-attribute

unknown_arguments: UnknownArguments = REJECT

Handed to the kernel's dispatch as unknown_arguments=, and read by spec_input_schema so the advertised schema is closed exactly when the call refuses an undeclared key.

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. Each is left out of the advertised inputSchema, and the caller's value for it is dropped before anything reads the arguments (handlers.utils.apply_tool_server_arguments). Checked against the spec when the binding is registered (ToolRegistry.register).

task_policy class-attribute instance-attribute

task_policy: TaskPolicy = FORBIDDEN

Whether a call is answered with a task handle instead of a result. See TaskPolicy. Read by maybe_create_task, which asks a kernel spec's own checks before it creates the task; the worker runs the call, under either policy, through the same dispatch an inline call takes.

unguarded class-attribute instance-attribute

unguarded: bool = field(default=False, init=False)

Whether a call runs the spec without a permission check.

Set only by the registrar, when it registers a spec declaring permissions=None with REQUIRE_TOOL_PERMISSIONS off, so nothing can set it at construction: a binding built by hand stays unmarked, and the kernel's dispatch refuses its unguarded spec (test_a_handbuilt_unguarded_kernel_tool_is_never_called).

is_selector property

is_selector: bool

Whether this is a selector tool, a read, rather than a service tool.

kind property

kind: SelectorKind | None

A selector tool's RETRIEVE / LIST; None for a service tool.

audience_projection cached property

audience_projection: AudienceProjection

The spec's resolved audience markings, derived once per binding.

The kernel's own audience_projection_for_spec, so the projected payload and the advertised outputSchema read the same markings.

KernelResourceBinding dataclass

Bases: ResourceBinding[Any]

A resource whose read is a kernel RETRIEVE SelectorSpec.

A ResourceBinding in every listing respect, so resources/list, resources/templates/list, completions and subscriptions read it as they read any other. resources/read runs spec through the kernel's dispatch / adispatch instead of calling selector directly, so the spec's own permissions, reads, shaping and presenter answer the read. mime_type and encoding are a resource's as on any binding, applied to what the presenter returns: TEXT serves its str verbatim and BLOB base64-encodes its bytes, on a binding built by hand as on a registered one.

selector is the spec's, set at registration, and nothing on this path calls it itself. scopes is normalised when the binding is built, so one built by hand and passed to server.resources.register gates its reads as a registered one does (test_a_handbuilt_kernel_resource_gates_on_its_scopes). permissions is not a constructor argument here and is always empty: the spec's permissions are the kernel's and dispatch asks them.

scopes class-attribute instance-attribute

scopes: tuple[str, ...] | None = None

See KernelToolBinding.scopes. A bare string is one scope, and () is refused, since it reads as a gate and admits every caller.

permissions class-attribute instance-attribute

permissions: tuple[PermissionCheck, ...] = field(default=(), init=False)

Always empty, so no check can sit beside the spec's own, which dispatch asks: a view's checks are asked by the transport, a spec's by the kernel.

unguarded class-attribute instance-attribute

unguarded: bool = field(default=False, init=False)

Whether a call runs the spec without a permission check.

Set only by the registrar, when it registers a spec declaring permissions=None with REQUIRE_TOOL_PERMISSIONS off, so nothing can set it at construction: a binding built by hand stays unmarked, and the kernel's dispatch refuses its unguarded spec (test_a_handbuilt_unguarded_kernel_resource_is_never_read).

ToolRegistry

Name to tool binding lookup.

Holds service and selector tools 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: KernelToolBinding) -> 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[KernelToolBinding]

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 view: a resource read by calling a selector.

A view is a selector callable plus a URI. register_ui_resource builds one for an MCP Apps UI resource, and a kernel spec is a KernelResourceBinding instead. The MCP server invokes the selector directly via resolve_callable_kwargs + run_selector over the kernel's pool and the URI-template variables, and mime_type advertises the type the body will carry. annotations and meta are emitted verbatim on the listing entry, and meta also on the contents block of resources/read.

One built by hand and passed straight to server.resources.register is registered unchecked: the registrars' refusals (an undeclared permission under REQUIRE_TOOL_PERMISSIONS, a template variable naming a seed or one of the kernel's CALL_SEEDS, a completer for no variable) are not asked, and no kernel spec stands behind it to refuse an unguarded call, so it carries its own permissions and scopes. A bare callable meant to be served with those checks is a RETRIEVE SelectorSpec registered with register_resource.

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

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.

permissions class-attribute instance-attribute

permissions: tuple[PermissionCheck, ...] = ()

The kernel PermissionChecks a caller must pass, every one of them.

A view is not a spec, so nothing dispatches it: each check is asked has_permission(principal, binding) with this binding as the spec (handlers.judge_binding), at resources/list, resources/read, a completion and a subscription's grant alike. Held to the kernel's rule for a spec's (registry.types.utils.binding_checks), but for one case: an empty sequence here is no checks, the field's default, and only register_ui_resource refuses an explicit [].

scopes class-attribute instance-attribute

scopes: tuple[str, ...] | None = None

The OAuth scopes a caller's token must carry, every one of them, asked before permissions. None is no scope gate; a bare string is one scope, and () is refused, as on KernelToolBinding.scopes.

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, as a tool's always_listed keeps it in tools/list.

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 the server fills for the read: a pool seed, the kernel's RESERVED_POOL_SEEDS, or its CALL_SEEDS (request and view, the call's own). Registration refuses one that does.

ResourceEncoding

Bases: str, Enum

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

The rendered value is what the spec's presenter returns (its selector's value, when it declares no presenter), and a view's selector's return as it is. 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.

On the kernel form, TEXT and BLOB are refused at registration beside a spec declaring affordances or allow_none: the first makes the presented value an object and the second makes a missing row None, and neither is a document or bytes.

Attributes:

Name Type Description
JSON

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

TEXT

The value is already the body, and must be 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, bytes, bytearray or memoryview, 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.

permissions class-attribute instance-attribute

permissions: tuple[PermissionCheck, ...] = ()

The kernel PermissionChecks a caller must pass, every one of them.

A prompt is not a spec, so nothing dispatches it: each check is asked has_permission(principal, binding) with this binding as the spec (handlers.judge_binding), at prompts/list, prompts/get and a completion of one of its arguments alike. A check on the principal alone, the usual case, is the same instance a spec carries. Held to the kernel's rule for a spec's (registry.types.utils.binding_checks), but for one case: an empty sequence here is no checks, the field's default, and only register_prompt refuses an explicit [].

scopes class-attribute instance-attribute

scopes: tuple[str, ...] | None = None

The OAuth scopes a caller's token must carry, every one of them, asked before permissions. None is no scope gate; a bare string is one scope, and () is refused, as on KernelToolBinding.scopes.

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 KernelToolBinding.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_tool refuses 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 render=.

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

MCPServer.register_tools(registry, *, overrides=None) registers every spec in a kernel SpecRegistry as a tool, in order, each under its registry name and through register_tool, so an entry and a direct registration of the same spec are the same tool. overrides maps a tool name to the register_tool knobs for it. A key the registry does not hold raises ValueError, and a refusal of any entry registers none of them. See Register tools from a shared spec registry.

unknown_arguments= takes the kernel's UnknownArguments, imported from django_service_specs: REJECT, the default, closes the advertised schema and refuses a call naming an undeclared argument.

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.

Tool schemas

What a kernel tool advertises, built from the kernel's own spec_input_schema and spec_output_schema with the MCP binding's answers applied: the names the server fills itself leave the inputSchema, a paginated tool gains page and limit, and its outputSchema describes the page envelope. Exposed for projects that want to introspect a tool's schema outside the listing. tools/list passes a tool that can present nothing (each binding answers it as can_present_nothing) an outputSchema whose root stays an object schema and admits the {} such a call is served as (Concepts).

kernel_tool_input_schema

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

The inputSchema a kernel tool advertises.

The kernel's spec_input_schema under the binding's unknown_arguments, so the schema is closed exactly when the call refuses an undeclared key, and a service tool's parameters sit at the top level as the call takes them. The kernel lists every parameter as declared, required meaning the call needs the value from someone, and leaves to the transport what it answers itself. So:

  • a name the server fills whatever the caller sends is left out of properties and required: the binding's server_arguments, and the kernel's server_owned_keys for pool_seeds, which holds the keys a typed kwargs= or input_data= provider always fills (test_a_name_a_typed_provider_returns_is_not_asked_for);
  • a parameter a provider may fill is offered and not required, because dispatch does not demand it of the caller: the kernel's provider_reached, the reader its shape check relaxes by. It names the keys a typed provider always fills too, which are withheld above already. Without it every such parameter is required (test_an_untyped_provider_leaves_every_read_offered_and_none_required, test_a_lookup_read_beside_an_untyped_provider_is_offered_and_not_required), and each seat's keys are its own (test_a_key_one_seat_may_decline_is_still_required_by_another);
  • a paginated LIST tool advertises page and limit, limit bounded by max_page_size when there is one, and each of its shapes says it applies to every item rather than to the envelope the outputSchema describes, in the words of query_param_on_pages (the server's AgentConventions.query_param_on_pages, already rendered; None leaves each shape as declared).

The same reading the agent route's toolset makes of the kernel's schema, so one spec asks the same of a caller on both.

kernel_tool_output_schema

kernel_tool_output_schema(
    binding: KernelToolBinding, *, handle_description: str | None
) -> dict[str, Any] | None

The outputSchema a kernel tool can advertise, before the binding's include_output_schema decides whether it does.

The kernel's spec_output_schema, enveloped when the tool paginates and projected through the binding's audience markings, so it names the fields the presented payload carries, and with the "null" the kernel admits for a spec that can present nothing restated as what this transport serves in its place (_admitting_nothing, _a_list). None for an image or audio tool, whose result is a media block with no structured content to describe, and for a spec the kernel has no schema for.

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 django_spec_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 (spec-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 django_spec_mcp import MCPServer
from django_spec_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 django_spec_mcp import MCPServer
from django_spec_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.