Skip to main content

MCP Authentication

This guide explains how to handle authentication in your MCP server when integrating with Diosc's BYOA (Bring Your Own Authentication) model.

The BYOA Principle

Your MCP server is a pass-through for authentication, not a validator.

User's Browser        Diosc Hub         Your MCP Server      Your API
│ │ │ │
│─ Token ───────────▶│ │ │
│ │─ Token ───────────▶│ │
│ │ │─ Token ────────▶│
│ │ │ │─ Validate
│ │ │ │
│ │ │◀─ Data ────────│
│ │◀─ Data ────────────│ │
│◀─ Response ────────│ │ │

Key insight: Only your API validates the token. Everyone else just forwards it.

Headers from Diosc

When Diosc calls your MCP server, it includes:

POST /messages HTTP/1.1
Host: your-mcp-server.example.com

# User's original auth
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# Diosc-prefixed headers (same data, clear origin)
X-User-Auth-Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# User identity (extracted from token, for logging)
X-User-Id: user_123
X-Tenant-Id: acme-corp

# Request tracking
X-Request-Id: req_abc123
X-Session-Id: sess_xyz789

Forwarding to Your API

Basic Pattern

async function callApi(endpoint: string, args: any, authHeaders: Record<string, string>) {
const response = await fetch(`https://api.example.com${endpoint}`, {
method: 'POST',
headers: {
// Forward all auth headers
'Authorization': authHeaders['Authorization'],
'Content-Type': 'application/json',
// Include tracking
'X-Request-Id': authHeaders['X-Request-Id']
},
body: JSON.stringify(args)
});

if (!response.ok) {
// Forward error as-is
throw new Error(`API error: ${response.status} ${await response.text()}`);
}

return response.json();
}

Complete Example

import express from 'express';

const app = express();

// Store auth headers per session
const sessionAuth = new Map<string, Record<string, string>>();

// Extract auth headers from request
function extractAuthHeaders(headers: any): Record<string, string> {
const auth: Record<string, string> = {};

// Primary auth header
if (headers['authorization']) {
auth['Authorization'] = headers['authorization'];
}

// Cookie-based auth
if (headers['cookie']) {
auth['Cookie'] = headers['cookie'];
}

// Custom auth headers (your API might need these)
const customHeaders = ['x-api-key', 'x-client-id', 'x-custom-auth'];
for (const header of customHeaders) {
if (headers[header]) {
auth[header] = headers[header];
}
}

// Diosc tracking headers (useful for logging)
if (headers['x-user-id']) auth['X-User-Id'] = headers['x-user-id'];
if (headers['x-tenant-id']) auth['X-Tenant-Id'] = headers['x-tenant-id'];
if (headers['x-request-id']) auth['X-Request-Id'] = headers['x-request-id'];

return auth;
}

// Handle MCP messages
app.post('/messages', express.json(), async (req, res) => {
const sessionId = req.query.sessionId as string;

// Update stored auth (might be refreshed)
sessionAuth.set(sessionId, extractAuthHeaders(req.headers));

const auth = sessionAuth.get(sessionId)!;
const { method, params } = req.body;

try {
if (method === 'tools/call') {
const result = await executeToolCall(params.name, params.arguments, auth);
sendSseResponse(sessionId, result);
}
res.status(202).end();
} catch (error) {
sendSseError(sessionId, error);
res.status(202).end();
}
});

async function executeToolCall(toolName: string, args: any, auth: Record<string, string>) {
switch (toolName) {
case 'search_orders':
return await callApi('/orders/search', args, auth);
case 'get_customer':
return await callApi(`/customers/${args.customerId}`, {}, auth);
default:
throw new Error(`Unknown tool: ${toolName}`);
}
}

async function callApi(path: string, body: any, auth: Record<string, string>) {
const response = await fetch(`https://api.example.com${path}`, {
method: 'POST',
headers: {
...auth,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});

if (response.status === 401) {
throw new Error('Authentication failed - user token may be expired');
}

if (response.status === 403) {
throw new Error('Access denied - user lacks permission');
}

if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}

return response.json();
}

Handling Auth Errors

When your API rejects the token, return a clear error:

async function callApi(path: string, auth: Record<string, string>) {
const response = await fetch(url, { headers: auth });

if (response.status === 401) {
// Token invalid or expired
return {
isError: true,
content: [{
type: 'text',
text: 'Your session has expired. Please log in again.'
}]
};
}

if (response.status === 403) {
// User lacks permission
return {
isError: true,
content: [{
type: 'text',
text: 'You do not have permission to perform this action.'
}]
};
}

// Success
return {
content: [{
type: 'text',
text: JSON.stringify(await response.json())
}]
};
}

The AI will explain these errors to the user in a helpful way.

Multi-Tenant Considerations

If your API requires tenant context:

async function callApi(path: string, auth: Record<string, string>) {
const tenantId = auth['X-Tenant-Id'];

// Option 1: Tenant in header
const response = await fetch(`https://api.example.com${path}`, {
headers: {
...auth,
'X-Tenant-ID': tenantId // Your API's expected header
}
});

// Option 2: Tenant in URL
const response = await fetch(`https://${tenantId}.api.example.com${path}`, {
headers: auth
});

// Option 3: Tenant from token (API extracts it)
const response = await fetch(`https://api.example.com${path}`, {
headers: auth // Token contains tenant claim
});
}

Token Refresh Handling

Diosc handles token refresh on the client side. Your MCP server just needs to:

  1. Forward whatever token it receives
  2. Return clear errors when tokens fail
  3. Accept updated tokens on subsequent requests
// Each request might have a fresh token
app.post('/messages', (req, res) => {
const sessionId = req.query.sessionId;

// Always use the latest auth headers
sessionAuth.set(sessionId, extractAuthHeaders(req.headers));

// ... handle request
});

Different Auth Strategies

JWT Bearer Token

// Most common pattern
headers: {
'Authorization': `Bearer ${token}`
}

API Key

// For service-to-service
headers: {
'X-API-Key': apiKey
}
// For cookie-based auth
headers: {
'Cookie': `session=${sessionId}`
}

Multiple Auth Methods

// Some APIs need multiple credentials
headers: {
'Authorization': `Bearer ${token}`,
'X-API-Key': clientApiKey,
'X-Client-ID': clientId
}

Security Best Practices

DO

// ✅ Forward headers as-is
headers: { 'Authorization': auth['Authorization'] }

// ✅ Log for debugging (without sensitive data)
console.log(`API call for user ${auth['X-User-Id']}`);

// ✅ Return clear error messages
throw new Error('Permission denied: Cannot delete orders');

DON'T

// ❌ Don't validate tokens
if (!verifyJwt(token)) throw new Error('Invalid');

// ❌ Don't log full tokens
console.log(`Token: ${auth['Authorization']}`);

// ❌ Don't store tokens long-term
database.save({ userId, token });

// ❌ Don't decode and use token claims for authorization
const claims = decodeJwt(token);
if (claims.role !== 'admin') throw new Error('Admins only');
// (Your API should check this, not the MCP server)

Testing Auth Flow

Test with curl

# Simulate Diosc calling your MCP server
curl -X POST http://localhost:3000/messages \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <test-token>" \
-H "X-User-Id: test-user" \
-d '{
"method": "tools/call",
"params": {
"name": "search_orders",
"arguments": {"status": "pending"}
}
}'

Test cases

  1. Valid token → API returns data → Success
  2. Expired token → API returns 401 → Clear error message
  3. Wrong permissions → API returns 403 → Permission denied message
  4. No token → API returns 401 → Ask user to log in
  5. Malformed token → API returns 401 → Clear error message

Next Steps