feat(example): Add FastMCP MCP server example - #45
Conversation
|
|
||
| This guide uses [Auth0 CLI](https://auth0.github.io/auth0-cli/) to configure an Auth0 tenant for secure MCP tool access. | ||
|
|
||
| ### Step 1: Authenticate with Auth0 CLI |
There was a problem hiding this comment.
Should we add installing the Auth0 CLI as a dependency? Or is this example usable without installing it?
There was a problem hiding this comment.
You can configure your tenant from the dashboard or with the Management API. The Auth0 CLI isn't required, but it simplifies the setup process.
There was a problem hiding this comment.
But the entire next steps rely on the auth0 CLI, which makes it a required prerequisite to follow any of the following steps.
There was a problem hiding this comment.
👍 That makes sense. I updated it to explicitly say that auth0 cli is a prerequisite to follow the rest of steps
There was a problem hiding this comment.
Perhaps, we can add a small sentence that explains everything can be configured through the manage dashboard as well if prefered.
| 2. Set the Default Audience: This ensures that users logging in interactively get access tokens that are valid for your | ||
| newly created MCP API. Replace `http://localhost:3001` with the same API identifier you used above. |
There was a problem hiding this comment.
Is there a reason why we do this in the context of MCP? Isn't this something that would work for any audience in any context? This just ensures that, when no audience is send along with authorize, this audience is used. Setting this as a default audience has consequences on existing clients and auth flows without audience.
There was a problem hiding this comment.
We are resorting to using default audience until we get the resource parameter support out.
More details here:
https://auth0.slack.com/archives/C091X0B50MA/p1753471387446879?thread_ts=1753459734.480379&cid=C091X0B50MA
and
https://oktawiki.atlassian.net/wiki/spaces/AFG1/pages/3666641175/RFD+resource+parameter?focusedCommentId=3669787109
We can explicitly call out that setting the default audience is a tenant-wide config change and that it affects all auth flows. WDYT?
There was a problem hiding this comment.
I think it might make sense to add a comment to this paragraph that indicates that this step is temporary until we have proper support for Resource Indicators for OAuth 2.0 as defined in RFC 8707. It is not an optional step, because if you skip it today, the issued AT will not be scoped specifically to the RS.
Good news is that support for RFC 8707 is coming very soon and we can tweak the instructions here to instead set the resource_parameter_profile to "compatibility".
| /** | ||
| * Wrapper for FastMCP tools that requires specific OAuth 2.0 scopes. | ||
| */ | ||
| export function requireScopes<T, R>( |
There was a problem hiding this comment.
Is enforcing scopes expected? In other examples, we use requireAuth with an optional scope parameter. I think typically, we want scopes to be used, but should it also be usable without scopes?
There was a problem hiding this comment.
For the tools that do not need specific tool scope enforcement, you'd pass the tool handler directly without the requireScopes wrapper.
pmalouin
left a comment
There was a problem hiding this comment.
still not finished reviewing, but queueing up a few early comments
| 2. Set the Default Audience: This ensures that users logging in interactively get access tokens that are valid for your | ||
| newly created MCP API. Replace `http://localhost:3001` with the same API identifier you used above. |
There was a problem hiding this comment.
I think it might make sense to add a comment to this paragraph that indicates that this step is temporary until we have proper support for Resource Indicators for OAuth 2.0 as defined in RFC 8707. It is not an optional step, because if you skip it today, the issued AT will not be scoped specifically to the RS.
Good news is that support for RFC 8707 is coming very soon and we can tweak the instructions here to instead set the resource_parameter_profile to "compatibility".
| auth0 users roles assign "auth0|USER_ID_HERE" --roles "YOUR_ROLE_ID_HERE" | ||
| ``` | ||
|
|
||
| ### Step 6: Configure and Deploy the Scope-Injection Action |
There was a problem hiding this comment.
Is this section absolutely needed? I just went ahead and skipped it and my AT contains the scope claim with the right value (derived from the RBAC roles + permissions defined in Step 5).
Can we double-check that this is indeed needed or perhaps isn't needed anymore?
We could leave a comment about the fact that any further customization not supported out of the box by RBAC can be done via a custom Post-Login action trigger, but I'd consider that optional.
There was a problem hiding this comment.
As discussed, we are removing the post-login action from the tenant settings and keep the tenant setup simple for now.
I've added this note for optional configuration: **Note:** Further customization not supported out of the box by RBAC can be done via a custom Post-Login action trigger.
pmalouin
left a comment
There was a problem hiding this comment.
finished reviewing ✔️ just a couple feedback items, but this is quite neat overall 💪
| readOnlyHint: true, | ||
| }, | ||
| parameters: greetParameters, | ||
| execute: requireScopes<z.infer<typeof greetParameters>, string>( |
There was a problem hiding this comment.
I was reviewing the docs for FastMCP, and it looks like one idiomatic way to authorize access to individual tools based on the current auth context is to declare a canAccess(auth) field method.
So we could create a helper function like:
function hasAllScopes(
requiredScopes: readonly string[]
): (auth: FastMCPAuthSession) => boolean {
return (auth: FastMCPAuthSession) => {
const userScopes = auth.scopes;
return requiredScopes.every((scope) =>
userScopes.includes(scope)
);
};
}... and use it in the tool declaration like this:
{
name: "greet",
description:
"Greet a user with personalized authentication information from Auth0.",
annotations: {
title: "Greet User (FastMCP)",
readOnlyHint: true,
},
parameters: greetParameters,
canAccess: hasAllScopes(["tool:greet"]), //<<<<
....
},This has the added benefit that the "list tools" command will filter out any tool that is not currently allowed (based on the scopes of the current AT).
Moreover, I think that this approach allows to get rid of requireScopes(), or more specifically, we don't need to wrap the tool's execute method with a scope-checking helper.
There was a problem hiding this comment.
👍 Thanks for the suggestion. This simplifies a lot
| // Validate required scopes | ||
| if (!validateScopes(decoded, MCP_TOOL_SCOPES)) { | ||
| throw new InsufficientScopeError( | ||
| `Token is missing required scopes: ${MCP_TOOL_SCOPES.join(", ")}` | ||
| ); | ||
| } |
There was a problem hiding this comment.
I'm not sure that this makes sense if we want to allow the user to only have a subset of scopes (vs all possible scopes supported by the MCP server). If I try to authenticate with an AT that does not have all of MCP_TOOL_SCOPES, then the error is thrown and I can't even connect to the MCP server (whereas I'd expect at least to be able to access a narrower set of tools).
Moreover, the error seen on MCP Inspector is not self-explanatory so it would lead to sub-par UX:

There was a problem hiding this comment.
This check was not needed there. Addressed in 420a2d0
| } | ||
| const decoded = (await apiClient.verifyAccessToken({ | ||
| accessToken, | ||
| })) as AccessTokenClaims; |
There was a problem hiding this comment.
Using as here could hide some inconsistencies between the actual data type of claims received via the token. I'd recommend implementing proper string guard clauses to extract the client_id, azp, scope, name and email claims. This way, you aren't doing a leap of faith from unknown to string, even though we know that auth0 will return those claims as strings.
A handy helper can be used to do the type narrowing:
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
...which can for example simplify the block below to:
extra: {
sub: decoded.sub,
...(isNonEmptyString(decoded.client_id) && { azp: decoded.client_id }),
...(isNonEmptyString(decoded.azp) && { azp: decoded.azp }),
...(isNonEmptyString(decoded.name) && { name: decoded.name }),
...(isNonEmptyString(decoded.email) && { email: decoded.email }),
},
Other typescript warnings can be addressed similarly.
There was a problem hiding this comment.
also related, but non-blocking, should we validate that sub is set and non-empty? (and throw if not)
There was a problem hiding this comment.
👍 We should validate sub claim and added that in
Co-authored-by: Patrick Malouin <p.malouin@gmail.com>
Co-authored-by: Patrick Malouin <p.malouin@gmail.com>
pmalouin
left a comment
There was a problem hiding this comment.
just a tiny comment, otherwise LGTM 🚀
Co-authored-by: Patrick Malouin <p.malouin@gmail.com>
| { | ||
| "name": "example-fastmcp-mcp", | ||
| "version": "0.1.0-beta.1", | ||
| "description": "FastMCP Server Example (Beta) — Example implementation of an MCP server using FastMCP framework with Auth0 authentication. This example is in beta and demonstrates secure MCP server patterns with OAuth 2.0 and JWT validation.", |
There was a problem hiding this comment.
What is the approach to move this out of beta post merging this example?
There was a problem hiding this comment.
Good call out. We are starting with beta and might not support every framework. The idea is to first validate those mcp examples and get some feedback. Framework popularity with mcp server integration is also a factor. Curious if there's an existing path we usually follow to move examples here out of beta
There was a problem hiding this comment.
No we have never realy used a beta concept with examples, as they are not released like versioned artifacts.
| /** | ||
| * Exposes /.well-known/oauth-authorization-server endpoint for backwards compatibility. | ||
| * This enables backward compatibility for clients that expect authorization server metadata | ||
| * to be available directly from this MCP server | ||
| */ | ||
| authorizationServer: { | ||
| issuer: `https://${AUTH0_DOMAIN}/`, | ||
| authorizationEndpoint: `https://${AUTH0_DOMAIN}/authorize`, | ||
| tokenEndpoint: `https://${AUTH0_DOMAIN}/oauth/token`, | ||
| jwksUri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`, | ||
| responseTypesSupported: ["code"], | ||
| scopesSupported: ["openid", "profile", "email", ...MCP_TOOL_SCOPES], | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Is this neccessary for integrating Auth0?
There was a problem hiding this comment.
Not to integrate with auth0, but still needed to keep backward compatibility for the MCP server
This is still done here as well: https://github.com/modelcontextprotocol/typescript-sdk/blob/3dd074f8a25b92994e0e8cc69d3ffe9112a1f32b/src/server/auth/router.ts#L207-L208
Description
This PR adds FastMCP MCP server example with Auth0 integration
References
https://auth0team.atlassian.net/browse/AIDX-114
Testing
Follow README.md#Auth0 Tenant Setup
to set up your auth0 tenant and use a MCP client like MCP inspector to test your MCP server.
Checklist