Per-request schema-level authorization for MCP tool servers. Type definitions ARE authorization policies.
MCP servers expose tools to LLM clients via tools/list. Today, every user sees the same tool schemas. If a user lacks permission to use a feature, the best you can do is reject the call with an error after the LLM already knows the capability exists and tried to use it.
Schema-level authorization solves this by shaping the JSON Schema each user sees before they can act on it. Different users hitting the same endpoint receive different tool lists, different input fields, and different output variants — based on their permissions. An LLM cannot hallucinate options it was never shown.
| Layer | What it gates | Mechanism |
|---|---|---|
| Tool visibility | Entire tools | authorization="permission" on the tool definition |
| Input field visibility | Individual input fields | Annotated[T, Requires("permission")] on Pydantic model fields |
| Output variant visibility | Union branches in output | Annotated[Model, Requires("permission")] on Union members |
pip install mcp-authorizationfrom typing import Annotated, Union
from pydantic import BaseModel
from mcp_authorization import (
Requires, define_tool, McpAuthServerOptions, create_starlette_app,
)
# Define schemas with authorization annotations
class ListOrdersInput(BaseModel):
status: str
include_archived: Annotated[bool | None, Requires("admin")] = None # field-level gate
class Summary(BaseModel):
type: str
count: int
class Detailed(BaseModel):
type: str
orders: list[str]
class Error(BaseModel):
error: str
ListOrdersOutput = Union[
Summary,
Annotated[Detailed, Requires("export_data")], # variant-level gate
Error,
]
# Define a tool
list_orders = define_tool(
name="list_orders",
description="List orders",
authorization="view_orders", # tool-level gate
input_schema=ListOrdersInput,
output_schema=ListOrdersOutput,
handler=lambda params, ctx: {"type": "summary", "count": 42},
)
# Wire up Starlette
app = create_starlette_app(McpAuthServerOptions(
name="my-server",
version="1.0.0",
tools=[list_orders],
context_builder=lambda req: MyAuthContext(req),
))Viewer calls tools/list and sees:
{
"name": "list_orders",
"inputSchema": {
"type": "object",
"properties": {
"status": { "type": "string" }
}
}
}Admin calls tools/list and sees:
{
"name": "list_orders",
"inputSchema": {
"type": "object",
"properties": {
"status": { "type": "string" },
"include_archived": { "type": "boolean" }
}
}
}The viewer's LLM never knows include_archived exists.
Attach to Pydantic fields or Union members via Annotated[T, marker]:
Requires(permission)— Gate this field/variant by permission. Removed from schema ifctx.can(permission)returns false.DependsOn(field)— GeneratesdependentRequiredin JSON Schema (this field is required only when the named field is present).DefaultFor(key)— Resolves a dynamic default fromctx.default_for(key)at schema compilation time.
from typing import Annotated
from pydantic import BaseModel
from mcp_authorization import Requires, DependsOn, DefaultFor
class AdvanceStepInput(BaseModel):
applicant_id: str
workflow_id: Annotated[str, DefaultFor("workflow_id")]
stage_id: Annotated[str | None, Requires("backward_routing")] = None
reason: Annotated[
str | None,
Requires("backward_routing"),
DependsOn("stage_id"),
] = NoneDefine an MCP tool with typed schemas and authorization:
define_tool(
name="advance_step",
description="Advance an applicant", # or callable: lambda ctx: "..."
authorization="manage_workflows", # tool-level permission gate
input_schema=AdvanceStepInput, # Pydantic BaseModel class
output_schema=AdvanceStepOutput, # Union type or BaseModel (optional)
handler=handle_advance_step, # async (params, ctx) -> Any
)Create a Starlette app that materializes a fresh MCP Server per request:
from mcp_authorization import McpAuthServerOptions, create_starlette_app
app = create_starlette_app(McpAuthServerOptions(
name="my-server",
version="1.0.0",
tools=[tool1, tool2],
context_builder=build_context, # (request) -> McpAuthContext
))The protocol your app implements to bridge its auth system:
class McpAuthContext(Protocol):
def can(self, permission: str) -> bool: ...
# default_for is optional — implement it if you use DefaultFor markersStrip JSON Schema keywords unsupported by Anthropic's strict tool use mode. Converts oneOf to anyOf, adds additionalProperties: false to all objects.
Each HTTP request creates a fresh MCP Server in stateless mode (no SSE, no sessions). The flow:
context_builderextracts user identity from the request ->McpAuthContextToolRegistry.materialize(ctx)filters tools by tool-level permissions- For each surviving tool,
compile_schema(pydantic_model, ctx)walks the type tree viaget_type_hints(include_extras=True), builds a permission map, generates JSON Schema via Pydantic'smodel_json_schema(), then prunes fields/variants wherectx.can()returns false - A low-level MCP
Serveris created with handlers fortools/list(returns pre-compiled schemas) andtools/call(executes handlers) - Connected to
StreamableHTTPServerTransportin stateless mode
This mirrors the architecture of mcp_authorization (Ruby gem) and mcp-authorization (TypeScript/Zod), adapted for Python/Pydantic.
The McpAuthContext protocol is deliberately minimal. For systems with complex RBAC (role matrices, action keys, feature flags), the shim is typically a few lines:
@dataclass
class AppContext:
user: User
def can(self, permission: str) -> bool:
return check_role_authorized_action(self.user.role_uuid, permission)
def default_for(self, key: str) -> Any | None:
return getattr(self.user.profile, key, None)
def build_context(request: Request) -> AppContext:
user = authenticate(request)
return AppContext(user=user)MIT