Skip to content

Key concepts

This page explains the moving parts behind the Quickstart. For exact signatures, see the API reference.

The tool registry and @tool

A ToolRegistry is an ordered, named collection of server-side tools. State lives on the instance — a DjangoAGUIView holds one, and tests build a fresh registry per scenario. There is no module-level global registry.

Each tool is a ToolSpec: a frozen dataclass bundling the callable with its name, description, a destructive flag, a ToolCategory, and the optional confirm (a human-readable confirmation prompt) and summary (a tool-call card label) strings. The @tool decorator (and the registry's add) builds the spec and registers it, defaulting the name to the function name and the description to the first paragraph of its docstring.

At registration the registry derives a JSON Schema from the function signature and stores it alongside the spec as a ToolBinding, so tool listings never re-introspect on each request. Tool callables must be fully typed — an untyped tool breaks schema generation.

The registry can dispatch synchronously (call) or asynchronously (acall); call refuses coroutine functions rather than silently returning an un-awaited coroutine.

Destructive metadata and x-destructive

AG-UI has no native concept of a "risky" tool, so build_input_schema stamps two JSON-Schema extensions at the schema root:

AG-UI passes these extensions through verbatim. A client (such as the @artooi/ag-ui-web-component) reads x-destructive and gates execution behind an inline confirmation card (showing x-confirm as the prompt and x-summary as the card label). The wire stays vanilla AG-UI — this gating is purely client-side and applies only to client-registered tools. Server-side tools (this package's @tool registry and drf-mcp-bridged tools) run mid-stream and are not gated: x-destructive reaches the LLM as a schema hint, but no server-side confirmation happens today. A real server-side gate is planned (a ToolGuard + typed ask_user mechanism). DEFAULT_SYSTEM_PROMPT steers the model to call destructive tools directly (with the right arguments) and let the client gate them, rather than refusing or asking for confirmation in-band.

build_input_schema handles the primitive parameter types — str, int, float, bool, list[T], dict[str, Any], and X | None unions; richer types fall back to an empty (but wire-valid) schema fragment.

Building the agent: AgentConfig and build_agent

build_agent turns a registry plus an AgentConfig into a Pydantic-AI Agent. AgentConfig is a frozen record bundling the resolved model, instructions, audit_logger, model_settings, retries, and the already-resolved toolsets / capabilities — so the call site passes one record instead of a long keyword list.

Each registry tool is registered as a plain Pydantic-AI tool. When an audit_logger is set, build_agent composes an AuditCapability — a Pydantic-AI capability on the wrap_tool_execute lifecycle hook — that times and records every tool the agent runs: registry tools and composed toolsets (drf-mcp / spec / attachment / skill tools) alike. Frontend tools declared in the request are merged by the adapter and are not registered here.

For total control over construction, set agent_factory= to a callable matching AgentFactoryFn; it replaces build_agent entirely.

The audit boundary: the AuditLogger protocol

AuditLogger is a runtime-checkable Protocol with a single method, record(event: AuditEvent). Each AuditEvent is a frozen record of one tool invocation: tool name, a string-ified arguments repr, duration in milliseconds, a success flag, and either an error string or a result size — plus optional request/tenancy context (ip_address, filled by the view from the driving request; organization_id / target_type / target_id for custom sinks that know their tenancy and domain objects).

Recording is non-raising: a sink that throws is caught by the AuditCapability and logged to the django_ag_ui.audit Python logger, so a broken audit backend degrades to lost audit records, never a broken agent run.

Two implementations ship:

Projects supply their own (Sentry, Honeycomb, custom) by passing audit_logger= an instance — so a logger that needs constructor arguments just works.

Streaming: DjangoAGUIView and the AGUIAdapter

DjangoAGUIView is an async, callable view instance. On each POST it:

  1. Establishes the user, and fails closed by defaultrequire_authenticated is True, so anonymous requests get 401 with JSON {"error": "authentication required"} (pass require_authenticated=False to serve them deliberately). Establishing who is acting is still the host's responsibility: a get_user(request) callable's return value is assigned onto request.user, so tools, the drf-mcp bridge, and conversation ownership act as that user. get_user may be sync or async; sync hooks run off the event loop in Django's sync executor, so the canonical token lookup Just Works:

    def get_user(request):
        token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
        return Token.objects.select_related("user").get(key=token).user
    

    Without a hook, the middleware-provided lazy request.user is materialized in a worker thread before the gate — with Django's DB-backed sessions, touching it on the event loop would raise SynchronousOnlyOperation. The catalog views (ToolsView and SkillsView) accept the same require_authenticated / get_user pair, so one policy covers the agent endpoint and the catalogs it advertises. 2. Parses the request body into a RunAgentInput via AGUIAdapter.build_run_input (returning HTTP 400 with an error count, not the raw payload, on a ValidationError). 3. Takes this endpoint's Agent — built once, on the first request, and reused by every run thereafter (via the factory or build_agent). See One agent, many runs. 4. Builds the run's dependenciesAgentDeps(user=request.user) — and passes them to the run. This is pydantic-ai's own seam for request-scoped values: tools, toolsets and capabilities read them off RunContext.deps rather than closing over the request.

    @tool(registry)
    def whoami(ctx: RunContext[AgentDeps]) -> str:
        """Report the acting user."""
        return str(ctx.deps.user)
    

    It is what makes spec tools act as the right user with nothing passed at the call site — SpecToolset's default extractor reads ctx.deps.user — and it is where a run's AG-UI state lands (deps.state), since AgentDeps satisfies pydantic-ai's StateHandler protocol. A request served without Django's auth middleware has no user attribute at all; that is an anonymous run (deps.user is None), not an error. 5. Wraps the agent in a pydantic_ai.ui.ag_ui.AGUIAdapter and streams its encoded events as a StreamingHttpResponse with Content-Type: text/event-stream, Cache-Control: no-cache, and X-Accel-Buffering: no.

Non-POST methods get 405 Method Not Allowed.

TOOL_CALL_RESULT carries an outcome

AG-UI's TOOL_CALL_RESULT event defines a content string and no field saying how the call went, so a tool that failed reaches a client byte-identical to one that succeeded — and every client renders both as a completed call. Pydantic-AI knows the difference (ToolReturnPart.outcome) and has nowhere in the event to put it.

This package adds it, as an optional field on the result event:

{"type": "TOOL_CALL_RESULT", "messageId": "…", "toolCallId": "…",
 "content": "that name is already taken", "role": "tool", "outcome": "failed"}
Value What it means
absent The call succeeded. Every AG-UI server that does not do this omits the field, so absence has to keep meaning success.
"failed" The call ran and failed — a bug, a definitive upstream error, or a domain refusal such as a conflict or a missing row.
"denied" The call was refused by a person or a guard, and never attempted. This is what a denied tool approval produces.
anything else Treat as success. "interrupted" reaches the wire because the value is forwarded verbatim, but it is not part of the rendering contract, and neither is any value pydantic-ai adds later.

The field is additive rather than a protocol change: AG-UI's event schemas allow unknown keys (extra="allow" on the Python models, passthrough on the TypeScript zod schemas), so it survives parsing on a client that has never heard of it. A client that reads it needs no version negotiation, and one that ignores it sees exactly the stream it saw before.

Only a server-side tool has an outcome here: a frontend tool executes in the browser and its result comes back in the next request, so the browser already knows how it went. The view marks itself as a coroutine function so Django awaits it under ASGI; served over WSGI it emits a one-time RuntimeWarning (SSE streaming needs ASGI). Frontend-declared tools in the request are merged into the catalog by the adapter automatically.

AGUIServer builds this view (plus its sub-views) from the registry and exposes a namespaced .urls tuple you mount at any prefix — see Mounting below.

One agent, many runs

The endpoint builds its Agent once and reuses it for every run. Building per request meant re-deriving a JSON Schema for each registered tool on every call — the most expensive thing the endpoint did, repeated to produce a byte-identical result.

What made the rebuild look unavoidable was that the agent used to carry request-shaped things. It no longer does; they ride the run instead, through pydantic-ai's own per-run parameters:

Rides the run Why it can't sit on the agent
The drf-mcp toolset Closes over the request so the agent acts as the logged-in user
The read_attachment toolset Closes over the request so the model reads only that user's files
The StepPersistence capability Keyed on this run's id
AgentDeps (acting user, client IP) Per-run by definition
model / instructions The two hooks below

So what stays on the agent is exactly what the constructor fixed: the registry tools, this endpoint's toolsets / capabilities, the spec capability, model settings, retries, the tool guard, and the audit logger. None of it can differ between two requests to the same endpoint, which is what makes the reuse provable rather than merely plausible. Two endpoints are two AGUIServers, two views and two agents — the agent is instance state, never module state.

Varying the agent per request

Two narrow hooks, each (request) -> value:

AGUIServer(
    registry,
    model_for_request=lambda request: request.tenant.model,
    instructions_for_request=lambda request: request.tenant.system_prompt,
)

model_for_request replaces this run's model — a string goes through the same API_KEY / provider= resolution the configured model does. instructions_for_request replaces this run's instructions.

They are narrow on purpose, and the reason is the reuse rather than ergonomics. A hook handed the whole request could vary the agent on anything it read off one, and that is not a set anyone can enumerate — so an agent reused behind it is either impossible to justify or wrong for the second tenant, and the second failure is silent. Two named axes can be reasoned about, and both ride the run rather than the agent.

They also sidestep the agent_factory= cliff: supplying a factory turns off the drf-mcp bridge, the spec capability, step persistence, the attachment toolset, MODEL_SETTINGS, RETRIES, toolsets and capabilities in one go. Varying the model per tenant should not cost all of that.

Instructions are deliberately absent from the agent

Even though the endpoint has a resolved default, the agent is built with none and the instructions are supplied per run. Pydantic-AI treats per-run instructions as additional to the agent's — which is exactly a replacement when the agent carries none. Baking them in would have made instructions_for_request either impossible or a cache key, and a key a project can vary per user is a key that never hits.

Mounting

AGUIServer is the package's front door — one instance-configured object holding the registry, stores, and auth policy, mounted the django.contrib.admin site.urls way. Construct it once and include() its .urls:

from django.urls import path

from django_ag_ui import AGUIServer

agent = AGUIServer(registry, csrf_exempt=False)

urlpatterns = [
    path("agent/", agent.urls),
]
  • The registry is passed once. The object builds the agent view and the tool catalog from it — no tools=registry echo.
  • You choose the mount point the Django way (path("<prefix>", agent.urls)); there is no prefix= argument.
  • .urls is namespaced. It returns the (patterns, app_name, namespace) triple path() mounts directly (like admin.site.urls — no include()), so the endpoints reverse as reverse("ag_ui:endpoint"), "ag_ui:tools", "ag_ui:skills", "ag_ui:threads", "ag_ui:thread", "ag_ui:attachments", "ag_ui:attachment", "ag_ui:transcribe". Two mounts don't collide; override the namespace with namespace="…".
  • Sub-views mount when their backend is active. The agent endpoint and its tool catalog always mount; skills mounts when a SkillRegistry is passed; threads / attachments / transcribe mount when their store/backend (resolved from settings by default, or passed explicitly) is not the Null one. A bare AGUIServer(registry) mounts only endpoint + tools.
  • One auth policy covers the whole mount, closed by default. require_authenticated / get_user / authorize forward to every view the object builds, and require_authenticated is True — a bare mount serves nobody who is not logged in.

Because the object holds its own registry and config, you can mount several with independent registries — one per surface — each namespaced separately.

Skills

A SkillRegistry is an instance (like the tool registry) holding a catalog of skills: pre-defined prompts the client surfaces as chips and/or a /-command palette. Skills are data, not callables — there is no @skill decorator; you register them imperatively:

from django_ag_ui import SkillRegistry

skills = SkillRegistry()
skills.add(
    "summarise",
    title="Summarise",
    prompt="Summarise the {selection} for me.",
    description="Condense the current selection.",
    chip=True,
)

Each entry is a frozen SkillSpec (name, title, prompt, optional description, send_immediately, chip). add(...) is the convenience constructor; register(SkillSpec(...)) takes a pre-built spec. The prompt is a static string that may contain {placeholder}s the client fills from its skill context before sending. send_immediately=True sends the prompt on pick instead of pre-filling the input; chip=True also surfaces the skill as a chip (the palette lists all skills regardless).

SkillRegistry.payload() returns the client catalog as a list of camelCase dicts (name, title, prompt, and the optional description, sendImmediately, chip keys, omitted when at their default). It is served by SkillsView (django_ag_ui.skills.skills_view.SkillsView) — a GET-only callable view — which AGUIServer mounts at <prefix>skills/ (named skills) when you pass skills=:

urlpatterns = [
    path("agent/", AGUIServer(registry, skills=skills).urls),
]

The web component fetches this endpoint via its data-skills-url attribute.

Tool metadata catalog

Server-side tools — the @tool registry and (when drf_mcp_server= is set) the drf-mcp tools — execute server-side, so their JSON Schema never reaches the browser. A client therefore can't read an x-summary off the schema to label a tool-call card. The tool catalog is the channel for those labels: a small read-only JSON endpoint the web component fetches via its data-tools-url attribute and uses to map a tool name → a friendly card label.

build_tool_catalog(registry) builds the catalog as a list of entries, each {"name", "summary", "description"?}:

  • summary is always present, resolved from a fallback chain: registry tools use @tool(summary=…) (ToolSpec.summary) → a prettified tool name (query_model"Query model"); drf-mcp tools use display_nametitle → a prettified name.
  • description (a longer blurb, e.g. for a tooltip) is included only when available — ToolSpec.description for registry tools, or drf-mcp display_descriptiondescription.

Registry tools win on name collisions. The drf-mcp display_name / display_description are drf-mcp's binding metadata (consumer-only, never on the MCP wire), so the catalog surfaces friendly labels for those tools too.

ToolsView (django_ag_ui.ToolsView) — a GET-only callable view holding the same ToolRegistry the agent uses — serves the catalog. AGUIServer builds it from the registry you pass and mounts it at <prefix>tools/ (named tools) automatically — no extra argument:

urlpatterns = [
    path("agent/", AGUIServer(registry).urls),
]

Conversation persistence

By default the server is stateless: the conversation lives in the message history the client posts on every turn. Persistence is opt-in via conversation_store= and modelled as a pluggable Protocol, exactly like the audit logger.

ConversationStore is a runtime-checkable Protocol with async load / save / delete, plus list and rename for the thread drawer (see Thread history below), each taking the request. A Conversation is a frozen record of a thread_id, the AG-UI Message list (the wire shape, round-tripped verbatim), and an owner_id for authorization scoping.

The implementations:

  • NullConversationStore — the default. load returns None; save/delete are no-ops. The view treats this store as "persistence off" and adds no overhead — it skips wiring an on_complete callback entirely.
  • DjangoSessionConversationStore — stores conversations in the Django session, namespaced by thread_id within the logged-in user's session (no migration). Durability spans that browser session.
  • ModelConversationStore — an abstract base for model-backed (or any synchronous) store. It provides the async wrapping (sync_to_async) and per-request owner scoping; a subclass implements the synchronous row operations (_fetch, _store, _remove, and the opt-in _list / _rename, which default to [] / no-op) against its own Django model. The package ships no concrete model on purpose, so it forces no migration — you define the model, its fields, and the owner relationship. For a ready-made one, see the reference store.

When a non-null store is configured, the view persists the run's full message history when the run finishes streaming, scoped to the authenticated user (owner_id).

A stored thread holds no file bytes the server itself produced. The base64 a read_attachment return serialises into is removed before the run's messages reach the store, and a message that was only those bytes is dropped rather than kept as an empty turn. Text parts survive, and so does the tool message describing what was read, so the thread still reads as a record of the exchange.

The rule is deliberately one-sided: the server never persists bytes it generated, and never discards bytes the client sent. Inline content a front end posts is stored exactly as it arrived, the same way message ids and the attachments field are. See File uploads for what that means for a follow-up question, and for how rows written before this existed are cleaned.

Thread history

The store also powers a chat-history drawer: a user's past conversations, each loadable, renamable, and deletable. Two Protocol methods back it, both owner-scoped:

  • list(*, request) returns ConversationMetathread_id, title, updated_at, previewmetadata only, no message bodies, so the drawer stays cheap. NullConversationStore returns []; DjangoSessionConversationStore enumerates the session's own threads (titles derived from the first user message, previews from the latest); ModelConversationStore._list defaults to [] until a subclass overrides it.
  • rename(thread_id, title, *, request) sets a thread's display title. The session store persists it (overriding the derived title); ModelConversationStore._rename is a no-op until overridden.

AGUIServer mounts ThreadsView automatically whenever the conversation store is active (a non-Null store, resolved from conversation_store= by default or passed as conversation_store=), exposing them over HTTP for the web component's data-threads-url:

Route Method Action
<prefix>threads/ GET list the user's threads (metadata only)
<prefix>threads/<id>/ GET that thread's messages (server-side rehydration)
<prefix>threads/<id>/ PATCH rename ({"title": "..."})
<prefix>threads/<id>/ DELETE delete the thread

Every operation is scoped to the acting user — a thread owned by someone else reads as 404, never another user's history — and the view carries the same require_authenticated / get_user auth seam as DjangoAGUIView.

A ready-made durable store

For cross-device, per-user history without writing your own model, opt into the django_pydantic_agent.contrib.store app: add "django_pydantic_agent.contrib.store" to INSTALLED_APPS, run migrate, and set conversation_store= to django_pydantic_agent.contrib.store.default_conversation_store.DefaultConversationStore. It ships a StoredConversation model and a ModelConversationStore subclass with denormalised title / preview / updated_at columns so the thread list is a single cheap query. Projects that don't opt in get no model and no migration.

Deferred to a later release

The plan's server-authoritative merge-by-id policy (reconciling stored history with the posted messages so the client can only append, not rewrite, past turns) is designed but not yet implemented; today the store mirrors the run's messages on completion and the client remains the source of truth for the posted history. (The owner-scoped rehydration endpoint is now shipped — GET <prefix>threads/<id>/ above.)

File uploads

A user can attach files to a conversation — drop a PDF or image into the composer, send a message, and let the agent read it. The design keeps the AG-UI wire vanilla: files upload out-of-band to their own endpoint and travel as lightweight refs (id / name / mime / size), never as base64 on the message stream — the same principle the tool metadata catalog uses to keep schemas off the wire.

The lifecycle:

  1. The composer uploads each file (multipart POST <prefix>attachments/) and gets back an AttachmentRef — a durable handle, not bytes.
  2. The user sends a message carrying the refs. They ride the message as an attachments field — AG-UI does not declare one, but ag_ui.core validates with extra="allow", so it arrives intact. The server derives a manifest from the posted messages (see RUN_CONTEXT) and gives it to the model as fenced context, so the model knows a file exists and what id reads it.
  3. When the model needs a file's contents, it calls the built-in read_attachment(attachment_id) tool, which resolves the bytes server-side, owner-scoped to the acting user.

The manifest is derived from the message list rather than from the current request's uploads, because the client clears its per-run list once a run settles — so refs stay visible on follow-up turns about the same file. A stored thread keeps the client's messages as posted, ids and attachments field included, so the chips (and the ids) survive a page reload.

Bytes reach the model, not the row

Step 3 is where the wire's "refs, never bytes" rule meets its one exception: for a PDF or an image inside the size cap, read_attachment returns the file itself, and the model needs it. That return serialises onto the wire as a synthetic user message whose whole content is a base64 document part — so left alone it lands in the conversation row, ships back to the browser on every thread load, and is posted again by the client on every turn after a reload. A 2.6 MB PDF is roughly 3.5 MB of base64 in a single row.

The bytes are therefore taken off the run's own new messages on the way to the store, and off a resumed run's server-loaded snapshot when that is dumped into the row. They still reach the model during the run, which is the only place they were ever needed.

What the server does not touch is the history the client posted. The rule is that the server never persists bytes it generated and never discards bytes the client sent, so an inline image a front end puts in a message reaches both the model and the row untouched. Stripping the posted history too would have been the tidier symmetry and the wrong one: ALLOW_UPLOADED_FILES governs provider file-id references, not inline content, so a data-sourced image part reaches the model whatever that setting says — taking it off the way in would silently blind the model to any pasted image.

Rows written before this are cleaned at rest, not by the run loop. A conversation stored by an earlier release keeps its base64 until something rewrites it, and that something is a management command rather than the next run: with django-pydantic-agent 0.15.0 or newer and its django_pydantic_agent.contrib.store app installed, manage.py agent_store_strip_inline_bytes (--dry-run to see what it would reclaim) does structural JSON surgery on the stored rows, keeping every message id and the attachments array intact. The attachments themselves are untouched in the attachment store, so the model still reaches every file by id.

What changes for a reader. A follow-up question about the same file, asked after a page reload, makes the model call read_attachment again instead of finding the file already in its history. Within one session that was always the case — the bytes never travelled the event stream, so the client never had them to post back — and the extra call is server-side, owner-scoped, and cheaper than the upload it replaces.

The store

AttachmentStore is the persistence seam, set via attachment_store=. Every method is async and owner-scoped — one user's id can never resolve another's file, the security boundary for the feature. The default NullAttachmentStore keeps uploads off (410 Gone); subclass the abstract ModelAttachmentStore for your own model, or opt into the ready-made durable store which keeps bytes in Django Storage (filesystem by default, S3/GCS via STORAGES).

The endpoints

AGUIServer mounts AttachmentsView automatically whenever the attachment store is active (a non-Null store, resolved from attachment_store= by default or passed as attachment_store=):

  • POST <prefix>attachments/ — multipart upload under the file field; validates size and type server-side, then returns 201 with the ref JSON.
  • GET <prefix>attachments/<id>/ — stream the bytes back (owner-checked), as an attachment with X-Content-Type-Options: nosniff so an uploaded text/html can't execute as a same-origin page; missing / cross-owner → 404.
  • DELETE <prefix>attachments/<id>/ — drop the attachment (204).

All owner-scoped, and authenticated by default like the catalog views — which is load-bearing here rather than a parity choice, since an anonymous caller has no files to reach. The web component reads data-attachments-url to drive the composer's upload tray.

Client-supplied run context

A RunAgentInput carries a context list — ordered {description, value} pairs the host page fills in with whatever the user is looking at. Pydantic-AI's AGUIAdapter deliberately leaves that field (like forwardedProps and parentRunId) to the consumer, so this package is what delivers it, alongside the attachment manifest derived from the posted messages.

Both arrive at the model in one fenced, labelled block headed <untrusted-client-context>, telling the model in as many words that everything inside it is data describing the user's situation rather than instructions, and that the operator's rules win. The marker itself is neutralised wherever a client value contains it, so the fence cannot be forged or closed early.

The block is delivered as additional run instructions, not as a message and not merged into the operator's prompt string. That placement is what keeps it out of the record: instructions live on the model request, so the text is never written into the stored thread and never echoed back to the browser. It is also re-rendered on every model request, so it survives compaction and stays in front of the model on the later steps of a long run — which is why it is capped (MAX_CHARS), with anything over the ceiling truncated behind a visible marker.

Cancelling a run

AG-UI has no server-side cancel route. A run is one streaming HTTP request; the client cancels it by aborting that request, and the server observes a disconnect. The view handles that disconnect explicitly rather than leaving the teardown to garbage collection:

  • Provider teardown is guaranteed. The view keeps a reference to the innermost event generator — the one whose context manager owns the model provider's streaming request — and closes it when the disconnect surfaces, so no orphaned upstream generation keeps running (or billing) after the client stopped listening.
  • The partial exchange is persisted. With a non-null conversation_store= configured, the truncated conversation — the client-posted history plus whatever assistant text and completed tool calls streamed before the disconnect — is saved with the same thread/owner scoping as a completed run, so a durable thread reflects reality. Partially streamed tool calls are dropped (half a JSON arguments string is not a usable record). With the default NullConversationStore, nothing is saved.
  • The cancellation is audited. The configured AuditLogger receives a run-level AuditEvent with tool_name="agent.run", success=False, and an error starting with "cancelled:" — distinguishable from tool failures in logs/Sentry without widening the protocol. duration_ms measures run start → cancellation.
  • Cancellation is never swallowed. The guard re-raises after observing; failures inside the persist/audit step are logged and do not replace the cancellation.

There is no setting to turn this off — cancellation handling is transport-level, and partial persistence simply follows the store you already configured (matching the client, which keeps the partial assistant bubble).

The drf-mcp toolset bridge

With the [drf-mcp] extra installed and drf_mcp_server= set, the view builds a per-request DRFMCPToolset — a Pydantic-AI toolset (an AbstractToolset subclass) that exposes a djangorestframework-mcp-server registry's tools to the agent in-process, with no network MCP hop.

  • Tool schemas are sourced from drf-mcp's own tools/list (via its public MCPServer.list_tools), so the agent sees the full advertised inputSchema — including a selector tool's filter / ordering / pagination arguments and the additionalProperties policy — not just the input serializer's fields.
  • Execution routes through drf-mcp's public MCPServer.acall_tool (its in-process transport surface, drf-mcp 0.9+), so serializer validation and permissions are honoured exactly as over HTTP — without reaching into handler internals.
  • The toolset hands the Django request and request.user to those methods, so the agent acts as the logged-in AG-UI user.

The bridge is imported lazily, only when drf_mcp_server= is set, keeping rest_framework_mcp an optional dependency.