Skip to content

Protocol types

JSON-RPC envelope, MCP message types, and error codes.

JSON-RPC

JsonRpcRequest dataclass

A JSON-RPC 2.0 request: has id and expects a response.

JsonRpcNotification dataclass

A JSON-RPC 2.0 notification: no id, no response expected.

JsonRpcResponse dataclass

A JSON-RPC 2.0 response: exactly one of result or error is set.

result_type class-attribute instance-attribute

result_type: ResultType = COMPLETE

Stamped into the result object as resultType.

Every result carries it from 2026-07-28 onward, so it is applied here — the one place a handler's return value becomes wire JSON — rather than in each handler. A result that has already named itself wins, which leaves room for a non-complete one to be built where it is produced.

JsonRpcError dataclass

JSON-RPC 2.0 error object.

code is typed as int so server-defined codes outside the JsonRpcErrorCode enum can still be represented faithfully.

JsonRpcErrorCode

Bases: IntEnum

JSON-RPC 2.0 standard error codes plus MCP-specific reservations.

JSON-RPC defines -32700 through -32600 and -32603. MCP then partitions the server-error range: -32000..-32019 is implementation-defined, -32020..-32099 is reserved for the spec itself, and everything allocated here sits in the implementation-defined half — with one exception.

-32002 is not ours to allocate. The resources spec names it for "Resource not found", and the 2026-07-28 revision singles it out as the one legacy code clients should keep recognising, so a spec-following client reads it as a missing resource whatever a server spends it on. It belongs to RESOURCE_NOT_FOUND and nothing else.

-32003 and -32004 are burned rather than reused: they were this package's own not-found codes before the wire values were aligned with the spec, so an older client still reads them as "resource/prompt not found" and "unknown tool". The next implementation-defined code allocated is -32006.

parse_message

parse_message(payload: dict[str, Any]) -> JsonRpcMessage

Classify a parsed JSON object as request / notification / response.

Raises ValueError if the payload has no recognizable shape — caller should translate that to a JSON-RPC -32600 (Invalid Request) error.

Display metadata

Icon dataclass

One entry in a wire type's icons array.

Icons are pure display metadata: a client shows them beside a tool, resource, prompt or the server itself. This package only emits them — fetching, sanitising and rendering are the client's problem, and the spec puts a long list of MUSTs on the consumer side precisely because icon bytes are untrusted input.

Attributes:

Name Type Description
src str

URI pointing at the image. Must be https: or data: — the spec requires clients to reject any other scheme, so an http: or file: icon would never render. Rejected at construction, so the failure is a startup error rather than a silent no-op.

mime_type str | None

Overrides the type the source serves, for a source that serves a generic application/octet-stream.

sizes tuple[str, ...]

WxH strings — ("48x48",), or ("any",) for a scalable format like SVG.

theme IconTheme | None

Which background the icon was drawn for. Omit when it works on both.

IconTheme

Bases: str, Enum

Which background an Icon was designed for.

Omitting the theme (None) tells the client the icon works on either, which is right for most artwork. Declare it only when shipping a light/dark pair.

Result envelope

ResultType

Bases: str, Enum

The discriminator every result carries from 2026-07-28 onward.

A MUST for servers implementing that revision and harmless before it — a legacy result object is an open shape, and a client on an older revision reads an absent resultType as complete — so it is emitted unconditionally rather than era-branched.

Attributes:

Name Type Description
COMPLETE

The result is the answer.

INPUT_REQUIRED

The result asks the client for input and expects the original request to be retried with the answers. Nothing here produces one yet; the vocabulary is the spec's.

TASK class-attribute instance-attribute

TASK = 'task'

A durable handle instead of the result — see rest_framework_mcp.tasks.

The spec types ResultType as "complete" | "input_required" | string, the open tail being how extensions add their own. Servers MUST NOT set this on any result other than a CreateTaskResult, which is why nothing stamps it centrally the way COMPLETE is stamped — the task handler sets it explicitly and no other handler can reach it.

CacheScope

Bases: str, Enum

How widely a cacheable result may be reused, per Cache-Control.

