Skip to content

Build MCP servers with external OAuth

When building production MCP servers, you often need to protect sensitive endpoints with OAuth authentication. This guide shows you how to build an MCP server with external OAuth authentication. We’ll use FusionAuth as an example, but you can use any OAuth 2.0 provider you have set up (such as Auth0, Okta, Keycloak, or your own OAuth server).

We’ll build an enhanced version of the Push Advisor API from earlier guides, adding OAuth-protected endpoints that demonstrate real-world authentication patterns.

In this guide, we’ll:

  • First, set up an API with both public and OAuth-protected endpoints
  • Next, configure an external OAuth provider (we’ll use FusionAuth as an example)
  • Then create a manual login flow for token testing
  • Finally, integrate with Gram’s OAuth configuration and test protected endpoints in the playground

First, we’ll start with extending the Push Advisor API with an OAuth-protected endpoint:

// Basic API endpoints
const endpoints = {
'/can-i-push-to-prod': 'Public - Check if it\'s safe to deploy',
'/vibe-check': 'Public - Random deployment vibe check',
'/deploy-history': 'Protected - View deployment history (OAuth required)',
'/login': 'Utility - Manual OAuth token generation page'
};

We’ve added a deploy-history endpoint that requires OAuth authentication. We’ve also added a login endpoint that provides a manual login page for token generation.

Before we implement OAuth protection in our API, we need to set up an OAuth provider. In this example, we’ll use FusionAuth, but the same principles apply to any OAuth 2.0 compliant provider like Auth0, Okta, Keycloak, or your own implementation.

First, we’ll set up our OAuth provider to work with our API. These steps are similar across different OAuth providers:

  1. Create Application: In your OAuth provider’s admin panel, create a new application/client named “Push Advisor API”

  2. OAuth Configuration:

    • Enable Authorization Code grant type
    • Set redirect URLs:
      https://gram-oauth.abdulbaaridavids04.workers.dev/token
      https://localhost:8787/token

OAuth Application Settings (FusionAuth example)

  1. Get Credentials: Note the Client ID and Client Secret for your configuration

Now we can configure our OAuth provider settings in the API. Replace these values with your actual OAuth provider’s configuration:

const OAUTH_CONFIG = {
url: 'https://fusionauth.ritza.co', // Replace with your OAuth provider's URL
clientId: 'your-application-client-id', // From your OAuth provider
clientSecret: 'your-application-client-secret', // From your OAuth provider
applicationName: 'Push Advisor API'
};
// Note: Replace the URL above with your OAuth provider's base URL:
// - Auth0: https://your-domain.auth0.com
// - Okta: https://your-domain.okta.com
// - Keycloak: https://your-keycloak-server.com/realms/your-realm
// - Custom server: https://your-oauth-server.com

Next, we’ll implement the OAuth protection within the API. We’ll add middleware that validates OAuth tokens against our external provider.

async function validateOAuthToken(request) {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { valid: false, error: 'Missing or invalid authorization header' };
}
const token = authHeader.substring(7);
if (!token) {
return { valid: false, error: 'No token provided' };
}
try {
// Check for demo tokens (for testing)
if (token.startsWith('demo_token_')) {
return {
valid: true,
scopes: ['api:read', 'api:write'],
userId: 'demo-user-' + token.split('_')[2]
};
}
// Validate against OAuth provider's introspection endpoint
const response = await fetch(`${OAUTH_CONFIG.url}/oauth2/introspect`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${OAUTH_CONFIG.clientId}:${OAUTH_CONFIG.clientSecret}`)}`
},
body: `token=${token}&client_id=${OAUTH_CONFIG.clientId}`
});
if (!response.ok) {
return { valid: false, error: 'Token validation failed' };
}
const tokenInfo = await response.json();
if (!tokenInfo.active) {
return { valid: false, error: 'Token is not active' };
}
return {
valid: true,
scopes: tokenInfo.scope ? tokenInfo.scope.split(' ') : [],
userId: tokenInfo.sub
};
} catch (error) {
return { valid: false, error: 'Token validation service unavailable' };
}
}

Next, we’ll implement endpoints that require authentication:

async function handleDeployHistory(request, corsHeaders) {
// This endpoint requires authentication
const validation = await validateOAuthToken(request);
if (!validation.valid) {
return new Response(JSON.stringify({
error: 'Authentication required',
message: 'This endpoint requires a valid OAuth token.',
required_scopes: ['api:read']
}), {
status: 401,
headers: {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer realm="Push Advisor API"',
...corsHeaders
}
});
}
// Check scopes
if (!hasRequiredScope(validation.scopes, 'api:read')) {
return new Response(JSON.stringify({
error: 'Insufficient permissions',
message: 'This endpoint requires api:read scope.'
}), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// Return protected data
const deployments = [
{
id: 'deploy-001',
timestamp: '2024-08-13T15:30:00Z',
environment: 'production',
status: 'success',
user: validation.userId
}
];
return new Response(JSON.stringify({
deployments,
authenticated_user: validation.userId,
message: 'This is a protected endpoint - you needed OAuth to see this!'
}), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}

We’ll also need to implement the OAuth discovery endpoint required by the MCP specification:

function handleOAuthDiscovery(corsHeaders) {
const discoveryDoc = {
"issuer": OAUTH_CONFIG.url,
"authorization_endpoint": `${OAUTH_CONFIG.url}/oauth2/authorize`,
"token_endpoint": `${OAUTH_CONFIG.url}/oauth2/token`,
"introspection_endpoint": `${OAUTH_CONFIG.url}/oauth2/introspect`,
"userinfo_endpoint": `${OAUTH_CONFIG.url}/oauth2/userinfo`,
"jwks_uri": `${OAUTH_CONFIG.url}/.well-known/jwks.json`,
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"scopes_supported": ["openid", "profile", "email", "api:read", "api:write"],
"code_challenge_methods_supported": ["plain", "S256"]
};
return new Response(JSON.stringify(discoveryDoc, null, 2), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}

For testing purposes, we’ll create a basic login page for token generation:

function handleLogin(request, corsHeaders) {
const loginPageHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Push Advisor API - Login</title>
</head>
<body>
<div class="container">
<h1>🔐 Push Advisor API - Login</h1>
<!-- OAuth Flow Section -->
<div class="oauth-flow">
<h3>🚀 OAuth Authorization</h3>
<p>Click below to authenticate with FusionAuth:</p>
<a href="${OAUTH_CONFIG.url}/oauth2/authorize?client_id=${OAUTH_CONFIG.clientId}&response_type=code&redirect_uri=https://gram-oauth.abdulbaaridavids04.workers.dev/token&scope=api:read%20api:write"
class="oauth-btn">Login with OAuth Provider</a>
</div>
<!-- Demo Token Section -->
<div class="demo-form">
<h3>⚠️ Demo Token Generation</h3>
<p>For testing: Generate a demo token</p>
<form id="tokenForm">
<input type="text" placeholder="Username" required>
<input type="password" placeholder="Password" required>
<button type="submit">Generate Demo Token</button>
</form>
</div>
<div class="instructions">
<h3>📋 How to Use</h3>
<p>Copy your token and use it in API requests:</p>
<code>curl -H "Authorization: Bearer YOUR_TOKEN" https://gram-oauth.abdulbaaridavids04.workers.dev/deploy-history</code>
</div>
</div>
</body>
</html>`;
return new Response(loginPageHTML, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders }
});
}

API Login Page

Next, we’ll document our OAuth configuration in the OpenAPI document, you can read more about how Gram uses the OpenAPI document here.

openapi: 3.1.0
info:
title: Push Decision API
version: 1.0.0
security:
- oauth2: [api:read, api:write]
- {} # Allow unauthenticated access as fallback
paths:
/deploy-history:
get:
summary: Get deployment history (Protected)
security:
- oauth2: [api:read]
responses:
'200':
description: Deployment history
'401':
description: Authentication required
components:
securitySchemes:
oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://fusionauth.ritza.co/oauth2/authorize
tokenUrl: https://fusionauth.ritza.co/oauth2/token
scopes:
api:read: Read access to API endpoints
api:write: Write access to API endpoints

Now we’ll configure Gram to work with our external OAuth server. After uploading the OpenAPI document, and creating a new toolset, go to the toolset settings page and click Connect OAuth.

In the OAuth configuration page, choose External Server instead of OAuth Proxy and enter the server slug as well as the metadata for your OAuth server.

Gram OAuth Server Configuration

The OAuth Authorization Server Metadata is a JSON object that describes the OAuth server. It’s used by Gram to discover the OAuth server’s capabilities.

{
"issuer": "https://fusionauth.ritza.co",
"authorization_endpoint": "https://fusionauth.ritza.co/oauth2/authorize",
"token_endpoint": "https://fusionauth.ritza.co/oauth2/token",
"introspection_endpoint": "https://fusionauth.ritza.co/oauth2/introspect",
"userinfo_endpoint": "https://fusionauth.ritza.co/oauth2/userinfo",
"jwks_uri": "https://fusionauth.ritza.co/.well-known/jwks.json",
"registration_endpoint": "https://fusionauth.ritza.co/oauth2/register",
"response_types_supported": ["code"],
"response_modes_supported": ["query", "form_post"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email", "api:read", "api:write"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"claims_supported": ["aud", "exp", "iat", "iss", "sub", "email", "email_verified", "preferred_username", "scope"],
"code_challenge_methods_supported": ["plain", "S256"],
"introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"registration_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}

You can read more about the OAuth Authorization Server Metadata here.

Finally, let’s test everything in the Gram playground.

After uploading our OpenAPI document and creating a toolset, we’ll go to the playground to test it. When we ask the AI agent to get deployment history, it will automatically detect that OAuth authentication is required:

Gram Playground Testing

Notice how the environment variables section shows PUSH_ADVISOR_OAUTH_ACCESS_TOKEN is <EMPTY>, and the agent tells us exactly what we need to do to authenticate. The agent can also discover the OAuth requirements automatically by calling the discovery endpoint.

we can see that an OAuth access token is required but not yet provided:

Gram Access Token Required

To test our protected endpoints, we need to get a valid OAuth token. Here’s how a user would get an OAuth token:

  1. Visit the login page: First, we go to the /login page provided by the API to start the authentication process

API Login Page

  1. Redirect to OAuth Provider: Click “Login with OAuth Provider” which redirects to your OAuth provider’s login screen (in this example, FusionAuth):

OAuth Provider Login Screen (FusionAuth example)

  1. Complete authentication: After logging in, your OAuth provider redirects back to our API with an authorization code

  2. Get the access token: Copy the access token from the success page

OAuth Token Success

Now we can add our OAuth token to the Authentication environment variables in Gram:

Gram Playground OAuth Token

Once we’ve added a valid OAuth token, we can test the protected endpoints in the playground:

Gram OAuth Complete

OAuth is a complex topic, and there are many ways to configure it. Here are some common issues and how to troubleshoot them.

Invalid redirect URI: Ensure all redirect URLs are configured in your OAuth provider

Token validation fails: Check that your client credentials are correct and the OAuth provider is accessible

CORS issues: Include proper CORS headers for browser-based OAuth flows

Scope mismatches: Verify that requested scopes match what’s configured in your OAuth provider