
TL;DR
A production Model Context Protocol server should not treat possession of any bearer token as sufficient authority. It should validate who issued the token, confirm that the token was minted for that specific MCP resource, enforce narrowly defined scopes at both the transport and tool layers, and record authorization decisions without logging credentials or sensitive payloads.
This tutorial builds that pattern with Python, the stable MCP Python SDK v1.x line, a separate OAuth authorization server, JWT validation through a JSON Web Key Set endpoint, baseline and tool-specific scopes, and structured audit events. The finished server distinguishes read access from approval authority and produces evidence that can be routed to a logging platform or SIEM.
Introduction
Model Context Protocol servers are becoming an execution boundary between AI applications and enterprise systems. That boundary may expose ticketing platforms, source repositories, cloud APIs, databases, infrastructure controllers, or internal workflows. Once an MCP tool can change a record or trigger an action, securing the server becomes less like protecting a chatbot and more like protecting an enterprise API.
The weak implementation is easy to recognize. A client sends a token, the server checks that the token can be decoded, and every authenticated caller receives access to every tool. That design proves only that a credential exists. It does not prove that the correct authorization server issued it, that the token was intended for this MCP server, that the calling identity has the required permission, or that the resulting action can be reconstructed later.
The current MCP authorization specification applies to HTTP-based transports and places the MCP server in the role of an OAuth resource server. The client obtains an access token from an authorization server, includes that token in each protected request, and identifies the target MCP resource during authorization. The server then validates the token before allowing the protocol request to reach protected tools.
This article targets the MCP authorization specification dated November 25, 2025 and the stable MCP Python SDK v1.x line. The example was prepared around MCP Python SDK 1.28.1 and assumes signed JWT access tokens. An environment using opaque access tokens should replace local JWT validation with standards-aligned token introspection or another authorization-server-supported validation mechanism.
What You Will Build
By the end of the walkthrough, you will have a Streamable HTTP MCP server that:
- publishes OAuth protected-resource metadata through the MCP SDK
- validates JWT signatures using authorization-server signing keys
- verifies issuer, expiration, subject, and audience claims
- requires a baseline scope before a client can use the MCP transport
- separates read permission from change-approval permission
- records authorization grants, denials, validation failures, and successful tool completion
- avoids logging bearer tokens and complete tool arguments
- provides a practical validation matrix for expected success and failure paths
The sample exposes two tools:
| Tool | Purpose | Required Tool Scope | Risk Level |
|---|---|---|---|
list_change_requests | Reads pending changes | changes:read | Low |
approve_change_request | Records approval | changes:approve | High |
Both tools also require the baseline transport scope mcp:connect because the server configures that scope in its MCP authorization settings.
Why MCP Authorization Requires More Than Token Validation
A secure design separates four questions that are often collapsed into one check.
Was the Token Issued by a Trusted Authority?
Issuer validation prevents the server from accepting a correctly signed token from the wrong identity system or tenant. The configured issuer must match the token’s iss claim according to the authorization server’s exact issuer convention.
Signature validation alone is not enough. An organization may operate several trusted issuers for different environments, tenants, or workload classes. A token signed by a known key can still be inappropriate for the server receiving it.
Was the Token Minted for This MCP Server?
Audience validation is one of the most important controls in the design. The client identifies the target resource when requesting authorization, and the authorization server mints an access token for that resource. The MCP server must then verify that its canonical resource identifier appears in the token audience.
Without that binding, a token intended for another API could be replayed against the MCP server. A valid signature does not make a token universally valid.
Does the Identity Have the Required Permission?
Authentication establishes identity. Scopes constrain what that identity may do. A read operation and an approval operation should not share one broad full-access permission merely because they exist on the same server.
The scope design should reflect business authority, not just code organization. A user who can inspect a change record does not automatically have permission to approve it. A service identity that can collect inventory should not inherit administrative rights because both capabilities are delivered through MCP.
Can the Decision Be Reconstructed?
Authorization without evidence is difficult to operate. Security teams need to know which subject and client invoked a tool, which scopes were required, which scopes were present, whether the request was granted or denied, and which correlation identifier connects the decision to the downstream action.
The audit record should capture the decision without becoming another credential store. Never record the bearer token itself. Avoid recording complete prompts, secrets, or tool payloads unless a separate data-governance decision explicitly permits it.
The Secure MCP Request Flow
The important detail in the following flow is that authentication, authorization, tool execution, and audit evidence remain distinct stages.

