Skip to content

Integrations

Optional bridges exposing an existing DRF surface as agent tools. Both are lazily imported and live under django_spec_agents.integrations rather than being re-exported at the package root, so the base install pulls neither dependency. See Integrations for which to choose.

Spec tools ([specs])

build_spec_capability

build_spec_capability

build_spec_capability(
    specs: Mapping[str, Any] | SpecSource,
    *,
    exclude_names: frozenset[str] = frozenset(),
) -> Any

A SpecCapability over specs, acting as the run's deps.user.

Each spec is called in process through the kernel's dispatch, which enforces its permissions. Nothing here closes over a request: SpecToolset's default extractor already reads ctx.deps.user, so a run given AgentDeps binds the acting user natively and the capability stays request-independent, which is what makes an agent built once reusable across runs.

Every spec here needs its own permissions. A spec with permissions=None makes SpecCapability raise ImproperlyConfigured rather than expose an ungated tool: over HTTP None may mean inherit, from the view and then the project's defaults, and off HTTP there is nothing to inherit from, so a spec properly guarded behind a view would become callable by whatever the model decides to call.

The require_permissions=False migration flag is not exposed here; a project migrating a large registry constructs SpecCapability itself and passes it to AgentConfig(capabilities=...). That path skips the tool-catalog registration this function participates in, so tool-call cards render unlabelled — a migration step, not a destination.

A registry is passed through as a registry, narrowed with its own subset when exclude_names bites. Flattening it to a mapping first would drop everything the entry carries beyond the spec -- including the OfflineContract that says what a caller with no HTTP request has to be told -- and drop it silently, since the resulting toolset is well-formed and merely missing declarations nobody asked it for.

Parameters:

Name Type Description Default
specs Mapping[str, Any] | SpecSource

A name -> ServiceSpec/SelectorSpec mapping, or a spec registry to read one from. Prefer the registry: it is the same specs plus the per-entry declarations an agent transport reads.

required
exclude_names frozenset[str]

Names a higher-precedence source (the @tool registry, the drf-mcp bridge) already claimed. They are dropped so that source wins the collision, since pydantic-ai raises UserError for a duplicate name at run time.

frozenset()

resolve_spec_mapping

resolve_spec_mapping

resolve_spec_mapping(specs: Mapping[str, Any] | SpecSource) -> Mapping[str, Any]

Return the name -> spec mapping, whether given one or a registry.

A SpecSource is recognised structurally, by having a specs() method.

Public because a transport needs the same normalisation before build_spec_capability runs, when it reserves each tool name for collision detection: iterating a registry yields RegisteredSpec records rather than names, so a transport that iterated the raw argument would fill its set with dataclasses and silently stop detecting collisions.

SpecSource

SpecSource

Bases: Protocol

Anything that can hand back a name -> spec mapping.

django-service-specs' SpecRegistry is the intended implementation: a project exposing the same specs over more than one transport declares them there once, and each transport reads that source.

It is matched structurally rather than imported, because this substrate depends on pydantic-ai-slim alone and the kernel arrives only with the optional [specs] extra. Naming SpecRegistry in a signature would force that dependency on every install or bury the type behind a lazy import where a signature cannot reach it.

A plain dict is not a SpecSource — it has no specs() — which is what lets a caller accept either and tell them apart.

specs

specs() -> dict[str, Any]

The name -> spec mapping this source declares.

MCP tools ([drf-mcp])

DRFMCPToolset

DRFMCPToolset

Bases: AbstractToolset[Any]

Exposes a drf-mcp MCPServer's tools as a Pydantic-AI toolset.

Built per request, so the agent acts as the request's logged-in user. Both schemas and execution route through drf-mcp's public in-process surface (MCPServer.list_tools / acall_tool), so the advertised parameters, serializer validation and permissions match the HTTP transport exactly — without the network hop. Tool definitions carry the default kind="function", the in-process kind the run loop calls itself; an external tool would instead be deferred to the client and never run.

Definitions once, availability every step. drf-mcp leaves a tool out of tools/list while an operation-scope affordance refuses it, and whether one does can change mid-run — an admin closes the books. So the definitions are listed once, with every tool the user may see, and each step asks drf-mcp which of them a fresh listing would leave out (aunavailable_tools): those are not offered that step, and the instructions name each with its reason, so a model asked for one can say why rather than guess or deny the operation exists. A server declaring no such condition answers that without leaving the event loop.

Failures split three ways, along MCP's protocol-vs-tool boundary:

  • JSON-RPC -32602 and tool-level validation_error and input_required results raise pydantic_ai.ModelRetry, so the model calls again with the field errors fixed or the missing input supplied, instead of the run dying;
  • every other tool-level failure (service_error, not_found, a timeout, an oversized result) raises pydantic_ai.ToolFailed, so the model reads the sentence and the call is recorded outcome="failed";
  • protocol faults (auth, rate limits, an internal error) raise RuntimeError and abort the run.

The mapping is the one spec_tools applies to the same exceptions raised in process, and a refusal reads identically through both: The books are closed. (code: books_closed).

Parameters:

Name Type Description Default
server Any

The drf-mcp MCPServer whose registry is bridged.

required
request HttpRequest

The request carried into every call; its user is the acting user.

required
exclude_names frozenset[str]

Names the @tool registry has already claimed. A colliding drf-mcp tool is skipped, so the registry wins — the rule build_tool_catalog applies — because pydantic-ai raises UserError for a duplicate name at run time.

frozenset()
max_retries int

Per-tool retry budget: how many times a ModelRetry is fed back to the model before the run aborts. The default matches pydantic-ai's own function-tool default.

1

get_tools async

get_tools(ctx: Any) -> dict[str, ToolsetTool[Any]]

The tools this step may call: every definition, less the unavailable.

Definitions come from drf-mcp's tools/list once per toolset, which is once per request. Availability is asked again here every step, because pydantic-ai calls this once per step and a condition may flip between two: offering a tool drf-mcp would leave out only invites a refusal, and withholding one that became available hides an operation the model could now perform. The call enforces every condition whatever was offered.

get_instructions async

get_instructions(ctx: Any) -> str | None

Name each tool left out this step, with the reason its condition gives.

get_tools withholds such a tool, so without this the model sees neither it nor any sign it exists, and a user asking for it gets a guess where a sentence was available. None when nothing is left out, which is every step for a server declaring no operation condition.

Asked separately from get_tools, as the spec-tools route asks: the run context carries no key that could safely scope one answer to one step, and asking twice costs a second evaluation of conditions that read only seeds. A condition flipping between the two reads can leave one step's tools and instructions disagreeing about a tool; the call's own enforcement is authoritative either way.