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 handlerasync def handler(request) -> ResponseIf you hand-rolled your MCP route
ASGI appmount_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 (initializetools/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 + access policy, 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