First-Party litestar-security Integration¶
Applications that standardize on litestar-security for multi-mechanism authentication, role/permission/scope evaluation, and RFC 9728 OAuth 2.0 Protected Resource Metadata integrate with litestar-mcp without any adapter boilerplate:
- Router Policy Forwarding (
MCPConfig.route_opt):LitestarMCPforwardsMCPConfig.route_optdirectly onto the mounted/mcpLitestarRouterand merges it into the JSON-RPC handler'soptmapping. - Native
Principal&SecurityContextPropagation: Internal ASGI tool and prompt dispatch (execute_tool,execute_prompt) preservesconnection.user(Principal),connection.auth(SecurityContext), andscope["litestar_security"]. - Zero-Config Worker Rehydration (
QueueTaskExecutor): When task-augmentedtools/callexecutions run asynchronously onlitestar-queuesworkers,litestar-mcpautomatically snapshots and rehydratesPrincipal,AuthorizationSnapshot,CredentialRestrictions, andSecurityContextinto the worker's synthesized request scope.
1. Installation¶
Install litestar-mcp alongside litestar-security (and include the queues extra if you run distributed MCP Tasks on litestar-queues):
2. Declaring Mechanism Policies with MCPConfig(route_opt=...)¶
In litestar-security, routes declare which authentication mechanisms protect them via opt={"auth": required("api-key")} (where required is exported from litestar_security). Pass that policy dictionary to MCPConfig(route_opt=...) so the /mcp router and every MCP JSON-RPC dispatch require the named mechanism:
"""Snippet: MCP route_opt policy wiring, guard enforcement, and principal propagation."""
# start-example
from typing import TYPE_CHECKING, Any
from litestar import Litestar, Request, get
from litestar.exceptions import PermissionDeniedException
from litestar_mcp import LitestarMCP, MCPConfig
if TYPE_CHECKING:
from litestar.connection import ASGIConnection
from litestar.handlers.base import BaseRouteHandler
def required(*mechanisms: str) -> dict[str, tuple[str, ...]]:
"""Build a route-level authentication mechanism requirement descriptor."""
return {"required_mechanisms": mechanisms}
def require_notes_read_scope(
connection: "ASGIConnection[Any, Any, Any, Any]",
handler: "BaseRouteHandler",
) -> None:
"""Enforce that the caller's SecurityContext or auth claims include notes:read."""
_ = handler
security_context = connection.scope.get("litestar_security") or connection.scope.get("auth")
if security_context is None:
msg = "Authentication context is required"
raise PermissionDeniedException(msg)
authorization = getattr(security_context, "authorization", security_context)
scopes = getattr(authorization, "scopes", None)
if scopes is None and isinstance(authorization, dict):
scopes = authorization.get("scopes", ())
if "notes:read" not in (scopes or ()):
msg = "Missing required scope: notes:read"
raise PermissionDeniedException(msg)
@get("/notes/audit", mcp_tool="audit_notes", guards=[require_notes_read_scope])
async def audit_notes(request: Request[Any, Any, Any]) -> dict[str, Any]:
"""Return caller principal and active authorization scopes for audit verification."""
principal = request.user
subject = getattr(principal, "subject", None) or getattr(principal, "id", str(principal))
security_context = request.scope.get("litestar_security") or request.auth
authorization = getattr(security_context, "authorization", security_context)
raw_scopes = getattr(authorization, "scopes", ())
if not raw_scopes and isinstance(authorization, dict):
raw_scopes = authorization.get("scopes", ())
return {
"subject": subject,
"scopes": sorted(str(scope) for scope in raw_scopes),
}
config = MCPConfig(
route_opt={"auth": required("api-key", "bearer-jwt")},
guards=[require_notes_read_scope],
)
app = Litestar(
route_handlers=[audit_notes],
plugins=[LitestarMCP(config)],
)
# end-example
def build() -> Litestar:
"""Return the configured Litestar application for snippet smoke testing."""
return app
When SecurityPlugin is installed on the Litestar application, you import required directly from litestar_security and pass MCPConfig(route_opt={"auth": required("api-key")}). litestar-mcp forwards route_opt to the /mcp router and relies on SecurityPlugin to evaluate credentials.
3. Reading Principal and SecurityContext in Tools and Guards¶
When litestar-security authenticates a request on /mcp, it populates three scope keys that remain available inside every MCP tool handler, dependency provider (Provide or Dishka FromDishka[T]), and route Guard:
| Scope / Connection Attribute | litestar-security Type |
Key Attributes | Description |
|---|---|---|---|
request.user / connection.user |
Principal |
user, account_id, subject, mechanisms, restrictions, attributes |
Wraps your domain user entity (principal.user) with canonical account_id, subject, and active mechanism names. |
request.auth / connection.auth |
SecurityContext |
principal, authorization, restrictions |
Complete per-request security evaluation context. |
request.scope["litestar_security"] |
SecurityContext |
principal, authorization, restrictions |
Explicit ASGI scope key read by litestar-security guards, evaluators, and litestar-mcp task snapshotting. |
security_context.authorization |
AuthorizationSnapshot |
roles, permissions, scopes, attributes, tenant_roles |
Immutable frozenset collections of effective roles, permissions, OAuth scopes, and per-tenant role mappings (Mapping[str, frozenset[str]]). |
security_context.restrictions |
tuple[CredentialRestrictions, ...] |
scopes, tenant_ids, roles |
Optional narrowing restrictions attached to scoped API keys or delegated tokens. |
4. RFC 9728 Protected Resource Metadata & WWW-Authenticate Challenges¶
MCP clients that support OAuth 2.1 / RFC 9728 discover your authorization server by inspecting the WWW-Authenticate: Bearer resource_metadata="..." response header returned when an unauthenticated request hits /mcp, then fetching GET /.well-known/oauth-protected-resource.
In litestar-security, configure both the challenge emission and the RFC 9728 metadata document on SecurityConfig:
- Declare a Bearer
SecuritySchemeon the Mechanism:litestar-securityemits theWWW-Authenticate: Bearer resource_metadata=...header only when the failing mechanism declaressecurity_scheme=SecurityScheme(type="http", scheme="bearer")and only on authentication evaluator failures (401 Unauthorized), never on post-authentication guard failures (403 Forbidden). - Publish
ProtectedResourceConfigonSecurityConfig.protected_resource:SecurityPluginmounts/.well-known/oauth-protected-resourceadvertising your resource identifier,authorization_servers, andscopes_supported.
from litestar import Litestar, Request, get
from litestar.openapi.spec import SecurityScheme
from litestar_security import (
ProtectedResourceConfig,
SecurityConfig,
SecurityPlugin,
TokenMechanism,
required,
)
from litestar_mcp import LitestarMCP, MCPConfig
@get("/notes", mcp_tool="list_notes")
async def list_notes(request: Request) -> dict[str, object]:
"""List notes for the authenticated principal."""
return {
"account_id": request.user.account_id,
"scopes": sorted(request.auth.authorization.scopes),
}
security_config = SecurityConfig(
mechanisms=[
TokenMechanism(
name="bearer-jwt",
security_scheme=SecurityScheme(type="http", scheme="bearer", bearer_format="JWT"),
verifier=...,
),
],
protected_resource=ProtectedResourceConfig(
resource="https://api.example.com/mcp",
authorization_servers=["https://auth.example.com"],
scopes_supported=["notes:read", "notes:write"],
),
)
mcp_config = MCPConfig(
route_opt={"auth": required("bearer-jwt")},
)
app = Litestar(
route_handlers=[list_notes],
plugins=[
SecurityPlugin(security_config),
LitestarMCP(mcp_config),
],
)
Evaluator Failures (401) vs Guard Failures (403)
If a request arrives without a valid token, litestar-security's mechanism evaluator rejects /mcp at the HTTP boundary with 401 Unauthorized and attaches WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource", prompting the MCP client to initiate the OAuth flow. Once authenticated, if a route guard rejects a specific tools/call due to missing permissions, the failure occurs inside the tool dispatch and is reported as an MCP tool error (isError: true).
5. Automatic Worker Rehydration in QueueTaskExecutor¶
When a client invokes a task-augmented tool (params.task under io.modelcontextprotocol/tasks) and QueueTaskExecutor dispatches the execution attempt to a separate litestar-queues worker process, the live HTTP connection is no longer present.
litestar-mcp bridges litestar-security across the queue boundary automatically through extract_principal_snapshot and rehydrate_security_scope in litestar_mcp.mcp.tasks:
flowchart LR
A["HTTP Request on /mcp (Principal & SecurityContext)"] --> B["extract_principal_snapshot (ToolTaskCall.principal)"]
B --> C["litestar-queues Task (litestar_mcp.mcp.run_tool_task)"]
C --> D["rehydrate_security_scope (scope['user'], scope['auth'], scope['litestar_security'])"]
D --> E["Worker Guards, Dishka & Tool Handler"]
What Is Captured at Enqueue Time (extract_principal_snapshot)¶
When MCPHandlerService creates the ToolTaskCall envelope, extract_principal_snapshot inspects request.user, request.auth, and request.scope.get("litestar_security") and serializes:
- Inner Domain User: JSON-safe fields of
principal.user(includingid,sub,user_id,account_id,username,email,roles,scopes,permissions,tenant,tenant_id) plus a__user_type__tag ("<module>:<QualName>") when the inner user is amsgspec.Structor@dataclass. PrincipalMetadata (__litestar_security__.principal):account_id,subject,mechanismstuple,attributesmapping, and principal-levelrestrictions.AuthorizationSnapshot(__litestar_security__.authorization):roles,permissions,scopes,attributes, andtenant_roles.- Context
CredentialRestrictions(__litestar_security__.restrictions):scopes,tenant_ids, androlesrestriction sets.
What Is Rehydrated in the Worker (rehydrate_security_scope)¶
When run_mcp_tool_task executes on a litestar-queues worker:
rehydrate_security_scopereconstructs the inner user struct viamsgspec.convert(when__user_type__resolves to a loadedmsgspec.Structor@dataclass) or wraps the user fields inPrincipalProxy(supporting bothuser.idattribute access anduser["id"]key lookup).- It imports
Principal,AuthorizationSnapshot,CredentialRestrictions, andSecurityContextfromlitestar_security.context(falling back to structurally identical frozen dataclasses iflitestar_securityis not imported) and reconstructs the immutable security graph. - It populates
scope["user"] = principal_obj,scope["auth"] = security_ctx, andscope["litestar_security"] = security_ctxon the worker's synthetic HTTP scope before DishkaScope.REQUESTdependencies, LitestarProvidedependencies, and routeguardsexecute.
Custom Fail-Closed Principal Rehydration (principal_loader)¶
If your worker must re-fetch the live account record from a database on every attempt (for example, to check whether an account was suspended between enqueue time and worker execution), pass an async principal_loader callback to QueueTaskExecutor:
from litestar_mcp import LitestarMCP, MCPConfig, MCPTaskConfig
from litestar_mcp.queues import QueueTaskExecutor
async def load_active_principal(owner_id: str) -> dict[str, object]:
"""Load the current user record from the database or raise if revoked."""
raw_user_id = owner_id.removeprefix("user:")
return {"id": raw_user_id, "sub": raw_user_id, "is_active": True}
config = MCPConfig(
tasks=MCPTaskConfig(
executor=QueueTaskExecutor(principal_loader=load_active_principal),
),
)
plugin = LitestarMCP(config)
When principal_loader returns a non-None user object, that object takes precedence as scope["user"] while scope["litestar_security"] continues to provide the rehydrated SecurityContext snapshot. See Distributed Task Queues for full QueueTaskExecutor configuration details.