Skip to content

feat(example): Add FastMCP MCP server example - #45

Merged
frederikprijck merged 20 commits into
auth0:mainfrom
patrickkang:add-example-fastmcp-mcp
Sep 8, 2025
Merged

feat(example): Add FastMCP MCP server example#45
frederikprijck merged 20 commits into
auth0:mainfrom
patrickkang:add-example-fastmcp-mcp

Conversation

@patrickkang

@patrickkang patrickkang commented Aug 18, 2025

Copy link
Copy Markdown
Contributor

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.

  • This change adds test coverage for new/changed/fixed functionality

Checklist

  • I have added documentation for new/changed functionality in this PR or in auth0.com/docs
  • All active GitHub checks for tests, formatting, and security are passing
  • The correct base branch is being used, if not the default branch

@patrickkang
patrickkang marked this pull request as ready for review August 18, 2025 14:03
Comment thread examples/example-fastmcp-mcp/README.md Outdated

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we add installing the Auth0 CLI as a dependency? Or is this example usable without installing it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But the entire next steps rely on the auth0 CLI, which makes it a required prerequisite to follow any of the following steps.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

👍 That makes sense. I updated it to explicitly say that auth0 cli is a prerequisite to follow the rest of steps

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps, we can add a small sentence that explains everything can be configured through the manage dashboard as well if prefered.

Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment on lines +116 to +117
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in 5643498

Comment thread examples/example-fastmcp-mcp/README.md Outdated
/**
* Wrapper for FastMCP tools that requires specific OAuth 2.0 scopes.
*/
export function requireScopes<T, R>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For the tools that do not need specific tool scope enforcement, you'd pass the tool handler directly without the requireScopes wrapper.

@pmalouin pmalouin left a comment

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.

still not finished reviewing, but queueing up a few early comments

Comment thread examples/example-fastmcp-mcp/README.md
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment on lines +116 to +117
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.

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

Comment thread examples/example-fastmcp-mcp/README.md Outdated
Comment thread examples/example-fastmcp-mcp/README.md Outdated
auth0 users roles assign "auth0|USER_ID_HERE" --roles "YOUR_ROLE_ID_HERE"
```

### Step 6: Configure and Deploy the Scope-Injection Action

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 pmalouin left a comment

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.

finished reviewing ✔️ just a couple feedback items, but this is quite neat overall 💪

readOnlyHint: true,
},
parameters: greetParameters,
execute: requireScopes<z.infer<typeof greetParameters>, string>(

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

👍 Thanks for the suggestion. This simplifies a lot

Comment on lines +59 to +64
// Validate required scopes
if (!validateScopes(decoded, MCP_TOOL_SCOPES)) {
throw new InsufficientScopeError(
`Token is missing required scopes: ${MCP_TOOL_SCOPES.join(", ")}`
);
}

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'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:
image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This check was not needed there. Addressed in 420a2d0

}
const decoded = (await apiClient.verifyAccessToken({
accessToken,
})) as AccessTokenClaims;

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.

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.

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.

also related, but non-blocking, should we validate that sub is set and non-empty? (and throw if not)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

👍 We should validate sub claim and added that in

@pmalouin pmalouin left a comment

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.

just a tiny comment, otherwise LGTM 🚀

Comment thread examples/example-fastmcp-mcp/README.md Outdated
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.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the approach to move this out of beta post merging this example?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No we have never realy used a beta concept with examples, as they are not released like versioned artifacts.

Comment on lines +65 to +78
/**
* 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],
},
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this neccessary for integrating Auth0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

frederikprijck
frederikprijck previously approved these changes Sep 5, 2025
@frederikprijck
frederikprijck merged commit fc2158d into auth0:main Sep 8, 2025
4 checks passed
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.

3 participants