Skip to main content
LumoAuth

LumoAuth for FastMCP

Add sign-in and per-tool access control to a FastMCP server in three lines.

lumoauth-fastmcp on PyPI Python 3.10+ FastMCP 4.1+ License: Apache-2.0

Quick start · How it works · Who is calling · Scopes and callers · Per-tool policy · Troubleshooting · Docs


from fastmcp import FastMCP
from lumoauth_fastmcp import LumoAuthProvider

mcp = FastMCP("Invoices", auth=LumoAuthProvider())

Set LUMOAUTH_ORG_ID and LUMOAUTH_MCP_SERVER_ID, and that is the whole integration. LumoAuth signs users in and issues the tokens. Your server checks each token and runs the tool.

Standard discovery
MCP clients read one well-known address on your server to find LumoAuth. No custom handshakes.

Sign-in and consent
LumoAuth shows the sign-in and consent screens. Your server never handles a password.

Token checks on your server
Each request is checked locally: signature, issuer, audience, expiry and DPoP binding.

The verified caller
current_caller() returns a person, an AI agent, or an agent that acts for a person.

Rules per tool
Require a scope, a person or an agent capability with one argument.

Policy in LumoAuth
Grant each tool to a person or an agent in LumoAuth. A change applies within 30 seconds, without a restart.

Quick start

You need Python 3.10 or later and a LumoAuth organization. Create an organization if you do not have one yet.

1. Register your server

In the LumoAuth portal:

  1. Open AI Agents → MCP servers and click Register MCP server.
  2. Set Server name to Invoices and Resource URI to http://localhost:8000/mcp.
  3. Click Next twice, then click Register MCP server.
  4. On the server's page, click Edit, then Next. Make two changes:
    • Under Scopes, click Use defaults.
    • Switch off Require DPoP sender-constrained tokens. Claude Code and the FastMCP client do not send DPoP proofs, so they cannot connect while this switch is on.
  5. Click Next, then Save changes. Copy the Server id (mcp_…) from the top of the page.

The Resource URI is the exact URL that clients connect to. A token for one Resource URI does not work at another.

2. Run the server

pip install lumoauth-fastmcp

Save this as server.py:

from fastmcp import FastMCP
from lumoauth_fastmcp import LumoAuthProvider, current_caller

mcp = FastMCP("Invoices", auth=LumoAuthProvider())

@mcp.tool
def whoami() -> str:
    """Show who is calling."""
    return current_caller().sub

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

Start it with your organization's slug and the server id from step 1. The slug is the part after /orgs/ in the portal's URL.

export LUMOAUTH_ORG_ID=acme
export LUMOAUTH_MCP_SERVER_ID=mcp_0123456789abcdef01234567
python server.py

For the EU region, also set LUMOAUTH_URL=https://eu.app.lumoauth.dev.

3. Call it without a token

$ curl -i http://localhost:8000/mcp
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer scope="mcp:read", resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"

Your server now rejects calls without a token. The 401 tells MCP clients where to find LumoAuth.

4. Sign in from a client

First, create an application for the client in the portal:

  1. Open Applications, click New application and choose Single-page or mobile app.
  2. Set Application name to Local MCP client and Redirect URI to http://localhost:8765/callback.
  3. Click Create application and copy the Client ID.
  4. On the Configuration tab, set Scopes to openid profile email offline_access mcp:read. Click Save configuration.

Then save this as client.py, with your client ID:

import asyncio

from fastmcp import Client
from fastmcp.client.auth import OAuth

auth = OAuth(client_id="client_0123456789abcdef0123456789abcdef", callback_port=8765)

async def main():
    async with Client("http://localhost:8000/mcp", auth=auth) as client:
        result = await client.call_tool("whoami")
        print(result.data)

asyncio.run(main())

Run it in a second terminal:

$ python client.py
01a18f7f-fe26-7f38-b46a-ac6bfabfbb78

Your browser opens on the LumoAuth sign-in page. After you sign in and approve, the script prints your user id. Your tool read that id from the verified token.

Connect Claude Code instead

Use the same application:

claude mcp add --transport http \
  --client-id client_0123456789abcdef0123456789abcdef \
  --callback-port 8765 \
  invoices http://localhost:8000/mcp

claude mcp login invoices

Your browser opens on the same sign-in page. Then claude mcp list shows the connection:

invoices: http://localhost:8000/mcp (HTTP) - ✔ Connected

Other MCP clients connect the same way if they let you set a client ID and a callback port. Troubleshooting explains the errors that you may see.

Before you go to production

  • Register a separate server with your public https:// address. LumoAuth accepts plain http:// only for localhost, 127.0.0.1 and [::1].
  • Under Allowed clients, choose Only the clients I pick.
  • Switch on Require DPoP sender-constrained tokens if all your clients can send proofs. See DPoP.

How it works

An MCP client calls your server without a token and gets a 401 that says where to sign in. The user signs in through LumoAuth, which issues a token for this server only. The client calls the tool with the token. Your server checks the token and runs the tool.

LumoAuth is the authorization server. It shows the consent screen and issues access tokens whose audience is your server's Resource URI. Your FastMCP server stays a resource server, and the provider gives it what the MCP specification asks of one:

  • Discovery. The server publishes protected resource metadata at /.well-known/oauth-protected-resource/.... This document names your LumoAuth organization as the authorization server.
  • Challenges. A request without a valid token gets 401 with a WWW-Authenticate header that points to that document. The challenge names the scopes that the registration advertises. It also states the real reason when a token is rejected: expired, wrong audience, replayed DPoP proof, and so on.
  • Verification. Tokens are verified locally: signature against the organization's keys, issuer, audience, expiry, at+jwt type, and no portal test tokens. A DPoP-bound token must arrive with the DPoP scheme and a fresh proof for this request.
  • Identity. Tools get the verified caller: a user, an AI agent, or an agent acting for a user.

The provider reads the server's registration from LumoAuth once at start-up: the Resource URI, the advertised scopes, and whether only DPoP-bound tokens are accepted. Restart the server after you change the registration.

Authentication applies to HTTP transports. A server that runs over stdio is started by the user's own MCP client and has no OAuth step.

Who is calling

current_caller() returns the verified claims of the current request as lumoauth.verify.AccessTokenClaims. This is the same object that verify_access_token in the LumoAuth Python SDK returns.

from lumoauth_fastmcp import current_caller

@mcp.tool
def list_invoices() -> list[dict]:
    caller = current_caller()
    if caller.is_agent:
        log.info("agent %s", caller.agent_id)          # "agt_…"
    elif caller.is_delegated:
        log.info("agent %s for user %s", caller.actor, caller.sub)
    return invoices_for(caller.sub)

current_caller() returns None on transports without OAuth, such as stdio and in-memory clients.

Scopes and callers

Pass the checks to FastMCP's auth= argument or to AuthMiddleware:

from fastmcp.server.middleware import AuthMiddleware
from lumoauth_fastmcp import require_agent, require_scopes, require_user

@mcp.tool(auth=require_scopes("invoices:refund"))
def refund(invoice_id: str) -> str: ...

@mcp.tool(auth=require_user())                       # people only, no agents
def change_bank_details(iban: str) -> str: ...

@mcp.tool(auth=require_agent("billing.reminders"))   # agents with this capability
def send_reminder(invoice_id: str) -> str: ...

require_user(allow_delegation=True) also admits an agent that acts for a person.

Add a new scope in two places: the server's Scopes, and the Scopes of each application that may ask for it. The checks follow LumoAuth's rule: a granted scope that ends in :* (such as invoices:*) covers the scopes under it, such as invoices:refund. Every other scope compares exactly. Prefer narrow scopes, because the MCP security best practices list wildcard scopes as a common mistake.

A component-level check hides the component from callers who fail it. To get an insufficient_scope step-up instead, put the scope check in AuthMiddleware. The client can then ask the user for the missing scope.

mcp = FastMCP(
    "Invoices",
    auth=LumoAuthProvider(),
    middleware=[AuthMiddleware(auth=require_scopes("invoices:read"))],
)

Per-tool policy from LumoAuth

A valid token proves that the caller may reach the server. tool_policy() also asks LumoAuth about each tool. It hides the tools that a caller may not run, and it rejects calls to them.

auth = LumoAuthProvider()
mcp = FastMCP("Invoices", auth=auth, middleware=[auth.tool_policy()])

The server asks as itself. It never forwards the caller's token, which the MCP specification forbids.

Every tool is denied until you grant it. With the policy on and no grants, a caller gets an empty list of tools. Set it up once:

  1. Create the server's own application. Open Applications, click New application and choose Machine to machine. Copy the Client ID and the Client secret, which is shown only once.

  2. Allow the scope. On the application's Configuration tab, set Scopes to mcp:authorize. The form also asks for a redirect URI, which this application does not use, so enter any https:// URL. Click Save configuration.

  3. Connect it to the MCP server. Edit the server. In the step Who may connect and how, select the application under Per-tool authorization.

  4. Give the server its credentials, then restart it:

    export LUMOAUTH_MCP_CLIENT_ID=...
    export LUMOAUTH_MCP_CLIENT_SECRET=...
    
  5. Grant access. Open Relationships. Under Namespaces, create the namespace that you need, with the relation invoke. Then, under Tuples, add one relationship for each grant:

    To grant Object namespace Object ID Relation Subject
    Every tool on the server mcp_server mcp_0123456789abcdef01234567 invoke user and the user's id
    One tool mcp_tool mcp_0123456789abcdef01234567/refund invoke agent and the agent's id

Relationships are part of the Fine-grained authorization add-on. Without it, every tool is denied.

Decisions are cached for 30 seconds (tool_policy(cache_ttl=...)), so a change takes effect within that time. When LumoAuth cannot be reached, the tool is rejected.

DPoP

