User-friendly just-in-time Auth with MCP
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:
- Initial Authorization: When a client connects, the server generates an
unclaimed grant (a temporary, anonymous session) and issues a
grantId
and saves agrantUserId
(pretty much only necessary because the OAuth Provider requires it). - 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.
- Email Authentication: The server generates a one-time password (OTP), emails it to the user, and waits for the user to submit the token.
- Token Validation: When the user submits the token, the server validates it, claims the grant for that user, and upgrades the session to authenticated.
- 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.