Skip to main content

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.

Dashboard walkthrough

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​

RequestBefore (unprotected)After (SDK mounted)
POST /mcp (no token)runs401 + WWW-Authenticate (tells the agent where to get a token)
POST /mcp (bad token)runs401
POST /mcp (valid token, missing scope)runs403 insufficient_scope
POST /mcp (valid token, granted scope)runs200 tool runs

How it works — the three gates​

Nothing reaches your tool code until three gates pass:

GateCheckFail
1. Token valid?JWT signature + live introspection401 + 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​

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​

VariableWhat it doesWhere it comes from
AUTHSEC_ISSUERThe OAuth issuer the SDK validates tokens against (iss claim).Always https://app.authsec.ai
AUTHSEC_AUTHORIZATION_SERVERThe AuthSec API origin used for non-issuer paths (scope matrix, manifest publish).Always https://app.authsec.ai
AUTHSEC_JWKS_URLThe 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_URLThe 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_IDYour application's UUID in AuthSec. Identifies which scope matrix and manifest to fetch.Setup tab
AUTHSEC_INTROSPECTION_CLIENT_IDThe client ID for introspection calls. Same value as AUTHSEC_RESOURCE_SERVER_ID.Setup tab
AUTHSEC_INTROSPECTION_CLIENT_SECRETThe shared secret for introspection. Shown once at application creation; rotate from the Setup tab if lost.Setup tab (one-time)
AUTHSEC_RESOURCE_URIThe 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_NAMEHuman-readable name for logs and the PRM metadata document.You choose it
AUTHSEC_POLICY_MODEHow 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_MODEHow 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_MANIFESTWhether 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
AUTHSEC_RESOURCE_URI must match exactly

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:

HandlerWhen to use
FastMCP instance (shown above)The standard path — auto-detected and wrapped
Async request handler — async def handler(request) -> ResponseIf 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:

FieldTypeWhat it controls
resource_server_idstrYour application's UUID
resource_uristrThe canonical Resource URI (audience for tokens)
issuerstrThe OAuth issuer
introspection_client_idstrClient ID for introspection calls
introspection_client_secretstrSecret for introspection calls
policy_modePolicyModeREMOTE_REQUIRED (fail closed) or REMOTE_WITH_LOCAL_FALLBACK
validation_modeValidationModeJWT_AND_INTROSPECT or JWT_ONLY
publish_manifestboolPublish the tool inventory at startup
tool_inventory_providerCallableFunction returning list[ManifestTool]
tool_scope_suggestionsdictOptional 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).

ParameterTypeWhat it does
appFastAPI or StarletteThe app to mount on
pathstrThe URL prefix (e.g. "/mcp")
handlerFastMCP, async handler, or ASGI appYour MCP handler (auto-detected)
cfgConfigThe 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​

SymptomCauseFix
Every tool call returns 403Tools aren't mapped or no default access policyMap tools + roles and access, then launch
401 even with a fresh tokenAUTHSEC_RESOURCE_URI doesn't match the URL agents callMake them identical (scheme, host, path)
Tools don't appear in the dashboardtool_inventory_provider not set, or manifest publish failedCheck the boot log for authsec manifest publish failed; set the provider
Scope changes not taking effectWithin the 30s cache windowWait 30s; if still stale, check the server can reach app.authsec.ai
remote_required denying everythingAuthSec unreachable — fail-closed by designFor dev only, relax AUTHSEC_POLICY_MODE; keep fail-closed in production
Lost the introspection secretShown once at creationRotate 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