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-authorizationfromtypingimportAnnotated, UnionfrompydanticimportBaseModelfrommcp_authorizationimport (
Requires, define_tool, McpAuthServerOptions, create_starlette_app,
)
# Define schemas with authorization annotationsclassListOrdersInput(BaseModel):
status: strinclude_archived: Annotated[bool|None, Requires("admin")] =None# field-level gateclassSummary(BaseModel):
type: strcount: intclassDetailed(BaseModel):
type: strorders: list[str]
classError(BaseModel):
error: strListOrdersOutput=Union[
Summary,
Annotated[Detailed, Requires("export_data")], # variant-level gateError,
]
# Define a toollist_orders=define_tool(
name="list_orders",
description="List orders",
authorization="view_orders", # tool-level gateinput_schema=ListOrdersInput,
output_schema=ListOrdersOutput,
handler=lambdaparams, ctx: {"type": "summary", "count": 42},
)
# Wire up Starletteapp=create_starlette_app(McpAuthServerOptions(
name="my-server",
version="1.0.0",
tools=[list_orders],
context_builder=lambdareq: 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.
fromtypingimportAnnotatedfrompydanticimportBaseModelfrommcp_authorizationimportRequires, DependsOn, DefaultForclassAdvanceStepInput(BaseModel):
applicant_id: strworkflow_id: Annotated[str, DefaultFor("workflow_id")]
stage_id: Annotated[str|None, Requires("backward_routing")] =Nonereason: 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 gateinput_schema=AdvanceStepInput, # Pydantic BaseModel classoutput_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:
frommcp_authorizationimportMcpAuthServerOptions, create_starlette_appapp=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:
classMcpAuthContext(Protocol):
defcan(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:
@dataclassclassAppContext:
user: Userdefcan(self, permission: str) ->bool:
returncheck_role_authorized_action(self.user.role_uuid, permission)
defdefault_for(self, key: str) ->Any|None:
returngetattr(self.user.profile, key, None)
defbuild_context(request: Request) ->AppContext:
user=authenticate(request)
returnAppContext(user=user)MIT