Logo
Agentailor
Published on

Implementing OAuth for MCP Clients: A Next.js + LangGraph.js Guide

DocuMentor AI logo

Short on time?

Get the key takeaways in under 10 seconds with DocuMentor AI, my Chrome extension. No account required.

Try it here →
Authors
  • avatar
    Name
    Ali Ibrahim
    Twitter
MCP Client OAuth Banner

Introduction

In our previous guide, we secured an MCP server with OAuth using Keycloak. The server now rejects unauthenticated requests. Great for security, but now your AI agent can't connect.

This guide covers the other side: implementing OAuth in your MCP client. We'll use the implementation from our fullstack AI agent template, which supports both authenticated and unauthenticated MCP servers.

What you'll learn:

  • How MCP clients detect and handle OAuth requirements
  • Implementing the OAuthClientProvider interface from the MCP SDK
  • Handling the authorization callback flow

Prerequisites: Familiarity with MCP basics and MCP OAuth concepts.

The Client's Role in MCP OAuth

Remember the hotel key card analogy? The server is the door that checks your key. The client is the guest who needs to get that key.

Here's what the client must do:

  1. Detect whether the server requires authentication
  2. Discover the authorization server's metadata
  3. Register as an OAuth client (if using Dynamic Client Registration)
  4. Redirect the user to authorize access
  5. Exchange the authorization code for tokens
  6. Store and refresh tokens as needed

We use a "lazy detection" approach: assume no auth is needed until the server tells us otherwise. This keeps the happy path fast while handling secured servers gracefully.

OAuth Flow from the Client Perspective

The flow works like this:

  1. User clicks "Connect" on an HTTP MCP server
  2. Client makes a test request to the server
  3. Server responds with 401 Unauthorized + WWW-Authenticate: Bearer header
  4. Client discovers the authorization server and registers (if needed)
  5. User is redirected to log in and approve access
  6. Authorization server redirects back with a code
  7. Client exchanges the code for tokens
  8. Connection established with authenticated requests

Implementation Deep Dive

Let's look at the three key pieces: detection, the OAuth provider, and the callback handler.

OAuth Detection

When connecting to an MCP server, we first check if it requires authentication:

export async function detectOAuthRequirement(serverUrl: string): Promise<OAuthDetectionResult> {
  const response = await fetch(serverUrl, { method: 'GET' })

  if (response.status === 401) {
    const wwwAuth = response.headers.get('www-authenticate')
    if (wwwAuth?.toLowerCase().startsWith('bearer')) {
      const resourceMetadataUrl = extractResourceMetadataUrl(wwwAuth)
      return { requiresAuth: true, resourceMetadataUrl }
    }
  }

  return { requiresAuth: false }
}

function extractResourceMetadataUrl(wwwAuthHeader: string): string | null {
  // Parse resource_metadata from: Bearer resource_metadata="https://..."
  const match = wwwAuthHeader.match(/resource_metadata="([^"]+)"/)
  return match ? match[1] : null
}

A 401 response with a Bearer challenge signals OAuth is required. The resource_metadata URL points to the protected resource metadata, which tells us where to find the authorization server.

The OAuthClientProvider

The MCP SDK defines an OAuthClientProvider interface that handles token storage and authorization redirects. Here's our server-side implementation:

import { OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/sdk/client/auth.js'

export class ServerOAuthProvider implements OAuthClientProvider {
  constructor(
    private serverId: string,
    private appUrl: string
  ) {}

  get redirectUrl(): string {
    return `${this.appUrl}/api/oauth/callback/${this.serverId}`
  }

  get clientMetadata() {
    return {
      redirect_uris: [this.redirectUrl],
      grant_types: ['authorization_code', 'refresh_token'],
      response_types: ['code'],
      client_name: 'Fullstack AI Agent',
      token_endpoint_auth_method: 'client_secret_post',
    }
  }

  async tokens(): Promise<OAuthTokens | undefined> {
    const server = await prisma.mCPServer.findUnique({ where: { id: this.serverId } })
    return server?.authTokens as OAuthTokens | undefined
  }

  async saveTokens(tokens: OAuthTokens): Promise<void> {
    await prisma.mCPServer.update({
      where: { id: this.serverId },
      data: { authTokens: tokens, oauthStatus: 'CONNECTED' },
    })
  }

  redirectToAuthorization(authorizationUrl: URL): never {
    // In server-side context, we can't redirect directly
    // Throw a special error that the API route will handle
    throw new Error(`REDIRECT_REQUIRED:${authorizationUrl.toString()}`)
  }
}

Key points:

  • redirectUrl: Where the authorization server sends users after login
  • tokens() / saveTokens(): Persist tokens in your database
  • redirectToAuthorization(): Since we're server-side, we throw an error that the API route catches and converts to a redirect response

The Callback Handler

After the user authorizes, the authorization server redirects to our callback endpoint:

// src/app/api/oauth/callback/[serverId]/route.ts
export async function GET(request: NextRequest, { params }: { params: { serverId: string } }) {
  const { serverId } = params
  const code = request.nextUrl.searchParams.get('code')

  if (!code) {
    return redirectWithError('Missing authorization code')
  }

  const server = await prisma.mCPServer.findUnique({ where: { id: serverId } })

  // Create transport with our OAuth provider
  const transport = new StreamableHTTPClientTransport(new URL(server.url), {
    authProvider: new ServerOAuthProvider(serverId, process.env.NEXT_PUBLIC_APP_URL!),
  })

  // Exchange code for tokens (SDK handles this)
  await transport.finishAuth(code)

  // Update status and redirect back to app
  await prisma.mCPServer.update({
    where: { id: serverId },
    data: { oauthStatus: 'CONNECTED', codeVerifier: null },
  })

  return NextResponse.redirect(new URL('/?oauth=success', process.env.NEXT_PUBLIC_APP_URL!))
}

The transport.finishAuth(code) method handles the token exchange using PKCE, and our OAuthClientProvider.saveTokens() persists them.

Quick Start

Want to try this yourself? Clone the template:

git clone https://github.com/agentailor/fullstack-langgraph-nextjs-agent
cd fullstack-langgraph-nextjs-agent
pnpm install

Set your environment variable for OAuth callbacks:

NEXT_PUBLIC_APP_URL=http://localhost:3000

Then add an OAuth-protected MCP server through the UI. The template handles detection, registration, and token management automatically.

For the complete implementation details, see the OAuth documentation.

Note: For production deployments, you'll want to encrypt tokens at rest, implement automatic refresh, and consider multi-tenant isolation. See the OAUTH.md security section for details.

Conclusion

With OAuth support on both sides, your AI agents can securely connect to protected MCP servers. The pattern is straightforward: detect the requirement, implement OAuthClientProvider, handle the callback.

The fullstack template provides a complete reference implementation you can use directly or adapt to your stack.

Enjoying content like this? Sign up for Agent Briefings, where I share insights and news on building and scaling MCP Servers and AI agents.

Resources

Agent Briefings

Level up your agent-building skills with weekly deep dives on MCP, prompting, tools, and production patterns.