Skip to content

onboardiq/mcp-authorization-python

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

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

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

Quick Start

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

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.
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"),
    ] = 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 gate
    input_schema=AdvanceStepInput,                     # Pydantic BaseModel class
    output_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:

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

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

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

License

MIT

About

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

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages