Build an MCP Client
Use this guide to build a Python MCP client that obtains authorization to call the Calculator server you secured in Secure Your MCP Server, using FastMCP's OAuth helper to handle discovery, registration, and token exchange for you, then calls its scope-protected tools.
What You Will Learn
- Build a single-file OAuth-capable MCP client with FastMCP
- Understand what FastMCP's
OAuthhelper automates: RFC 9728 discovery, Dynamic Client Registration, the Authorization Code flow with PKCE, a local callback server, and token caching
- Call scope-protected tools on the Calculator server from your own client code
Prerequisites
- About 10 minutes
- Completed Secure Your MCP Server, with the Calculator server registered and runnable, and
thunderid.certexported (see "Didn't build the server yet?" below if not)
uv, which manages Python and dependencies automatically
Clone the Calculator MCP Sample, follow the certificate export and .env setup in Secure Your MCP Server, then start the server with uv run server.py from samples/apps/mcp-calculator-sample/server/. Once it's listening at http://localhost:8000/mcp, continue below.
Enable Dynamic Client Registration
The client you build in this guide registers itself automatically the first time it runs, using Dynamic Client Registration (DCR), so enable DCR before you create it. Add the following to deployment.yaml in your ThunderID distribution:
oauth:
dcr:
enabled: true
insecure: true
insecure: true lets anyone who can reach the server register OAuth clients without authentication. Use it for local development only.
Restart ThunderID after saving the file.
If you want to confirm DCR is open before running the client, see the optional verification probe in Claude Code.
Create the Client
This client assumes the same layout as the sample: a client directory next to the server directory that holds server.py and the thunderid.cert you exported earlier. Create a client directory alongside your server's directory, and create client.py inside it:
# /// script
# requires-python = ">=3.11"
# dependencies = ["fastmcp>=3.4,<4"]
# ///
"""OAuth-authenticated client for the Calculator MCP server."""
import asyncio
from pathlib import Path
from fastmcp import Client
from fastmcp.client.auth import FileTreeStore, OAuth
MCP_URL = "http://localhost:8000/mcp"
THUNDERID_CA_CERT = "../server/thunderid.cert"
TOKEN_CACHE_DIR = Path(__file__).parent / ".mcp-oauth-cache"
CALLBACK_PORT = 52360
oauth = OAuth(
mcp_url=MCP_URL,
token_storage=FileTreeStore(data_directory=TOKEN_CACHE_DIR),
callback_port=CALLBACK_PORT,
)
async def main() -> None:
async with Client(MCP_URL, auth=oauth, verify=THUNDERID_CA_CERT) as client:
tools = await client.list_tools()
print("Tools:", sorted(tool.name for tool in tools))
result = await client.call_tool("add", {"a": 7, "b": 5})
print("add(7, 5) =", result.data)
result = await client.call_tool("divide", {"a": 10, "b": 4})
print("divide(10, 4) =", result.data)
if __name__ == "__main__":
asyncio.run(main())
The # /// script header is inline script metadata (PEP 723), the same mechanism server.py uses. uv run client.py reads it and resolves fastmcp automatically, in an isolated environment, so there's no separate pip install step.
FastMCP's OAuth helper does the OAuth work for you:
- On the first connection, it fetches the server's RFC 9728 protected-resource metadata to discover ThunderID as the authorization server, then registers itself as a new OAuth application named "FastMCP Client" through Dynamic Client Registration. ThunderID issues client credentials as part of that registration, and FastMCP stores them in its local cache.
- It opens the system browser for an Authorization Code flow with PKCE, using a local callback server pinned to
CALLBACK_PORT = 52360. ThunderID exact-matches registered redirect URIs, so the callback port must stay the same on every run, unlike the SDK's default of a new random port per run. If port52360is already in use on your machine, the callback server fails to bind. - Neither
OAuthnorclient.pypasses an explicitscopesargument. The scope comes from the Calculator server's advertisedscopes_supported, so the authorization request ends up asking for all four calculator scopes automatically. It also carries aresourceparameter that pins the token to this MCP server (RFC 8707). token_storage=FileTreeStore(data_directory=TOKEN_CACHE_DIR)persists the registered client's info and the issued tokens toclient/.mcp-oauth-cache/, so later runs reuse them instead of registering again, or asking you to sign in again while the cached token is still valid. The cache is plaintext JSON on disk, an acceptable trade-off for a local quickstart, and it's gitignored.Client(MCP_URL, auth=oauth, verify=THUNDERID_CA_CERT)trusts ThunderID's self-signed certificate for every TLS call the client makes: OAuth discovery, Dynamic Client Registration, the token exchange, and the MCP calls themselves.
Run and Sign In
Run the client from the client directory:
uv run client.py
Don't set SSL_CERT_FILE. uv itself honors that variable while it resolves fastmcp from PyPI, and it then fails to verify pypi.org against ThunderID's certificate. The client trusts ThunderID's certificate through Client(..., verify=THUNDERID_CA_CERT) instead, so no environment variable is needed.
On the first run, a browser tab opens with ThunderID's sign-in page. Sign in with your test user, and the client completes the OAuth flow and calls the tools:
Tools: ['add', 'divide', 'multiply', 'subtract']
add(7, 5) = 12.0
divide(10, 4) = 2.5
If you delete .mcp-oauth-cache/ while the "FastMCP Client" application still exists in the Console, the next run fails with Registration failed: 400 invalid_client_metadata "An application with the same name already exists"; delete that application in the Console and run again.
If your deployment doesn't allow Dynamic Client Registration, you can skip it by passing a pre-registered client_id (and optional client_secret) to OAuth(...) instead, from an MCP Client application you register the same way as Register an MCP Client for Inspector. One difference: under Redirect URIs, set http://localhost:52360/callback instead of Inspector's http://localhost:6274/oauth/callback, since this client's callback server is pinned to CALLBACK_PORT = 52360.
Run uv run client.py a second time. It skips the browser while the cached access token is still valid, about an hour by default, because the client reuses the client registration and tokens stored in .mcp-oauth-cache/. Once that token expires, the client opens the browser again to sign in. To see scope enforcement in action, try the scope experiment in Connect with MCP Inspector: remove a scope from a token and watch the matching tool disappear from the list.
What's Next
Check out the complete Calculator MCP Sample in the ThunderID repository.