User-friendly just-in-time Auth with MCP

    Kent C. DoddsKent C. Dodds

    When building Model Context Protocol (MCP) servers, authorization is a critical piece of the puzzle for real-world practical MCP servers. Right now most servers require an OAuth flow before you can use the server at all. If websites did that, you wouldn't have many users. Instead, users get to play around with parts of the website experience before they have to authenticate. MCP servers should be no different.

    In this post, I'll walk you through the unique authorization flow I implemented for the Epic Me MCP server, why it matters, and how you can adapt similar patterns in your own MCP apps.

    The User Experience: Fast, Frictionless, and Familiar

    Let's start with what the user sees:

    • When connecting to the Epic Me MCP server (for example, via Claude), the authorization process is almost invisible. The browser opens and closes quickly—no login forms, no explicit approval screens. (In the future, it would be great if the client handled this in the background).
    • If a user tries to perform an action that requires authentication (like saving a journal entry), the server prompts for their email, sends a one-time token, and validates it—all without requiring a traditional login flow.
    • The available tools, resources, and prompts dynamically adjust based on whether the user is authenticated.

    This is a departure from the typical OAuth experience (think: GitHub or Google login), but it's designed to be both secure and low-friction for the kinds of workflows MCP enables.

    How It Works: The Technical Flow

    Here's a high-level overview of the flow:

    1. Initial Authorization: When a client connects, the server generates an unclaimed grant (a temporary, anonymous session) and issues a grantId and saves a grantUserId (pretty much only necessary because the OAuth Provider requires it).
    2. Unauthenticated Actions: The user can explore public tools, resources, and prompts. If they try to do something that requires authentication, the server prompts for their email.
    3. Email Authentication: The server generates a one-time password (OTP), emails it to the user, and waits for the user to submit the token.
    4. Token Validation: When the user submits the token, the server validates it, claims the grant for that user, and upgrades the session to authenticated.
    5. Dynamic Tool Availability: The set of available tools, resources, and prompts updates based on the user's authentication state.

    Let's look at some code and see how this comes together.

    Code Highlights: Key Pieces of the Flow

    1. Registering Unauthenticated and Authenticated Tools

    The server defines two sets of tools: those available to everyone, and those only for authenticated users. Here's a simplified excerpt from src/tools.ts:

    agent.unauthenticatedTools = [
    agent.server.tool(
    'authenticate',
    `Authenticate to your account or create a new account. Ask for the user's email address before authenticating. Only do this when explicitly told to do so.`,
    {
    email: z
    .string()
    .email()
    .describe("The user's email address for their account."),
    },
    async ({ email }) => {
    const grant = await requireGrantId()
    const { otp } = await generateTOTP({
    period: 30,
    digits: 6,
    algorithm: 'SHA-512',
    })
    await agent.db.createValidationToken(email, grant.id, otp)
    await sendEmail({
    to: email,
    subject: 'EpicMeMCP Validation Token',
    html: `Here's your EpicMeMCP validation token: ${otp}`,
    text: `Here's your EpicMeMCP validation token: ${otp}`,
    })
    return createReply(
    `The user has been sent an email to ${email} with a validation token. Please have the user submit that token using the validate_token tool.`,
    )
    },
    ),
    // ...
    ]
    agent.authenticatedTools = [
    agent.server.tool(
    'create_entry',
    'Create a new journal entry',
    createEntryInputSchema,
    async (entry) => {
    const user = await requireUser()
    const createdEntry = await agent.db.createEntry(user.id, entry)
    // ...
    return {
    /* ... */
    }
    },
    ),
    // ...
    ]

    2. The Email Authentication Flow

    When a user needs to authenticate, the server:

    • Generates a one-time password (OTP)
    • Stores a validation token in the database
    • Sends the OTP to the user's email

    Relevant code from src/tools.ts:

    ;async ({ email }) => {
    const grant = await requireGrantId()
    const { otp } = await generateTOTP({
    period: 30,
    digits: 6,
    algorithm: 'SHA-512',
    })
    await agent.db.createValidationToken(email, grant.id, otp)
    await sendEmail({
    to: email,
    subject: 'EpicMeMCP Validation Token',
    html: `Here's your EpicMeMCP validation token: ${otp}`,
    text: `Here's your EpicMeMCP validation token: ${otp}`,
    })
    return createReply(
    `The user has been sent an email to ${email} with a validation token. Please have the user submit that token using the validate_token tool.`,
    )
    }

    3. Validating the Token and Claiming the Grant

    Once the user submits the token, the server:

    • Looks up the validation token and associated grant
    • Finds or creates the user by email
    • Claims the grant for that user
    • Cleans up the validation token

    From src/db/index.ts:

    export class DB {
    async validateTokenToGrant(grantId: number, validationToken: string) {
    const validationResult = await this.#db
    .prepare(
    `SELECT id, email, grant_id FROM validation_tokens WHERE grant_id = ?1 AND token_value = ?2 LIMIT 1`,
    )
    .bind(grantId, validationToken)
    .first()
    if (!validationResult) {
    throw new Error('Invalid validation token')
    }
    // Find or create user by email
    // ...
    // Claim the grant for the user
    // ...
    // Delete the validation token
    // ...
    }
    }

    Dynamic Tool, Resource, and Prompt Availability

    A key design feature: the set of available tools, resources, and prompts changes based on the user's authentication state. This is managed in the server's onStateUpdate and updateAvailableItems methods:

    export class EpicMeMCP extends Agent {
    onStateUpdate(state: State | undefined, source: Connection | 'server') {
    const result = super.onStateUpdate(state, source)
    if (source === 'server') {
    void this.updateAvailableItems()
    }
    return result
    }
    async updateAvailableItems() {
    // Enable/disable tools, resources, prompts based on user state
    // ...
    }
    }

    While most clients don't yet support dynamic updates, this pattern sets the stage for more responsive, user-aware MCP apps in the future.

    Why This Matters: Flexibility and User Experience

    • No upfront login required: Users can explore the app and only authenticate when needed.
    • Familiar, secure flow: Email-based OTP is a common, user-friendly pattern.
    • Dynamic capabilities: The server can expose different features to different users (or even roles/scopes in the future).
    • Future-proof: As MCP clients evolve, this approach will enable even richer, more dynamic user experiences.

    What's Next?

    I expect the MCP ecosystem to evolve toward even more granular, dynamic authorization—think: different tools for admins, premium users, or based on custom scopes. For now, this pattern is a great way to make your MCP server both secure and welcoming.

    If you want to see the full implementation, check out the Epic Me MCP repo.

    Share

    Hey, I've got kind of a unique way to authorize an MCP server, and I think that this use case is going to be supported, but the way that we do it will probably change in the future. But a lot of applications or MCP apps, I guess, are going to do things in this way. So first, let's take a look at what the user experience is like. So I'm going to open up Cloud and you'll notice that it's going to go through the authorization process and then it just closes the browser. Now that's a little bit different because normally you would expect, like if you're authorizing with GitHub or something like that.

    It's going to have a little thing that explains what you're giving permissions for and different things like that. And I think that actually could still be a thing in the future where you have different scopes that you want to authorize. But what's interesting is I wasn't actually logged in. It just automatically approved it. So what's going on here?

    Well, here I'm going to say that the MCP server that I have configured is the Epic Me MCP server. So it allows you to write journal entries. So I'm going to say, Please write a journal entry about my kids' last day at school yesterday. And, of course, like the actual use cases, I would write the journal entry myself, but we'll let the LLM generate something for me. And it's like, give me some details.

    Why don't you just make something up? So again, normally you'd write that yourself. But here it's going to create the journal entry. It looks like it's not interpreting my intent that it was actually like, save it into the database or into the Epic Me server. So could you put that in my Epic Me journal database, please?

    And so now it should know, oh, OK, you want me to actually save this. I need to authenticate you first. OK, great. This is actually exactly what I would expect to happen. If it tried to use one of the tools, it would get an error indicating that it would need to do this.

    So it just knew that from the description of the tools, which is cool. So I'm going to give it my email, me at kentcdods.com. And now it's going to use the tool to perform the authorization flow. So the authenticate tool. And so, sweet, it sent an email to me.

    And so now if I check my email, I've got it over here, and that gave me a token, which I will paste into here, and this will call the validate tool. So it authenticated, now it's validating. And what's interesting is we've actually already gone through the OAuth flow for this, And so what's going on here? And I will show you the code for this here in just a second. But yeah, now we're validated.

    And now the MCP server knows that my session is one that is able to create entries for this specific email address. And so it knows who I am and that I'm able to do that. So let's go ahead and take a look at how this is accomplished and what things we can do with this. So the way that this works is I'm using CloudFlare and I'm using the workers OAuth provider right here. And here's my Epic Me MCP server.

    It's pretty standard, I would say, but it does have a couple of interesting things. Here we have unauthenticated tools and authenticated tools. Same for resources and prompts. And I have this special onStateUpdate to update available items. So as the user's state changes, so when I set the user ID in the state, then I'm going to update the available items based off of whether I have a user authorized.

    So I have some tools that are available for users who are unauthenticated, so like the authenticate tool itself and various others and then tools that are only available for authenticated users like creating entries and stuff like that. Unfortunately, no clients support the list changed event for tools and things. So this doesn't do anything yet. But this is how I would do that, or how I will do that in the future, is you just expand the number of tools that you have based off of the current data that you have for the current user. And I think that's really cool.

    And that could apply to scopes and everything as well. So how the authorization stuff works is right here when a request is made to authorize or when a request is made to authorize or when a request is made to initiate, we get a 401 because we're not authorized. And so we trigger the OAuth flow, which the client triggers. It's going to make the request to slash authorize because our well-known indicates that's where you go. And typically right here is where you're going to redirect to some approval page and show the user the approval page so they can approve.

    And at that point, you're going to go through the rest of this stuff. But I just skip that altogether. I don't have you approve. I don't even have a web page at all. There's no HTML sent from this server.

    And so what I do is I generate a grant user ID. So this is a made up user ID. We don't have a user in the database that's associated to this grant user ID. And then we have the grant ID. So we create an unclaimed grant.

    All this does is it inserts into the database a value for the grant user ID. And so we can associate a particular authorization grant with this ID. And then we have access to that grant ID and the grant user ID as part of this user ID here. So that I can, what I'm effectively doing here is I'm taking this MCP client and associating it with something in the database. And then it's basically an unclaimed grant.

    So the user is actually not logged in, even though the client thinks that they are logged in. And then we redirect them back to the successful page here. So this is the complete authorization. So that would be the callback on the client side. So how do I turn that into a claimed grant?

    Well, if we look at our tools, we've got our unauthenticated tools here, and one of them is to authenticate. And so here, authenticate your account or create a new account. Ask for the user's email address before authenticating. Only do this when explicitly told to do so. It actually, it wasn't explicitly told to do that, but that worked out anyway for us.

    It asked for my email address, and then with this, I generate a one-time password, and I create a validation token. So that's another table in my database for validation tokens that associates an email and a grant ID, and then of course the token value. So if the user can provide me with this token value, then I know that that email should own that grant. So then we send the email, and then the user reads their email, they grab the validation token. We first require a grant ID that's associated to this client.

    And so if I go back here, that comes in here as the props. And so all that require grant ID does is ensure that the agent prompts has a grant ID associated to it. So they've already gone through the OAuth flow. This should probably never happen. They should never be able to get through here without a grant ID, but weird things happen.

    So then we get the grant and then we validate the token to grant. So we use the grant ID and the validation token and so that is going to get the ID, the email, and the grant ID from validation tokens where The grant ID is equal to the one that is associated to this client, and the token is the one that the user gave me. And if we find one, then I can select from the users, the ID from users where the email is the same, and if there is no user, then we can create one. And at that point, we can claim the grant by saying, okay, so update grants, set the user ID to the one for the user that we just created or the user that we found. And then we're going to delete the validation token to just kind of clean up.

    And with that, now we have successfully claimed this grant, and future authenticated tools can require the user. And so this will take the grant ID and find the user by the grant ID. And so this is how I am managing this flow. I do think that in the future, MCP servers are going to act a lot like websites do now. And websites right now are going to show you Some things that everybody can see whether you're logged in or not.

    Some things that you can only see if you're not logged in, like a login button. And some things that you can only see if you're logged in, like update your profile picture or something like that. And so, these three categories, I think, will be very prevalent in the MCP space. And actually, we could break it down even further, even if you think about websites, admins can see like an edit page button where like regular users won't see that button. And so The tools and resources and prompts that are available will change based off of the user that you are.

    In the future, hopefully we have clients that actually support the list changed events so that they can update as we go. And I think that it is important that we build our MCP servers in such a way that it doesn't require authorization right up front, but that you can actually use the MCP server as a brand new person who's just kind of exploring things, just like we do with websites. So that's how I get that working today. I do think that this will be improved and libraries and things will be built to make this sort of thing easier. But hopefully that is helpful to anybody who wants to play around with this today.