Integrations

One decorator, N context providers. Each module below lives behind an optional extra and is imported by nothing in the core; the shared halves — identity resolution, refusal rendering, tool-manifest filtering — are in base and tools so a FastAPI refusal and a Flask refusal are the same refusal. See web frameworks and agents.

sentrykit.integrations.base

What every framework provider shares: identity resolution, lifecycle, and refusal rendering.

sentrykit has one decorator and N context providers. A provider is small on purpose — it does exactly two things, and both of them are the same on every framework:

  1. Establish ambient context at the boundary, from the framework’s request plus an app-supplied auth → principal-id callback, resolving that id through a GrantStore so the principal arrives at the decision path already materialized (roles, parent edges, attributes, argument constraints, expiries already applied).

  2. Render a sentrykit refusal as an HTTP response carrying the _authz envelope at whatever the effective verbosity allows.

Everything genuinely framework-specific — how a header is read, how a response is built, which hook runs at teardown — is a handful of methods on the subclass. The status mapping and the envelope renderer live here so a FastAPI 403 and a Flask 403 are the same 403.

The per-request lifecycle is establish-then-reset, and the reset is correctness

A provider must release the context it established, in a finally or in the framework’s teardown hook. This is not tidiness — it is the failure mode: Flask runs a request on a pooled worker thread and its test client runs one in the caller’s own context, so an un-reset ContextVar from request N is still ambient when request N+1 starts. Request N+1 would then be authorized as request N’s principal — the worst failure this library can have.

So the contract is:

scope = provider.establish(request)      # at the boundary
try:
    ...                                  # the handler, and anything it calls
finally:
    provider.release(scope)              # ALWAYS, even on the error path

BaseContextProvider.release() is deliberately total: it resets the scope when there is one, clears the ambient context outright when there is not, and drops the last envelope either way, so nothing about request N survives into request N+1.

An unauthenticated request establishes no principal and gets no scope. That is the fail-closed rule doing its job: every protected call inside such a request raises NoContext, which renders as a 403. The provider still clears the context on the way in, because “no principal” must mean no principal rather than “whatever the last request left behind”.

Refusals are rendered from the exception, not from ambient state

Ambient state is not reliably there when a refusal is rendered. A sync FastAPI route runs in an AnyIO worker thread, which receives a copy of the context — so a ContextVar written inside it, including the one last_envelope() reads, is discarded on return and is simply not there when the exception handler runs. FastAPI’s yield-dependency teardown has the same problem from the other direction: it unwinds before the exception handler.

Every sentrykit error already carries structured detail — the refused action and its determining policies, the offending argument name and reason, the allowed set, pydantic’s field errors — so error_envelope() reads the exception and nothing else. The rendering is then identical on every framework and on every code path, which is the only way the tests can mean anything.

sentrykit.integrations.base.DEFAULT_PRINCIPAL_HEADER: Final = 'x-sentrykit-principal'

Header the DSN-built default identify reads a principal id from.

