[DRAFT] docs: Add Tutorial to protect MCP server with Fusionauth #4115
[DRAFT] docs: Add Tutorial to protect MCP server with Fusionauth #4115sixhobbits wants to merge 37 commits into
Conversation
Edit: Protecting MCP Servers With FusionAuth OAuth
| ``` | ||
| </Aside> | ||
|
|
||
| You also need a **FusionAuth Enterprise license**. Custom OAuth scopes require an Enterprise license. [Contact FusionAuth](/pricing) to obtain a license key. |
There was a problem hiding this comment.
They actually are an essentials feature: https://fusionauth.io/docs/get-started/core-concepts/plans-features#essentials-features
There was a problem hiding this comment.
If someone already has a paid plan, they can find their key in https://account.fusionauth.io/account/plan/
There was a problem hiding this comment.
Done. Changed "Enterprise" to "Essentials" throughout, and added a link to account.fusionauth.io/account/plan/ for existing paid customers.
|
|
||
| ### Configure Your FusionAuth License | ||
|
|
||
| Before starting the services, add your FusionAuth Enterprise license key to the Kickstart configuration. |
There was a problem hiding this comment.
| Before starting the services, add your FusionAuth Enterprise license key to the Kickstart configuration. | |
| Before starting the services, add your FusionAuth license key to the Kickstart configuration. |
There was a problem hiding this comment.
Done, addressed in comment above.
|
|
||
| ### Verify That FusionAuth Is Running | ||
|
|
||
| Before you continue, confirm that FusionAuth started successfully. Check that you can access it: |
There was a problem hiding this comment.
Don't we know this because we did a docker ps above?
There was a problem hiding this comment.
Done. Removed the section and folded the admin UI tip into an <Aside>.
| Before you continue, confirm that FusionAuth started successfully. Check that you can access it: | ||
|
|
||
| ```bash | ||
| curl http://localhost:9011/.well-known/openid-configuration | jq .issuer |
There was a problem hiding this comment.
So jq is a prerequisite too?
There was a problem hiding this comment.
Done. Addressed in comment above.
| * **Email:** `admin@example.com` | ||
| * **Password:** `password` | ||
|
|
||
| Navigate to **Applications** to see the MCP Server application that the Kickstart configuration created. |
There was a problem hiding this comment.
Please follow the style guide and use the proper components.
https://github.com/FusionAuth/fusionauth-site/blob/main/DocsDevREADME.md#docs
There was a problem hiding this comment.
Done. Updated to use <Aside> and <Breadcrumb> throughout the guide.
| ``` | ||
| Available MCP clients: | ||
| 1. Claude Desktop | ||
| 2. Cursor |
There was a problem hiding this comment.
Why only these two? Are these the two you tested with?
Is there anything different for another MCP client?
There was a problem hiding this comment.
Done. Replaced the fixed client menu with a name prompt: run the script once for each MCP client you want to register, giving it any name you like.
The wildcard redirect URLs mean it works for any MCP client, not just Claude Desktop and Cursor. The prerequisites section now says "an MCP client such as Claude Desktop or Cursor" rather than listing specific clients.
- print("\nAvailable MCP clients:")
- for i, (key, config) in enumerate(CLIENT_CONFIGS.items(), 1):
- print(f" {i}. {config['name']}")
- print(f" {len(CLIENT_CONFIGS) + 1}. All")
- choice = input("\nSelect clients to register (comma-separated numbers): ").strip()
+ client_name = input("\nEnter a name for this MCP client (e.g. Claude Desktop): ").strip()| ### For Claude Desktop | ||
|
|
||
| 1. Open Claude Desktop. | ||
| 2. Navigate to **Settings → Developer**. |
There was a problem hiding this comment.
and anywhere else you are describing navigation.
There was a problem hiding this comment.
Done. Used <Breadcrumb> for Settings -> Developer and <InlineUIElement> for buttons in both the Claude Desktop and Cursor sections.
- 2. Navigate to **Settings → Developer**.
- 3. Click **Edit Config**.
+ 2. Navigate to <Breadcrumb>Settings -> Developer</Breadcrumb>.
+ 3. Click <InlineUIElement>Edit Config</InlineUIElement>.| * **Token caching:** Cache UserInfo responses to avoid validating tokens on every request. Set the cache TTL to match token expiration. | ||
| * **Refresh tokens:** Configure your FusionAuth application to issue refresh tokens for long-lived sessions. | ||
| * **Multiple scopes:** As you add more tools, create specific scopes for each tool and configure the token verifier to require the appropriate scopes. | ||
| * **Monitoring:** Log token validations, track which users call which tools, and monitor token expiration patterns. |
There was a problem hiding this comment.
Where do you that monitoring? in FusionAuth? in the MCP server?
There was a problem hiding this comment.
Done. Clarified that logging happens in the MCP server and FusionAuth separately, and added bullets for stale client cleanup and preregistering clients at scale.
- * **Monitoring:** Log token validations, track which users call which tools, and monitor token expiration patterns.
+ * **Monitoring:** Log token validations and tool calls in the MCP server. In FusionAuth, use the login records API to track which clients have authenticated and when.
+ * **Stale client cleanup:** Use FusionAuth's login records API to identify client applications that haven't authenticated recently, then deregister them to keep your application list clean.
+ * **Preregistering clients at scale:** If you need to onboard many MCP clients, automate registration using the FusionAuth Application API rather than running the setup script manually for each one.|
|
||
| OAuth clients redirect users back to a specific URL after authentication. If FusionAuth doesn't recognize the redirect URL, it rejects the request. When you see this error, look at the error message to find which URL was attempted (for example, `http://localhost:16442/oauth/callback`). Then add that URL to your client application's authorized redirect URLs in FusionAuth. | ||
|
|
||
| You can fix this in two ways: |
There was a problem hiding this comment.
Let's just give the person one way.
There was a problem hiding this comment.
Done. Removed the API approach and kept only the Admin UI method.
- You can fix this in two ways:
- 1. **Via the FusionAuth Admin UI:** Navigate to **Applications → [Your Client] → OAuth tab → Authorized redirect URLs**, then add the missing URL.
- 2. **Via the API:** Update the application with a `PATCH` request: ...
+ To fix this, navigate to <Breadcrumb>Applications -> [Your Client] -> OAuth -> Authorized redirect URLs</Breadcrumb> in the FusionAuth admin UI and add the missing URL.| The script prompts you for a name for the client, then creates an OAuth application in FusionAuth. Run it once for each MCP client you want to register. It uses wildcard redirect URLs so the OAuth callback works on any port: | ||
|
|
||
| ```python | ||
| REDIRECT_URLS = [ |
There was a problem hiding this comment.
Hmmm. The wildcard is a pretty big security risk, because it means anyone can stand up a process listening on a port and conceivably get the user to click on a link and get an authorization code.
I think I asked about how the port numbers were determined--are they documented someplace or did you discover them via experimentation.
There was a problem hiding this comment.
Wildcard URLs are not something we tend to want to encourage. Are they required here?
There was a problem hiding this comment.
No longer required. We've switched to ExactMatch URL validation with a pinned callback port (3334 by default).
The setup script registers the exact redirect URLs for that port, and users can pass --port if they need a different one.
| * `FUSIONAUTH_EXTERNAL_URL` adds the public localhost URL (`http://localhost:9011`), which browsers use during the OAuth flow. | ||
| * `MCP_SERVER_URL` adds the public URL of the MCP server, used by FastMCP to construct OAuth redirect URLs. | ||
| * `MCP_APP_ID` adds the application ID for the MCP server application. | ||
| * `MCP_APP_SECRET` is a placeholder client secret. Token validation uses the UserInfo endpoint, which requires no client authentication, so this value is not used at runtime. |
There was a problem hiding this comment.
I see this variable used elsewhere. If it is not required, should we remove it?
There was a problem hiding this comment.
MCP_APP_SECRET has been removed. Token validation now uses validate_jwt, which requires no client credentials.
Commit: a8458fc
| # Validate token via UserInfo endpoint. With the profile, email, and | ||
| # openid scopes requested, the response includes user profile data | ||
| # (name, email) so no separate API call is needed. | ||
| resp = requests.get( |
There was a problem hiding this comment.
Rather than use the requests library, can you please use the FusionAuth client library: https://github.com/FusionAuth/fusionauth-python-client/blob/develop/src/main/python/fusionauth/fusionauth_client.py#L3914
There was a problem hiding this comment.
Done. The UserInfo call now uses client.retrieve_user_info_from_access_token(access_token.token) from FusionAuthClient. The requests library has been removed entirely.
Commit: a8458fc
| # to extract them. FastMCP uses scopes to enforce access control on | ||
| # individual tools. | ||
| import base64 | ||
| payload_b64 = token.split('.')[1] |
There was a problem hiding this comment.
Rather than handrolling our JWT token handling, please use introspect: https://github.com/FusionAuth/fusionauth-python-client/blob/develop/src/main/python/fusionauth/fusionauth_client.py#L1891
There was a problem hiding this comment.
We tried introspect two ways and hit a wall.
FusionAuth's introspect endpoint requires the client_id in the request to match the aud claim of the token.
In this setup the aud is the MCP client app (e.g. Claude Code's app Id), but the MCP server is a separate FusionAuth application and doesn't hold the client's credentials.
There's no way to pass an API key instead. Introspect only accepts Basic Auth with client_id:client_secret.
We tested:
- Calling
introspect_access_tokenwith the MCP server'sclient_id. FusionAuth returnedactive: false, meaning it rejected the token as invalid because theclient_iddidn't match the token'saud - Calling introspect directly via HTTP with the MCP server's Basic Auth credentials. Same result
- Calling introspect with the issuing client's credentials returned
active: true, but the MCP server doesn't hold those credentials
We switched to validate_jwt (/api/jwt/validate), which validates the JWT cryptographically using FusionAuth's public keys and returns the full claims including scope.
There was a problem hiding this comment.
Hmm. Okay. I seem to remember another similar issue with introspect in the past. Ah, here it is: FusionAuth/fusionauth-issues#3010
Can you please mention this in a brief aside and link to the issue so that no one else tries to use introspect in this situation?
There was a problem hiding this comment.
Done. Added a note to the Token Validation Approach section explaining why introspect doesn't work here and linking to issue #3010.
If you try to use FusionAuth's introspect endpoint here, it will return a 401. Introspection requires that the credentials used to call the endpoint belong to the same application that issued the token. Because the MCP server is a separate FusionAuth application from each MCP client, it cannot introspect the client's tokens. See FusionAuth issue #3010 for details. The
/api/jwt/validateendpoint does not have this restriction.
|
|
||
| The `required_scopes=["openid", "profile", "email", "get_name"]` parameter ensures that: | ||
|
|
||
| * Only tokens containing all four scopes can execute tools |
There was a problem hiding this comment.
Same comment about bullet point lists. Either have them be full sentences or don't start them with capitals.
|
|
||
| FusionAuth requires an Essentials license (or higher) to use custom scopes. The Kickstart configuration: | ||
|
|
||
| 1. Activates the license |
There was a problem hiding this comment.
Same comment about bullet point lists. Either have them be full sentences or don't start them with capitals.
|
|
||
| * **HTTPS:** Use HTTPS for all endpoints, remove the `--allow-http` flag, and configure proper TLS certificates. | ||
| * **Token caching:** Cache UserInfo responses to avoid validating tokens on every request. Set the cache TTL to match token expiration. | ||
| * **Refresh tokens:** Configure your FusionAuth application to issue refresh tokens for long-lived sessions. |
There was a problem hiding this comment.
Do MCP clients handle the refresh grant?
There was a problem hiding this comment.
Yes, mcp-remote handles the refresh grant automatically. It stores tokens in ~/.mcp-auth and refreshes them when they expire.
Support varies across other MCP clients though, so we've added a note to the article about this.
|
@jamesdanielwhitford some other comments too:
I worked through the application here: jamesdanielwhitford/fusionauth-protected-mcp-server.git . Let me know if that is the correct version. |
Co-authored-by: Dan Moore <dan@fusionauth.io>
Co-authored-by: Dan Moore <dan@fusionauth.io>
… tip Co-authored-by: Dan Moore <dan@fusionauth.io>
Co-authored-by: Dan Moore <dan@fusionauth.io>
|
Ack, one more issue, sorry. We will want all the code excerpts to be pulled in from the github repository using the remote code component. You can see the documentation here: https://github.com/FusionAuth/fusionauth-astro-components and here's an example: https://github.com/FusionAuth/fusionauth-site/blob/main/astro/src/content/docs/get-started/start-here/step-2.mdx?plain=1 This is for anything that is beyond a few shell commands. |
I made some updates for consistency as I see FA hasn't been using this branch structure:
|
I've switched all code excerpts beyond shell commands to use The code is currently at I'll review any remaining snippets that may still need updating and do a full QA pass on the guide tomorrow. |
|
Hi @mooreds your comments from the previous rounds have been addressed. Here's a summary of everything that's changed since the last review: Token verificationReplaced the custom JWT decode approach with FusionAuth Python client throughoutBoth UserInfo call moved into the toolThe verifier validates the token and extracts scopes only. The Scope handlingRequired scopes reduced to just MCP server application removed from kickstartNo longer needed since Code repo restructuredSplit into three folders: Article now uses RemoteCode componentsAll code blocks pull directly from the repo rather than being hardcoded. The Remote setup instructions improved
Full QA pass completeTested all three client paths (config file, CLI tools via Known issue: RemoteCode renderingThe |
|
Thanks @jamesdanielwhitford . Appreciate the summary and the reworking. When you say you did a test, do you mean you used FusionAuth as the authorization server? Or do you mean you successfully used the existing MCP server code with a different authorization server? Or something else. I just spent a few hours modifying FusionAuth to work with a previous version of your MCP server code (added PKCE to the openid discovery doc, add support for the resource parameter/RFC 8707), so wanted to make sure I wasn't missing anything. |
@mooreds We successfully tested this against our remote (
We don't know exactly why Stricter clients that require We'll update the article's prerequisites with a minimum FusionAuth version once we know which release includes your changes. |
|
Thanks for the insights! I am using the same version of mcp-remote but ran into an issue where it complained without the PKCE config. Weird. Anyway, we're not far off from being spec compliant. Will let you know when these are released so that we can be confident that anyone reading the doc can follow it without error. |
|
Notes for the future:
|
|
@mooreds is this ready to go? or does it need more iteration? if it's ready, I'm happy to merge and roll it out. |
|
it is not ready to go. I was waiting until FusionAuth/fusionauth-issues#1767 is released, so that we can use FusionAuth for the MCP server. This issue is actively under development. |
|
Cool, converted to a draft PR so this is easier to track. |
No description provided.