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
¶
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
¶
Consumer-only blurb, the sibling of display_name and likewise
never emitted on the MCP wire.
invalidates
class-attribute
instance-attribute
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
Whether this is a selector tool, a read, rather than a service tool.
kind
property
¶
A selector tool's RETRIEVE / LIST; None for a service tool.
audience_projection
cached
property
¶
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
¶
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
¶
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
¶
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 ¶
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 ¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
Display icons, emitted in this resource's listing entry. Purely presentational; nothing in dispatch reads them.
always_listed
class-attribute
instance-attribute
¶
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 ¶
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 |
|
BLOB |
The value is binary, |
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
¶
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
¶
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
¶
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
¶
Display icons, emitted in this prompt's listing entry. Purely presentational; nothing in dispatch reads them.
always_listed
class-attribute
instance-attribute
¶
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
|
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 ¶
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]
|
|
resource_domains |
Sequence[str]
|
Images, stylesheets, scripts, fonts. A view loading
Django |
frame_domains |
Sequence[str]
|
Origins the view may itself embed in an iframe. |
base_uri_domains |
Sequence[str]
|
Permitted values for the document's |
to_dict ¶
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 |
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 |
to_dict ¶
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 |
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 ¶
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_domainsin 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
diagnosticsis on. Seebridge.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. 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 |
None
|
Returns:
| Type | Description |
|---|---|
str
|
A complete |
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
¶
Refuse the undeclared key with InvalidArguments, at the level it appeared.
PASSTHROUGH
class-attribute
instance-attribute
¶
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
propertiesandrequired: the binding'sserver_arguments, and the kernel'sserver_owned_keysforpool_seeds, which holds the keys a typedkwargs=orinput_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
LISTtool advertisespageandlimit,limitbounded bymax_page_sizewhen there is one, and each of itsshapessays it applies to every item rather than to the envelope theoutputSchemadescribes, in the words ofquery_param_on_pages(the server'sAgentConventions.query_param_on_pages, already rendered;Noneleaves 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 ¶
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 ¶
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
¶
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 ¶
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
¶
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_eventsthe oldest is dropped. - Replay is a separate, opt-in collaborator — pair this with
RedisSSEReplayBufferfor cross-workerLast-Event-IDresume. - The Redis client's lifecycle is the consumer's: close it during ASGI lifespan shutdown.
active_streams
property
¶
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 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 ¶
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
¶
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 ¶
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
¶
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
¶
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.