Python SDK
The Python SDK is the supported, production-ready way to add AuthSec to a Python MCP server. It is at full feature parity with the Go SDK as of 4.3.0: same Config shape, same PolicyMode / ValidationMode enums, same RFC 9728 protected-resource metadata, same scope-matrix fetcher with deny-all on incomplete policy, same one-way manifest publisher.
If you haven't already, walk through From zero to a launched MCP server — it covers the surrounding setup (registration, scope mapping, activation) that the SDK plugs into.
Install
pip install authsec-sdk
Latest version: 4.3.0. Requires Python ≥ 3.10.
The runtime API lives under authsec_sdk.runtime:
from authsec_sdk.runtime import Config, PolicyMode, ValidationMode, mount_mcp
The legacy decorators (protected_by_AuthSec, run_mcp_server_with_oauth) still live in the top-level authsec_sdk package and continue to work against the same backend. Use the runtime API for new code — it matches the Go SDK exactly and is what the admin UI's "Show Python config" panel generates.
Five-line quickstart
A complete executable lives at sdk-authsec/packages/python-sdk/examples/protect_existing_mcp_server.py. Paste from there — it's the canonical reference.
import os
from fastapi import FastAPI
from authsec_sdk.runtime import Config, PolicyMode, ValidationMode, mount_mcp
cfg = Config(
issuer="https://dev.api.authsec.dev",
authorization_server="https://dev.api.authsec.dev",
jwks_url="https://dev.api.authsec.dev/oauth/jwks",
introspection_url="https://dev.api.authsec.dev/oauth/introspect",
introspection_client_id="525da3b4-4206-4070-ad68-90cc3a6de43b",
introspection_client_secret=os.environ["AUTHSEC_INTROSPECTION_CLIENT_SECRET"],
resource_server_id="525da3b4-4206-4070-ad68-90cc3a6de43b",
resource_uri="https://mcp.example.com/mcp",
resource_name="GitHub MCP Server",
policy_mode=PolicyMode.REMOTE_REQUIRED,
validation_mode=ValidationMode.JWT_AND_INTROSPECT,
publish_manifest=True,
)
app = FastAPI()
mount_mcp(app, "/mcp", your_existing_mcp_handler, cfg)
That's the whole integration. The SDK takes over from here:
- The FastAPI app serves
/mcp(your handler, wrapped) and/.well-known/oauth-protected-resource/mcp(RFC 9728 metadata that the 401 challenge URL points at). - Every incoming request gets its
Authorization: Bearer ...token verified. tools/callis gated by the scope mapping you configured on the Tools tab in the admin UI.- On startup, the SDK pushes the tool inventory to AuthSec so the admin UI shows your tools.
Where Config values come from
Every Config field maps to a field in the POST /authsec/resource-servers response. Paste, don't hand-write:
Config field | Response field | What it is |
|---|---|---|
issuer | issuer_url | The AuthSec backend's OAuth issuer URL. |
authorization_server | issuer_url | Same value — the SDK uses this to construct the sdk-policy URL. |
jwks_url | jwks_uri | Public keys for local JWT verification. |
introspection_url | introspection_endpoint | Token introspection endpoint (Basic auth). |
resource_server_id | id | The UUID of this resource server. |
introspection_client_id | id | Username half of the Basic auth pair. Same value as resource_server_id. |
introspection_client_secret | introspection_secret | Password half. Only shown once; rotate via the admin UI. |
resource_uri | resource_url | Your MCP server's externally-reachable URL. What aud claims are bound to. |
The two SDK URLs (scope_matrix_url, manifest_url) in the response are derived from authorization_server + resource_server_id — you don't pass them in directly.
Build a Config from environment variables
For container deployments, use from_env() instead of hand-constructing:
from authsec_sdk.runtime import from_env, mount_mcp
cfg = from_env() # reads AUTHSEC_* env vars
cfg.validate() # raises ValueError on any misconfiguration
mount_mcp(app, "/mcp", your_handler, cfg)
The supported env vars match the admin UI's .env block exactly:
AUTHSEC_ISSUER
AUTHSEC_AUTHORIZATION_SERVER
AUTHSEC_JWKS_URL
AUTHSEC_INTROSPECTION_URL
AUTHSEC_INTROSPECTION_CLIENT_ID
AUTHSEC_INTROSPECTION_CLIENT_SECRET
AUTHSEC_RESOURCE_URI
AUTHSEC_RESOURCE_NAME
AUTHSEC_RESOURCE_SERVER_ID
AUTHSEC_SUPPORTED_SCOPES # space-separated
AUTHSEC_POLICY_MODE # remote_required | remote_with_local_fallback | local_only | open
AUTHSEC_VALIDATION_MODE # jwt_and_introspect | jwt_only | introspection_only | jwt_or_introspect
AUTHSEC_PUBLISH_MANIFEST # true | false
PolicyMode
Controls where the SDK gets its tool→scope mapping from. Same semantics as the Go SDK.
| Mode | When to use | What happens |
|---|---|---|
PolicyMode.REMOTE_REQUIRED | Production. Default when resource_server_id is set. | SDK fetches the scope matrix from AuthSec at startup. If the fetch fails, startup fails. Fresh policy is pulled lazily on stale reads. |
PolicyMode.REMOTE_WITH_LOCAL_FALLBACK | Resilient deployments. | Try remote first; fall back to Config.tool_scopes if AuthSec is unreachable. Both resource_server_id and tool_scopes must be set. |
PolicyMode.LOCAL_ONLY | Air-gapped or test environments. | Never call AuthSec. Use Config.tool_scopes only. |
PolicyMode.OPEN | Test only. Do not use in production. | Skip all tool-level policy. Any valid token can call any tool. |
When policy_mode is left as PolicyMode.UNSET, the SDK infers:
REMOTE_REQUIREDifresource_server_idis setLOCAL_ONLYiftool_scopesis set butresource_server_idisn'tOPENif neither is set
ValidationMode
Controls how the SDK verifies each token.
| Mode | What it does | Tradeoff |
|---|---|---|
ValidationMode.JWT_ONLY | Verify signature locally against JWKS. | Lowest latency. Revocations not detected until token expires. |
ValidationMode.INTROSPECTION_ONLY | Call /oauth/introspect on every request. | Highest fidelity (catches revocations and tenant-membership suspensions). Adds a network call per request. |
ValidationMode.JWT_OR_INTROSPECT | Try JWT first; fall back to introspection on parse failure. | Tolerant. Used during gradual rollouts. |
ValidationMode.JWT_AND_INTROSPECT | Verify both; either failure denies. | Strictest. Recommended default when both URLs are set. |
JWT_AND_INTROSPECT is what the admin UI defaults to in the "Show Python config" panel. It's the same default the Go SDK ships.
What happens at startup
mount_mcp() registers a FastAPI/Starlette startup event that does three things:
- Validates the Config. Any misconfigured field raises
ValueErrorbefore your app starts serving. - Fetches the scope matrix from
GET /authsec/resource-servers/{rsid}/sdk-policy. InREMOTE_REQUIREDmode, a failed fetch is fatal — the app refuses to start. The matrix is cached for 5 minutes by default and refreshed lazily in the background. - Publishes the tool manifest (if
publish_manifest=True) by synthesising an MCPinitialize→notifications/initialized→ paginatedtools/listhandshake against your handler andPUTting the result to/authsec/resource-servers/{rsid}/sdk-manifest. This is best-effort and never blocks startup; the admin UI uses the manifest to show your tool inventory and suggest scopes.
What happens on each request
1. Bearer token extracted from Authorization header.
Missing? → 401 with WWW-Authenticate: Bearer realm=..., resource_metadata=...
2. Token validated per validation_mode.
JWT signature bad? Introspection active=false? → 401 invalid_token.
3. Audience checked against resource_uri.
Wrong aud? → 401 invalid_token.
4. If body is a tools/call JSON-RPC request:
Resolve tool scope from scope matrix.
If scope matrix unavailable in REMOTE_REQUIRED mode → 503 policy_unavailable.
If tool absent from policy → 403 insufficient_scope (deny-by-default).
If tool requires scopes the token doesn't carry → 403 insufficient_scope.
5. Principal stashed in request.state.authsec_principal AND a contextvar.
6. Wrapped handler is invoked with the original body replayed.
Access the authenticated principal
Downstream handlers can read the validated principal in two ways:
# 1. Via request state (Starlette/FastAPI):
async def my_tool(request: Request):
principal = request.state.authsec_principal
print(principal.subject, principal.scopes)
# 2. Via the contextvar (works in nested async calls without threading the request through):
from authsec_sdk.runtime import principal_from_context
async def deep_helper():
p = principal_from_context()
if p:
print(p.subject)
The Principal carries: subject, issuer, audience (list), scopes (list), claims (raw dict), and active (bool — introspection's active field).
Manifest publishing
If your MCP server can't accept a synthetic tools/list (custom auth on initialize, non-HTTP transport, dynamic tool registration), use the escape hatch:
from authsec_sdk.runtime import ManifestTool
def my_tool_inventory():
return [
ManifestTool(
name="create_pr",
description="Open a pull request",
input_schema={"type": "object", "properties": {"repo": {"type": "string"}}},
annotations={"destructiveHint": False},
suggested_scopes=["mcp:tools:write", "github.pr:write"],
),
# ...
]
cfg = Config(
# ...
publish_manifest=True,
tool_inventory_provider=my_tool_inventory,
)
When tool_inventory_provider is set, the synthetic handshake is skipped and your callable's output is used directly.
Tool scope suggestions
The SDK author's recommended scope set per tool, used only for the manifest payload (the admin UI reads these to pre-populate scope dropdowns). Distinct from tool_scopes, which is the local enforcement fallback.
cfg = Config(
# ...
tool_scope_suggestions={
"list_repos": ["mcp:tools:read", "github.repo:read"],
"delete_repo": ["mcp:tools:admin"],
},
)
Error types
from authsec_sdk.runtime import (
TokenInvalidError, # signature / claims / audience failed
TokenInactiveError, # introspection returned active=false
InsufficientScopeError, # token lacks the required scopes for the tool
PolicyUnavailableError, # REMOTE_REQUIRED and scope matrix unreachable
PolicyIncompleteError, # backend signals policy_complete=false (deny-all)
)
mount_mcp() converts all of these into the right HTTP status codes (401, 401, 403, 503, 503) with the matching WWW-Authenticate challenges, so you typically don't need to catch them. They're exported so you can build custom middleware around them if your handler isn't a FastAPI route.
Parity with the Go SDK
Same Config fields, same enum values, same RFC 9728 paths, same tool_policy array preference over the legacy tools map, same deny-all-on-incomplete semantics, same manifest payload shape. If you switch a customer between Go and Python SDKs the admin UI sees the same manifest and applies the same policy.
The one platform difference: Python's mount_mcp is ASGI-only (FastAPI / Starlette). If you need WSGI (Flask, Django) support today, file an issue and we'll prioritize.
Backward compatibility
The 4.x legacy decorator API is unchanged:
from authsec_sdk import protected_by_AuthSec, run_mcp_server_with_oauth
@protected_by_AuthSec("list_repos", description="…")
async def list_repos(args, session):
return [{"type": "text", "text": "…"}]
run_mcp_server_with_oauth(client_id="…", app_name="…")
This continues to work against the same AuthSec backend. New deployments should use mount_mcp; the decorator API will not get the new features (PolicyMode, manifest publishing, RFC 9728 metadata) and is in maintenance mode.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
ValueError: introspection client credentials are required | introspection_url set but no introspection_client_id/introspection_client_secret. | Set them; both are returned in the POST /resource-servers response (id and introspection_secret). |
| App refuses to start with "REMOTE_REQUIRED initial scope matrix fetch failed" | The SDK can't reach /authsec/resource-servers/{id}/sdk-policy. | Either the URL is wrong, the credentials are stale, or the resource server hasn't been activated yet. Activate it on the Launch tab in the admin UI. |
Every request returns 401 invalid_token with error_description="audience mismatch" | The token's aud claim doesn't match cfg.resource_uri. | Confirm the client requested the right resource indicator (resource=https://your-mcp.example.com/mcp). MCP client protected-resource discovery should set this automatically. |
403 insufficient_scope even though the token has the right scope | The scope matrix hasn't been populated yet (no tool→scope mapping configured in admin UI), so the tool defaults to deny. | Map the tool to its required scopes on the Tools tab in the admin UI. |
| Manifest publish silently failing | Manifest publish is best-effort; failures are logged at WARNING and never block startup. | Check logs for authsec.manifest and confirm introspection_client_id + introspection_client_secret are correct. |
PolicyIncompleteError: state=needs_setup | The resource server is registered but its tool inventory hasn't been scanned. | Trigger a scan from the Tools tab, or wait for the SDK's first manifest publish to populate it. |
Where to look in the codebase
sdk-authsec/packages/python-sdk/src/authsec_sdk/runtime/config.py— Config + mode enums + validationsdk-authsec/packages/python-sdk/src/authsec_sdk/runtime/validator.py— JWT + introspectionsdk-authsec/packages/python-sdk/src/authsec_sdk/runtime/scope_matrix.py— fetch + cache + deny-allsdk-authsec/packages/python-sdk/src/authsec_sdk/runtime/server.py—Runtime+mount_mcpsdk-authsec/packages/python-sdk/src/authsec_sdk/runtime/manifest.py—publish_manifestsdk-authsec/packages/python-sdk/examples/protect_existing_mcp_server.py— full working examplesdk-authsec/packages/python-sdk/tests/test_runtime.py— 24 unit tests
Changelog
See CHANGELOG.md. 4.3.0 ships the runtime API at full Go parity.