Skip to content

FastMCP Integration

FastMCP is the most popular Python framework for building MCP servers. Its auth model separates concerns cleanly:

  • Authgent is the Authorization Server — it handles OAuth 2.1 flows, issues JWTs, manages client registration, and serves metadata endpoints.
  • FastMCP is the Resource Server — it only validates JWTs. No OAuth logic runs in your MCP server.

When an MCP client connects, FastMCP’s RemoteAuthProvider fetches Authgent’s OAuth metadata, redirects the client through the authorization flow, and validates the resulting JWT using Authgent’s JWKS endpoint. Your MCP server never implements any OAuth endpoints.

Terminal window
pip install fastmcp

FastMCP includes all necessary JWT verification dependencies. No additional packages required.

# server.py — FastMCP with Authgent
import os
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
# Authgent's base URL — FastMCP fetches all OAuth metadata from here
AUTHGENT_ISSUER = os.environ.get("AUTHGENT_ISSUER", "https://auth.yourcompany.com")
MCP_AUDIENCE = os.environ.get("MCP_AUDIENCE", "https://mcp.yourcompany.com")
auth = RemoteAuthProvider(
# Authgent's discovery endpoint
# FastMCP fetches /.well-known/oauth-authorization-server automatically
authorization_server_url=AUTHGENT_ISSUER,
# The audience claim your tokens must contain
# Must match the MCP server URL you registered in Authgent
audience=MCP_AUDIENCE,
# Scopes this MCP server requires for all requests
required_scopes=["mcp:read"],
)
mcp = FastMCP(
name="My MCP Server",
auth=auth,
)

You can require specific scopes on individual tools. This is enforced at the middleware level — if the token lacks the required scope, the request is rejected before your tool code runs.

@mcp.tool()
async def list_projects() -> list[dict]:
"""List all projects. Requires the default mcp:read scope."""
ctx = mcp.get_context()
user_id = ctx.auth.claims.get("sub")
return await db.get_projects_for_user(user_id)
@mcp.tool(scopes=["mcp:write"])
async def create_project(name: str, description: str) -> dict:
"""Create a project. Requires mcp:write scope in addition to mcp:read."""
ctx = mcp.get_context()
return await db.create_project(
owner=ctx.auth.claims["sub"],
name=name,
description=description,
)
@mcp.tool(scopes=["admin"])
async def delete_project(project_id: str) -> dict:
"""Delete a project. Requires admin scope."""
ctx = mcp.get_context()
return await db.delete_project(project_id, deleted_by=ctx.auth.claims["sub"])

Every authenticated request has claims available via the context:

@mcp.tool()
async def whoami() -> dict:
"""Return the authenticated user's identity."""
ctx = mcp.get_context()
claims = ctx.auth.claims
return {
"user_id": claims.get("sub"),
"email": claims.get("email"),
"name": claims.get("name"),
"scopes": claims.get("scope", "").split(" "),
"issuer": claims.get("iss"),
"audience": claims.get("aud"),
"expires_at": claims.get("exp"),
}
@mcp.tool()
async def get_user_data() -> dict:
"""Fetch data scoped to the authenticated user."""
ctx = mcp.get_context()
user_id = ctx.auth.claims["sub"]
# Use the user ID to scope database queries
# Only return data the authenticated user owns
return await db.get_user_data(user_id)
if __name__ == "__main__":
# HTTP transport for remote MCP clients (Claude Desktop, Cursor, etc.)
mcp.run(transport="http", host="0.0.0.0", port=8000, path="/mcp")

Transport options:

TransportUse caseAuth
httpRemote clients (Claude Desktop, Cursor)OAuth via Authgent
sseServer-sent events (legacy)OAuth via Authgent
stdioLocal dev, subprocessNo auth needed

For production, always use http transport with Authgent.

Terminal window
npx @modelcontextprotocol/inspector http://localhost:8000/mcp

The inspector automatically:

  1. Discovers /.well-known/oauth-protected-resource on your MCP server
  2. Finds Authgent’s authorization endpoint from the metadata
  3. Runs the OAuth flow (Authorization Code + PKCE)
  4. Sends authenticated requests to your tools
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
EXPOSE 8000
CMD ["python", "server.py"]
fastmcp>=2.0.0
version: "3.9"
services:
authgent:
image: authgent/authgent:latest
ports:
- "8080:8080"
environment:
AUTHGENT_ISSUER: http://authgent:8080
AUTHGENT_SIGNING_KEY: /keys/ec-private.pem
AUTHGENT_SIGNING_ALG: ES256
AUTHGENT_DB_DSN: sqlite:///data/authgent.db
volumes:
- ./keys/ec-private.pem:/keys/ec-private.pem:ro
- authgent-data:/data
mcp-server:
build: .
ports:
- "8000:8000"
environment:
AUTHGENT_ISSUER: http://authgent:8080
MCP_AUDIENCE: http://mcp-server:8000
depends_on:
- authgent
volumes:
authgent-data:

Start everything:

Terminal window
# Generate signing keys first
openssl ecparam -genkey -name prime256v1 -noout -out keys/ec-private.pem
chmod 600 keys/ec-private.pem
# Start both services
docker compose up -d
# Verify Authgent is running
curl http://localhost:8080/.well-known/oauth-authorization-server | jq .
# Test with MCP Inspector
npx @modelcontextprotocol/inspector http://localhost:8000/mcp
Error: token_expired — JWT exp claim is in the past

Authgent issues tokens with a 5-minute TTL by default. If you see this error:

  • Check that your server’s clock is synchronized (NTP)
  • The MCP client should automatically refresh tokens — if it doesn’t, it may be using an older SDK version
Error: invalid_audience — expected "https://mcp.yourcompany.com"

The audience in your RemoteAuthProvider must exactly match the resource URL registered in Authgent. Check:

  • No trailing slash differences (https://mcp.yourcompany.com vs https://mcp.yourcompany.com/)
  • The URL matches what you configured in Authgent’s resource registration
Error: insufficient_scope — required scope "mcp:write" not present

The token was issued without the required scope. This happens when:

  • The MCP client didn’t request the scope during authorization
  • The user didn’t consent to the scope
  • The scope isn’t configured in Authgent’s allowed scopes for this resource
Error: ConnectionError — Cannot connect to authorization_server_url

In Docker Compose, use the service name (http://authgent:8080) not localhost. From outside Docker, use http://localhost:8080.