A provider built from a bare DSN (fastapi://) has no application auth to call into, so it falls back to a header. That is enough for an example app, a smoke test, or a service behind a gateway that has already authenticated the caller — and nowhere near enough for anything else, which the docs say in as many words. Real deployments pass identify=.

sentrykit.integrations.base.DEFAULT_REQUEST_ID_HEADER: Final = 'x-request-id'

Header a correlation id is taken from when the caller supplies one.

sentrykit.integrations.base.DEFAULT_STATUS: Final = 500

Status for a sentrykit error that predates its own entry in STATUS_BY_ERROR.

sentrykit.integrations.base.NARROWED_ARGUMENTS_HEADER: Final = 'x-sentrykit-narrowed-arguments'

Response header naming the narrowed arguments, comma-separated, first-seen order.

sentrykit.integrations.base.NARROWED_HEADER: Final = 'x-sentrykit-narrowed'

Response header announcing that a value the caller proposed did not survive.

Emitted only when something was narrowed, which is the same discipline as_dict() applies to the narrowed field: the exceptional outcome is the one worth a line, and two headers on every ordinary response is a cost paid by every caller to say “nothing happened”. See ADR-029.

sentrykit.integrations.base.STATUS_BY_ERROR: Final[Mapping[type[BaseException], int]] = {<class 'sentrykit.errors.ArgumentRejected'>: 400, <class 'sentrykit.errors.AuditUnavailable'>: 500, <class 'sentrykit.errors.AuthzDenied'>: 403, <class 'sentrykit.errors.ConfigurationError'>: 500, <class 'sentrykit.errors.DefaultUnavailable'>: 400, <class 'sentrykit.errors.NoContext'>: 403, <class 'sentrykit.errors.NoValidArguments'>: 400, <class 'sentrykit.errors.ReservedKey'>: 403, <class 'sentrykit.errors.SentrykitError'>: 500, <class 'sentrykit.errors.ValidationFailed'>: 422}

Exception → HTTP status. Looked up along the MRO, so a subclass inherits its base’s status.

sentrykit.integrations.base.STATUS_PHRASES: Final[Mapping[int, str]] = {400: 'Bad Request', 403: 'Forbidden', 422: 'Unprocessable Entity', 500: 'Internal Server Error'}

Generic reason phrases, used as the whole body when verbosity is off.

class sentrykit.integrations.base.BaseContextProvider(*, identify: Callable[[RequestT], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RequestT], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'http')[source]

Bases: Generic[RequestT]

Everything a context provider does that is not framework-specific.

A subclass supplies four small things — narrow(), read_header(), render_error(), and a name — and inherits identity resolution, environment construction, the establish/release lifecycle, and the shared refusal rendering.

Omitting identify is logged, once, at construction

A provider built without identify resolves identity with its own fallback, and every shipped fallback ends at something the request supplies — principal_header here, an _meta key or a deps attribute in the agent providers. So whoever can set that chooses who they are. That is the right wiring for an example app and for a service whose gateway has already authenticated the caller, and the wrong one everywhere else — a distinction only the deploying engineer can draw, and one they cannot draw from a default that says nothing. So construction emits a WARNING on the sentrykit.integrations logger naming the provider class and the key it will read. It fires once per provider, not once per request, and not at all when identify is given.

Parameters:
  • identify – The app’s auth → identity callback. Given the framework request, it returns a principal id, an EntityRef, a ready-made Principal, or None for an unauthenticated caller. Defaults to reading principal_header, which is enough for an example app and not enough for anything else, and is warned about when it is what you get.

  • grants – Where a principal id is materialized. Defaults to the installed governor’s grant store, resolved lazily at request time so install() and the provider can be wired in either order.

  • environment – Extra Cedar request context per request, merged over channel, request_id and the principal type. Policies read these as context.*.

  • principal_header – Header the default identify reads.

  • request_id_header – Header an incoming correlation id is taken from.

  • channel – The channel established for every request. http for a web provider; the agent providers pass agent.

property name: str

Short name for this provider, e.g. fastapi.

Returns:

The provider’s DSN scheme.

Raises:

NotImplementedError – Always; every subclass names itself.

property channel: Literal['http', 'agent', 'internal']

The channel this provider establishes — http for a web boundary.

property principal_header: str

Header the default identify reads a principal id from.

property grants: GrantStore | None

The grant store principals are materialized from.

Falls back to the installed governor’s store when none was passed, so install(grant_store=..., context_provider=...) needs no second wiring step and the two can be configured in either order.

Returns:

The store, or None when neither the provider nor the governor has one — in which case only an identify returning a whole Principal can produce one.

narrow(request: object) → RequestT[source]

Check that request is this framework’s request type, and return it as such.

The one place the boundary between “some object arrived” and “a Starlette request arrived” is crossed. It is a method rather than a cast so a provider wired to the wrong framework fails with a configuration error naming both, instead of an AttributeError three frames deeper.

Parameters:

request – The object the framework hook handed over.

Returns:

The same object, typed.

Raises:

NotImplementedError – Always; every subclass narrows to its own request type.

read_header(request: RequestT, name: str) → str | None[source]

Read one request header, case-insensitively.

Parameters:
  • request – The framework request.

  • name – The header name.

Returns:

The value, or None when the header is absent.

Raises:

NotImplementedError – Always; header access is framework-specific.

render_error(error: Exception) → object[source]

Render a sentrykit refusal as this framework’s response.

Parameters:

error – A SentrykitError.

Returns:

The framework-native response, carrying the _authz envelope at whatever the effective verbosity allows.

Raises:

NotImplementedError – Always; building a response is framework-specific.

error_response(error: Exception) → tuple[int, dict[str, object]][source]

Resolve the status and JSON body for a refusal.

The whole of render_error() that is not framework-specific. Both shipped providers call this and then wrap the result, which is what makes a FastAPI 403 and a Flask 403 byte-identical below the framework’s own serialization.

Parameters:

error – The raised exception.

Returns:

(status, body).

identity_of(request: RequestT) → str | EntityRef | Principal | None[source]

Run the app’s auth callback, or fall back to the principal header.

Parameters:

request – The framework request.

Returns:

The identity, or None for an unauthenticated caller.

resolve_principal(request: RequestT) → Principal | None[source]

Turn a request into a materialized principal.

The identity comes from the app; the authority comes from the grant store. Resolving through get_principal() is what makes roles, group edges, per-argument constraints and expiries part of the decision without the web layer knowing any of them exist.

Parameters:

request – The framework request.

Returns:

The principal, or None when the caller is unauthenticated or the store has never heard of them. Both are None on purpose: an unknown id is not an error the boundary should raise, it is a call that fails closed a few frames later with a refusal the caller can be shown.

environment_for(request: RequestT) → dict[str, object][source]

Build the Cedar request context for one request.

channel and request_id are always present — the first so a policy can say “not over HTTP”, the second so the decision, the operational logs and the audit record share one key. Anything the app’s environment factory returns is merged on top, including over those two, because an app that has a better request id than we do should win.

Parameters:

request – The framework request.

Returns:

The environment to establish.

request_id(request: RequestT) → str[source]

Return the correlation id for one request.

Parameters:

request – The framework request.

Returns:

The caller’s request_id_header when they supplied one, otherwise a fresh UUID4. Trusting the caller’s id is deliberate — it is what makes a trace span a gateway and a service — and it is never used as anything but a label.

establish(request: object) → ContextScope | None[source]

Establish ambient context for one request, at the boundary.

Parameters:

request – The framework’s request object.

Returns:

A ContextScope to hand to release() at teardown, or None when the caller is unauthenticated. None is not a failure: the context has still been cleared, and every protected call inside the request will fail closed with NoContext.

Raises:

ConfigurationError – If the request is not this framework’s, or an identity resolved with no grant store to materialize it from.

narrowing() → tuple[bool, tuple[str, ...]][source]

What the boundary should report about narrowing, for this request.

Reads the EnvelopeRecorder establish() installed, so it answers even when the decision was made in a sync route’s context copy – where last_envelope() returns None. The union across every decision in the request is what a response-level report needs: one route may make several protected calls.

Returns:

Whether anything was narrowed, and the argument names, in first-seen order. Both empty when nothing was narrowed, when verbosity is off, or when no recorder is installed.

release(scope: ContextScope | None) → None[source]

Undo establish(), at teardown. Always safe to call.

Total by design: it resets the scope when there is one, clears the context outright when there is not, and drops the last envelope either way. A pooled worker thread that served principal A must start the next request holding nothing of A — not the principal, not the environment, and not the envelope of A’s last decision.

Parameters:

scope – Whatever establish() returned, including None.

class sentrykit.integrations.base.ContextProvider(*args, **kwargs)[source]

Bases: Protocol

How a framework establishes ambient context and renders sentrykit’s refusals.

Declared here, with the other three contracts, so the plugin machinery is uniform across all four entry-point groups. The implementations live under sentrykit/integrations/, behind optional extras, and the core must not import them to know the contract. Registered under the sentrykit.context_providers entry-point group.

property name: str

Short name for this provider, e.g. fastapi.

establish(request: object) → object[source]

Resolve the principal and environment from a framework request and establish them.

Parameters:

request – The framework’s request object.

Returns:

A ContextScope to reset at the end of the boundary.

render_error(error: Exception) → object[source]

Render a sentrykit refusal as the framework’s error response.

Parameters:

error – A SentrykitError.

Returns:

The framework-native response, carrying the _authz envelope at whatever the effective verbosity allows.

sentrykit.integrations.base.EnvironmentFactory

Extra Cedar request context contributed per request — auth strength, tenant, break-glass.

alias of Callable[[RequestT], Mapping[str, object]]

sentrykit.integrations.base.Identify

An app’s auth → identity callback. None means “unauthenticated”, not “error”.

alias of Callable[[RequestT], str | EntityRef | Principal | None]

sentrykit.integrations.base.Identity = str | sentrykit.cedar.EntityRef | sentrykit.context.Principal

What an app’s auth callback may hand back.

A str is a principal id and is read as User::"<id>"; an EntityRef names the type explicitly. Both are resolved through get_principal(), which is the point — roles, group edges and per-argument constraints come from the store, not from the request.

A ready-made Principal is accepted as the escape hatch for an app that has no grant store, or that materializes principals its own way. It is used as-is.

sentrykit.integrations.base.action_of(error: Exception) → str | None[source]

Return the action an error refers to, when it names one.

AuthzDenied and NoContext both carry the action; the argument errors do not. The action doubles as the key for a per-action verbosity override, so resolving it here means an action configured verbosity="debug" gets a debug-level refusal body too.

Parameters:

error – The raised exception.

Returns:

The action uid, or None.

sentrykit.integrations.base.error_envelope(error: Exception, *, verbosity: Literal['off', 'summary', 'verbose', 'debug']) → dict[str, object] | None[source]

Build the _authz body for a refusal, trimmed to verbosity.

Built from the exception alone — see this module’s docstring for why ambient state is not available on every path. The field names match AuthzEnvelope so a caller reads one vocabulary whether the call was allowed or refused, and the trimming rules match it too:

off

No envelope at all.

summary

The decision, the action and resource where the error names them, the reason, and the names and reasons of rejected arguments. No values.

verbose

Adds the offending values, the allowed set a caller needs in order to retry, and the raw input pydantic rejected.

debug

Adds the determining policies and the engine errors behind a refusal.

Parameters:
  • error – The raised exception.

  • verbosity – How much to include.

Returns:

The envelope body, or None when verbosity is off.

sentrykit.integrations.base.error_payload(error: Exception, *, settings: Settings | None = None) → dict[str, object][source]

Build the whole JSON body for a refusal.

Shaped so it reads naturally to both audiences: detail is what FastAPI and Flask callers already expect to find on an error, error is the machine-readable class name a client can branch on, and _authz is the same envelope the success path attaches to a mapping result.

At verbosity="off" the body collapses to a generic reason phrase — no action names, no reasons, no envelope — because off means the caller is told nothing beyond the status.

Parameters:
  • error – The raised exception.

  • settings – Effective settings. Defaults to the process settings resolved for the action the error names, so a per-action verbosity override reaches the refusal body as well as the success envelope.

Returns:

A JSON-ready mapping.

sentrykit.integrations.base.narrowing_headers(narrowed: bool, arguments: Sequence[str]) → dict[str, str][source]

Build the response headers that report narrowing, for any framework.

Lives here rather than in each adapter so the two web providers cannot disagree about the header names or about when to emit them — the kind of drift that makes a documented contract a per-framework surprise.

Deliberately excludes guidance. It is a sentence of prose, it can exceed 300 characters, and one of its two forms contains an em dash, which is not spellable in an HTTP field value without encoding. A header is for a fact a machine acts on; the prose belongs in the body or in last_envelope(). ADR-029 records that decision and its cost.

Parameters:
  • narrowed – Whether anything was narrowed while serving this response.

  • arguments – The narrowed argument names.

Returns:

The headers to add, empty when nothing was narrowed. The argument header is omitted — while NARROWED_HEADER is still emitted — if any name cannot be spelled in a field value, so the caller is told that narrowing happened even when it cannot be told which, and is never handed a list that silently dropped an entry.

sentrykit.integrations.base.reason_for(error: Exception) → str[source]

One sentence explaining a refusal, safe to show at summary verbosity.

Safe means: no argument values, no policy ids, no internal detail. Names of things the caller already knows — the action they invoked, the argument they passed — are fine, and a constraint’s own reason is written to be shown to a caller.

Parameters:

error – The raised exception.

Returns:

A sentence-case explanation.

sentrykit.integrations.base.status_for(error: Exception) → int[source]

Map a sentrykit error to its HTTP status.

Walks type(error).__mro__ so a host that subclasses one of the shipped errors gets its base’s status rather than a surprise 500.

Parameters:

error – The raised exception.

Returns:

The status code, defaulting to DEFAULT_STATUS.

sentrykit.integrations.tools

Tool-manifest filtering: deciding what a principal is even shown.

The agent half of one-decorator-N-providers. A web provider has one job — establish context at the boundary — because an HTTP client already knows which URLs exist. An agent boundary has a second job: it tells the model what it may attempt, and that manifest is per-caller.

This module is the shared half of that, and it is deliberately framework-free. It imports no mcp and no pydantic_ai; MCP’s tools/list and Pydantic-AI’s prepare are two spellings of the same question, and they get one answer so they cannot drift apart.

The invariant

A tool is hidden from a principal if and only if invoking it would be refused by the authorization gate.

Both halves matter, and the tests assert both together.

Only if — hiding is never the enforcement. The dispatch path runs the identical decision, so a client that ignores the manifest and calls a hidden tool by name is refused exactly as if it had never been hidden. Manifest filtering that were the enforcement would be a hole the moment a caller skipped tools/list, and “the model did not know the tool existed” is not an access-control argument.

If — a tool the principal may invoke is never hidden. Hiding is a usability affordance: it stops the model proposing calls that cannot succeed, burning turns and context on refusals it could not have predicted. A filter that hid more than it had to would silently narrow what the agent can do for its user.

Why “the gate” and not “the whole pipeline”

plan() is run in full — this is the mechanism sentrykit.engine was split for, in as many words — but its outcomes are read in two groups:

  • AuthzDenied and NoContext are about entitlement. The principal may not perform this action at all, whatever they pass. ⇒ hidden.

  • DefaultUnavailable, NoValidArguments, ArgumentRejected and ValidationFailed are about arguments, and the manifest probe supplies none. A tool with a required parameter and no policy default raises DefaultUnavailable for everybody; treating that as “denied” would hide every such tool from every principal. ⇒ visible, and enforced on dispatch once the model has proposed something to enforce against.

So the manifest answers the only question that is decidable before the model has proposed anything, and the argument-level pipeline answers the rest at dispatch. That split is the honest reading of the invariant above, and ENTITLEMENT_ERRORS is the one place it is written down.

An unprotected tool is not a governed tool

A callable with no @protect wrapper carries no action, no resource and no argument spec, so there is nothing to decide about it. It is reported as ToolDecision.governed False and left visible: sentrykit does not silently hide a tool its author never asked it to govern, and pretending to have made a decision about one would be worse than saying so. Hosts that want everything governed assert that themselves — which is a one-line test over decide_tools(), and the shipped examples have it.

sentrykit.integrations.tools.ENTITLEMENT_ERRORS: Final[tuple[type[SentrykitError], ...]] = (<class 'sentrykit.errors.AuthzDenied'>, <class 'sentrykit.errors.NoContext'>)

Refusals that mean this principal may not do this at all, so the tool is hidden.

Every other SentrykitError is about the arguments of a particular call, which a manifest probe has not got — see this module’s docstring. Matched with isinstance, so a host subclassing one of these inherits its meaning.

class sentrykit.integrations.tools.ToolDecision(name: str, visible: bool, governed: bool, error: SentrykitError | None = None, plan: Plan | None = None)[source]

Bases: object

What the shared gate concluded about one tool, for one principal.

Returned rather than a bare bool because the two agent providers need different parts of it: MCP drops the hidden names from a tools/list result, Pydantic-AI returns None from prepare for the same tools, and both want the refusal for a debug log that explains why a tool the developer expected to see is missing.

Variables:
  • name (str) – The advertised tool name.

  • visible (bool) – Whether the tool should appear in this principal’s manifest.

  • governed (bool) – Whether the underlying callable carries a @protect wrapper. False means no decision was made and visible is True by default — see the module docstring.

  • error (sentrykit.errors.SentrykitError | None) – The refusal that hid it, or the argument-level refusal that did not. None when planning succeeded or the tool is ungoverned.

  • plan (sentrykit.engine.Plan | None) – The successful plan, when there was one. Carries the action, the resource and the principal’s argument constraints, which is what an optional schema-narrowing pass reads.

sentrykit.integrations.tools.decide_tool(name: str, fn: Callable[[...], object], *, principal: Principal, governor: Governor | None = None, environment: Mapping[str, object] | None = None) → ToolDecision[source]

Decide whether one tool belongs in principal’s manifest.

Runs plan() with no proposed arguments — the same call the enforcement path makes, minus the arguments the model has not proposed yet — and reads the outcome per this module’s docstring.

Parameters:
  • name – The advertised tool name, for the returned decision and the logs.

  • fn – The registered callable. Unprotected callables are reported ungoverned and visible.

  • principal – Who the manifest is for.

  • governor – The governor to decide against. Defaults to the one pinned to the protected callable, and failing that to the process-installed one.

  • environment – Cedar request context for the probe, merged over the ambient environment. A provider passes the same environment it would establish for a dispatch, so a policy conditioning on context.channel sees agent here too.

Returns:

The decision.

Raises:

ConfigurationError – If fn is protected but no governor can be resolved. A manifest that silently showed everything because nothing was installed would be the exact failure the fail-closed rule exists to prevent.

sentrykit.integrations.tools.decide_tools(tools: Mapping[str, Callable[[...], object]] | Iterable[tuple[str, Callable[[...], object]]], *, principal: Principal, governor: Governor | None = None, environment: Mapping[str, object] | None = None) → tuple[ToolDecision, ...][source]

Decide a whole manifest, in the order the tools were given.

Parameters:
  • tools – The registered tools, as a mapping or as (name, callable) pairs.

  • principal – Who the manifest is for.

  • governor – As for decide_tool().

  • environment – As for decide_tool().

Returns:

One decision per tool, in input order.

Raises:

ConfigurationError – As for decide_tool().

sentrykit.integrations.tools.governed_meta(fn: object) → ProtectedMeta | None[source]

Return the @protect metadata for a tool’s callable, unwrapping one adapter layer.

An agent provider rarely registers the protected function itself. MCP registers a proposable() shim; Pydantic-AI registers a ctx-taking adapter around that shim, which is two hops. Each layer records what it wraps under __wrapped__, so the whole chain is walked rather than a fixed number of links – a filter that stopped one hop short would read a governed tool as ungoverned and advertise it to everybody — exactly the failure the manifest filter exists to prevent.

Parameters:

fn – The registered tool callable.

Returns:

The metadata, or None when nothing in the chain is protected.

sentrykit.integrations.tools.permitted_tools(tools: Mapping[str, Callable[[...], object]] | Iterable[tuple[str, Callable[[...], object]]], *, principal: Principal, governor: Governor | None = None, environment: Mapping[str, object] | None = None) → tuple[str, ...][source]

Return the names principal may be shown, in the order the tools were given.

The convenience form of decide_tools() for a caller that needs only the names.

Parameters:
  • tools – The registered tools, as a mapping or as (name, callable) pairs.

  • principal – Who the manifest is for.

  • governor – As for decide_tool().

  • environment – As for decide_tool().

Returns:

The permitted names.

Raises:

ConfigurationError – As for decide_tool().

sentrykit.integrations.tools.proposable(fn: Callable[[...], Any], *, optional: Sequence[str] | None = None) → Callable[[...], Any][source]

Re-declare a protected callable so a caller may omit the arguments policy supplies.

Both agent frameworks validate a proposal against the tool’s advertised JSON schema before dispatch, so an argument the schema marks required can never be left out and can therefore never be filled in from the principal’s defaults – and that holds on each of the two frameworks independently. Without this shim, default injection is unreachable from an agent at all, which would leave one of the three things sentrykit does unusable at the very boundary the agent integrations exist to govern.

This is the same move the FastAPI example already makes one layer out: its route declares region: str | None = None and forwards only what the caller actually supplied. An absent argument has to be expressible at the boundary, and on an agent boundary the boundary is the tool signature.

The returned shim satisfies two introspections at once, which is the whole trick:

  • inspect.signature() reports the shim’s parameters, because a callable carrying its own __signature__ stops the unwrapping walk – so the framework advertises the optional schema; while

  • inspect.unwrap() still reaches the protected callable through __wrapped__, so governed_meta() finds the @protect metadata and the manifest filter governs the tool instead of waving it through as unprotected.

Every synthesized parameter is made keyword-only. Agent frameworks propose arguments by name, so nothing is lost, and it removes the ordering trap where making one parameter optional would leave a non-default parameter standing after a default one.

!!! warning

A parameter listed in optional cannot carry a meaningful None: the shim reads None as not proposed and drops it, exactly as the FastAPI example’s if region is not None does. Do not list a genuinely nullable parameter.

Parameters:
  • fn – The protected callable to register as a tool. Sync or async; the shim matches.

  • optional – Which parameters become omittable. Defaults to every parameter with no Python-level default – precisely the set whose value could only have come from policy, and so precisely the set worth making omittable.

Returns:

The shim, to hand to @server.tool() or to tool_adapter().

Raises:

ConfigurationError – If fn’s signature cannot be introspected, or if optional names a parameter it does not declare or declares positional-only – which could not be proposed by name and so could not be omitted either.

sentrykit.integrations.fastapi

The FastAPI / Starlette context provider.

Wire it once and every @protect-ed callable reached from a request — the route itself, a service function three frames deeper, a helper in another module — is governed by the same ambient principal:

from fastapi import FastAPI
from sentrykit.integrations.fastapi import protect_app

app = FastAPI()
protect_app(app, provider=FastAPIProvider(identify=lambda request: request.headers.get("x-user")))

protect_app adds a pure-ASGI middleware and registers the exception handlers. Both are available separately for apps that want to place them by hand.

Why a pure-ASGI middleware, and never a sync dependency

There are four places a provider could establish context, and only three of them work:

Established in

reaches an async def route

reaches a def route

pure-ASGI middleware

yes

yes

BaseHTTPMiddleware

yes

yes

async def dependency

yes

yes

`def` (sync) dependency

no

no

A sync dependency runs in an AnyIO worker thread with a copy of the context, so its establishment is discarded the moment it returns and every protected call downstream then fails closed. This module ships no sync-dependency form, so there is nothing to reach for by mistake. A sync route is fine — anyio.to_thread.run_sync copies the context into the worker, so the principal established upstream travels with it.

The middleware is pure ASGI rather than BaseHTTPMiddleware for two reasons: it needs no task group and no response buffering, and — because Starlette builds the stack as ServerErrorMiddleware → user middleware → ExceptionMiddleware → router — the context it establishes is still ambient when an exception handler runs. A yield-dependency’s is not: the AsyncExitStack unwinds below ExceptionMiddleware, so its teardown happens first.

class sentrykit.integrations.fastapi.FastAPIProvider(*, identify: Callable[[RequestT], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RequestT], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'http')[source]

Bases: BaseContextProvider[Request]

Establishes ambient context from a Starlette request and renders refusals as JSON.

Construction is inherited from BaseContextProvider; see there for identify, grants, environment and the header options. This class supplies only the four framework seams.

classmethod from_dsn(dsn: DSN) → FastAPIProvider[source]

Build from fastapi:// — the registry’s door, used by install().

A DSN-built provider has no application auth to call into, so it identifies callers by the header named in ?header= (default DEFAULT_PRINCIPAL_HEADER). That suits an example app or a service behind a gateway that has already authenticated the caller. Anything else should construct the provider directly and pass identify=.

Parameters:

dsn – The parsed address. Options: header (principal id header), request_id_header.

Returns:

The provider.

property name: str

fastapi.

narrow(request: object) → Request[source]

Check that request is a Starlette request.

Parameters:

request – The object the boundary handed over.

Returns:

The same object, typed.

Raises:

ConfigurationError – If it is not a Starlette request — which means this provider was wired to the wrong framework.

read_header(request: Request, name: str) → str | None[source]

Read one header from a Starlette request. Starlette’s headers are case-insensitive.

render_error(error: Exception) → JSONResponse[source]

Render a sentrykit refusal as a JSON response carrying the _authz envelope.

Parameters:

error – A SentrykitError.

Returns:

The response, at the status status_for() maps the error to.

class sentrykit.integrations.fastapi.SentrykitMiddleware(app: Callable[[MutableMapping[str, Any], Callable[[], Awaitable[MutableMapping[str, Any]]], Callable[[MutableMapping[str, Any]], Awaitable[None]]], Awaitable[None]], provider: FastAPIProvider | None = None)[source]

Bases: object

Pure-ASGI middleware that establishes context for the request and releases it after.

The release is in a finally and is not optional: a reused ASGI task that kept the previous request’s principal would authorize the next caller as somebody else. See sentrykit.integrations.base for the measurement behind that.

Non-HTTP scopes (lifespan, websocket) pass straight through untouched.

Parameters:
  • app – The next ASGI application in the stack.

  • provider – The provider to establish with. Defaults to the one wired into the installed governor by install(context_provider=...), and failing that to a header-identifying FastAPIProvider.

sentrykit.integrations.fastapi.context_dependency(provider: FastAPIProvider | None = None) → Callable[[Request], AsyncIterator[None]][source]

Build an async yield-dependency that establishes context for one route.

The route-scoped alternative to SentrykitMiddleware, for an app that governs some of its routes and not others:

@app.get("/orders", dependencies=[Depends(context_dependency())])
def list_orders() -> dict[str, object]: ...

It must be async: see this module’s docstring for why a sync dependency cannot work. One difference from the middleware is worth knowing — the dependency’s teardown runs before an exception handler, so ambient context is already released by the time a refusal is rendered. That costs nothing, because refusals are rendered from the exception.

Parameters:

provider – The provider to establish with. Defaults as for SentrykitMiddleware.

Returns:

The dependency callable, ready to hand to Depends.

sentrykit.integrations.fastapi.install_error_handlers(app: FastAPI, provider: FastAPIProvider | None = None) → None[source]

Register a handler for every sentrykit error on app.

One handler per entry in STATUS_BY_ERROR, plus SentrykitError itself as the catch-all. Starlette resolves a handler by walking the exception’s MRO, so a host that subclasses a shipped error is covered without registering anything.

Parameters:
sentrykit.integrations.fastapi.protect_app(app: FastAPI, *, provider: FastAPIProvider | None = None, middleware: bool = True, handlers: bool = True) → FastAPIProvider[source]

Wire sentrykit into app in one call.

Parameters:
  • app – The application.

  • provider – The provider to use. Defaults as for SentrykitMiddleware.

  • middleware – Whether to add SentrykitMiddleware. Turn it off when context is established per route with context_dependency() instead.

  • handlers – Whether to register the exception handlers.

Returns:

The provider that was wired, so a caller can hold on to it.

sentrykit.integrations.flask

The Flask context provider.

The WSGI half of one-decorator-N-providers. Same decorator, same envelope, same statuses — a different pair of hooks:

from flask import Flask
from sentrykit.integrations.flask import FlaskProvider, protect_app

app = Flask(__name__)
protect_app(app, provider=FlaskProvider(identify=lambda request: request.headers.get("x-user")))

Establish in `before_request`, release in `teardown_request`

Flask’s hook ordering is what makes this work. For a request whose view raises, Flask runs:

before_request  ->  view raises  ->  errorhandler renders  ->  teardown_request

so the handler still sees the ambient principal, and teardown_request is the last hook — the right place for the release. Three details follow from the same observation and each one is a bug if ignored:

  • teardown_request runs even when `before_request` raised, so the release has to cope with there being no scope to release.

  • before_request and teardown_request fire even for a 404, so establishment cannot assume a view was matched.

  • Everything happens on one thread, so the ContextVar token captured on the way in is always resettable on the way out.

That release is the whole reason this module is careful. Flask serves a request on a pooled worker thread, and its test client serves one in the caller’s own context: an un-reset ContextVar from request N is still ambient when request N+1 starts, and request N+1 would be authorized as request N’s principal. §5 of the same notes measured it.

Flask async def views need the asgiref extra and are not exercised here; sync views are the shape this provider is built for.

sentrykit.integrations.flask.SCOPE_KEY: str = '_sentrykit_scope'

Attribute on flask.g the request’s context scope is parked under.

g rather than a module global because it is request-scoped and thread-local already, which is exactly the lifetime the scope has. The name is public so a host debugging a leak knows what to look at.

class sentrykit.integrations.flask.FlaskProvider(*, identify: Callable[[RequestT], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RequestT], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'http')[source]

Bases: BaseContextProvider[Request]

Establishes ambient context from a Flask request and renders refusals as JSON.

Construction is inherited from BaseContextProvider; see there for identify, grants, environment and the header options. This class supplies only the four framework seams plus the two Flask hooks.

classmethod from_dsn(dsn: DSN) → FlaskProvider[source]

Build from flask:// — the registry’s door, used by install().

A DSN-built provider has no application auth to call into, so it identifies callers by the header named in ?header= (default DEFAULT_PRINCIPAL_HEADER). Real deployments construct the provider directly and pass identify=.

Parameters:

dsn – The parsed address. Options: header (principal id header), request_id_header.

Returns:

The provider.

property name: str

flask.

narrow(request: object) → Request[source]

Check that request is a Flask request, unwrapping the request-local proxy.

Inside a request, flask.request is a werkzeug.local.LocalProxy, not a flask.Request. The proxy forwards __class__, so isinstance alone would accept it and quietly hand the proxy to the application’s identify callback — an object whose attribute lookups fail the moment the request context is gone. It is unwrapped here instead, so identify and environment always receive the real request and may keep it.

Parameters:

request – The object the boundary handed over — a request or a proxy to one.

Returns:

The underlying request, typed.

Raises:

ConfigurationError – If it is not a Flask request — which means this provider was wired to the wrong framework.

read_header(request: Request, name: str) → str | None[source]

Read one header from a Flask request. Werkzeug’s headers are case-insensitive.

render_error(error: Exception) → tuple[Response, int][source]

Render a sentrykit refusal as a JSON response carrying the _authz envelope.

Parameters:

error – A SentrykitError.

Returns:

(response, status) — the pair Flask accepts from an error handler, at the status status_for() maps the error to.

before_request() → None[source]

Establish ambient context for the current request, parking the scope on g.

Registered as a before_request hook. Takes no arguments and returns None, which is Flask’s contract for “carry on with the request” — the provider never short-circuits a request itself, because refusing is the decorator’s job and an unauthenticated caller may well be hitting a route that is not protected at all.

after_request(response: Response) → Response[source]

Add the narrowing headers to the outgoing response.

Registered as an after_request hook, which is the last place the headers are still mutable and runs before teardown_request drops the recorder. Flask serves a request on one thread in one context, so unlike the ASGI side there is no copied-context problem here — the recorder is used anyway, because it is the channel that answers the response-level question (a view may make several protected calls) and because one answer shared by both frameworks is worth more than two shaped by their own plumbing.

Parameters:

response – The response Flask is about to send.

Returns:

The same response, with the headers added when anything was narrowed. An unprotected route, or one that narrowed nothing, is returned untouched.

teardown_request(error: BaseException | None = None) → None[source]

Release the context established for the current request.

Registered as a teardown_request hook, and total: a request whose before_request never ran, or ran and raised, has no scope on g, and that is a no-op rather than a KeyError. It tolerates being called with no application context at all, so a host driving the hooks by hand cannot turn a missing context into a teardown exception that masks the real one.

Parameters:

error – The exception that ended the request, or None. Accepted because Flask passes it; the release is the same either way, since release() is what makes the next request safe and that is owed regardless of how this one ended.

sentrykit.integrations.flask.install_error_handlers(target: Flask | Blueprint, provider: FlaskProvider | None = None) → None[source]

Register a handler for every sentrykit error on target.

One handler per entry in STATUS_BY_ERROR, plus SentrykitError itself as the catch-all. Flask resolves a handler by walking the exception’s MRO, so a host that subclasses a shipped error is covered without registering anything.

Parameters:
sentrykit.integrations.flask.protect_app(app: Flask, *, provider: FlaskProvider | None = None) → FlaskProvider[source]

Wire sentrykit into app in one call.

Registers the before_request establishment, the after_request narrowing headers, the teardown_request release, and the error handlers, application-wide.

Parameters:
  • app – The application.

  • provider – The provider to use. Defaults to the one wired into the installed governor by install(context_provider=...), and failing that to a header-identifying FlaskProvider. Call install() first if you want it picked up — see _effective_provider() for why the default is resolved here and not per request.

Returns:

The provider that was wired, so a caller can hold on to it.

sentrykit.integrations.flask.protect_blueprint(blueprint: Blueprint, *, provider: FlaskProvider | None = None) → FlaskProvider[source]

Wire sentrykit into one blueprint rather than the whole application.

For an app where only part of the surface is governed — an /api blueprint in front of an unprotected marketing site, or two blueprints authenticating callers differently. The hooks are blueprint-scoped (Blueprint.before_request / Blueprint.after_request / Blueprint.teardown_request), so they run only for this blueprint’s routes, and its error handlers likewise.

Parameters:
  • blueprint – The blueprint.

  • provider – The provider to use. Defaults as for protect_app().

Returns:

The provider that was wired.

sentrykit.integrations.mcp

The MCP tool-server context provider — the model proposes, the policy decides.

Wire it once and every @protect-ed tool on the server is governed by the principal the call arrived for:

from mcp.server.mcpserver import MCPServer
from sentrykit.integrations.mcp import governed_tool, protect_server

server = MCPServer("orders")


@governed_tool(server, action="order.read", resource='Order::"o-1"')
def read_order(order_id: str, limit: int = 10) -> dict[str, object]: ...


protect_server(server)

protect_server installs one SentrykitToolMiddleware, which does two things and nothing else: it establishes ambient context around every tools/call and releases it after, and it filters every tools/list result down to the tools the caller may actually invoke.

What this is built against

mcp 2.0 — mcp.server.mcpserver.MCPServer and the ServerMiddleware protocol in mcp.server.context. That one distribution is the whole dependency: sentrykit[mcp] installs mcp>=2,<3, the entry point and the mcp:// DSN name the same thing, and nothing here reaches for any other MCP package.

Two names you may meet elsewhere are not this. mcp 2.0 renamed its own ergonomic server class mcp.server.fastmcp.FastMCP → MCPServer, so a 1.x example will import a class that no longer exists. And the separately published fastmcp project is a different library: it dropped mcp as a dependency, does not import against mcp 2.0, and carries its own tool-authorization parameter. Neither is needed here, and neither is installed by this extra.

Register protected tools with `governed_tool`, and nothing else

This is the one sharp edge in the module, so it is stated plainly: a `@protect`-ed callable handed straight to `@server.tool()` will have its refusals swallowed. mcp’s Tool.run wraps whatever the body raised in a ToolError and MCPServer._handle_call_tool turns that into a CallToolResult(isError=True) with no structured content, so the caller gets the string "Error executing tool …" and the _authz envelope is gone before any middleware can see it. The gate still ran and the side effect was still refused — but the model is told nothing it can act on.

governed_tool() is what closes that. It registers the tool behind two thin layers, each solving one measured problem and neither of them optional:

  • a proposable() shim, so an argument the principal’s policy would supply is advertised as omittable and can actually be omitted. mcp validates a proposal against the advertised schema before the body runs, so without this a parameter with no Python default could never be filled in from a policy default; and

  • a translator that catches SentrykitError and re-raises it as an mcp.shared.exceptions.MCPError, which is the one exception type _handle_call_tool re-raises rather than swallowing. That is what carries a refusal out of the tool body intact.

Both layers keep __wrapped__ pointing at the protected callable, so the manifest filter still finds its @protect metadata, and governed_tool hands the protected callable back unchanged — registering a tool does not take a function away from the rest of the program.

A refusal never reaches the tool, and the decision is made exactly once

The gate runs inside @protect, before the body — so the side effect is refused before it happens, which is architecture §8 made concrete. The refusal then travels out as a _ToolRefusal and the middleware renders it as a CallToolResult carrying isError and the _authz envelope in its structured content, so the model gets a structured reason it can act on instead of an opaque error string.

Deciding it here instead — planning the call in the middleware ahead of call_next — also works, and is not what this module does, because it decides every allowed call twice: one allowed dispatch then writes two allowed audit records, and a library positioned audit-first cannot have its own log double-count. It also forces the caller-facing envelope to be reconstructed from the pre-flight plan, so defaults_applied describes a decision the decorator had not made. Both problems disappear when the refusal is carried out rather than predicted.

What the middleware still owns is the rendering, and that shape is not a style choice. Measured against a live client, the three options differ:

the middleware…

what the client sees

lets the error propagate

isError, and structuredContent is `None` — envelope gone

returns a plain dict

a ValidationError for the missing resultType — the connection dies

returns a CallToolResult

isError, structuredContent={"_authz": …} ✔

A tools/list result is the other way round: by the time the middleware sees it, it is an already-serialized mapping that does carry its discriminator, so filtering rewrites it in place.

The manifest is filtered, and the filter is not the enforcement

See sentrykit.integrations.tools for the invariant and why it is written the way it is. The short version: hiding a tool and refusing it are the same decision made twice, so a client that skips tools/list and calls a hidden tool by name gets refused just the same.

Finding the callable behind an advertised name

mcp 2.0 exposes no public accessor for a registered tool’s underlying function, and the manifest filter needs one in order to read its @protect metadata. registered_tools() reads the server’s tool manager defensively — any failure degrades to “no tools discovered” rather than to an exception on a live dispatch — and every entry point here accepts an explicit tools= mapping for a host that would rather not depend on that at all.

sentrykit.integrations.mcp.CALL_TOOL_METHOD: str = 'tools/call'

The inbound method the middleware establishes context around.

sentrykit.integrations.mcp.LIST_TOOLS_METHOD: str = 'tools/list'

The inbound method whose result the middleware filters.

sentrykit.integrations.mcp.NARROWED_ARGUMENTS_META_KEY: str = 'sentrykit/narrowedArguments'

_meta key naming the narrowed arguments, in first-seen order.

sentrykit.integrations.mcp.NARROWED_META_KEY: str = 'sentrykit/narrowed'

_meta key announcing that a tool call ran with a value the model did not propose.

class sentrykit.integrations.mcp.MCPProvider(*, identify: Callable[[ServerRequestContext[Any, Any]], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[ServerRequestContext[Any, Any]], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'agent')[source]

Bases: BaseContextProvider[ServerRequestContext[Any, Any]]

Establishes ambient context from an MCP request and renders refusals as tool results.

Construction is inherited from BaseContextProvider; see there for identify, grants, environment and the header options. The channel defaults to agent rather than http, so a policy written when { context.channel == "agent" } applies to every tool call without the server saying anything.

Where the principal comes from

read_header looks in two places, in order:

  1. ``ctx.meta`` — the _meta object on the request params. It is an open map, both Client.call_tool and Client.list_tools accept meta=, and it is the only carrier that exists on every transport including stdio and in-memory. This is what makes the example server and the whole test suite drivable without a socket.

  2. The transport request’s headers, when the transport has one. None on stdio.

Either is enough for an example, a smoke test, or a server behind a gateway that has already authenticated the caller, and nowhere near enough for anything else. A real deployment passes identify=, typically reaching for mcp.server.mcpserver.authenticated_principal(), which resolves the OAuth (client, issuer, subject) identity from the ambient access token.

classmethod from_dsn(dsn: DSN) → MCPProvider[source]

Build from mcp:// — the registry’s door, used by install().

Parameters:

dsn – The parsed address. Options: header (the _meta key, or header name, a principal id is read from), request_id_header.

Returns:

The provider.

property name: str

mcp.

narrow(request: object) → ServerRequestContext[Any, Any][source]

Check that request is an MCP server request context.

Parameters:

request – The object the boundary handed over.

Returns:

The same object, typed.

Raises:

ConfigurationError – If it is not a ServerRequestContext — which means this provider was wired to the wrong framework.

read_header(request: ServerRequestContext[Any, Any], name: str) → str | None[source]

Read one value from the request’s _meta, falling back to transport headers.

Parameters:
  • request – The MCP request context.

  • name – The _meta key, or header name, to read. Matched case-insensitively against headers, exactly against _meta keys — _meta is an open map whose keys the client chooses, and quietly matching a near-miss there would be guessing at identity.

Returns:

The value, or None when neither carrier has it.

render_error(error: Exception) → CallToolResult[source]

Render a sentrykit refusal as a failed tool result carrying the _authz envelope.

The body is error_payload()’s — byte-identical to what a FastAPI or Flask caller would receive for the same refusal, because a refusal should read the same whether the caller was a browser or a model.

Parameters:

error – A SentrykitError.

Returns:

A CallToolResult with is_error set, the caller-facing reason as text content, and the whole payload as structured content.

class sentrykit.integrations.mcp.SentrykitToolMiddleware(server: MCPServer | None = None, *, provider: MCPProvider | None = None, tools: Mapping[str, Callable[[...], object]] | None = None, filter_manifest: bool = True)[source]

Bases: object

Server middleware that governs tool dispatch and filters the tool manifest.

Implements mcp.server.context.ServerMiddleware. Registered outermost-first on MCPServer.middleware, it wraps every inbound message; everything that is neither tools/call nor tools/list passes straight through untouched.

For a tools/call the lifecycle is the web providers’ exactly: establish on the way in, release in a finally. The release is not tidiness — a dispatch that kept the previous caller’s principal would authorize the next model call as somebody else, which is the worst failure this library can have.

Parameters:
  • server – The server whose tools are being governed, used to resolve an advertised tool name back to its callable for the manifest filter. Optional when tools is given.

  • provider – The provider to establish with. Defaults to the one wired into the installed governor by install(context_provider=...), and failing that to a _meta-identifying MCPProvider.

  • tools – An explicit tool name → callable mapping, for a host that would rather not rely on registered_tools(). Takes precedence over server when both are given.

  • filter_manifest – Whether to filter tools/list. Turning it off leaves enforcement completely intact — see sentrykit.integrations.tools — and is for a server that would rather advertise a stable manifest to every caller.

async dispatch(ctx: ServerRequestContext[Any, Any], call_next: Callable[[ServerRequestContext[Any, Any]], Awaitable[BaseModel | dict[str, Any] | None]]) → BaseModel | dict[str, Any] | None[source]

Establish context for one tools/call, run it, and release.

A SentrykitError raised by a protected tool is caught and rendered as a failed tool result rather than allowed to propagate, because a propagating error loses the _authz envelope (see the module docstring). Anything else the tool raises is the tool’s business and passes through untouched.

A tool registered through governed_tool() delivers its refusal wrapped in a _ToolRefusal, since that is the only shape mcp lets out of a tool body. A bare SentrykitError is caught too — that is what a failure in the middleware’s own establishment path raises, and it deserves the same rendering.

The decision itself is made exactly once, inside the decorator, so one dispatch writes one audit record. Nothing here re-decides.

Parameters:
  • ctx – The request context.

  • call_next – The rest of the middleware chain.

Returns:

The tool’s result, or the refusal.

async manifest(ctx: ServerRequestContext[Any, Any], call_next: Callable[[ServerRequestContext[Any, Any]], Awaitable[BaseModel | dict[str, Any] | None]]) → BaseModel | dict[str, Any] | None[source]

Filter a tools/list result down to what this caller may invoke.

The result arrives already serialized and carrying its own resultType, so the tools array is rewritten in place and the server’s _meta is left exactly as it built it. The freshness hints are not: see _keep_private().

An unauthenticated caller is shown nothing: with no principal there is no entitlement to reason about, and every one of those tools would refuse on dispatch. That is the same fail-closed rule as everywhere else, applied to the manifest.

Parameters:
  • ctx – The request context.

  • call_next – The rest of the middleware chain.

Returns:

The result, with tools narrowed.

sentrykit.integrations.mcp.governed_tool(server: MCPServer, *, action: str | None = None, resource: str | None = None, name: str | None = None, description: str | None = None, optional: Sequence[str] | None = None) → Callable[[Callable[[...], Any]], Callable[[...], Any]][source]

Register a protected callable on server as a governed tool.

The MCP spelling of sentrykit.integrations.pydantic_ai.governed_tool(), deliberately the same name and the same shape, because the two boundaries should read identically to somebody who has only ever wired the other one:

@governed_tool(server, action='Action::"orders.search"', resource='Tool::"orders"')
def search_orders(region: str, limit: int) -> dict[str, object]: ...

It does the three things a governed MCP tool needs, so a host does not have to remember them separately: applies @protect when the callable does not already carry it, makes the policy-suppliable arguments omittable with proposable(), and registers the result behind the translator that lets a refusal out of the tool body. The module docstring says why none of those three is optional.

The decorated function is returned unchanged – protected, and still directly callable by ordinary application code under an established context. Registering a tool should not take a function away from the rest of the program.

Parameters:
  • server – The server to register on.

  • action – The Cedar action, as for protect(). Ignored when the callable is already protected, since re-protecting it would stack two gates and silently discard whichever pair the author wrote first.

  • resource – The Cedar resource, as for protect(). Ignored when the callable is already protected.

  • name – Tool name advertised to the model. Defaults to the function’s name.

  • description – Tool description. Defaults to the function’s docstring.

  • optional – Which parameters the model may omit, as for proposable(). Defaults to every parameter with no Python default – precisely the set whose value could only have come from policy.

Returns:

The decorator, which returns the protected callable.

Raises:

ConfigurationError – If action or resource cannot be parsed, or the callable’s signature cannot be introspected.

sentrykit.integrations.mcp.protect_server(server: MCPServer, *, provider: MCPProvider | None = None, tools: Mapping[str, Callable[[...], object]] | None = None, filter_manifest: bool = True) → MCPProvider[source]

Wire sentrykit into server in one call.

Appends one SentrykitToolMiddleware to server.middleware. Calling it twice on the same server is a no-op the second time, so a module that wires at import and a test that wires in a fixture cannot double-govern a dispatch.

Parameters:
  • server – The MCP server.

  • provider – The provider to use. Defaults as for SentrykitToolMiddleware.

  • tools – An explicit tool name → callable mapping.

  • filter_manifest – Whether to filter tools/list.

Returns:

The provider that was wired, so a caller can hold on to it.

sentrykit.integrations.mcp.registered_tools(server: MCPServer) → dict[str, Callable[[...], object]][source]

Map every tool registered on server to the callable behind it.

mcp 2.0 has no public accessor for this — MCPServer._tool_manager is private, though the list_tools() it exposes is an ordinary method and each Tool carries .name and .fn. So this reads it defensively and never raises: if the internal shape ever changes, the result is an empty mapping, every tool reads as ungoverned-and-visible, and enforcement on dispatch is completely unaffected. Degrading the manifest is survivable; raising from inside a live tools/list is not.

Parameters:

server – The MCP server.

Returns:

Tool name → registered callable. Empty when the tools could not be read, with a warning logged.

sentrykit.integrations.pydantic_ai

The Pydantic-AI context provider — an agent acts for a human, and policy knows it.

Wire it per tool and every call the model proposes is authorized as the person the agent is running on behalf of:

from dataclasses import dataclass

from pydantic_ai import Agent
from sentrykit.decorator import protect
from sentrykit.integrations.pydantic_ai import governed_tool


@dataclass
class Caller:
    principal: str          # the human, not the agent


agent = Agent("...", deps_type=Caller)


@governed_tool(agent, action="order.read", resource='Order::"o-1"')
@protect(action="order.read", resource='Order::"o-1"')
def read_order(order_id: str, limit: int = 10) -> dict[str, object]:
    """Read an order."""
    ...


result = await agent.run("what is on order o-1?", deps=Caller(principal="alice"))

governed_tool registers the tool, bridges RunContext.deps to ambient context for the duration of each dispatch, and installs the per-run manifest filter. @protect is the same decorator a Flask view uses; nothing about the tool’s body knows it is being called by a model.

On-behalf-of is the whole point, and it is not a special mechanism

An agent acting for a human is that human, as far as the policy is concerned. The principal established here is the one named in deps — never a service identity for the agent process — so a delegation chain, a team edge or an expiry granted to alice is what governs the call the model proposed on alice’s behalf. Cedar resolves the chain; the model never sees it, and could not influence it if it tried, because deps is supplied by the application at run() time and the model has no way to write to it.

Why the tool is a plain function and the adapter is synthesized

A @agent.tool function takes ctx: RunContext[…] as its first parameter. Wrapping that in @protect would make ctx a proposed argument: it would go into the Cedar request context, through the per-principal argument filter, and into the pydantic validation model — wrong three times over, and the last one raises.

So the tool you write is a plain, ctx-free, @protect-ed function, and tool_adapter() builds the ctx-taking callable Pydantic-AI actually registers. The adapter carries a synthesized __signature__ that prepends ctx, which produces exactly the right tool definition: ctx absent from the JSON schema, the docstring’s argument descriptions intact. The author still writes one decorator, which is the whole promise.

…and why the adapter re-declares the arguments as optional

The same synthesis solves a second problem, and it is the one that decides whether default injection exists on this boundary at all. Pydantic-AI validates the model’s proposal against the advertised JSON schema before dispatch, so a parameter the schema marks required can never be omitted — and an argument that can never be omitted can never be filled in from the principal’s policy default. The whole “which defaults to inject when they pass none” half of what sentrykit does would be unreachable from an agent.

proposable() is therefore applied inside tool_adapter(), between the protected callable and the ctx-prepending layer. It re-declares every parameter that has no Python-level default as keyword-only and optional, and forwards only the arguments the model genuinely proposed. The result is that a model may leave region out, the omission reaches the decorator as a real omission, and policy supplies the value — which is exactly what the FastAPI example’s route does one layer out when it writes if region is not None. Name the set yourself with optional= when the default is wrong for your tool.

Establish per dispatch, not per run

release happens in a finally around each individual tool call, not around the whole agent.run(...). A run is a loop: the model proposes, a tool runs, the model proposes again. Holding one establishment across all of that would mean a principal outliving the dispatch it was established for — and the no-leak test asserts exactly that it does not, by driving two dispatches inside one contextvars context and checking the principal is gone in between.

Refusals come back as data, not as exceptions

A refused tool returns the _authz payload as its result — the same body a FastAPI caller would get for the same refusal — with is_error implied by decision: "deny". The side effect never happened, and the model is told why in a form it can act on: pick a different tool, ask the user for authorization, or explain the refusal. Letting the error propagate would abort the run and tell the user’s agent nothing useful. Hosts that would rather fail the run outright pass on_refusal="raise".

sentrykit.integrations.pydantic_ai.DEFAULT_DEPS_ATTRIBUTE: str = 'principal'

Attribute (or mapping key) on RunContext.deps the principal id is read from.

sentrykit.integrations.pydantic_ai.OnRefusal

What a governed tool does with a refusal.

return

Hand the _authz payload back as the tool’s result. The run continues and the model can adapt. The default, and the reason is in this module’s docstring.

raise

Let the SentrykitError propagate and end the run. For a host that treats any refusal as a bug in its own prompt or wiring.

alias of Literal[‘return’, ‘raise’]

class sentrykit.integrations.pydantic_ai.PydanticAIProvider(*, deps_attribute: str = DEFAULT_DEPS_ATTRIBUTE, identify: Callable[[RunContext[Any]], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RunContext[Any]], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'agent')[source]

Bases: BaseContextProvider[RunContext[Any]]

Establishes ambient context from RunContext.deps and renders refusals as results.

Construction is inherited from BaseContextProvider with one addition, deps_attribute; see there for identify, grants and environment. The channel defaults to agent.

Parameters:
  • deps_attribute – Where the principal id lives on deps — a mapping key when deps is a mapping, an attribute otherwise. Ignored when identify is given, which is what an application with a richer deps type should pass.

  • identify – As for the base class, but receiving the RunContext.

  • grants – As for the base class.

  • environment – As for the base class.

  • principal_header – Fallback key read from deps when deps_attribute yields nothing, so a plain dict deps shaped like a request also works.

  • request_id_header – Fallback key for a correlation id. Rarely needed — see request_id().

  • channel – The channel established for every dispatch.

classmethod from_dsn(dsn: DSN) → PydanticAIProvider[source]

Build from pydantic-ai:// — the registry’s door, used by install().

Parameters:

dsn – The parsed address. Options: deps_attribute, header, request_id_header.

Returns:

The provider.

property name: str

pydantic-ai.

property deps_attribute: str

Where the principal id is read from on deps.

narrow(request: object) → RunContext[Any][source]

Check that request is a Pydantic-AI run context.

Parameters:

request – The object the boundary handed over.

Returns:

The same object, typed.

Raises:

ConfigurationError – If it is not a RunContext — which means this provider was wired to the wrong framework.

identity_of(request: RunContext[Any]) → str | EntityRef | Principal | None[source]

Resolve who this dispatch acts for, from deps.

Overrides the base class’s header-reading default because deps is a typed object an application designed, not a bag of strings. In order: the app’s identify callback when one was given; then deps itself when it is already a Principal or a bare id; then deps_attribute on it; then principal_header.

deps=None means unauthenticated, which is a legitimate state — an agent run with no caller — and every governed tool in it will fail closed.

Parameters:

request – The run context.

Returns:

The identity, or None.

read_header(request: RunContext[Any], name: str) → str | None[source]

Read one named value from deps.

Parameters:
  • request – The run context.

  • name – A mapping key when deps is a mapping; otherwise an attribute name, with - read as _ so a header-shaped name still resolves.

Returns:

The value as a string, or None.

request_id(request: RunContext[Any]) → str[source]

Return the correlation id for one dispatch.

Pydantic-AI already has one: RunContext.run_id is stable across every tool call in a run, which is exactly the correlation an audit trail wants — one id spanning the model’s whole chain of proposals rather than a fresh UUID per tool. The tool call’s own id goes into the environment instead, so a record can still be traced to a single dispatch.

Parameters:

request – The run context.

Returns:

The run id, falling back to the base class’s behaviour when there is none.

environment_for(request: RunContext[Any]) → dict[str, object][source]

Build the Cedar request context for one dispatch.

Adds tool (the tool the model proposed) and tool_call_id to what the base class contributes, so a policy can condition on which tool is being invoked without the action id having to encode it, and an audit record names the individual proposal.

Parameters:

request – The run context.

Returns:

The environment to establish.

render_error(error: Exception) → dict[str, object][source]

Render a sentrykit refusal as the mapping a governed tool returns.

The body is error_payload()’s — the same one the web providers put in an HTTP response and the MCP provider puts in a tool result — so _authz reads identically wherever a refusal surfaces.

Parameters:

error – A SentrykitError.

Returns:

The refusal payload, carrying detail, error and _authz.

sentrykit.integrations.pydantic_ai.governed_tool(agent: Agent[Any, Any], *, action: str | None = None, resource: str | None = None, provider: PydanticAIProvider | None = None, name: str | None = None, description: str | None = None, on_refusal: Literal['return', 'raise'] = 'return', filter_manifest: bool = True, optional: Sequence[str] | None = None) → Callable[[Callable[[...], Any]], Callable[[...], Any]][source]

Register a protected callable on agent as a governed tool.

Does the three things a governed tool needs, so a host does not have to remember them separately: applies @protect when the callable does not already carry it, registers tool_adapter()’s adapter via agent.tool, and attaches tool_prepare() so the tool is hidden from any run whose principal could not invoke it.

The decorated function is returned unchanged — protected, and still directly callable by ordinary application code under an established context. Registering a tool should not take a function away from the rest of the program.

Parameters:
  • agent – The agent to register on.

  • action – The Cedar action, as for protect(). Ignored when the callable is already protected.

  • resource – The Cedar resource, as for protect(). Ignored when the callable is already protected.

  • provider – Defaults as for tool_adapter().

  • name – Tool name advertised to the model. Defaults to the function’s name.

  • description – Tool description. Defaults to the function’s docstring.

  • on_refusal – What to do with a refusal.

  • filter_manifest – Whether to hide the tool from runs that could not invoke it. Turning it off leaves enforcement completely intact.

  • optional – Which parameters the model may omit so policy can supply them, as for tool_adapter(). Defaults to every parameter with no Python-level default.

Returns:

The decorator, which returns the protected callable.

sentrykit.integrations.pydantic_ai.tool_adapter(fn: Callable[[...], object], *, provider: PydanticAIProvider | None = None, on_refusal: Literal['return', 'raise'] = 'return', optional: Sequence[str] | None = None) → Callable[[...], Any][source]

Wrap a plain protected callable as the ctx-taking function Pydantic-AI registers.

The returned adapter establishes ambient context from ctx, calls fn with the arguments the model proposed, and releases in a finally.

Its signature is built in two layers, and both are load-bearing. Innermost, proposable() re-declares fn’s parameters so the ones policy could supply are omittable — without which the model must invent a value for every parameter and default injection never happens, per this module’s docstring. Outermost, a synthesized __signature__ prepends ctx: RunContext[Any]. What the model is shown is therefore fn’s tool definition with ctx excluded, the docstring’s argument descriptions intact, and required empty for the omittable parameters.

Both layers record what they wrap under __wrapped__, and governed_meta() walks the whole chain, so the @protect metadata is still reachable from the registered adapter.

Parameters:
  • fn – The tool body — a plain, ctx-free callable, normally @protect-ed. Sync and async are both supported; the adapter is always async, since Pydantic-AI awaits it either way.

  • provider – The provider to establish with. Defaults to the one wired into the installed governor, and failing that to a deps-reading PydanticAIProvider.

  • on_refusal – What to do with a SentrykitError.

  • optional – Which parameters the model may omit, as for proposable(). Defaults to every parameter with no Python-level default. Name the set explicitly when a parameter takes a meaningful None, which the shim would otherwise read as not proposed.

Returns:

The adapter.

Raises:

ConfigurationError – If fn’s signature cannot be introspected, if it already declares a parameter named ctx, or if optional names a parameter it does not declare.

sentrykit.integrations.pydantic_ai.tool_filter(tools: Mapping[str, Callable[[...], object]], *, provider: PydanticAIProvider | None = None) → Callable[[RunContext[Any], list[ToolDefinition]], Any][source]

Build a whole-agent prepare_tools hook over an explicit tool mapping.

The one-hook alternative to a prepare per tool, for an agent assembled from tools it did not register through governed_tool():

agent = Agent(model, deps_type=Caller, prepare_tools=tool_filter({"read_order": read_order}))

A definition whose name is absent from tools is left alone rather than dropped — this hook governs what it was told about and does not quietly remove an agent’s other tools.

Parameters:
  • tools – Tool name → the callable behind it.

  • provider – Defaults as for tool_adapter().

Returns:

A ToolsPrepareFunc, ready to pass to Agent(...).

sentrykit.integrations.pydantic_ai.tool_prepare(fn: Callable[[...], object], *, provider: PydanticAIProvider | None = None) → Callable[[RunContext[Any], ToolDefinition], Any][source]

Build the per-tool prepare hook that hides fn when its principal may not run it.

This is Pydantic-AI’s list_tools: returning None from prepare drops the tool from the definitions offered to the model for that run. The decision is decide_tool()’s — the same gate the dispatch path runs — so a hidden tool is a denied tool and vice versa. See sentrykit.integrations.tools for why that equivalence is stated as an invariant and why hiding is never the enforcement.

Parameters:
  • fn – The tool body, or the adapter around it. Either resolves to the same metadata.

  • provider – The provider whose environment the probe runs under. Defaults as for tool_adapter().

Returns:

A ToolPrepareFunc, ready to pass to agent.tool.