Skip to main content

Secure Your MCP Server

Use this guide to build a Python MCP server whose tools are each protected by a ThunderID OAuth scope, using FastMCP, then verify the setup end to end with MCP Inspector.

What You Will Learn
  • Turn an MCP server into an OAuth 2.0 resource server that answers unauthenticated calls with a 401 and RFC 9728 protected-resource metadata
  • Validate ThunderID JWTs offline (signature, issuer, audience, and expiry) against the JWKS endpoint
  • Protect each tool with its own scope, so a token missing a scope cannot see or call that tool
Prerequisites
  • About 10 minutes
  • uv, which manages Python and dependencies automatically
  • Node.js 20+ (for MCP Inspector)
  • Your preferred code editor

How It Works

Your MCP server becomes an OAuth 2.0 resource server. When a client connects without a token, the server responds with a 401 and RFC 9728 metadata that points the client to ThunderID for authentication. The client runs an Authorization Code + PKCE flow, gets a JWT scoped to the user's role-based permissions, and presents it on reconnect. The server validates the token offline against ThunderID's JWKS and filters visible tools by the token's scopes.

UserMCP Cliente.g., Claude Desktop, MCP InspectorDiscovers auth, signs in, calls toolsThunderIDAuthorization ServerSigns users in andissues scoped JWTsYour MCP ServerOAuth 2.0 Resource ServerPublishes RFC 9728 metadata, validates JWTsSign in (Auth Code + PKCE)Scoped access token (JWT)Call tools with tokenValidate JWT via JWKS
1

Run ThunderID

Start a local ThunderID instance. Pick the method that works best for you:

$npx thunderid

Requires Node.js 18+

Full install guide →

Once it's running, the console is available at https://localhost:8090/console.

2

Register the MCP Resource and Scopes

  1. Sign in to the Console.

    Test User

    If you used the default setup, sign in to the Console as admin with the password generated during setup and printed to the setup output (unless you supplied your own).

  2. Navigate to Resource Servers and click Add resource server.

  3. On the Type step, select MCP.

  4. On the Name step, enter:

    • MCP Server Name: Calculator MCP
    • Identifier: http://localhost:8000/mcp
  5. On the Permission Delimiter step, keep the default colon (:) and click Create MCP server. If your deployment has multiple organization units, click Continue instead, select one, then click Create MCP server.

Now add each of the four calculator tools as a tool permission:

  1. On the Capabilities tab, the server has no capabilities yet. Click Add tool permission.

  2. Repeat for the remaining three tools: click the + icon in the panel header, then select Add tool permission from the menu.

  3. For each tool, enter the Name. ThunderID generates the Handle automatically from the name (lowercased), so confirm it matches the table below before clicking Add: the handle becomes the exact scope require_scopes(...) checks for in server.py.

    NameHandleGenerated permission
    Addaddadd
    Subtractsubtractsubtract
    Multiplymultiplymultiply
    Dividedividedivide
3

Create a Role for the Calculator Permissions

Registering the permissions in the previous step only defines them. ThunderID puts a scope into an access token only when the signing-in user actually holds the matching permission through a role, and scopes the user doesn't hold are silently dropped from the token. A token missing a tool's scope means that tool never appears in the tool list, so before you connect Inspector, create a role that grants the four calculator permissions and assign it to the user you'll sign in with.

  1. Navigate to Roles and click Add Role.
  2. On the Create a Role step, enter a name (for example, Calculator) and click Continue. If prompted, select an organization unit and click Continue again.
  3. On the Permissions step, expand Calculator MCP and check add, subtract, multiply, and divide under Tools.
  4. Click Continue to create the role.
  5. Open the role from the Roles list, select the Assignments tab, and click Add.
  6. In the Add Assignment dialog, on the Users tab, select admin and click Add Selected.
4

Create the Server

The whole server lives in a single file. Create server.py.

FastMCP's RemoteAuthProvider turns the server into an OAuth 2.0 resource server: it publishes RFC 9728 protected-resource metadata and rejects requests without a valid token before any tool runs. The JWTVerifier validates each token offline, with no call back to ThunderID per request, by checking the signature against the JWKS endpoint, the issuer, the audience, and the expiry:

server.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["fastmcp>=3.4,<4", "python-dotenv>=1"]
# ///
"""Scope-protected calculator MCP server."""

import os

import httpx
from dotenv import load_dotenv
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import RemoteAuthProvider, require_scopes
from fastmcp.server.auth.providers.jwt import JWTVerifier

load_dotenv(override=True)

ISSUER = os.environ["THUNDERID_ISSUER"]
JWKS_URI = os.environ["THUNDERID_JWKS_URI"]
AUDIENCE = os.environ["MCP_AUDIENCE"]
CA_CERT = os.environ.get("THUNDERID_CA_CERT")

SCOPES = ["add", "subtract", "multiply", "divide"]

verifier = JWTVerifier(
jwks_uri=JWKS_URI,
issuer=ISSUER,
audience=AUDIENCE,
http_client=httpx.AsyncClient(verify=CA_CERT) if CA_CERT else None,
)

mcp = FastMCP(
"Calculator",
auth=RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[ISSUER],
base_url="http://localhost:8000",
scopes_supported=SCOPES,
),
)
No install step

The # /// script header at the top of the file is inline script metadata (PEP 723). uv run server.py reads it and resolves fastmcp and python-dotenv automatically, in an isolated environment, so there's no separate pip install step.

Append the four tools below the auth setup, still in the same server.py file. Each tool declares the single scope it needs with require_scopes(...). A token that doesn't carry that scope can't see the tool in tools/list and can't call it:

server.py
@mcp.tool(auth=require_scopes("add"))
def add(a: float, b: float) -> float:
"""Add two numbers."""
return a + b


@mcp.tool(auth=require_scopes("subtract"))
def subtract(a: float, b: float) -> float:
"""Subtract b from a."""
return a - b


@mcp.tool(auth=require_scopes("multiply"))
def multiply(a: float, b: float) -> float:
"""Multiply two numbers."""
return a * b


@mcp.tool(auth=require_scopes("divide"))
def divide(a: float, b: float) -> float:
"""Divide a by b."""
if b == 0:
raise ToolError("Cannot divide by zero.")
return a / b


if __name__ == "__main__":
mcp.run(transport="http", host="localhost", port=8000)
5

Configure the Environment

A local ThunderID instance uses a self-signed certificate, so the server needs that certificate exported to verify the JWKS endpoint over HTTPS. With ThunderID running, run this from the same directory as server.py:

openssl s_client -connect localhost:8090 -showcerts </dev/null 2>/dev/null | openssl x509 > thunderid.cert

Create a .env file next to server.py:

.env
THUNDERID_ISSUER=https://localhost:8090
THUNDERID_JWKS_URI=https://localhost:8090/oauth2/jwks
MCP_AUDIENCE=http://localhost:8000/mcp
THUNDERID_CA_CERT=./thunderid.cert
VariableDescription
THUNDERID_ISSUERMust exactly match the iss claim ThunderID puts in every access token.
THUNDERID_JWKS_URIWhere the server fetches ThunderID's public signing keys to verify token signatures.
MCP_AUDIENCEThe resource server identifier you registered in the previous step. JWTVerifier rejects tokens whose aud claim doesn't match.
THUNDERID_CA_CERTPath to the certificate you just exported. Must point to the actual file location; a relative path like ./thunderid.cert is resolved from the directory you start the server in.

Both the issuer and the JWKS URI are discoverable from https://localhost:8090/.well-known/openid-configuration if you're pointing at a non-default host or port.

Certificate not found at startup

If the server fails at startup with a FileNotFoundError, THUNDERID_CA_CERT doesn't point to the exported file. Check the path, or re-export the certificate. The export only writes a local file, nothing is added to your system trust store, and it regenerates whenever ThunderID is reinstalled or rebuilt, so re-export after a rebuild.

6

Run and Verify Protection

Start the server:

uv run server.py

It serves the MCP endpoint at http://localhost:8000/mcp.

With no token, the server rejects the request:

curl -si -X POST http://localhost:8000/mcp
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"

Fetch that metadata directly:

curl -s http://localhost:8000/.well-known/oauth-protected-resource/mcp

The response names ThunderID as the authorization server and lists the four scopes:

{
"resource": "http://localhost:8000/mcp",
"authorization_servers": ["https://localhost:8090/"],
"scopes_supported": ["add", "subtract", "multiply", "divide"],
"bearer_methods_supported": ["header"]
}
7

Connect with MCP Inspector

Register an MCP Client for Inspector

ThunderID has a dedicated application type for MCP clients like Inspector. In the Console:

  1. Go to Applications and click Add Application.
  2. Select MCP Client.
  3. Enter a name (for example, Calculator Inspector).
  4. If prompted, select an organization unit.
  5. On the Client type step, select On behalf of a user. This configures the Authorization Code flow with PKCE as a public client.
  6. Under Redirect URIs, add http://localhost:6274/oauth/callback.
  7. Click Finish.
  8. From the completion screen, copy the Client ID. No client secret is required because this is a public client.

Allow the MCP Inspector Origin

MCP Inspector runs in the browser at http://localhost:6274 and calls ThunderID cross-origin, so its origin must be allowed in the CORS configuration.

Create or update config/resources/server_configs/cors.yaml in your ThunderID distribution, or use PUT /server-config/cors after startup:

name: cors
value:
allowedOrigins:
- "http://localhost:6274"

If the file already exists, append the origin to the existing allowedOrigins list instead of replacing it. Restart ThunderID after editing the file.

Launch and Connect

Inspector's proxy runs on Node.js and must also trust the self-signed ThunderID certificate to discover the OAuth endpoints, so reuse the certificate you exported earlier. Run this from the same directory as server.py:

NODE_EXTRA_CA_CERTS=./thunderid.cert npx @modelcontextprotocol/inspector

Inspector opens in your browser at http://localhost:6274.

  1. Set Transport Type to Streamable HTTP and URL to http://localhost:8000/mcp.
  2. Expand the Authentication section and select OAuth.
  3. Enter the Client ID you copied earlier. Leave Client Secret empty.
  4. Click Connect. Inspector reads your server's protected-resource metadata, discovers ThunderID as the authorization server, and requests all four scopes by default.
  5. A browser tab opens with ThunderID's sign-in page. Sign in with your test user and approve the request.
  6. You're redirected back to Inspector, now connected.
  7. Open the Tools tab and click List Tools. You see add, subtract, multiply, and divide.
Sign-in page never appears

If the browser shows "Cannot GET /authorize" instead of the sign-in page, first confirm Transport Type is set to Streamable HTTP, since the deprecated SSE transport probes without a token and misroutes the authorize request to Inspector's own proxy. If Transport Type is already correct, Inspector was launched without NODE_EXTRA_CA_CERTS or the path is wrong, so it couldn't fetch ThunderID's OAuth discovery metadata. Relaunch with the variable pointing at the exported certificate.

Success

The tools you see in Inspector are exactly the ones your token's scopes allow. Try it: click Disconnect, edit the Scope field in the Auth panel to remove divide, then Connect again. Sign in once more and list the tools. divide is gone, because the token Inspector obtained this time never carries that scope, and require_scopes("divide") hides the tool from anyone without it.

What's Next

Explore with AI

ThunderID LogoThunderID Logo

Product

DocsAPIsSDKs
© Copyright Linux Foundation Europe.For web site terms of use, trademark policy and other project policies please see https://linuxfoundation.eu/en/policies.