This design deliberately treats the authorization server and MCP server as separate roles. They may be operated by the same organization, but they should not be conceptually merged.
The authorization server authenticates identities and issues tokens. The MCP server validates those tokens and enforces access to its own resources.
Prerequisites and Assumptions
This walkthrough assumes:
- Python 3.10 or newer
uvor another Python package manager- an OAuth or OpenID Connect authorization server that issues signed JWT access tokens
- a JWKS endpoint containing the public signing keys
- an MCP resource identifier registered as a valid audience
- authorization-server support for the required scopes
- a test client capable of connecting to an OAuth-protected MCP server
- TLS termination in front of the production service
The sample uses the MCP Python SDK v1.x line because it remains the recommended stable production line at the time of writing. The project retains an upper dependency bound below version 2 so that a future major release cannot be installed without an intentional migration and test cycle.
The authorization server remains outside the scope of this tutorial. In an enterprise environment, it may be an existing identity platform, an API authorization service, or an internal security service. Do not build a new identity provider inside the MCP server simply to avoid integrating with the established one.
Design the Scope Model Before Writing Code
Scope design is an architecture decision. Renaming functions later is easy. Changing the meaning of an issued permission after clients depend on it is not.
Use scopes that describe stable authority:
| Scope | Grants | Does Not Grant |
|---|---|---|
mcp:connect | Use of the protected MCP transport | Permission to execute every tool |
changes:read | Read access to change records | Approval or modification rights |
changes:approve | Authority to record approval | Deployment execution or emergency override |
Three rules keep the model usable.
Start with a Minimal Baseline
The baseline scope should enable the smallest useful interaction. Do not advertise every privileged scope as part of initial access. Broad initial grants increase blast radius and make consent screens meaningless.
The example uses mcp:connect as the transport-level baseline. Tool permissions remain separate.
Keep Read and Write Authority Separate
Read scopes are not harmless, but they usually carry a different consequence than mutation scopes. Separate them so that service identities, users, and clients receive only the authority they need.
A reporting agent may need changes:read. An approval assistant may need changes:approve. Neither permission should imply the other unless that combination is deliberately granted.
Do Not Use Scopes as the Only Policy Input
A scope is a coarse authorization signal, not a complete business policy. The approval tool still validates its input. A production version may also check:
- change risk
- target environment
- separation-of-duties rules
- maintenance windows
- ticket state
- human approval requirements
- delegated authority
- client trust level
- workload identity
- emergency change policy
The server should combine token-derived authority with server-side policy. It should never assume that a claimed scope replaces application authorization logic.
Create the Python Project
Create the project and install the stable MCP SDK line, PyJWT with cryptographic support, and Pydantic settings support.
uv init secure-mcp-server cd secure-mcp-server uv add "mcp[cli]>=1.27,<2" "PyJWT[crypto]>=2,<3" "pydantic-settings>=2,<3"
The MCP package already depends on some supporting libraries, but declaring the security-sensitive dependencies explicitly makes the application’s intent and version policy visible in its lock file.
Create a .env file using values from your authorization server and MCP deployment.
SECURE_MCP_ISSUER_URL=YOUR_AUTHORIZATION_SERVER_ISSUER_URI SECURE_MCP_RESOURCE_SERVER_URL=YOUR_CANONICAL_MCP_RESOURCE_URI SECURE_MCP_JWKS_URL=YOUR_AUTHORIZATION_SERVER_JWKS_URI SECURE_MCP_JWT_ALGORITHM=RS256 SECURE_MCP_JWKS_CACHE_SECONDS=300 SECURE_MCP_JWT_LEEWAY_SECONDS=30
The resource-server value must match the resource identity used during authorization and the audience expected in the token. Treat differences in host name, path, port, query string, and trailing slash as configuration defects until proven otherwise.
Do not commit the .env file. These particular values are usually not secrets, but the same file often grows to include client credentials or service configuration that should be managed through a secret store.
Implement the Protected MCP Server
Create server.py with the following implementation.
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
import jwt
from jwt import PyJWKClient
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="SECURE_MCP_",
extra="ignore",
)
issuer_url: AnyHttpUrl
resource_server_url: AnyHttpUrl
jwks_url: AnyHttpUrl
jwt_algorithm: str = "RS256"
jwks_cache_seconds: int = 300
jwt_leeway_seconds: int = 30
settings = Settings()
logging.basicConfig(level=logging.INFO, format="%(message)s")
audit_logger = logging.getLogger("mcp.audit")
def parse_scopes(claims: dict[str, Any]) -> list[str]:
"""Normalize common OAuth scope claim formats."""
scopes: set[str] = set()
scope_claim = claims.get("scope")
if isinstance(scope_claim, str):
scopes.update(scope_claim.split())
scp_claim = claims.get("scp")
if isinstance(scp_claim, str):
scopes.update(scp_claim.split())
elif isinstance(scp_claim, list):
scopes.update(str(value) for value in scp_claim)
return sorted(scopes)
class JwtTokenVerifier(TokenVerifier):
"""Validate JWT access tokens issued for this MCP resource."""
def __init__(self) -> None:
self._jwks_client = PyJWKClient(
str(settings.jwks_url),
cache_jwk_set=True,
lifespan=settings.jwks_cache_seconds,
timeout=5,
)
def _verify_sync(self, token: str) -> AccessToken | None:
try:
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=[settings.jwt_algorithm],
audience=str(settings.resource_server_url),
issuer=str(settings.issuer_url),
leeway=settings.jwt_leeway_seconds,
options={"require": ["exp", "iss", "aud", "sub"]},
)
subject = str(claims["sub"])
client_id = str(
claims.get("client_id")
or claims.get("azp")
or subject
)
return AccessToken(
token=token,
client_id=client_id,
scopes=parse_scopes(claims),
expires_at=int(claims["exp"]),
resource=str(settings.resource_server_url),
subject=subject,
claims=claims,
)
except (jwt.PyJWTError, TypeError, ValueError):
return None
async def verify_token(self, token: str) -> AccessToken | None:
return await asyncio.to_thread(self._verify_sync, token)
def write_audit_event(
*,
event: str,
result: str,
tool_name: str,
correlation_id: str,
access_token: AccessToken | None,
required_scopes: set[str],
reason: str | None = None,
) -> None:
"""Write a structured event without exposing the bearer token."""
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event,
"result": result,
"tool_name": tool_name,
"correlation_id": correlation_id,
"subject": access_token.subject if access_token else None,
"client_id": access_token.client_id if access_token else None,
"required_scopes": sorted(required_scopes),
"granted_scopes": sorted(access_token.scopes) if access_token else [],
"reason": reason,
}
audit_logger.info(
json.dumps(
record,
separators=(",", ":"),
sort_keys=True,
)
)
def require_scopes(
tool_name: str,
required_scopes: set[str],
correlation_id: str,
) -> AccessToken:
"""Enforce tool-specific scopes from the verified request context."""
access_token = get_access_token()
if access_token is None:
write_audit_event(
event="authorization.decision",
result="denied",
tool_name=tool_name,
correlation_id=correlation_id,
access_token=None,
required_scopes=required_scopes,
reason="missing_authenticated_context",
)
raise PermissionError("Authentication is required.")
missing_scopes = required_scopes.difference(access_token.scopes)
if missing_scopes:
write_audit_event(
event="authorization.decision",
result="denied",
tool_name=tool_name,
correlation_id=correlation_id,
access_token=access_token,
required_scopes=required_scopes,
reason="missing_scope:" + ",".join(sorted(missing_scopes)),
)
raise PermissionError(
"Missing required scope: "
+ ", ".join(sorted(missing_scopes))
)
write_audit_event(
event="authorization.decision",
result="granted",
tool_name=tool_name,
correlation_id=correlation_id,
access_token=access_token,
required_scopes=required_scopes,
)
return access_token
mcp = FastMCP(
"Enterprise Change Control",
json_response=True,
token_verifier=JwtTokenVerifier(),
auth=AuthSettings(
issuer_url=settings.issuer_url,
resource_server_url=settings.resource_server_url,
required_scopes=["mcp:connect"],
),
)
@mcp.tool()
async def list_change_requests(
environment: str = "production",
) -> dict[str, Any]:
"""List open change requests for an environment."""
correlation_id = uuid.uuid4().hex
access_token = require_scopes(
tool_name="list_change_requests",
required_scopes={"changes:read"},
correlation_id=correlation_id,
)
result = {
"environment": environment,
"items": [
{
"change_id": "CHG-1042",
"risk": "low",
"status": "pending",
},
{
"change_id": "CHG-1057",
"risk": "medium",
"status": "pending",
},
],
"subject": access_token.subject,
"correlation_id": correlation_id,
}
write_audit_event(
event="tool.completed",
result="success",
tool_name="list_change_requests",
correlation_id=correlation_id,
access_token=access_token,
required_scopes={"changes:read"},
)
return result
@mcp.tool()
async def approve_change_request(
change_id: str,
justification: str,
) -> dict[str, Any]:
"""Record approval for a change request."""
correlation_id = uuid.uuid4().hex
access_token = require_scopes(
tool_name="approve_change_request",
required_scopes={"changes:approve"},
correlation_id=correlation_id,
)
if len(justification.strip()) < 20:
write_audit_event(
event="tool.validation",
result="denied",
tool_name="approve_change_request",
correlation_id=correlation_id,
access_token=access_token,
required_scopes={"changes:approve"},
reason="justification_too_short",
)
raise ValueError(
"Provide an approval justification "
"of at least 20 characters."
)
result = {
"change_id": change_id,
"status": "approval_recorded",
"approved_by": access_token.subject,
"correlation_id": correlation_id,
}
write_audit_event(
event="tool.completed",
result="success",
tool_name="approve_change_request",
correlation_id=correlation_id,
access_token=access_token,
required_scopes={"changes:approve"},
)
return result
if __name__ == "__main__":
mcp.run(transport="streamable-http")
How the Implementation Works
The code is intentionally divided into five control layers.
Configuration Establishes the Trust Contract
issuer_url identifies the trusted token issuer. resource_server_url identifies this MCP server as an OAuth resource. jwks_url tells the verifier where to retrieve public signing keys.
These values form a trust contract and should be managed as reviewed deployment configuration rather than scattered string constants.
The algorithm is configured explicitly. The verifier does not accept whatever algorithm appears in an untrusted token header. This prevents algorithm confusion and keeps the application aligned with the authorization server’s approved signing policy.
JWT Verification Rejects the Wrong Token Early
JwtTokenVerifier retrieves the signing key identified by the token header and validates:
- cryptographic signature
- allowed signing algorithm
ississuer claimaudaudience claimexpexpiration claimsubsubject claim
The JWKS client caches the key set to reduce network calls. The lookup is executed through asyncio.to_thread because JWKS retrieval and parsing are synchronous operations and should not block the MCP event loop.
The verifier returns None for an invalid token. It does not log the token or expose detailed cryptographic errors to the caller. Detailed server-side diagnostics can be added carefully, but they should never include credential material.
The SDK Enforces the Baseline Transport Scope
AuthSettings configures the server’s authorization metadata and baseline scope. A caller must present a valid token containing mcp:connect before it can use the protected MCP transport.
This is the coarse entry gate. It proves that the client has authority to connect, but it does not grant every business operation exposed by the server.
Each Tool Enforces Its Own Authority
get_access_token() retrieves the verified access-token context for the current request. The require_scopes() helper compares the required tool scopes with the granted scopes and records the decision.
The read tool requires changes:read. The approval tool requires changes:approve. A caller with baseline access and read authority can inspect records without inheriting approval rights.
Audit Events Preserve the Decision Chain
Every authorization decision includes:
- UTC timestamp
- event type
- result
- tool name
- correlation identifier
- subject
- OAuth client identifier
- required scopes
- granted scopes
- denial reason when applicable
The logger does not include the bearer token. It also avoids recording the full justification or other tool arguments.
A production system can add carefully selected business identifiers, data classifications, policy versions, downstream transaction identifiers, and trace identifiers without turning the audit stream into a sensitive-data dump.
Run the Server
Start the server with the Streamable HTTP transport.
uv run python server.py
In production, place the process behind a hardened ingress, reverse proxy, or API gateway that provides:
- TLS
- approved host validation
- request-size limits
- connection controls
- rate limiting
- network policy
- centralized telemetry
- health monitoring
- denial-of-service protections
Do not expose an unencrypted remote MCP endpoint.
The server should publish protected-resource metadata based on the configured authorization settings. A compatible client can use that metadata to discover the trusted authorization server and request the baseline scope.
Validate the Authorization Paths
Do not stop after confirming that one valid token works. Security controls are proven by expected failures as much as successful requests.
Use a test client or API test harness to exercise the following matrix.
| Test | Token Properties | Expected Result |
|---|---|---|
| No token | None | Authentication challenge and protected-resource discovery information |
| Invalid signature | Untrusted key | Request rejected |
| Wrong issuer | Valid signature, wrong iss | Request rejected |
| Wrong audience | Valid token for another API | Request rejected |
| Expired token | Past exp | Request rejected |
| Baseline only | mcp:connect | MCP connection succeeds, tool scope checks deny both tools |
| Read access | mcp:connect changes:read | Read tool succeeds, approval tool is denied |
| Approval access | mcp:connect changes:approve | Approval tool succeeds, read tool remains denied |
| Full delegated access | All three scopes | Both tools succeed |
| Invalid approval input | Approval scopes, short justification | Tool validation denies the operation and records evidence |
A successful audit record should resemble the following structure.
{
"client_id": "approved-mcp-client",
"correlation_id": "6f88c838d56b44f687b41f70c0f3c28a",
"event": "authorization.decision",
"granted_scopes": [
"changes:read",
"mcp:connect"
],
"reason": null,
"required_scopes": [
"changes:read"
],
"result": "granted",
"subject": "user-or-workload-subject",
"timestamp": "2026-07-19T14:30:00+00:00",
"tool_name": "list_change_requests"
}
A denial should retain the same correlation structure while recording the missing permission.
{
"client_id": "approved-mcp-client",
"correlation_id": "a82b59cc14564566a793018aec1c5b40",
"event": "authorization.decision",
"granted_scopes": [
"changes:read",
"mcp:connect"
],
"reason": "missing_scope:changes:approve",
"required_scopes": [
"changes:approve"
],
"result": "denied",
"subject": "user-or-workload-subject",
"timestamp": "2026-07-19T14:31:00+00:00",
"tool_name": "approve_change_request"
}
The two records make it possible to distinguish a failed credential from a valid identity that lacks business authority.
Understand the Step-Up Authorization Limitation
The MCP authorization specification defines a runtime insufficient-scope pattern in which the server can return a forbidden response with an authorization challenge naming the additional scopes. A capable client may then perform step-up authorization and retry the operation.
The sample’s transport-level baseline scope is handled by the MCP SDK. The tool-specific PermissionError enforces authorization inside the tool, but it should not be assumed to generate a complete transport-level step-up challenge automatically.
Depending on the client and SDK behavior, it may surface as a tool execution error rather than a standards-shaped forbidden response.
There are three practical ways to handle this in production.
Separate Trust Tiers into Different MCP Endpoints
Place low-risk read tools on one protected MCP resource and privileged mutation tools on another. Each resource can advertise and require its own baseline scopes.
This creates a strong boundary and makes transport-level authorization behavior easier to reason about.
Add an Authorization Middleware or Gateway
A gateway can map tool intent to required scopes before the request reaches the tool and produce the required challenge when additional permission is needed.
The gateway must preserve:
- MCP semantics
- client identity
- audience validation
- request correlation
- audit evidence
- denial behavior
- transport security
Extend the Server with an Explicit Step-Up Pattern
Implement a tested server extension that returns the expected insufficient-scope response and scope challenge. Treat this as protocol infrastructure, not as a string replacement around a generic exception.
For high-consequence tools, separate endpoints or a dedicated policy-enforcement layer are often easier to audit than one large server containing dozens of mixed-risk tools.
Troubleshooting Common Failures
Every Valid Token Returns Unauthorized
Check the exact issuer and audience values. A common failure is using the public MCP endpoint as one value, an application identifier as another, and an authorization-server API identifier as a third.
Decide which canonical resource identifier the token will use, configure it consistently, and test the final deployed form.
Also verify that the JWT contains a kid value matching a published signing key and that the configured algorithm matches the issuer’s token-signing policy.
The Client Connects but Every Tool Is Denied
Inspect the token’s granted scopes and the issuer’s scope-claim format. Some issuers use a space-delimited scope string, while others use scp as a string or array.
The sample supports those common forms, but your identity platform may use a custom claim.
Do not silently map a broad role claim to every MCP permission. Make the translation explicit, versioned, and reviewable.
Key Rotation Causes Intermittent Failures
JWKS caching improves performance, but cache duration affects key-rotation behavior. The PyJWT JWKS client can refresh when it cannot find a matching key identifier.
A reused key identifier or nonstandard rotation process may require a shorter cache or coordinated rollout.
Monitor key-lookup failures separately from ordinary invalid-token failures. A sudden cluster of lookup errors may indicate an issuer outage or rotation problem rather than hostile traffic.
Audit Logs Are Too Noisy
Separate security decisions from routine tool completion. Authorization denials, repeated missing-scope events, wrong-audience tokens, and key-validation failures deserve different severity and alerting policies.
Do not solve noise by removing identity and scope fields. Solve it through:
- event classification
- sampling of low-value success events
- retention tiers
- correlation with traces
- alert thresholds
- per-client baselines
- privileged-action monitoring
The Downstream API Requires Its Own Token
Do not pass the client’s MCP access token directly to a downstream API unless that architecture is explicitly designed and validated for the downstream audience.
Token passthrough breaks audience isolation, weakens accountability, and can bypass controls.
Use a separate service credential, delegated token exchange, or another approved downstream authorization flow. Preserve the original MCP subject and client identity in the audit chain without reusing the wrong token.
Tool Arguments Contain Sensitive Data
Do not log complete tool payloads by default. Tool arguments may contain:
- credentials
- personal data
- ticket comments
- proprietary system names
- database queries
- source code
- regulated records
- change justifications
- internal infrastructure details
Log a safe business identifier, a classification, a hash, or a redacted summary instead. The audit design should be reviewed with security, privacy, and records-retention stakeholders.
Production Hardening Checklist
Before placing the server in front of enterprise systems, complete these controls:
- enforce TLS for every remote MCP and authorization endpoint
- register one canonical resource identifier and validate it as the token audience
- use short-lived access tokens appropriate to the operational risk
- support signing-key rotation and monitor JWKS retrieval failures
- pin approved JWT algorithms instead of trusting the token header
- maintain narrow, documented, versioned scope semantics
- avoid wildcard and omnibus scopes
- separate high-risk mutation tools from low-risk discovery tools when practical
- add business-policy checks beyond OAuth scopes
- require human approval for actions whose consequences exceed delegated authority
- never pass an MCP token to an unrelated downstream audience
- exclude tokens, secrets, prompts, and sensitive payloads from default logs
- route authorization denials and privileged tool actions to a monitored audit destination
- retain correlation identifiers through downstream systems
- rate-limit repeated authorization failures and suspicious client behavior
- test revocation, expiration, wrong-audience, wrong-issuer, and key-rotation paths
- document emergency disablement for a client, scope, tool, and entire server
- review the authorization model whenever tools or consequences change
Operational Ownership
Security does not end when the code compiles. Assign ownership for each part of the control chain.
| Capability | Recommended Owner | Operational Responsibility |
|---|---|---|
| Authorization server | Identity or security platform team | Identity proofing, client registration, token policy, and key rotation |
| MCP resource configuration | Platform or MCP service owner | Canonical audience, metadata, and transport protection |
| Scope catalogue | Application owner with security review | Stable permission semantics and least-privilege design |
| Tool policy | Business system owner | Business rules, separation of duties, and approval boundaries |
| Audit pipeline | Security operations or observability team | Ingestion, retention, alerting, and investigation access |
| Incident response | Joint service and security ownership | Token compromise, client disablement, and evidence preservation |
This ownership model prevents a common failure in which developers implement token parsing but no team owns scope governance, evidence retention, emergency revocation, or privileged-action review.
Conclusion
A secure MCP server is an OAuth resource server with a business authorization layer, not merely a collection of functions behind a bearer-token check. The server must validate token issuer and audience, enforce minimum transport access, apply tool-specific permissions, and create evidence that explains why an action was allowed or denied.
The Python implementation in this tutorial provides a practical starting point: stable SDK dependencies, JWKS-backed JWT verification, baseline and tool scopes, and structured audit events that avoid credential leakage. It also makes the boundary of the example explicit. Tool-level exceptions enforce permission, but they do not automatically guarantee a complete step-up authorization exchange.
For low-risk tools, one server with careful per-tool authorization may be appropriate. For privileged operations, separate MCP resources, policy middleware, or an authorization-aware gateway can produce a cleaner trust boundary.
The right design is the one that keeps identity, authority, consequence, and evidence aligned from the client request through the downstream action.
External References
- Model Context Protocol: Authorization
Canonical URL: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - Model Context Protocol: Security Best Practices
Canonical URL: https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices - Model Context Protocol: Understanding Authorization in MCP
Canonical URL: https://modelcontextprotocol.io/docs/tutorials/security/authorization - Model Context Protocol Python SDK: Authorization
Canonical URL: https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/docs/authorization.md - Model Context Protocol Python SDK: MCP Python SDK v1.x
Canonical URL: https://github.com/modelcontextprotocol/python-sdk/tree/v1.x - PyPI: mcp 1.28.1
Canonical URL: https://pypi.org/project/mcp/ - IETF RFC Editor: RFC 9728, OAuth 2.0 Protected Resource Metadata
Canonical URL: https://www.rfc-editor.org/rfc/rfc9728.html - IETF RFC Editor: RFC 8707, Resource Indicators for OAuth 2.0
Canonical URL: https://www.rfc-editor.org/rfc/rfc8707.html - IETF RFC Editor: RFC 9700, Best Current Practice for OAuth 2.0 Security
Canonical URL: https://www.rfc-editor.org/rfc/rfc9700.html - PyJWT: Usage Examples
Canonical URL: https://pyjwt.readthedocs.io/en/stable/usage.html - PyJWT: API Reference
Canonical URL: https://pyjwt.readthedocs.io/en/stable/api.html
Once an AI agent has access to enterprise systems, identity cannot be treated as a one-time setup task. It needs a lifecycle....