Derived, never configured. PUBLIC licenses any intermediary to serve the response across authorization contexts, so a result that varies by caller and is labelled PUBLIC is a cross-tenant disclosure with a cache in front of it — precisely the mistake a settings knob would invite. The handlers work it out from what shaped the response: a permission-filtered listing is PRIVATE, an unfiltered one PUBLIC, and a resource body always PRIVATE.

Discovery and the initialize handshake

DiscoverResult dataclass

The server's response to a server/discover request.

server/discover is the 2026-07-28 revision's replacement for the initialize handshake: the same three answers, but as an ordinary request rather than a stateful negotiation, so it can be cached, repeated or skipped.

Two shape differences from InitializeResult, neither cosmetic:

  • supportedVersions is a list, not a negotiated single version. Nothing is agreed here; the client picks one and puts it on subsequent requests.
  • serverInfo moves into _meta under a reserved key, because it is self-reported and unverified and clients SHOULD NOT change behaviour or make security decisions from it.

Implementation dataclass

Identifies an MCP client or server: name + version, with an optional title.

Mirrors the spec's Implementation extends BaseMetadata, Icons.

Attributes:

Name Type Description
name str

"Intended for programmatic or logical use" — the stable identifier. What distinguishes two servers to a client, and what server-scoped state keys off. Not interchangeable with title; the split is the spec's own.

version str

The implementation's version string.

title str | None

"Intended for UI and end-user contexts" — the human-readable label. Clients fall back to name when absent.

description str | None

UI copy a client shows next to the server's name in a connection list. Not the initialize instructions string, which tells the model how to use this server and is consumed as context. MCPServer(description=...) sets instructions; this comes from the SERVER_INFO setting.

website_url str | None

A link a client can offer alongside the name.

icons tuple[Icon, ...]

Display icons, emitted only when non-empty.

ClientCapabilities dataclass

Capability bundle the client advertises in initialize.

All fields are open-ended dicts because the MCP spec leaves room for future capability sub-keys. We round-trip them verbatim.

extensions class-attribute instance-attribute

extensions: dict[str, Any] | None = None

Protocol extensions the client implements, keyed by extension id — how a client says it supports MCP Apps (io.modelcontextprotocol/ui).

Parsed for introspection only; nothing gates on it, since the spec merely SHOULD-s a check and unsupporting clients are required to ignore what they did not ask for.

ServerCapabilities dataclass

Capability bundle the server advertises in the initialize response.

Every field defaults to None and is omitted from the payload when unset, because a capability is a promise: a client that sees resources will call resources/list, and one that sees completions will send completion/complete. handle_initialize populates only what this server can actually answer.

There is deliberately no logging field — the 2026-07-28 revision deprecated the logging utility outright.

extensions class-attribute instance-attribute

extensions: dict[str, Any] | None = None

Extensions this server supports, keyed by identifier.

First-class in the 2026-07-28 schema and the counterpart to the per-request clientCapabilities.extensions. Values are per-extension settings objects; {} means "supported, nothing to configure", which is what every extension here uses.

Distinct from experimental: that is a free-for-all with no naming rules, while extension keys are namespaced identifiers a client matches exactly.

InitializeParams dataclass

Parsed initialize request params.

InitializeResult dataclass

The server's response to an initialize request.

Tools

Tool dataclass

An MCP tool descriptor as returned by tools/list.

input_schema and output_schema are JSON Schema documents. annotations carries the MCP ToolAnnotations hint bundle (readOnlyHint, destructiveHint), a closed spec-defined set.

meta is the base-protocol _meta bundle, emitted under "_meta". It stays a free-form dict at this wire boundary rather than a closed dataclass because _meta is MCP's open extension namespace — any extension may add its own key. The same holds for every meta field on the other wire types here.

icons class-attribute instance-attribute

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

Display icons for this entry, emitted only when non-empty.

ToolContentBlock dataclass

One entry in a ToolResult's content array.

The spec models content as a five-member union — text, image, audio, resource_link, resource — whose members share no fields beyond type, annotations and _meta. This is one dataclass with every member's fields optional rather than five behind a union: content is a wire boundary, the shape is decided by type, and to_dict emits only what is set.

Construct through the classmethods, not the initialiser. Each takes exactly the fields its block type requires, which is where the spec's rules live — an image without a mimeType or a resource_link without a uri is not a block a client can use, and the constructors make those unrepresentable. text_block and embedded_resource are named around the text and resource fields they would otherwise shadow.

