Python FastMCP Integration
Policy-based authorization for FastMCP servers using SAPL (Streaming Attribute Policy Language). The sapl-fastmcp library provides two authorization paths: a global SAPLMiddleware that intercepts all MCP operations with full constraint handler support, and a per-component auth=sapl() check for simpler binary permit/deny decisions. Both paths query the SAPL PDP for every tool call, resource read, and prompt access.
What is SAPL?
SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes.
Three core concepts:
- Authorization subscription: your app sends
{ subject, action, resource, environment }to the PDP. - PDP decision: the PDP evaluates policies and returns
PERMITorDENY, optionally with obligations, advice, or a replacement resource. - Constraint handlers: registered handlers execute the policy’s instructions (log, filter, transform, cap values, etc.).
A PDP decision looks like this:
{
"decision": "PERMIT",
"obligations": [{ "type": "logAccess", "message": "Patient record accessed" }],
"advice": [{ "type": "notifyAdmin" }]
}
decision is always present (PERMIT, DENY, INDETERMINATE, or NOT_APPLICABLE). The other fields are optional. obligations and advice are arrays of arbitrary JSON objects (by convention with a type field for handler dispatch), and resource (when present) replaces the component’s return value entirely.
For a deeper introduction to SAPL’s subscription model and policy language, see the SAPL documentation.
What is MCP?
The Model Context Protocol (MCP) is a standardized interface for AI agents and LLMs to access external tools, resources, and prompts. FastMCP is a Python framework for building MCP servers.
Authorization matters because MCP servers expose capabilities to AI agents that may act on behalf of different users with different privilege levels. A single MCP server might serve tools for querying public data alongside tools that access PII or perform destructive operations. Without authorization, every agent has full access to every tool regardless of who it represents.
Installation
pip install sapl-fastmcp
This also installs sapl-base, which provides the PDP client, constraint engine, and content filtering. The library requires Python 3.12+ and FastMCP 3.1.0+.
A complete working demo with JWT authentication, constraint handlers, stealth mode, and both authorization paths is available at sapl-python-demos/fastmcp_demo.
Choosing an Authorization Path
The library offers two ways to enforce authorization. They can be used independently or together on the same server.
| Aspect | SAPLMiddleware |
auth=sapl() |
|---|---|---|
| Enforcement point | Single middleware intercepts all operations | Each component has its own auth check |
| Constraint handlers | Full lifecycle (ON_DECISION, METHOD_INVOCATION, FILTER_PREDICATE, etc.) | ON_DECISION only |
| Stealth mode | Supported (hides from listings, masks denial as not-found) | Not supported (warning logged) |
| Finalize callbacks | Supported | Not supported |
| Listing filter | Multi-decide hides unauthorized stealth components | FastMCP’s built-in per-component visibility |
| Decorator overrides | @pre_enforce / @post_enforce customize per component |
Not applicable (fields set in sapl() call) |
| Pre/post enforce | Both supported | Pre-enforce only |
| Setup complexity | Slightly more (create PDP client + constraint service explicitly) | Simpler (configure_sapl() + sapl()) |
Use the middleware when you need constraint handlers that modify arguments or filter results, stealth mode, finalize callbacks, or post-enforce. Use auth=sapl() for simpler setups where a binary permit/deny per component is sufficient.
Setup: Per-Component Auth (auth=sapl())
Call configure_sapl() once before the server starts. This creates the singleton PDP client and constraint service:
from sapl_base import PdpConfig
from sapl_fastmcp import configure_sapl, get_constraint_service, sapl
configure_sapl(PdpConfig(
base_url="http://localhost:8443",
allow_insecure_connections=True,
))
To register ON_DECISION constraint handlers (the only type supported in the auth path):
get_constraint_service().register_runnable(AccessLoggingProvider())
Then protect individual components with auth=sapl():
from fastmcp import FastMCP
mcp = FastMCP("my-server", auth=jwt_verifier)
# Defaults: subject=token claims, action="hello", resource="mcp"
@mcp.tool(auth=sapl())
def hello(name: str) -> str:
return f"Hello, {name}!"
# Static override: action="read_status" instead of "get_time"
@mcp.tool(auth=sapl(action="read_status"))
def get_time() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
# Callable override: extract username from token claims
@mcp.tool(auth=sapl(
subject=lambda ctx: ctx.token.claims.get("preferred_username") if ctx.token else "anonymous",
action="write_config",
resource="server_config",
secrets=lambda ctx: {"raw_token": ctx.token.token if ctx.token else None},
))
def write_config(key: str, value: str) -> dict:
return {"key": key, "value": value, "status": "updated"}
Subscription Field Defaults (Auth Path)
| Field | Default |
|---|---|
subject |
Token claims dict, or client_id, or "anonymous" |
action |
Component name (e.g., "hello", "server_status") |
resource |
"mcp" |
environment |
Not sent |
secrets |
Not sent |
Each field accepts a static value, a Callable[[AuthContext], Any], or None (use default). Falsy values like 0, "", or False are valid overrides and will not trigger the default.
Setup: Middleware (SAPLMiddleware)
Create the PDP client and constraint service explicitly, then pass them to the middleware:
from fastmcp import FastMCP
from sapl_base import PdpClient, PdpConfig
from sapl_base.constraint_engine import ConstraintEnforcementService
from sapl_fastmcp.middleware import SAPLMiddleware
pdp = PdpClient(PdpConfig(
base_url="http://localhost:8443",
allow_insecure_connections=True,
))
constraint_service = ConstraintEnforcementService()
constraint_service.register_runnable(AccessLoggingProvider())
constraint_service.register_method_invocation(LimitResultsProvider())
constraint_service.register_filter_predicate(FilterByClassificationProvider())
mcp = FastMCP(
name="analytics",
auth=jwt_verifier,
middleware=[SAPLMiddleware(pdp, constraint_service)],
)
The PDP client and constraint service are injected at construction time. The middleware does not depend on module-level globals, so you can configure different PDP connections for different servers.
Components without a @pre_enforce or @post_enforce decorator pass through with no PDP call, allowing gradual adoption.
Enforcement Decorators (@pre_enforce / @post_enforce)
These decorators are only used with the middleware path. They attach metadata to functions as fn.__sapl__; the middleware reads this metadata at request time. The decorators do not wrap the function, preserving its identity for FastMCP’s introspection.
@pre_enforce
The PDP is queried before the tool executes. The tool only runs on PERMIT.
from sapl_fastmcp.decorators import pre_enforce
@mcp.tool(tags={"public"})
@pre_enforce()
def query_public_data(dataset: str, date_range: str = "last_30d") -> dict:
return {"dataset": dataset, "rows": 14823}
With overrides:
@mcp.tool(tags={"pii"})
@pre_enforce(
resource=lambda ctx: {
"name": ctx.component.name,
"tags": list(ctx.component.tags),
"segment": ctx.arguments.get("segment"),
},
stealth=True,
)
def query_customer_data(segment: str, limit: int = 10) -> dict:
return {"segment": segment, "total_matches": 2847, "limit": limit}
@post_enforce
The tool executes first, then the PDP is queried with the return value available in the subscription context. If the decision is not PERMIT, the result is suppressed.
from sapl_fastmcp.decorators import post_enforce
@mcp.tool(tags={"engineering"})
@post_enforce(resource=lambda ctx: {
"name": ctx.component.name,
"tags": list(ctx.component.tags),
"model": ctx.arguments.get("model_id"),
"result_summary": ctx.return_value,
})
def run_model(model_id: str, dataset: str) -> dict:
return {"model_id": model_id, "status": "completed", "accuracy": 0.924}
Use @post_enforce when the policy needs to see the actual return value to decide. For example, a policy might permit access only if the result’s classification level is below a threshold.
You cannot apply both @pre_enforce and @post_enforce to the same function. Attempting to do so raises TypeError.
Subscription Field Defaults (Middleware Path)
| Field | Default |
|---|---|
subject |
Token claims dict, or client_id, or "anonymous" |
action |
Operation verb: "call", "read", or "get" |
resource |
{"name": component_name, "arguments": {...}, "tags": [...]} |
environment |
Not sent |
secrets |
Not sent |
Each field accepts a static value, a Callable[[SubscriptionContext], Any], or None (use default).
SubscriptionContext Reference
The SubscriptionContext is available to callable field overrides in the middleware path:
| Field | Type | Description |
|---|---|---|
token |
AccessToken or None |
OAuth token from the request |
component |
Any |
The FastMCP Tool, Resource, ResourceTemplate, or Prompt object |
operation |
"call", "read", "get", "list", or None |
MCP operation verb |
arguments |
dict[str, Any] |
Tool or prompt arguments (empty for resources) |
uri |
str or None |
Resource URI (only for read operations) |
return_value |
Any |
Tool return value (@post_enforce only, None otherwise) |
For the auth=sapl() path, callable fields receive an AuthContext from FastMCP instead of SubscriptionContext. The AuthContext provides token (the AccessToken) and component (the FastMCP component).
Stealth Mode
When stealth=True is set on @pre_enforce or @post_enforce, two things happen:
- The component is hidden from listings when the subject is not authorized. The listing filter uses multi-decide to batch-query the PDP for all stealth components at once.
- Denial raises
NotFoundErrorinstead ofAccessDeniedError, making hidden components indistinguishable from non-existent ones.
@mcp.tool(tags={"pii", "export"})
@pre_enforce(action="export_data", stealth=True)
def export_csv(query_ref: str, columns: str = "all") -> dict:
return {"query_ref": query_ref, "rows_exported": 2847}
An unauthorized user calling this tool receives the same NotFoundError they would get for a tool that does not exist. The tool also does not appear in tools/list responses for that user.
Stealth only works with SAPLMiddleware. Using stealth=True with auth=sapl() logs a warning and has no effect.
Finalize Callbacks
The finalize parameter on decorators provides an async callback that runs after enforcement regardless of outcome. It receives the AuthorizationDecision and SubscriptionContext:
async def _purge_finalize(decision, ctx: SubscriptionContext) -> None:
"""In production this would commit or rollback a database transaction."""
logger.info(
"purge_finalize: decision=%s, dataset=%s",
decision.decision.value,
ctx.arguments.get("dataset_id"),
)
@mcp.tool(tags={"destructive", "compliance"})
@pre_enforce(finalize=_purge_finalize, stealth=True)
def purge_dataset(dataset_id: str, reason: str) -> dict:
return {"dataset_id": dataset_id, "status": "purged", "records_deleted": 15234}
The callback signature is async def finalize(decision: AuthorizationDecision, ctx: SubscriptionContext) -> None.
The finalize callback always runs, even when the tool throws an exception. Exceptions in the finalize callback itself are logged and swallowed; they never affect the enforcement outcome. Use finalize for transaction commit/rollback, resource cleanup, or audit logging.
Finalize only works with SAPLMiddleware. It has no effect with auth=sapl().
How Enforcement Works
The Deny Invariant
Only PERMIT grants access. The PDP can return four possible decisions (PERMIT, DENY, INDETERMINATE, NOT_APPLICABLE), and only PERMIT ever results in your tool running. Everything else means denial.
A PERMIT with obligations is not a free pass. The enforcement point checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the decision is treated as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the failure is logged and the request proceeds.
| Aspect | Obligation | Advice |
|---|---|---|
| All handled? | Required. Unhandled obligations deny access. | Optional. Unhandled advice is silently ignored. |
| Handler failure | Denies access. | Logs a warning and continues. |
Enforcement Locations (Middleware Path)
For tool calls, resource reads, and prompt access, constraint handlers can intervene at four points:
| Location | When | What constraints do |
|---|---|---|
| On decision | Decision arrives | Side effects (logging, audit) |
| Pre-method invocation | Before tool executes (@pre_enforce only) |
Modify tool arguments |
| On return value | After tool returns | Transform, filter, or replace result |
| On error | Tool throws | Transform or observe error |
Pre-Enforce Lifecycle
The middleware builds an authorization subscription from the decorator options (or defaults) and sends it to the PDP. If the decision is not PERMIT, AccessDeniedError is raised (or NotFoundError if stealth). If the decision is PERMIT, the middleware resolves all constraint handlers, runs on-decision handlers, runs method-invocation handlers (which can modify tool arguments), executes the tool, and applies return-value handlers.
Post-Enforce Lifecycle
The tool executes first. Then the middleware builds the authorization subscription including the return value and queries the PDP. If not PERMIT, the return value is suppressed. If PERMIT, return-value handlers can transform the result. Method-invocation handlers do not run because the tool has already executed.
Auth Path Lifecycle
The auth=sapl() path uses gate-level enforcement only. It builds the subscription, queries the PDP, runs ON_DECISION handlers (obligations strict, advice best-effort), and returns a boolean. Resource replacement in the auth path is not supported and causes denial. There is no argument modification, result transformation, or error mapping.
Constraint Handlers
When the PDP returns a decision with obligations or advice, the constraint enforcement service resolves and executes all matching handlers.
Handler Types Reference
| Type | Protocol | When | Middleware | auth=sapl() |
|---|---|---|---|---|
runnable |
RunnableConstraintHandlerProvider |
On decision | Yes | Yes |
method_invocation |
MethodInvocationConstraintHandlerProvider |
Before tool (@pre_enforce) |
Yes | No |
consumer |
ConsumerConstraintHandlerProvider |
After tool, inspects result | Yes | No |
mapping |
MappingConstraintHandlerProvider |
After tool, transforms result | Yes | No |
filter_predicate |
FilterPredicateConstraintHandlerProvider |
After tool, filters list elements | Yes | No |
error_handler |
ErrorHandlerProvider |
On error, inspects | Yes | No |
error_mapping |
ErrorMappingConstraintHandlerProvider |
On error, transforms | Yes | No |
Registering Handlers
For the middleware path, register handlers on the ConstraintEnforcementService:
constraint_service = ConstraintEnforcementService()
constraint_service.register_runnable(AccessLoggingProvider())
constraint_service.register_method_invocation(LimitResultsProvider())
constraint_service.register_filter_predicate(FilterByClassificationProvider())
For the auth path, register handlers via get_constraint_service():
from sapl_fastmcp import get_constraint_service
get_constraint_service().register_runnable(AccessLoggingProvider())
Example: On-Decision Handler (Logging)
from sapl_base.constraint_types import Signal
class AccessLoggingProvider:
def is_responsible(self, constraint) -> bool:
return isinstance(constraint, dict) and constraint.get("type") == "logAccess"
def get_signal(self) -> Signal:
return Signal.ON_DECISION
def get_handler(self, constraint):
message = constraint.get("message", "Tool access")
subject = constraint.get("subject", "unknown")
action = constraint.get("action", "unknown")
def handler() -> None:
logger.info("ACCESS LOG: %s -- subject=%s, action=%s", message, subject, action)
return handler
Example: Method Invocation Handler (Argument Capping)
from sapl_base.constraint_types import MethodInvocationContext
class LimitResultsProvider:
def is_responsible(self, constraint) -> bool:
return isinstance(constraint, dict) and constraint.get("type") == "limitResults"
def get_handler(self, constraint):
max_limit = int(constraint.get("maxLimit", 10))
def handler(context: MethodInvocationContext) -> None:
current = context.kwargs.get("limit")
if current is None:
return
try:
current = int(current)
except (TypeError, ValueError):
context.kwargs["limit"] = max_limit
return
if current > max_limit:
context.kwargs["limit"] = max_limit
return handler
Example: Filter Predicate Handler (Classification Filter)
class FilterByClassificationProvider:
def is_responsible(self, constraint) -> bool:
return isinstance(constraint, dict) and constraint.get("type") == "filterByClassification"
def get_handler(self, constraint):
allowed = set(constraint.get("allowedLevels", []))
def predicate(element) -> bool:
if isinstance(element, dict):
return element.get("classification") in allowed
return True
return predicate
Built-in Constraint Handlers
ContentFilteringProvider
Constraint type: filterJsonContent
Transforms response values by deleting, replacing, or blackening fields. A policy can attach this obligation:
obligation
{
"type": "filterJsonContent",
"actions": [
{ "type": "blacken", "path": "$.ssn", "discloseRight": 4 },
{ "type": "delete", "path": "$.internalNotes" },
{ "type": "replace", "path": "$.classification", "replacement": "REDACTED" }
]
}
The blacken action supports these options:
| Option | Type | Default | Description |
|---|---|---|---|
path |
string | (required) | Dot-notation path to a string field |
replacement |
string | block character | Character used for masking |
discloseLeft |
number | 0 |
Characters to leave unmasked from the left |
discloseRight |
number | 0 |
Characters to leave unmasked from the right |
length |
number | (masked section length) | Override the length of the masked section |
ContentFilterPredicateProvider
Constraint type: jsonContentFilterPredicate
Filters array elements or nullifies single values that do not meet conditions.
{
"type": "jsonContentFilterPredicate",
"conditions": [
{ "path": "$.classification", "type": "!=", "value": "top-secret" }
]
}
ContentFilter Limitations
The built-in content filter supports simple dot-notation paths only ($.field.nested). Recursive descent ($..ssn), bracket notation ($['field']), array indexing ($.items[0]), wildcards ($.users[*].email), and filter expressions ($.books[?(@.price<10)]) are not supported.
Registration
For the auth path, built-in handlers are auto-registered by configure_sapl(). For the middleware path, register them on the ConstraintEnforcementService manually, or call configure_sapl() and pass its service to the middleware.
STDIO Transport
SAPL authorization is bypassed for the STDIO transport. STDIO is a local subprocess transport with no network boundary and no authentication context (no tokens, no headers). This matches FastMCP’s built-in AuthorizationMiddleware behavior.
All middleware hooks pass through without PDP calls when the transport is STDIO. From an authorization perspective, constraining agent actions over STDIO requires a different trust and identity model that is outside the scope of the current integration.
Manual PDP Access
For cases where neither middleware nor auth=sapl() fits, access the PDP client directly:
from sapl_fastmcp import get_pdp_client
from sapl_base import AuthorizationSubscription, Decision
pdp = get_pdp_client()
subscription = AuthorizationSubscription(
subject="anonymous",
action="read",
resource="hello",
)
decision = await pdp.decide_once(subscription)
if decision.decision == Decision.PERMIT and not decision.obligations:
# proceed
...
When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement.
Writing SAPL Policies for MCP
SAPL policies evaluate against the subscription fields your MCP server sends. This section uses the demo’s analytics.sapl policy file as a running example.
Subscription Shape
With the middleware path using default subscription fields:
subjectis the JWT claims dict (includespreferred_username,realm_access.roles, etc.)actionis the operation verb ("call","read","get")resourceis a dict withname,arguments,tags, and optionallyuri
Tag-Based Policies
Components tagged in FastMCP have their tags included in the resource. A policy granting access to all public components:
policy "public-access"
permit
"public" in resource.tags;
Role-Based Policies
With JWT claims as the subject, check roles from the identity provider:
policy "engineering-access"
permit
"engineering" in resource.tags;
"ENGINEER" in subject.realm_access.roles;
Obligation Examples
Attach constraints that handlers enforce at runtime:
policy "analyst-customer-queries"
permit
resource.name == "query_customer_data";
"ANALYST" in subject.realm_access.roles;
obligation
{
"type": "limitResults",
"maxLimit": 5
}
obligation
{
"type": "logAccess",
"message": "Customer PII query (result limit enforced)",
"subject": subject.preferred_username,
"action": action
}
The limitResults obligation is handled by a MethodInvocationConstraintHandlerProvider that caps the limit argument before the tool executes. The logAccess obligation is handled by a RunnableConstraintHandlerProvider that logs the access event.
Filter Predicate Obligations
Filter list results based on element properties:
policy "analyst-export-listing"
permit
resource.name == "list_data_exports";
"ANALYST" in subject.realm_access.roles;
obligation
{
"type": "filterByClassification",
"allowedLevels": ["public", "internal"]
}
The FilterByClassificationProvider removes list elements whose classification field is not in the allowed set.
Advice (Best-Effort)
Use advice instead of obligation when failure should not block access:
policy "pii-access"
permit
"pii" in resource.tags;
"ANALYST" in subject.realm_access.roles | "COMPLIANCE" in subject.realm_access.roles;
advice
{
"type": "logAccess",
"message": "PII data accessed",
"subject": subject.preferred_username,
"action": action
}
Policy Set Ordering
Use first or abstain to apply the first matching policy. More specific policies should come before general ones:
set "analytics"
first or abstain
policy "analyst-customer-queries" // specific: analyst + customer data + obligations
permit ...
policy "public-access" // general: all authenticated users + public tag
permit ...
policy "default-deny" // catch-all: log and deny
deny ...
Default Deny
A catch-all deny policy at the end ensures unauthorized access is logged:
policy "default-deny"
deny
obligation
{
"type": "logAccess",
"message": "Unauthorized access attempt denied",
"subject": subject.preferred_username,
"action": action
}
Demo Application
A complete working demo is available at sapl-python-demos/fastmcp_demo. It includes:
- Middleware server (
middleware_server.py) with@pre_enforce,@post_enforce, stealth mode, finalize callbacks, and three constraint handler types - Per-component auth server (
auth_server.py) withauth=sapl()on every tool, resource, and prompt - MCP client (
client.py) that exercises both servers - Automated E2E test (
demo.py) with a decision matrix across 4 users with different roles (ANALYST, ENGINEER, COMPLIANCE, INTERN) - SAPL policy file (
analytics.sapl) with tag-based, role-based, and obligation-driven policies
Configuration Reference
PdpConfig parameters passed to configure_sapl() or PdpClient():
| Parameter | Type | Default | Description |
|---|---|---|---|
base_url |
str |
"https://localhost:8443" |
PDP server URL |
token |
str |
None |
Bearer token / API key |
username |
str |
None |
Basic auth username |
password |
str |
None |
Basic auth password |
timeout |
float |
5.0 |
PDP request timeout in seconds |
allow_insecure_connections |
bool |
False |
Allow HTTP (never use in production) |
token (API key) and username/password (Basic Auth) are mutually exclusive.
SAPLMiddleware constructor parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
pdp |
PdpClient |
(required) | PDP client instance |
constraint_service |
ConstraintEnforcementService |
new instance | Constraint enforcement service |
enforce_listing |
bool |
True |
Enable multi-decide listing filter for stealth components |
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| All decisions INDETERMINATE | PDP unreachable | Check base_url, verify PDP is running |
| AccessDeniedError despite PERMIT | Unhandled obligation | Check handler is_responsible() matches the obligation type |
| Handler not firing | Missing registration | Register on the constraint service before server starts |
Subject is "anonymous" |
No JWT configured or STDIO transport | Configure an auth provider on FastMCP |
| Stealth warning in logs | stealth=True with auth=sapl() |
Use SAPLMiddleware for stealth mode |
RuntimeError: SAPL not configured |
Missing configure_sapl() |
Call configure_sapl() before server starts |
| STDIO requests bypass auth | Expected behavior | SAPL skips STDIO (local transport, no auth context) |
| Content filter throws | Unsupported path syntax | Only simple dot paths supported ($.field.nested) |
License
Apache-2.0