Chain several specs into one tool¶
A single MCP tool usually wraps one spec. Sometimes a meaningful operation is a sequence: retrieve a record, write a related one, then write a third that depends on both. Doing that as three separate tool calls pushes orchestration onto the client and leaves three chances to end up with half-written state.
register_chain_tool runs an ordered list of steps as one tool. Each
step binds its result to an alias; later steps read earlier outputs
through ctx[alias]. The whole sequence runs in one transaction by
default.
arguments → validate(input_serializer or first step's) → ctx.args
→ step "acct" (selector) → ctx["acct"]
→ step "sub" (service) → ctx["sub"]
→ step "welcome" (service, reads acct + sub)
→ render the output step
→ ToolResult
(all inside transaction.atomic() when atomic=True)
Define the steps¶
Each ChainStep is an alias, a ServiceSpec / SelectorSpec, and an
optional inputs(ctx) callable that builds that step's kwargs from the
validated arguments (ctx.args) and any prior output (ctx[alias]):
from rest_framework_mcp import ChainStep, MCPServer
from django_service_specs import SelectorKind
from rest_framework_services import SelectorSpec, ServiceSpec
server.register_chain_tool(
name="onboard_account",
input_serializer=OnboardInput, # or omit → first step's schema
steps=[
ChainStep(
"acct",
SelectorSpec(kind=SelectorKind.RETRIEVE, selector=get_account),
inputs=lambda ctx: {"pk": ctx.args["account_id"]},
),
ChainStep(
"sub",
ServiceSpec(service=create_subscription, output_selector_spec=SUB_OUT),
inputs=lambda ctx: {"account": ctx["acct"], "plan": ctx.args["plan"]},
),
ChainStep(
"welcome",
ServiceSpec(service=send_welcome, output_selector_spec=WELCOME_OUT),
# derives from BOTH prior steps
inputs=lambda ctx: {"account": ctx["acct"], "subscription": ctx["sub"]},
),
],
output_alias="welcome", # default: the last step
)
inputs is optional. When omitted, a step receives
{"data": ctx.args}, which suits a first service step whose callable
takes the validated input as data.
Whatever inputs returns, the transport's seeds are laid over it:
request, user, progress (a no-op reporter, as a chain step reports
nowhere) and every name the server's
pool_seeds= registers. So an inputs that
forwards ctx.args whole cannot let a client argument stand in for the
caller's identity or a project's tenant, and a step's affordance condition
reading a registered seed is asked with it, as the same spec would be as a
tool of its own.
Atomicity and errors¶
atomic=True (the default) wraps every step in a single
transaction.atomic(). If any step raises ServiceError or
ServiceValidationError, every prior write rolls back and the call
answers with an isError: true tool result, as a single-spec tool does,
whose error object names the failing step:
A step refused by its service's affordances also carries the refusal's
code beside failedStep, so a client can tell which rule stopped the
chain without matching on the sentence:
{"error": {"type": "service_error", "message": "The books are closed.",
"code": "books_closed", "failedStep": "void"}}
Any other ServiceError carries no code key.
Set atomic=False to let each step commit independently (no rollback).
What the tool returns¶
- Default: the last step's rendered output.
output_alias="acct": render a specific step instead.output_all=True: return{alias: rendered}for every step that declares an output serializer.
A step is rendered through its serializer — ServiceSpec.
output_selector_spec.output_serializer or
SelectorSpec.output_serializer — and its output-context provider sees
the resolved data (result / instance / page), exactly as a
single-spec tool does. A LIST renders as a list, whether the step is a
LIST selector or a service whose output_selector_spec re-fetches a
LIST, and a chain never paginates, so the tool's outputSchema advertises
a bare array for such an output step. A many=True service step renders the
list its service returned the same way, and, as when it is dispatched as a tool
of its own, its output re-fetch never runs. The step is handed whatever data
its inputs put in the pool, so build the list there; a chain whose first step
is many=True must declare its own input_serializer rather than inherit the
step's, which describes one item.
Affordances on a rendered step¶
A selector spec's affordances render as an affordances object on each
item, from answers the selector tool's dispatch computes as it fetches the
rows. A chain step runs its selector directly and computes none, so
registering a chain refuses a rendered step — the output step, or any
step under output_all — whose serializer would render affordances that ask
a condition:
ImproperlyConfigured: Chain tool 'orders': step 'out' is rendered through a
selector spec declaring affordances ['cancel'], ...
Register that spec as a selector or service tool of its own, where the answers are computed and rendered, or drop the affordances from the step. An intermediate step is not rendered, so it may declare them freely.
Permissions¶
Each step's spec.permission_classes are AND-combined with the
chain-level permissions= and judged up front, each step's classes against a
stand-in carrying the step's alias as view.action and the chain's arguments
as request.data. A step permission refusing on those blocks the whole chain
before any step runs.
Each step then judges its class-level permissions again against its own view,
before its inputs run and its target is looked up, so a caller a step's
permission denies is answered the same whether the row exists or not, and the
lookup does not run. The object-level half, has_object_permission, cannot run
before that, because there is no row yet. It runs on each row as its step
resolves it: a RETRIEVE
selector step's row, and the instance a service step's instance_selector_spec
fetches. A denial answers the whole call as a JSON-RPC permission error, not as
a failed step, and under atomic=True every earlier write rolls back.
Scope¶
Chains deliberately do not run the selector post-fetch pipeline
(filter / order / paginate) — that belongs on a single
register_selector_tool. A selector
step's result is used as-is (rendered many=True for kind=LIST).
A RETRIEVE step resolves to its one row the way the selector tool does, so a
selector may return a queryset (Invoice.objects.filter(pk=pk)) or the instance
(Invoice.objects.get(pk=pk)) and the next step receives the row either way. It
may also report a missing row by raising: DoesNotExist, or Http404 from
get_object_or_404, which is a missing row here as it is in the kernel's
dispatch. A LIST step whose selector raises Http404 fails the chain as
not_found too (rows: not found, with failedStep), while DoesNotExist
escaping a LIST selector is a fault in it, as in the kernel. When a RETRIEVE
step finds no row, it fails as the selector tool does, naming the step, and an
atomic chain rolls back:
{"error": {"type": "not_found", "message": "target: no matching instance found",
"failedStep": "target"}}
A spec with allow_none=True passes None on instead. As the output step it is
a result with nothing to present, served as {} under a schema that admits it
(Concepts).
A service step's output_selector_spec re-read runs after its write, so it is
never reported as not found, as a service tool's is not: a RETRIEVE re-read
that finds nothing, by returning nothing or by raising DoesNotExist or
Http404, passes None on. A LIST re-read raising Http404 is a fault, a
RuntimeError chained from it, because the write has happened and a not-found
would say it had not.