Skip to content

[DRAFT] docs: Add Tutorial to protect MCP server with Fusionauth #4115

Draft
sixhobbits wants to merge 37 commits into
FusionAuth:mainfrom
ritza-co:draft/add-mcp-oauth-tutorial
Draft

[DRAFT] docs: Add Tutorial to protect MCP server with Fusionauth #4115
sixhobbits wants to merge 37 commits into
FusionAuth:mainfrom
ritza-co:draft/add-mcp-oauth-tutorial

Conversation

@sixhobbits
Copy link
Copy Markdown
Collaborator

No description provided.

@sixhobbits sixhobbits requested review from a team as code owners February 20, 2026 16:58
```
</Aside>

You also need a **FusionAuth Enterprise license**. Custom OAuth scopes require an Enterprise license. [Contact FusionAuth](/pricing) to obtain a license key.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone already has a paid plan, they can find their key in https://account.fusionauth.io/account/plan/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Changed "Enterprise" to "Essentials" throughout, and added a link to account.fusionauth.io/account/plan/ for existing paid customers.

183814462


### Configure Your FusionAuth License

Before starting the services, add your FusionAuth Enterprise license key to the Kickstart configuration.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, addressed in comment above.

183814462


### Verify That FusionAuth Is Running

Before you continue, confirm that FusionAuth started successfully. Check that you can access it:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we know this because we did a docker ps above?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed the section and folded the admin UI tip into an <Aside>.

183814462

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So jq is a prerequisite too?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Addressed in comment above.

183814462

* **Email:** `admin@example.com`
* **Password:** `password`

Navigate to **Applications** to see the MCP Server application that the Kickstart configuration created.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please follow the style guide and use the proper components.

https://github.com/FusionAuth/fusionauth-site/blob/main/DocsDevREADME.md#docs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Updated to use <Aside> and <Breadcrumb> throughout the guide.

183814462

```
Available MCP clients:
1. Claude Desktop
2. Cursor
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only these two? Are these the two you tested with?

Is there anything different for another MCP client?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

a585b33

### For Claude Desktop

1. Open Claude Desktop.
2. Navigate to **Settings → Developer**.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use breadcrumb here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and anywhere else you are describing navigation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>.

183814462

* **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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do you that monitoring? in FusionAuth? in the MCP server?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

183814462

Comment thread astro/src/content/docs/extend/examples/protecting-mcp-servers.mdx

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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just give the person one way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

