Protect your MCP server (Python SDK)
The SDK wraps your MCP endpoint with OAuth token validation, per-tool
scope enforcement, and automatic metadata publishing. Your tool code stays
untouched — mount_mcp is the entire integration.
For the step-by-step dashboard setup with screenshots (register the application, configure scopes, map tools, set access policy, launch), see Protect an application.
What you'll have at the end
| Request | Before (unprotected) | After (SDK mounted) |
|---|---|---|
POST /mcp (no token) | runs | 401 + WWW-Authenticate (tells the agent where to get a token) |
POST /mcp (bad token) | runs | 401 |
POST /mcp (valid token, missing scope) | runs | 403 insufficient_scope |
POST /mcp (valid token, granted scope) | runs | 200 tool runs |
How it works — the three gates
Nothing reaches your tool code until three gates pass:
| Gate | Check | Fail |
|---|---|---|
| 1. Token valid? | JWT signature + live introspection | 401 + WWW-Authenticate (tells the agent where to authenticate) |
| 2. Which tool? | Parse the tools/call request | — |
| 3. Scopes sufficient? | Token's scopes vs. the tool's required scope (live policy from dashboard, 30s cache) | 403 insufficient_scope |
All three pass → your tool code runs.
Two things happen automatically at startup:
- PRM publishing — your server self-describes at
/.well-known/oauth-protected-resource/mcp(RFC 9728) so agents can discover where to get tokens. You never write this endpoint. - Manifest publishing — the SDK publishes your tool inventory to the dashboard, so admins see each tool by name and assign scopes.
Prerequisites
- Python ≥ 3.10
pip install authsec-sdk fastapi uvicorn python-dotenv mcp- A registered application with credentials from the Setup tab
Installation
pip install authsec-sdk
The complete server
A runnable server — two tools, fully protected:
# server.py
from fastapi import FastAPI
from mcp.server.fastmcp import FastMCP
from authsec_sdk import from_env, mount_mcp, ManifestTool
from dotenv import load_dotenv
mcp = FastMCP("my-server")
@mcp.tool()
def add_no(a: float, b: float) -> float:
return a + b
@mcp.tool()
def multiply_no(a: float, b: float) -> float:
return a * b
def my_tools():
return [
ManifestTool(
name="add_no",
description="Add two numbers",
input_schema={
"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"],
},
),
ManifestTool(
name="multiply_no",
description="Multiply two numbers",
input_schema={
"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"],
},
),
]
load_dotenv()
cfg = from_env() # reads all AUTHSEC_* env vars
cfg.tool_inventory_provider = my_tools # explicit manifest for the dashboard
app = FastAPI()
mount_mcp(app, "/mcp", mcp, cfg) # ← the entire integration
uvicorn server:app --host 0.0.0.0 --port 8000
Notice what you did not write: no token parsing, no JWKS fetching, no scope checks, no metadata endpoint, no per-tool guards. The tools are plain functions.
Environment variables
The SDK reads its entire config from environment variables via from_env().
Copy the values from the application's Setup tab. All shell flavors
(bash, PowerShell, Docker, Kubernetes) are in
Environment setup.
# ── Identity: who issues tokens ──
AUTHSEC_ISSUER=https://app.authsec.ai
AUTHSEC_AUTHORIZATION_SERVER=https://app.authsec.ai
AUTHSEC_JWKS_URL=https://app.authsec.ai/oauth/jwks
AUTHSEC_INTROSPECTION_URL=https://app.authsec.ai/oauth/introspect
# ── Your application (from the Setup tab) ──
AUTHSEC_RESOURCE_SERVER_ID=<uuid>
AUTHSEC_INTROSPECTION_CLIENT_ID=<uuid>
AUTHSEC_INTROSPECTION_CLIENT_SECRET=<one-time secret>
AUTHSEC_RESOURCE_URI=https://your-server.example.com/mcp
AUTHSEC_RESOURCE_NAME=my-mcp-server
# ── Behavior ──
AUTHSEC_POLICY_MODE=remote_required
AUTHSEC_VALIDATION_MODE=jwt_and_introspect
AUTHSEC_PUBLISH_MANIFEST=true
Variable reference
| Variable | What it does | Where it comes from |
|---|---|---|
AUTHSEC_ISSUER | The OAuth issuer the SDK validates tokens against (iss claim). | Always https://app.authsec.ai |
AUTHSEC_AUTHORIZATION_SERVER | The AuthSec API origin used for non-issuer paths (scope matrix, manifest publish). | Always https://app.authsec.ai |
AUTHSEC_JWKS_URL | The JSON Web Key Set endpoint. The SDK fetches public keys from here to verify JWT signatures. | Always https://app.authsec.ai/oauth/jwks |
AUTHSEC_INTROSPECTION_URL | The token introspection endpoint (RFC 7662). The SDK calls this to check if a token is still active (not revoked). | Always https://app.authsec.ai/oauth/introspect |
AUTHSEC_RESOURCE_SERVER_ID | Your application's UUID in AuthSec. Identifies which scope matrix and manifest to fetch. | Setup tab |
AUTHSEC_INTROSPECTION_CLIENT_ID | The client ID for introspection calls. Same value as AUTHSEC_RESOURCE_SERVER_ID. | Setup tab |
AUTHSEC_INTROSPECTION_CLIENT_SECRET | The shared secret for introspection. Shown once at application creation; rotate from the Setup tab if lost. | Setup tab (one-time) |
AUTHSEC_RESOURCE_URI | The canonical URI of your protected resource. Tokens are audience-bound to this exact URI -- scheme, host, and path must match what agents call. | You set this at registration |
AUTHSEC_RESOURCE_NAME | Human-readable name for logs and the PRM metadata document. | You choose it |
AUTHSEC_POLICY_MODE | How the SDK behaves when the scope matrix is unavailable. remote_required = fail closed (deny all). remote_with_local_fallback = serve from a local cache if available. | You choose; remote_required recommended for production |
AUTHSEC_VALIDATION_MODE | How tokens are validated. jwt_and_introspect = signature check plus live introspection (catches revoked tokens). jwt_only = signature only (faster, but won't catch revocations for up to the token's lifetime). | You choose; jwt_and_introspect recommended |
AUTHSEC_PUBLISH_MANIFEST | Whether to publish the tool inventory to AuthSec at startup. Set true so the dashboard's Tools tab stays in sync with your code. | You choose; true recommended |
Three things must be identical: (1) the Public base URL + protected
path you registered, (2) this env var, and (3) the URL your server is
actually deployed on. A mismatch means invalid_audience on every
request -- even with a valid token.
mount_mcp — handler forms
mount_mcp(app, path, handler, cfg) accepts three handler types:
| Handler | When to use |
|---|---|
| FastMCP instance (shown above) | The standard path — auto-detected and wrapped |
Async request handler — async def handler(request) -> Response | If you hand-rolled your MCP route |
ASGI app — mount_mcp(app, "/mcp", wrap_asgi_handler(asgi_app), cfg) | If your handler is a full ASGI application |
tool_inventory_provider — declaring your tools
When you pass a FastMCP instance to mount_mcp, the SDK has no built-in
way to enumerate your tools for the manifest. You must set
cfg.tool_inventory_provider — a function returning a list of
ManifestTool:
cfg.tool_inventory_provider = my_tools
Without it, the manifest publish fails non-fatally: the server runs but
the startup log shows authsec manifest publish failed (non-fatal): ...
and no tools appear in the dashboard's Tools tab.
Alternative: pass rpc_handler= to mount_mcp (an in-process
JSON-RPC callable). The SDK then enumerates tools by performing a
synthetic MCP handshake (initialize → tools/list) against it.
SDK class reference
from_env()
from authsec_sdk import from_env
cfg = from_env() # reads all AUTHSEC_* env vars, returns a Config
Factory function that reads the environment variables listed above and
returns a fully configured Config object. This is the recommended way
to initialize the SDK -- no manual wiring.
Config
The configuration object passed to mount_mcp. Key fields:
| Field | Type | What it controls |
|---|---|---|
resource_server_id | str | Your application's UUID |
resource_uri | str | The canonical Resource URI (audience for tokens) |
issuer | str | The OAuth issuer |
introspection_client_id | str | Client ID for introspection calls |
introspection_client_secret | str | Secret for introspection calls |
policy_mode | PolicyMode | REMOTE_REQUIRED (fail closed) or REMOTE_WITH_LOCAL_FALLBACK |
validation_mode | ValidationMode | JWT_AND_INTROSPECT or JWT_ONLY |
publish_manifest | bool | Publish the tool inventory at startup |
tool_inventory_provider | Callable | Function returning list[ManifestTool] |
tool_scope_suggestions | dict | Optional scope hints per tool (shown in dashboard) |
mount_mcp()
from authsec_sdk import mount_mcp
rt = mount_mcp(app, path, handler, cfg)
Mounts the AuthSec-protected MCP route on a FastAPI/Starlette app.
Returns a Runtime instance for advanced use (e.g. hooking startup).
| Parameter | Type | What it does |
|---|---|---|
app | FastAPI or Starlette | The app to mount on |
path | str | The URL prefix (e.g. "/mcp") |
handler | FastMCP, async handler, or ASGI app | Your MCP handler (auto-detected) |
cfg | Config | The configuration from from_env() |
rpc_handler= | Callable (optional) | In-process JSON-RPC handler for manifest enumeration |
ManifestTool
from authsec_sdk import ManifestTool
ManifestTool(
name="add_no",
description="Add two numbers",
input_schema={"type": "object", "properties": {...}, "required": [...]},
annotations=None, # MCP annotations (readOnlyHint, destructiveHint)
suggested_scopes=None, # scope hints for the dashboard mapping UI
)
Used in tool_inventory_provider to declare tools for the manifest.
Each entry becomes a row on the dashboard's Tools tab.
Verify the protection
Two quick checks after starting the server:
# 1. PRM metadata is served (agents discover your server through this)
curl https://your-server.example.com/.well-known/oauth-protected-resource/mcp
# 2. Unauthenticated calls get a 401 + WWW-Authenticate challenge
curl -i -X POST https://your-server.example.com/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
Then run the dashboard's Run protection check on the application's Setup tab — see Run the protection test for what each of the eight checks proves.
Under the hood
Token validation (runtime/validator.py) —
in jwt_and_introspect mode each request's token is (1) signature-verified
against AuthSec's JWKS, checking iss, aud, exp, and (2) introspected
live so revoked tokens die immediately. Verification runs in a worker thread
— your event loop never blocks.
Scope matrix (runtime/scope_matrix.py) — the tool→scope mapping is fetched from AuthSec and cached for 30s. That's the "propagates in ~30 seconds" guarantee — dashboard changes reach a running server within that window, no redeploy.
Manifest (runtime/manifest.py) —
at startup the SDK collects your tool inventory and PUTs it to AuthSec.
The inventory comes from cfg.tool_inventory_provider, or from a
synthetic MCP handshake if you passed rpc_handler= instead.
PRM (runtime/metadata.py) —
the RFC 9728 document, with scopes_supported synced live from the
dashboard.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every tool call returns 403 | Tools aren't mapped or no default access policy | Map tools + access policy, then launch |
401 even with a fresh token | AUTHSEC_RESOURCE_URI doesn't match the URL agents call | Make them identical (scheme, host, path) |
| Tools don't appear in the dashboard | tool_inventory_provider not set, or manifest publish failed | Check the boot log for authsec manifest publish failed; set the provider |
| Scope changes not taking effect | Within the 30s cache window | Wait 30s; if still stale, check the server can reach app.authsec.ai |
remote_required denying everything | AuthSec unreachable — fail-closed by design | For dev only, relax AUTHSEC_POLICY_MODE; keep fail-closed in production |
| Lost the introspection secret | Shown once at creation | Rotate from the application's Setup tab |
What's next
The server is protected. Now onboard its callers:
- M2M auth — service accounts for pipelines, CI, backend services
- ID-JAG delegation — AI agents acting on behalf of users