annotations carry audience / priority / lastModified — the identical bundle resources use, which is why it stays a free-form dict.

text_block classmethod

text_block(
    text: str,
    *,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
) -> ToolContentBlock

A plain text block — what every tool result carries today.

image classmethod

image(
    data: bytes | str,
    *,
    mime_type: str,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
) -> ToolContentBlock

An image block. data may be raw bytes or an existing base64 string.

mime_type is required by the spec, not merely recommended — a client has no other way to know how to decode the bytes.

audio classmethod

audio(
    data: bytes | str,
    *,
    mime_type: str,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
) -> ToolContentBlock

An audio block — the same contract as image.

resource_link(
    uri: str,
    *,
    name: str,
    description: str | None = None,
    mime_type: str | None = None,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
) -> ToolContentBlock

A pointer to a resource the client can read separately.

The cheapest way to return something large or non-JSON: the URI is one this server's own resources/read already serves, so no bytes ride on the tool-result path. Per the spec a linked resource need not appear in resources/list.

embedded_resource classmethod

embedded_resource(
    resource: ResourceContents,
    *,
    annotations: dict[str, Any] | None = None,
    meta: dict[str, Any] | None = None,
) -> ToolContentBlock

A resource's contents inlined into the result.

Prefer resource_link unless the client cannot make a second round trip — embedding spends the caller's context on bytes it may not need.

ToolContentKind

Bases: str, Enum

What a tool's rendered payload becomes in the result's content array.

Declared per binding rather than sniffed from the payload, for the same reason as ResourceEncoding: a base64 str and a text body are indistinguishable by inspection.

Embedded (resource) blocks have no kind here on purpose — inlining contents means producing them, which resources/read already does, so a tool returns a RESOURCE_LINK and lets the client decide whether to spend context on the body. ToolContentBlock.embedded_resource remains available for a caller building blocks by hand.

Attributes:

Name Type Description
TEXT

One text block rendered per the binding's OutputFormat, mirrored in structuredContent. The default.

IMAGE

The payload is the media itself — bytes, or a str already in base64. Carries no structuredContent: binary is not JSON, and an outputSchema over it would describe a shape that never arrives.

AUDIO

As IMAGE.

RESOURCE_LINK

The payload names resources rather than containing them — one mapping with uri / name (plus optional description / mimeType), or a list of them. structuredContent is kept, since the links are JSON.

ToolResult dataclass

The result of a successful tools/call.

Attributes:

Name Type Description
content list[ToolContentBlock]

The human-readable / token-efficient projection the encoder produces (JSON, TOON, or a media block).

structured_content Any

The same answer JSON-shaped, for clients to parse directly. UNSET — the default — means the result carries no structured channel at all and the key is left off the wire. A payload of None is a value: it is emitted as "structuredContent": null, so a client branching on the key's presence to decide whether the tool offers structured output is not told "no" by a tool whose answer simply is null.

is_error bool

True when the tool itself reported failure. Distinct from a JSON-RPC protocol error — the envelope is still a successful response and the failure detail lives inside the result.

meta dict[str, Any] | None

The base-protocol _meta bundle on the result envelope, so per-call, unlike the static _meta a tools/list entry carries.

Resources

Resource dataclass

A concrete MCP resource as returned by resources/list.

uri is the canonical address (e.g. "invoices://1"); mime_type advertises how the contents will be encoded by resources/read.

icons class-attribute instance-attribute

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

Display icons for this entry, emitted only when non-empty.

ResourceTemplate dataclass

A parameterised resource address (RFC 6570 URI Template).

Returned by resources/templates/list. Clients fill in the template variables and call resources/read with the resulting URI.

icons class-attribute instance-attribute

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

Display icons for this entry, emitted only when non-empty.

ResourceContents dataclass

One contents entry returned by resources/read.

Either text or blob is set — never both. blob is base64- encoded per the MCP spec.

Prompts

Prompt dataclass

An MCP prompt descriptor as returned by prompts/list.

A server-defined template the client invokes by name to get back a sequence of LLM messages. Arguments are filled in at prompts/get time and threaded into the rendering callable as kwargs.

icons class-attribute instance-attribute

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

Display icons for this entry, emitted only when non-empty.

PromptArgument dataclass

One declared input to an MCP prompt.

Mirrors the spec's PromptArgument: a name plus optional description, with a flag for whether the client must supply it. Advertised in prompts/list.

PromptMessage dataclass

One conversation turn returned by prompts/get.

The spec accepts user or assistant for role, and content is one content block — text, image, audio or an embedded resource. It stays a plain dict because that is exactly the wire shape; block is the typed way in and text covers the common case.

text classmethod

text(role: str, text: str) -> PromptMessage

Convenience constructor for the common case of a text turn.

block classmethod

block(role: str, block: ToolContentBlock) -> PromptMessage

Build a turn from any content block.

The spec uses one content vocabulary across tool results and prompt messages, so this reuses ToolContentBlock rather than growing a parallel set. resource_link is the one member prompts do not accept — a prompt message embeds content, it does not point at it.

GetPromptResult dataclass

Result envelope returned by prompts/get.

description surfaces the prompt's purpose to the client UI alongside the rendered messages.

Completion

Completion dataclass

The completion object inside a completion/complete result.

Attributes:

Name Type Description
values tuple[str, ...]

Suggestions ranked by relevance, capped at MAX_COMPLETION_VALUES by the spec.

total int | None

Count of all matches. Optional, and left unset by this package: knowing it would mean counting every match, which is the work a queryset-backed completer should be allowed to skip.

has_more bool

Whether the completer had more to give.

Subscriptions

See Server-pushed notifications for how to wire one up.

SubscriptionFilter dataclass

What a subscription asked for, and what the server agreed to.

One type for both directions, because they are the same shape: the client sends a filter on subscriptions/listen and the server answers with the subset it will honour. Two types could drift, and the point of the acknowledgement is that the client can compare them.

Empty means nothing is delivered. The spec makes every type opt-in and says the server MUST NOT send what was not requested, so an absent field is a refusal rather than a default: a client that sends {} is acknowledged with nothing and its stream closes immediately.

from_params classmethod

from_params(raw: Any) -> SubscriptionFilter

Read a filter off the wire, ignoring anything unusable.

A malformed entry is dropped rather than failing the request: the acknowledgement reports what actually took effect, so the drop is visible, which beats rejecting a subscription over one bad key.

to_dict

to_dict() -> dict[str, Any]

The wire form, omitting everything not asked for.

Only truthy entries appear: the acknowledgement reads as "these are the things you will receive", so a false or an empty list in it would read as a promise about something.

NotificationKind

Bases: str, Enum

The notification types a client can opt in to, other than by URI or id.

Opt-in is a MUST, not a courtesy: "the server MUST NOT send notification types the client has not explicitly requested." So this is a closed set, and anything outside the request's filter is not sent.

The values are the notification methods with the notifications/ prefix stripped, and the filter field names are their camelCase forms — kept mechanically related so a new kind cannot be added to one and forgotten in the other.

filter_field property

filter_field: str

The SubscriptionFilter key that opts in to this kind.

SubscriptionBroker

Bases: Protocol

Topic-keyed pub/sub for notifications, with many subscribers per topic.

Deliberately not SSEBroker, which this package already has for the legacy GET stream: that one keys on session id and allows a session at most one live subscriber. Here a notification is addressed to a topic, whose subscriber set is discovered at publish time, and several clients — or several subscriptions from one client — legitimately watch the same resource, so replacing the previous subscriber would silently disconnect them.

Topics are opaque strings built by rest_framework_mcp.subscriptions.utils — a resource URI, a task id, a notification kind. The broker never interprets them, so adding a new notification type needs no change here.

A topic is not an authorization boundary. Anyone who can name a topic can receive it, so the subscription checks what the caller may watch before it attaches — once per subscription rather than once per delivery, and without the broker having to understand principals.

subscribe returns a queue that receives every payload published to any of topics until unsubscribe. One queue per subscription, not per topic — a subscription watching five resources reads one stream, which is what the wire format wants. It is awaitable because that is load-bearing: it must not return until the subscription is genuinely live, or the caller emits "you are subscribed" while publishes still go nowhere.

active_subscriptions property

active_subscriptions: int

How many subscriptions this broker is currently feeding.

Per process, not per cluster. It bounds this worker's occupancy — see MAX_CONCURRENT_SUBSCRIPTIONS — and a cluster-wide count would cost a round trip per subscribe to bound a per-worker resource.

InMemorySubscriptionBroker

In-process topic fan-out, for development and tests.

A multi-worker deployment needs a cross-process broker. The write that triggers a notification lands on whichever worker served that request while the subscriber's stream is parked on a different one, so an in-process broker delivers to nobody, silently, and the failure looks exactly like "the resource never changed". Subscriptions are a single-worker feature until a cross-process broker is passed to MCPServer(subscription_broker=…).

Not a default. MCPServer constructs no broker at all when none is given, precisely so this class cannot be reached by accident.

Unlike the session broker this keeps a set of queues per topic, and a queue may sit under several topics at once — one subscription watching five resources reads a single stream. Both directions of that mapping are kept so unsubscribe does not have to walk every topic.

publish async

publish(topic: str, payload: Any) -> int

Deliver to every subscriber of topic; returns how many got it.

0 means nobody was listening, which is ordinary and not an error: notifications are best-effort by design, and a client that missed one re-reads the resource.

RedisSubscriptionBroker

Cross-process topic fan-out over Redis pub/sub. The deployable one.

InMemorySubscriptionBroker delivers to nobody once the publisher and the subscriber's stream land on different workers, and does so silently, so anything past one worker wants this:

from redis.asyncio import Redis
from rest_framework_mcp.subscriptions.redis_subscription_broker import (
    RedisSubscriptionBroker,
)

server = MCPServer(
    name="my-app",
    subscription_broker=RedisSubscriptionBroker(
        Redis.from_url("redis://…"), namespace="my-app"
    ),
)

Pass namespace whenever one Redis serves more than one server. Topic names are built from caller-supplied values — a notification kind, a resource URI — so two servers that register the same SelectorSpec under the same URI derive the same topic. Sharing a broker (or two brokers on the same default prefix) then routes one server's change signals to the other's subscribers, past the resources/read permission grant_subscription gates them on. The cache-backed session and task stores fold the server's name into their key prefix for exactly this reason; a Redis client is the consumer's to construct, so here it is a constructor argument. The value is hashed into the prefix, since name is free-form.

One Redis channel per topic, one listener task per subscription. The mapping is many-to-many — a subscription watches several topics and several subscriptions watch one topic — so each subscription gets a single task subscribed to all of its channels, feeding the one queue its stream reads. That keeps the task count proportional to live subscriptions rather than to topics, and it is subscriptions that connections bound.

The Redis client's lifecycle belongs to the consumer — close it during ASGI lifespan shutdown, as with the other Redis collaborators here.

subscribe async

subscribe(topics: frozenset[str]) -> asyncio.Queue[Any]

Register the channels before returning, then pump them.

The await is the whole point. Registering inside the background task would return a queue that is not yet subscribed, and the caller emits "you are subscribed" immediately afterwards, so everything published in that window goes nowhere while the client has been told otherwise — a race this class's own deployment makes likely, since the publisher is another process and waits for nobody.

publish async

publish(topic: str, payload: Any) -> int

Publish to topic's channel; returns Redis's subscriber count.

The count is cluster-wide receivers, not confirmed deliveries, and a listener still connecting reports as zero. A diagnostic, not a delivery guarantee: notifications are best-effort and a client that missed one re-reads the resource.

topic_for_resource

topic_for_resource(uri: str) -> str

The topic notifications/resources/updated for uri goes to.

Exact URI, never a prefix. The spec permits notifying about a sub-resource of the one subscribed to, which invites prefix matching, but a prefix match over a free-form URI guesses at a scheme this package does not own and fails both ways: invoices://1 would match invoices://11, while a tenant-scoped t1/invoices://… would match nothing.

A publisher that wants a collection watched says so, by publishing the collection URI alongside the instance one — an invalidates= naming both invoices://{pk} and invoices:// is explicit and reviewable where a matching rule is neither.

Tasks

The io.modelcontextprotocol/tasks extension. See Long-running work for how to wire it up; these are the types.

Task dataclass

The wire shape of a task, in every message that carries one.