183814462

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 = [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wildcard URLs are not something we tend to want to encourage. Are they required here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this variable used elsewhere. If it is not required, should we remove it?

Copy link
Copy Markdown
Contributor

@jamesdanielwhitford jamesdanielwhitford Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MCP_APP_SECRET has been removed. Token validation now uses validate_jwt, which requires no client credentials.

Commit: a8458fc

Comment thread astro/src/content/docs/extend/examples/protecting-mcp-servers.mdx Outdated
# 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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that should return scope.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Calling introspect_access_token with the MCP server's client_id. FusionAuth returned active: false, meaning it rejected the token as invalid because the client_id didn't match the token's aud
  2. Calling introspect directly via HTTP with the MCP server's Basic Auth credentials. Same result
  3. 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/validate endpoint does not have this restriction.

4a1f539

Comment thread astro/src/content/docs/extend/examples/protecting-mcp-servers.mdx Outdated

The `required_scopes=["openid", "profile", "email", "get_name"]` parameter ensures that:

* Only tokens containing all four scopes can execute tools
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about bullet point lists. Either have them be full sentences or don't start them with capitals.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bullet point edits made here: 00e0ed4


FusionAuth requires an Essentials license (or higher) to use custom scopes. The Kickstart configuration:

1. Activates the license
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about bullet point lists. Either have them be full sentences or don't start them with capitals.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bullet point edits made here: 00e0ed4


* **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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do MCP clients handle the refresh grant?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread astro/src/content/docs/extend/examples/protecting-mcp-servers.mdx Outdated
@mooreds
Copy link
Copy Markdown
Contributor

mooreds commented Mar 3, 2026

@jamesdanielwhitford some other comments too:

  • do we use branches for the 'competed' version of other example applications? or for the 'remote' version?
  • why do we need a MCP application (the first one that is created by kickstart)? isn't every MCP client a separate application?

I worked through the application here: jamesdanielwhitford/fusionauth-protected-mcp-server.git . Let me know if that is the correct version.

jamesdanielwhitford and others added 5 commits March 3, 2026 10:08
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>
@mooreds
Copy link
Copy Markdown
Contributor

mooreds commented Mar 3, 2026

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.

@jamesdanielwhitford
Copy link
Copy Markdown
Contributor

jamesdanielwhitford commented Mar 3, 2026

@jamesdanielwhitford some other comments too:

  • do we use branches for the 'competed' version of other example applications? or for the 'remote' version?
  • why do we need a MCP application (the first one that is created by kickstart)? isn't every MCP client a separate application?

I worked through the application here: jamesdanielwhitford/fusionauth-protected-mcp-server.git . Let me know if that is the correct version.

I made some updates for consistency as I see FA hasn't been using this branch structure:

  1. We've moved away from branches for the starter/completed/remote variants. The repo now has a single main branch with three self-contained folders: unprotected-local-mcp (starter), protected-local-mcp (completed), and protected-remote-mcp (remote deployment variant).

  2. The MCP Server application in the kickstart was used for token verification via the introspect endpoint. We've since switched to FusionAuth's /api/jwt/validate endpoint, which doesn't require client credentials, so the MCP Server application is no longer needed and has been removed from the kickstart. You're right that each MCP client gets its own application.

  3. The correct repo is the ritza-co fork at https://github.com/ritza-co/fusionauth-protected-mcp-server. But my code is identical. The article references https://github.com/fusionauth/fusionauth-protected-mcp-server, which is where it will live once transferred. In the meantime, the ritza-co fork at https://github.com/ritza-co/fusionauth-protected-mcp-server has the same code on main. FA can fork this ritza-co fork and only needs to fork main.

@jamesdanielwhitford
Copy link
Copy Markdown
Contributor

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've switched all code excerpts beyond shell commands to use <RemoteCode> components with a codeRoot frontmatter variable pointing to https://raw.githubusercontent.com/fusionauth/fusionauth-protected-mcp-server/main.

The code is currently at ritza-co/fusionauth-protected-mcp-server. That is the correct repo to work from before publishing.

I'll review any remaining snippets that may still need updating and do a full QA pass on the guide tomorrow.

@jamesdanielwhitford
Copy link
Copy Markdown
Contributor

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 verification

Replaced the custom JWT decode approach with FusionAuthClient.validate_jwt(), which verifies the token signature using FusionAuth's public keys and returns claims including scope. Added an aside explaining why introspect can't be used here: the MCP server is a separate FusionAuth application from each MCP client, so introspect returns 401. Links to issue #3010.

FusionAuth Python client throughout

Both server.py and setup_clients.py now use fusionauth-client for all FusionAuth API calls. The requests library has been removed entirely.

UserInfo call moved into the tool

The verifier validates the token and extracts scopes only. The get_name tool calls retrieve_user_info_from_access_token to fetch the user's name after validation.

Scope handling

Required scopes reduced to just get_name. Setup script uses Strict scope handling and ExactMatch URL validation with a pinned callback port (3334 by default, overridable with --port).

MCP server application removed from kickstart

No longer needed since validate_jwt requires no client credentials.

Code repo restructured

Split into three folders: unprotected-local-mcp (starter), protected-local-mcp (completed local), protected-remote-mcp (remote deployment, MCP server only, no FusionAuth or database).

Article now uses RemoteCode components

All code blocks pull directly from the repo rather than being hardcoded. The codeRoot currently points at fusionauth/fusionauth-protected-mcp-server. The working code is at ritza-co/fusionauth-protected-mcp-server. Once that is forked or transferred to the FusionAuth org, the codeRoot will need to be updated to point at the final repo location.

Remote setup instructions improved

--fusionauth-url and --api-key are now shown as required flags for remote deployments. The remote setup script enforces --api-key explicitly.

Full QA pass complete

Tested all three client paths (config file, CLI tools via claude mcp add-json, connector UI) against a live remote stack. All three pass end to end.

Known issue: RemoteCode rendering

The <RemoteCode> component occasionally truncates the first character of the first line in a tagged block (e.g. lass instead of class). This appears to be a component bug rather than a tag format issue. Happy to raise it separately if useful.

@mooreds
Copy link
Copy Markdown
Contributor

mooreds commented Mar 4, 2026

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.

@jamesdanielwhitford
Copy link
Copy Markdown
Contributor

jamesdanielwhitford commented Mar 5, 2026

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 (fusionauth.ritza.co, running 1.53.3) and local (latest Docker image, 1.63.0) FusionAuth setups and it works as expected even without the code_challenge_methods_supported entry in the discovery doc.

fusionauth.ritza.co does not include code_challenge_methods_supported in its OIDC discovery document. Despite this, testing passed with mcp-remote 0.1.38.

We don't know exactly why mcp-remote 0.1.38 proceeded without the field. Our best guess is that it doesn't require the field to be present and just sends S256 regardless, but we can't back that up with a source link.

Stricter clients that require code_challenge_methods_supported to be present in the discovery doc before proceeding would fail without it, and newer client versions may well be stricter.

We'll update the article's prerequisites with a minimum FusionAuth version once we know which release includes your changes.

@mooreds
Copy link
Copy Markdown
Contributor

mooreds commented Mar 5, 2026

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.

@mooreds
Copy link
Copy Markdown
Contributor

mooreds commented Mar 6, 2026

Notes for the future:

  • add the get_name scope to the Claude Desktop application when configuring it
  • set it up as a third party application so that the user is prompted to consent to the scopes

@nathan-contino
Copy link
Copy Markdown
Contributor

@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.

@mooreds
Copy link
Copy Markdown
Contributor

mooreds commented Apr 15, 2026

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.

@nathan-contino nathan-contino marked this pull request as draft April 15, 2026 17:14
@nathan-contino
Copy link
Copy Markdown
Contributor

Cool, converted to a draft PR so this is easier to track.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants