Skip to content

Instantly share code, notes, and snippets.

@jeffmylife
Created June 12, 2025 23:43
Show Gist options
  • Save jeffmylife/0ce777109b463a9d09584b49f9b97557 to your computer and use it in GitHub Desktop.
Save jeffmylife/0ce777109b463a9d09584b49f9b97557 to your computer and use it in GitHub Desktop.
mcp server with clerk next.js setup

fyi this walkthrough is an ai summary so it might not be perfect

πŸš€ Build an Authenticated Remote MCP Server with Clerk & Next.js

Welcome to the ultimate guide for building a remote Model Context Protocol (MCP) server that requires authentication! We'll use Clerk for user management, Next.js for our frontend, and Cloudflare Workers for our MCP server. By the end, you'll have Claude Desktop connecting to your authenticated MCP server that can access Gmail on behalf of your users.

🎯 What We're Building

Imagine this: You tell Claude "Check my latest emails from my boss" and Claude seamlessly:

  1. Authenticates you through Clerk
  2. Uses your Gmail OAuth tokens
  3. Fetches your emails via your custom MCP server
  4. Responds with the information you need

Architecture Overview:

Claude Desktop β†’ MCP Client β†’ Your Cloudflare Worker β†’ Gmail API
                     ↑
                Clerk Auth + Google OAuth Tokens

πŸ“‹ Prerequisites

πŸ—οΈ Part 1: Setting Up the Foundation

Step 1: Create Your Next.js App with Clerk

Let's start by creating our Next.js application:

npx create-next-app@latest mcp-gmail-server --typescript --tailwind --eslint --app
cd mcp-gmail-server
npm install @clerk/nextjs next-auth

Step 2: Configure Clerk

  1. Create a Clerk Application:

    • Go to Clerk Dashboard
    • Create a new application
    • Choose "Email" and optionally "Google" as sign-in methods (we'll use NextAuth for Gmail OAuth)
    • Note your NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY
  2. Set up environment variables:

Create .env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
  1. Create Providers Component:

Create src/app/providers.tsx:

'use client'

import { ClerkProvider } from '@clerk/nextjs'
import { SessionProvider } from 'next-auth/react'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <SessionProvider>
        {children}
      </SessionProvider>
    </ClerkProvider>
  )
}
  1. Configure Layout:

Update src/app/layout.tsx:

import { Providers } from './providers'
import './globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  )
}

Step 3: Create Authentication Pages

Create src/app/sign-in/[[...sign-in]]/page.tsx:

import { SignIn } from '@clerk/nextjs'

export default function Page() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <SignIn />
    </div>
  )
}

Create src/app/sign-up/[[...sign-up]]/page.tsx:

import { SignUp } from '@clerk/nextjs'

export default function Page() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <SignUp />
    </div>
  )
}

πŸ” Part 2: Setting Up Gmail OAuth

Step 4: Configure Google Cloud Console

  1. Create a Google Cloud Project:

  2. Create OAuth 2.0 Credentials:

    • Go to "Credentials" β†’ "Create Credentials" β†’ "OAuth 2.0 Client IDs"
    • Application type: "Web application"
    • IMPORTANT: Authorized redirect URIs: http://localhost:3000/api/auth/callback/google (note: /callback/google, not /gmail/callback)
    • For production, add your production domain: https://yourdomain.com/api/auth/callback/google
    • Note your Client ID and Client Secret
  3. Add Gmail OAuth to environment:

Update .env.local:

# ... existing Clerk vars ...
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-random-secret-string

Step 5: Create Gmail OAuth Flow

Create src/types/next-auth.d.ts:

import NextAuth from "next-auth"

declare module "next-auth" {
  interface Session {
    accessToken?: string
    refreshToken?: string
  }
}

Create src/app/api/auth/[...nextauth]/route.ts:

import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'

const handler = NextAuth({
  debug: true, // Enable for debugging
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: {
        params: {
          scope: 'openid email profile https://www.googleapis.com/auth/gmail.readonly',
          access_type: 'offline',
          prompt: 'consent',
        },
      },
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
      }
      return token
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken as string
      session.refreshToken = token.refreshToken as string
      return session
    },
  },
})

export { handler as GET, handler as POST }

🏭 Part 3: Building the Cloudflare Worker MCP Server

Step 6: Set Up Cloudflare Worker

Create a new directory for your worker:

mkdir mcp-worker
cd mcp-worker
npm init -y
npm install @cloudflare/workers-oauth-provider @modelcontextprotocol/sdk agents hono zod
npm install -D wrangler typescript

Create wrangler.toml:

name = "mcp-gmail-server"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]

[[kv_namespaces]]
binding = "OAUTH_KV"
id = "your-kv-namespace-id"

[[durable_objects]]
binding = "MCP_AGENT"
class_name = "McpAgent"
script_name = "mcp-gmail-server"

[env.production]
[[env.production.kv_namespaces]]
binding = "OAUTH_KV"
id = "your-production-kv-id"

Step 7: Create the MCP Server

Create src/index.ts:

import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Hono } from "hono";
import OAuthProvider from "@cloudflare/workers-oauth-provider";

type Env = {
  OAUTH_KV: KVNamespace;
  MCP_AGENT: DurableObjectNamespace;
};

type Props = {
  userId: string;
  email: string;
  accessToken: string;
  refreshToken: string;
};

export class GmailMCP extends McpAgent<Env, unknown, Props> {
  server = new McpServer({
    name: "Gmail MCP Server",
    version: "1.0.0",
  });

  async init() {
    console.log('Initializing Gmail MCP with props:', {
      userId: this.props.userId,
      email: this.props.email,
      hasAccessToken: !!this.props.accessToken,
    });

    // Get user info tool
    this.server.tool(
      "getUserInfo",
      "Get authenticated user information",
      {},
      async () => ({
        content: [
          {
            type: "text",
            text: JSON.stringify({
              userId: this.props.userId,
              email: this.props.email,
            }),
          },
        ],
      })
    );

    // List emails tool
    this.server.tool(
      "listEmails",
      "List recent emails from Gmail",
      {
        maxResults: z.number().min(1).max(50).default(10),
        query: z.string().optional().describe("Gmail search query"),
      },
      async ({ maxResults, query }) => {
        console.log('Listing emails with token:', this.props.accessToken?.substring(0, 20) + '...');
        
        const gmailUrl = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
        gmailUrl.searchParams.set("maxResults", maxResults.toString());
        if (query) {
          gmailUrl.searchParams.set("q", query);
        }

        const response = await fetch(gmailUrl.toString(), {
          headers: {
            Authorization: `Bearer ${this.props.accessToken}`,
          },
        });

        if (!response.ok) {
          const errorText = await response.text();
          console.error('Gmail API error:', response.status, errorText);
          throw new Error(`Gmail API error: ${response.status} ${response.statusText}`);
        }

        const data = await response.json();
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(data, null, 2),
            },
          ],
        };
      }
    );

    // Get specific email tool
    this.server.tool(
      "getEmail",
      "Get a specific email by ID",
      {
        messageId: z.string().describe("Gmail message ID"),
      },
      async ({ messageId }) => {
        const response = await fetch(
          `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`,
          {
            headers: {
              Authorization: `Bearer ${this.props.accessToken}`,
            },
          }
        );

        if (!response.ok) {
          const errorText = await response.text();
          console.error('Gmail API error:', response.status, errorText);
          throw new Error(`Gmail API error: ${response.status} ${response.statusText}`);
        }

        const data = await response.json();
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(data, null, 2),
            },
          ],
        };
      }
    );
  }
}

// Auth handler
const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: any } }>();

app.get("/authorize", async (c) => {
  const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
  
  // For local development
  const baseUrl = c.req.header("host")?.includes("localhost") 
    ? "http://localhost:3000" 
    : "https://your-production-domain.com";
    
  const authUrl = new URL(`${baseUrl}/mcp-auth`);
  authUrl.searchParams.set("state", btoa(JSON.stringify(oauthReqInfo)));
  
  return Response.redirect(authUrl.toString(), 302);
});

app.get("/callback", async (c) => {
  const code = c.req.query("code");
  const state = c.req.query("state");
  
  if (!state || !code) {
    return c.text("Missing state or code", 400);
  }

  const oauthReqInfo = JSON.parse(atob(state));
  
  // For local development
  const baseUrl = c.req.header("host")?.includes("localhost") 
    ? "http://localhost:3000" 
    : "https://your-production-domain.com";
  
  const verifyResponse = await fetch(`${baseUrl}/api/mcp/verify`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code }),
  });

  if (!verifyResponse.ok) {
    const errorText = await verifyResponse.text();
    console.error('Verification failed:', errorText);
    return c.text("Verification failed", 400);
  }

  const userData = await verifyResponse.json();
  console.log('User data received:', { 
    userId: userData.userId, 
    email: userData.email,
    hasAccessToken: !!userData.accessToken 
  });

  const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
    request: oauthReqInfo,
    userId: userData.userId,
    metadata: { label: userData.email },
    scope: oauthReqInfo.scope,
    props: {
      userId: userData.userId,
      email: userData.email,
      accessToken: userData.accessToken,
      refreshToken: userData.refreshToken,
    } as Props,
  });

  return Response.redirect(redirectTo, 302);
});

export default new OAuthProvider({
  apiRoute: "/sse",
  apiHandler: GmailMCP.mount("/sse") as any,
  defaultHandler: app as any,
  authorizeEndpoint: "/authorize",
  tokenEndpoint: "/token",
  clientRegistrationEndpoint: "/register",
});

πŸ”— Part 4: Connecting Next.js and Cloudflare Worker

Step 8: Create Shared Storage Module

Create src/lib/temp-storage.ts:

// Shared temporary storage for MCP authentication codes
// In production, use a proper database or Redis
const tempStorage = new Map<string, {
  userId: string;
  email: string;
  accessToken: string;
  refreshToken: string;
  expires: number;
}>();

export function storeAuthData(code: string, data: {
  userId: string;
  email: string;
  accessToken: string;
  refreshToken: string;
}) {
  tempStorage.set(code, {
    ...data,
    expires: Date.now() + 5 * 60 * 1000, // 5 minutes
  });
}

export function getAuthData(code: string) {
  const data = tempStorage.get(code);
  if (!data || data.expires < Date.now()) {
    if (data) {
      tempStorage.delete(code); // Clean up expired data
    }
    return null;
  }
  
  // Clean up after retrieval
  tempStorage.delete(code);
  return data;
}

export function cleanupExpiredData() {
  const now = Date.now();
  for (const [code, data] of tempStorage.entries()) {
    if (data.expires < now) {
      tempStorage.delete(code);
    }
  }
}

Step 9: Create MCP Authentication Pages

Create src/app/mcp-auth/page.tsx:

'use client'

import { useAuth } from '@clerk/nextjs'
import { useSession, signIn } from 'next-auth/react'
import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'

export default function MCPAuth() {
  const { isSignedIn, userId } = useAuth()
  const { data: session } = useSession()
  const searchParams = useSearchParams()
  const [isAuthorizing, setIsAuthorizing] = useState(false)

  useEffect(() => {
    if (isSignedIn && session?.accessToken && !isAuthorizing) {
      handleAuthorization()
    }
  }, [isSignedIn, session, isAuthorizing])

  const handleAuthorization = async () => {
    setIsAuthorizing(true)
    
    const state = searchParams.get('state')
    if (!state) return

    try {
      // Generate a temporary code
      const code = crypto.randomUUID()
      
      console.log('Generated code:', code)
      console.log('User data:', { userId, email: session?.user?.email })
      
      // Store user data temporarily
      const storeResponse = await fetch('/api/mcp/store', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          code,
          userId,
          email: session?.user?.email,
          accessToken: session?.accessToken,
          refreshToken: session?.refreshToken,
        }),
      })

      if (!storeResponse.ok) {
        throw new Error(`Store failed: ${storeResponse.status}`)
      }

      console.log('Data stored successfully, redirecting to worker')

      // Redirect back to worker with code - adjust URL for local vs production
      const workerUrl = window.location.hostname === 'localhost' 
        ? 'http://localhost:8787' 
        : 'https://your-worker.your-subdomain.workers.dev'
        
      const callbackUrl = new URL(`${workerUrl}/callback`)
      callbackUrl.searchParams.set('code', code)
      callbackUrl.searchParams.set('state', state)
      
      console.log('Redirecting to:', callbackUrl.toString())
      window.location.href = callbackUrl.toString()
    } catch (error) {
      console.error('Authorization failed:', error)
    }
  }

  if (!isSignedIn) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-center">
          <h1 className="text-2xl font-bold mb-4">MCP Server Authorization</h1>
          <p className="mb-4">Please sign in to authorize the MCP server</p>
          <a href="/sign-in" className="bg-blue-500 text-white px-4 py-2 rounded">
            Sign In
          </a>
        </div>
      </div>
    )
  }

  if (!session?.accessToken) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-center">
          <h1 className="text-2xl font-bold mb-4">Gmail Authorization Required</h1>
          <p className="mb-4">Please connect your Gmail account to continue</p>
          <button
            onClick={() => signIn('google')}
            className="bg-red-500 text-white px-4 py-2 rounded"
          >
            Connect Gmail
          </button>
        </div>
      </div>
    )
  }

  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="text-center">
        <h1 className="text-2xl font-bold mb-4">Authorizing MCP Server...</h1>
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
      </div>
    </div>
  )
}

Step 10: Create API Endpoints

Create src/app/api/mcp/store/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { storeAuthData } from '@/lib/temp-storage'

export async function POST(request: NextRequest) {
  try {
    const data = await request.json()
    const { code, userId, email, accessToken, refreshToken } = data

    console.log('Storing auth data for code:', code, 'userId:', userId)

    storeAuthData(code, {
      userId,
      email,
      accessToken,
      refreshToken,
    })

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Failed to store auth data:', error)
    return NextResponse.json({ error: 'Failed to store data' }, { status: 500 })
  }
}

Create src/app/api/mcp/verify/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { getAuthData } from '@/lib/temp-storage'

export async function POST(request: NextRequest) {
  try {
    const { code } = await request.json()
    
    console.log('Verifying code:', code)
    
    const data = getAuthData(code)
    if (!data) {
      console.log('Code not found or expired')
      return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 })
    }

    console.log('Code verified successfully for user:', data.userId)

    return NextResponse.json({
      userId: data.userId,
      email: data.email,
      accessToken: data.accessToken,
      refreshToken: data.refreshToken,
    })
  } catch (error) {
    console.error('Verification failed:', error)
    return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
  }
}

πŸš€ Part 5: Local Development & Testing

Step 11: Set Up Local Development

  1. Create KV namespace for local development:
cd mcp-worker
wrangler kv namespace create "OAUTH_KV"
  1. Update wrangler.toml with the KV ID returned from the command above

  2. Start local development servers:

Terminal 1 - Next.js app:

cd mcp-gmail-server
npm run dev

Terminal 2 - Cloudflare Worker:

cd mcp-worker
wrangler dev --local --port 8787

Step 12: Test Your MCP Server Locally

  1. Test with MCP Inspector:
npx @modelcontextprotocol/inspector@latest
  1. Connect to your local worker:

    • URL: http://localhost:8787/sse
    • Follow the authentication flow
  2. Authentication Flow:

    • MCP Inspector connects β†’ redirects to Clerk sign-in
    • Sign in with Clerk β†’ redirects to Google OAuth
    • Complete Google OAuth β†’ get Gmail permissions
    • Authorization completes β†’ MCP tools available

Step 13: Troubleshooting Common Issues

πŸ”§ "Gmail API error: Unauthorized"

  • This means you're using Clerk tokens instead of Google OAuth tokens
  • Make sure to complete the Google OAuth flow by clicking "Connect Gmail"
  • Check that your Google Cloud Console redirect URI is correct: http://localhost:3000/api/auth/callback/google

πŸ”§ "Invalid token" errors

  • Google OAuth tokens expire quickly (usually 1 hour)
  • Sign out and sign back in to get fresh tokens
  • Check token format: Google tokens start with ya29. not user_

πŸ”§ MCP Inspector shows 401 Unauthorized

  • Make sure both Next.js (port 3000) and Worker (port 8787) are running
  • Check that you're signed in with both Clerk AND Google OAuth
  • Verify the worker URL is correct: http://localhost:8787/sse

πŸ”§ "Missing state or code" errors

  • Clear browser cache and cookies
  • Make sure redirect URIs match exactly in Google Cloud Console
  • Check that both services are running on correct ports

🌐 Part 6: Production Deployment

Step 14: Deploy to Production

  1. Deploy Cloudflare Worker:
cd mcp-worker
wrangler deploy
  1. Create production KV namespace:
wrangler kv namespace create "OAUTH_KV" --env production
  1. Update wrangler.toml with production KV ID

  2. Deploy Next.js app (Vercel, Netlify, etc.)

  3. Update environment variables for production:

    • Update Google Cloud Console redirect URIs for production domain
    • Update NEXTAUTH_URL to production domain
    • Update worker URLs in MCP auth page

Step 15: Connect Claude Desktop

Update your Claude Desktop configuration (~/.claude/config.json):

For Local Development:

{
  "mcpServers": {
    "gmail-mcp": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "http://localhost:8787/sse"
      ]
    }
  }
}

For Production:

{
  "mcpServers": {
    "gmail-mcp": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://your-worker.your-subdomain.workers.dev/sse"
      ]
    }
  }
}

πŸŽ‰ Congratulations!

You've successfully built a complete authenticated MCP server! Here's what you accomplished:

βœ… Clerk Authentication - Users sign in through your Next.js app
βœ… Google OAuth - Secure access to Gmail API with proper tokens
βœ… Remote MCP Server - Running on Cloudflare Workers
βœ… Local Development - Full local testing environment
βœ… Production Ready - Deployment instructions for production
βœ… Claude Integration - AI assistant can access Gmail on behalf of users

πŸ”§ Next Steps

Enhance Your MCP Server:

  • Add more Gmail operations (send emails, search, labels)
  • Implement token refresh logic for expired tokens
  • Add rate limiting and error handling
  • Support multiple OAuth providers (Slack, GitHub, etc.)
  • Add user permissions and scopes management

Production Considerations:

  • Replace temporary storage with proper database (PostgreSQL, Redis)
  • Implement proper error handling and logging
  • Add monitoring and alerting
  • Set up CI/CD pipeline
  • Configure custom domains and SSL

πŸ“š Key Resources


🎯 Pro Tip: This architecture is incredibly flexible! You can easily swap Gmail for any OAuth-enabled service (Slack, GitHub, Notion, etc.) by changing the API endpoints and scopes. The authentication flow remains the same.

🚨 Important: Always test the complete authentication flow (Clerk + Google OAuth) before deploying to production. The most common issue is using Clerk tokens instead of Google OAuth tokens for API calls.

Happy building! πŸš€

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