One type serves three roles the spec names separately — the CreateTaskResult body, the tasks/get result and the notifications/tasks params — because they are the same object with the same fields. The spec's WorkingTask / InputRequiredTask / CompletedTask / FailedTask / CancelledTask split is only which extra field is present, which is a property of status; to_dict emits the right one and refuses the wrong one.

Timestamps are ISO 8601 strings, stored as strings. They come from the store and go out verbatim; nothing here parses or compares them. A datetime would invite a comparison against now(), and TTL expiry belongs to the store — the only component that knows its backend's clock.

ttl_ms is None for "no expiry", the spec's own encoding (ttlMs: number | null) rather than an omission: the field is always present.

to_dict

to_dict() -> dict[str, Any]

Project to the wire, carrying only the field this status licenses.

Gated on the status rather than on whether a field happens to be set, so a record holding both a result and an error cannot emit a shape no spec variant describes.

TaskStatus

Bases: str, Enum

Lifecycle of a task, per the extension.

WORKING and INPUT_REQUIRED are live; the other three are terminal and a task never leaves them.

FAILED is narrower than it looks. The spec forbids using the status to represent non-JSON-RPC errors. A tool that raises ServiceError has completed — it produced a well-formed CallToolResult carrying isError: true. FAILED is for the task machinery itself failing: the worker died, the payload could not be revived. Getting this backwards would hide every tool error behind a status the client reads as "the server broke".

TaskPolicy

Bases: str, Enum

Whether a binding may — or must — answer with a task handle.

A policy surface, not a wire field. The shipped extension makes "the server the sole decider; clients do not signal task preference on the request itself", so the decision has to be made on this side, and the binding is where every other per-tool knob lives.

Attributes:

Name Type Description
FORBIDDEN

Never a task. The default, so every tool registered before this existed behaves exactly as it did.

OPTIONAL

A task for a client that declared the extension, an ordinary inline call for one that did not. The safe choice for a slow tool that can still run inline.

REQUIRED

A task, or nothing — a client that did not declare the extension gets -32021. For work that genuinely cannot finish inside a request, where running it anyway would just hit the deadline.

TaskStore

Bases: Protocol

Pluggable persistence for tasks, mirroring SessionStore.

Unlike SessionStore, an in-process implementation is not deployable. A session only has to be recognised by the process that minted it, while a task is created by a web worker and finished by a different process. So InMemoryTaskStore is a development and test convenience, and DjangoCacheTaskStore is the default.

Four operations, no more:

  • create writes the seed record and must not return until the task is durable: the spec forbids answering with a CreateTaskResult before a tasks/get for that id would resolve, and answering first is the one race that hands a client an id it cannot use.
  • get reads one back, or None if it never existed or has expired. Both are the same answer to a caller: -32602.
  • save overwrites in place, preserving the original expiry.
  • delete removes one.

Expiry belongs to the store. ttlMs is on the record, but only the store knows what clock its backend keeps and only the store can drop an entry unasked. Callers never compare a timestamp to now.

No locking, deliberately. Last write wins, and the guard against a finished task being reopened lives in rest_framework_mcp.tasks.transition_task — one rule in one place beats four backends implementing compare-and-swap differently.

TaskExecutor

Bases: Protocol

Where a created task goes to be worked on.

One method taking one string, because that is the entire seam. The record is already in the store when this is called, so the id finds everything, and keeping the payload out of the queue message stops the queue holding a copy of the arguments that can drift from the stored ones or outlive the task.

A Celery consumer writes:

@shared_task
def run_mcp_task(task_id: str) -> None:
    my_server.run_task(task_id)

class CeleryExecutor:
    def enqueue(self, task_id: str) -> None:
        run_mcp_task.delay(task_id)

…and nothing in this package imports Celery. The protocol is satisfied just as well by an RQ or Dramatiq call, a ThreadPoolExecutor.submit in a test, or a management command that drains the store on a schedule.

enqueue must not run the work. It is called on the request path with the client waiting for its CreateTaskResult, so anything slow here reintroduces exactly the blocking the extension exists to remove.

Failures here are not silent. If enqueue raises, the task is already durable and would otherwise sit in working forever, so the caller marks it failed and the client finds out by polling — a status it can act on, rather than a handle that never resolves.

TaskRecord dataclass

What a store holds: the wire Task plus what a worker needs.

A superset rather than a parallel type. The extra fields never reach the client — no task message has a slot for them — but they are why a task can outlive the request that created it: the worker that finishes it shares nothing with that request except this record.

The scopes are stored, and that is the point. Without them the worker rebuilds a token that proves nothing, and every ScopeRequired binding denies the call it had already been authorized for — silently, long after the client was told the work had started.

TokenInfo.raw is not stored: it is backend-defined credential material, and persisting it would put that in a cache with a week-long fallback TTL. A rehydrated token has raw=None, so a permission reaching into it is one that cannot run as a task.

The separation is a security boundary. to_wire is the only route from a record to a message, which is what stops a principal id or a scope list leaking into a response because a field was added to the wrong dataclass.

Attributes:

Name Type Description
task Task

The wire task — the only part a client ever sees.

tool_name str

The tool the worker replays, verbatim.

arguments dict[str, Any]

The arguments it replays, verbatim.

principal_id str

The owner, in the form principal_for_token already produces for sessions.

user_pk Any

Rehydrates the user on the worker.

scopes tuple[str, ...]

Rebuild the worker's TokenInfo so its permission checks see what the request path saw.

audience str | None

Rebuilds that TokenInfo's audience.

enqueued bool

Whether the task reached the executor, so a worker cannot be tricked into running it twice.

progress class-attribute instance-attribute

progress: float | None = None

How far along the running task said it was, or None if it never said.

Written by report_task_progress, what a task's progress kwarg-pool seed resolves to. Server-side only, by protocol: the wire Task carries statusMessage and no numeric field, so a polling client sees only the rendered string this and total produce.

total class-attribute instance-attribute

total: float | None = None

What progress counts toward, or None for an open-ended count.

None is the ordinary case for work that cannot say how much there is — the reporter renders a bare count rather than inventing a denominator.

input_responses class-attribute instance-attribute

input_responses: dict[str, Any] = field(default_factory=dict)

Answers the client has supplied via tasks/update, keyed as the matching inputRequests were.

Accumulated rather than replaced, because the spec lets a client answer a strict subset of what is outstanding and come back for the rest; a worker parked on input_required reads this to find out what it was told.

The spec requires the server never to reuse a key over a task's lifetime, so a key appearing here is answered for good and a later request picks a new one.

with_task

with_task(**changes: Any) -> TaskRecord

Return a copy with fields changed on the embedded Task.

Saves every caller a nested replace(record, task=replace(record.task, ...)).

DjangoCacheTaskStore

Task store backed by django.core.cache. The default.

Cross-process, which for tasks is not a nice-to-have: the web worker that creates a task and the worker that finishes it are different processes, so a store they do not share cannot work at all (see InMemoryTaskStore).

Namespacing follows DjangoCacheSessionStore exactly — the server's name, hashed into the key prefix, so two servers in one project cannot read or overwrite each other's tasks. The digest is for key hygiene (name is free-form; memcached rejects spaces and caps length), not secrecy.

Records are serialised to plain dicts rather than pickled. The cache holds them across a deploy, and a pickled dataclass is a version of this package's class definition: rename a field and every in-flight task becomes unreadable exactly when a worker tries to finish it. Plain dicts also keep the store usable under a JSON-serialising backend.

Expiry is absolute, stamped once at create from ttlMs and carried in the envelope, so save renews the cache timeout to the remaining lifetime rather than restarting the clock — otherwise a task reporting progress often would never expire.

A malformed id is a miss, not an exception. taskId comes off the wire and lands in a cache key, and the memcached backends reject keys with spaces or control characters and keys over 250 bytes — raising out of a handler with no arm for it. An id that cannot be one this package minted cannot name a record either, so it is answered as the absence it is.

InMemoryTaskStore

Task store held in one process's memory. Development and tests only.

Not a deployable backend, and not for the usual reason. An in-memory session store is merely restart-fragile; an in-memory task store is broken by design, because a task is created on a web worker and finished somewhere else — the worker writes its result into a dict the web process cannot see, and every poll answers "unknown task" until the client gives up. It fails silently and looks like a hung job, which is why DjangoCacheTaskStore is the default. This class exists so tests and a single-process runserver can exercise the machinery without a cache.

No expiry: ttlMs is advisory here, and a process short-lived enough to use this store is its own garbage collection.