DPoP ties a token to the client's key, so a stolen copy cannot be replayed. Switch on Require DPoP sender-constrained tokens when all your clients can send proofs. Agents built on the LumoAuth JavaScript, Python and Go SDKs can do so. Claude Code, ChatGPT and the FastMCP client cannot.

With the switch on, the server accepts only DPoP-bound tokens and challenges with the DPoP scheme. Bound tokens are checked on every server, whatever the switch says.

Proof ids are stored per process to stop replays. With several workers, pass a shared store:

LumoAuthProvider(replay_cache=MyRedisReplayCache())

The store has one method, check_and_add(key, ttl). It returns True for a key that it already holds, which means a replay. Otherwise it stores the key for ttl seconds and returns False.

The proof's htu must equal the URL that the client called. The provider builds that URL from the origin of the Resource URI and the request path. This suits a TLS-terminating proxy that keeps paths. For a proxy that rewrites paths, pass request_url=.

Configuration

Every argument is optional and falls back to an environment variable.

Argument Environment variable Default
org_id LUMOAUTH_ORG_ID required unless issuer is set
server_id LUMOAUTH_MCP_SERVER_ID none; without it pass resource
resource LUMOAUTH_MCP_RESOURCE from the registration
lumoauth_url LUMOAUTH_URL https://app.lumoauth.dev
issuer LUMOAUTH_ISSUER {lumoauth_url}/orgs/{org_id}/api/v1 (set it for a custom domain)
client_id, client_secret LUMOAUTH_MCP_CLIENT_ID, LUMOAUTH_MCP_CLIENT_SECRET only for tool_policy()
base_url origin of the Resource URI
required_scopes none
scopes_supported the registration's basic scopes
require_dpop the registration's Require DPoP switch
authorization_server_metadata True: also serve LumoAuth's metadata at /.well-known/oauth-authorization-server for older clients

Troubleshooting

What you see Why What to do
LumoAuthProvider needs the organization at start-up LUMOAUTH_ORG_ID is not set. Export it in the terminal that runs the server.
LumoAuth has no active MCP server at start-up The server id or the organization slug is wrong. Copy both again from the portal.
Protected resource … does not match expected … The client connects to a URL that differs from the Resource URI. localhost and 127.0.0.1 count as different. Use the Resource URI exactly. FastMCP's start-up banner prints 127.0.0.1, which is not what you registered.
invalid_dpop_proof or DPoP proof required Require DPoP is on and the client sends no proof. Switch it off, then restart the server.
invalid_scope … not allowed for this client The application does not allow a scope that the client asks for. Add the scope to the application's Scopes. Many clients add offline_access on their own.
Invalid Redirect URL on a LumoAuth page The client's redirect URI is not registered. LumoAuth accepts a changing port only on 127.0.0.1 and [::1], not on localhost. Create an application with the exact URI, and give the client a fixed callback port.
Dynamic client registration is not enabled The client tried to register itself, which is off by default. Give the client a client ID, as in step 4.
The list of tools is empty tool_policy() is on and the caller has no grant. See Per-tool policy.
A registration change has no effect The provider reads the registration once, at start-up. Restart the server.

Standards

Plain name Standard
Protected resource metadata RFC 9728
Resource indicators RFC 8707
DPoP RFC 9449
JWT access tokens (at+jwt) RFC 9068
Model Context Protocol authorization MCP Authorization specification

The MCP documentation explains how LumoAuth uses each one.

Requirements

Python 3.10 or later. The package installs FastMCP 4 (4.1 or later) and the LumoAuth Python SDK 1.2 or later, the first release with lumoauth.verify.

Development

python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest

License

Apache-2.0

Metadata

Release files for lumoauth-fastmcp 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lumoauth-fastmcp 1.0.0
File Size Uploaded Provenance Actions
lumoauth_fastmcp-1.0.0.tar.gz 30.7 kB Without provenance Details for lumoauth_fastmcp-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for lumoauth-fastmcp 1.0.0
File Interpreter ABI Platform Actions
lumoauth_fastmcp-1.0.0-py3-none-any.whl Python 3 none any Details for lumoauth_fastmcp-1.0.0-py3-none-any.whl

Total release size: 58.3 kB

Release files / lumoauth_fastmcp-1.0.0.tar.gz

Download URL lumoauth_fastmcp-1.0.0.tar.gz
Size 30.7 kB
Tags Source
SHA-256 checksum
How to use checksums
f385924d97261d70b7b1f014a3fcc036ff31df77553340541b51b095ae7d02cf
BLAKE2b-256 checksum
How to use checksums
23d72d6daaf6e823e95bc1da4dc76c25d4511eb180806c30f334ab087205dd95
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release files / lumoauth_fastmcp-1.0.0-py3-none-any.whl

Download URL lumoauth_fastmcp-1.0.0-py3-none-any.whl
Size 27.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d49edcd0630cda952bc3cbc86afc8c1b7b6e9222744e26ea7d9dd19cd87703fc
BLAKE2b-256 checksum
How to use checksums
4b63bc6638bab427d6105a748767d5799baed42d46f1ee75e30a0674766111e3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Security for lumoauth-fastmcp 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page