Skip to content

Latest commit

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

mcp-authorization

Per-request schema-level authorization for MCP tool servers. Type definitions ARE authorization policies.

The Problem

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.

Three Layers of Authorization

LayerWhat it gatesMechanism
Tool visibilityEntire toolsauthorization="permission" on the tool definition
Input field visibilityIndividual input fieldsAnnotated[T, Requires("permission")] on Pydantic model fields
Output variant visibilityUnion branches in outputAnnotated[Model, Requires("permission")] on Union members

Quick Start

pip install mcp-authorization
fromtypingimportAnnotated, 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.

API Reference

Metadata Markers

Attach to Pydantic fields or Union members via Annotated[T, marker]:

  • Requires(permission) — Gate this field/variant by permission. Removed from schema if ctx.can(permission) returns false.
  • DependsOn(field) — Generates dependentRequired in JSON Schema (this field is required only when the named field is present).
  • DefaultFor(key) — Resolves a dynamic default from ctx.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"),
] =None

define_tool(**kwargs)

Define 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_starlette_app(options)

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
))

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 markers

strict_sanitize(schema)

Strip JSON Schema keywords unsupported by Anthropic's strict tool use mode. Converts oneOf to anyOf, adds additionalProperties: false to all objects.

Architecture

Each HTTP request creates a fresh MCP Server in stateless mode (no SSE, no sessions). The flow:

  1. context_builder extracts user identity from the request -> McpAuthContext
  2. ToolRegistry.materialize(ctx) filters tools by tool-level permissions
  3. For each surviving tool, compile_schema(pydantic_model, ctx) walks the type tree via get_type_hints(include_extras=True), builds a permission map, generates JSON Schema via Pydantic's model_json_schema(), then prunes fields/variants where ctx.can() returns false
  4. A low-level MCP Server is created with handlers for tools/list (returns pre-compiled schemas) and tools/call (executes handlers)
  5. Connected to StreamableHTTPServerTransport in stateless mode

This mirrors the architecture of mcp_authorization (Ruby gem) and mcp-authorization (TypeScript/Zod), adapted for Python/Pydantic.

Integrating with Existing RBAC

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)

License

MIT

About

Per-request schema-level authorization for MCP tool servers — type definitions ARE authorization policies (Python/Pydantic)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages