Documentation Index
Fetch the complete documentation index at: https://docs.deploystack.io/llms.txt
Use this file to discover all available pages before exploring further.
OAuth System Clarification: DeployStack implements three distinct OAuth systems:
- User → DeployStack OAuth (Social Login) - See OAuth Providers
- MCP Client → DeployStack OAuth (API Access) - See OAuth2 Server - How VS Code, Cursor, Claude.ai authenticate to satellite APIs
- User → MCP Server OAuth (External Service Access) - This document - How users authorize external services like Notion, Box, Linear
This document covers system #3 - OAuth authentication with external MCP servers.
Overview
This document covers the backend implementation for OAuth 2.1 authentication with external MCP servers that require user authorization, such as Notion, Box, Linear, and GitHub Copilot.
When MCP Servers Require OAuth
MCP servers that access user-specific resources (files, issues, repositories) require OAuth authorization. Examples:
- Notion MCP Server (
https://mcp.notion.com/) - Access user’s Notion pages
- Box MCP Server (
https://mcp.box.com/) - Access user’s Box files
- Linear MCP Server (
https://mcp.linear.app/sse) - Access user’s Linear issues
- GitHub Copilot MCP - Access GitHub repositories
User Flow
- Install - User initiates MCP server installation in frontend
- Authorize - Backend redirects to OAuth provider’s authorization page
- Callback - OAuth provider redirects back with authorization code
- Token Storage - Backend exchanges code for tokens, encrypts and stores them
- Use - Satellite injects tokens when connecting to MCP server
Architecture Overview
The OAuth implementation includes:
- OAuth Discovery Service - Detects OAuth requirement and discovers endpoints using RFC 8414/9728
- Authorization Endpoint - Initiates OAuth flow with PKCE, state parameter, and resource parameter
- Callback Endpoint - Exchanges authorization code for tokens
- Re-Authentication Endpoint - User-initiated token refresh when automatic refresh fails
- Token Service - Handles token exchange and refresh operations
- Client Registration Service - Implements RFC 7591 Dynamic Client Registration (DCR)
- Encryption Service - AES-256-GCM encryption for tokens at rest
- Token Refresh Job - Background cron job refreshing expiring tokens
Database Tables
mcpOauthProviders - Pre-registered OAuth providers (for non-DCR auth servers)
oauthPendingFlows - Temporary storage during OAuth flow (10-minute expiry)
mcpServerInstallations - MCP server installations
mcpOauthTokens - Encrypted access and refresh tokens
Implementation Components
OAuthDiscoveryService
File: services/backend/src/services/OAuthDiscoveryService.ts
Purpose: Detects if an MCP server requires OAuth and discovers OAuth endpoints using RFC 8414 and RFC 9728.
When OAuth Detection Runs
OAuth detection occurs automatically in two scenarios:
- Server Creation (
POST /mcp/servers/global) - When global admin creates new MCP server
- Server Updates (
PUT /mcp/servers/global/:id) - When global admin updates existing MCP server
Why re-check on updates?
- Server’s OAuth configuration might have changed
- URL might be updated to a different endpoint
- Transport type might change from stdio → http/sse or vice versa
- Security verification ensures
requires_oauth flag stays accurate
OAuth Detection
The service uses a hybrid detection approach to handle different server configurations:
Detection Strategy:
- Try GET first (fast path for most servers like Notion, Box, Linear)
- Try POST with MCP protocol request if GET returns non-401 (handles servers like Harmonic AI that only protect POST endpoints)
// Try GET first (most common case)
const getResponse = await fetch(mcpServerUrl, { method: 'GET' });
if (getResponse.status === 401 && getResponse.headers.get('www-authenticate')?.includes('Bearer')) {
// OAuth detected via GET
requiresOauth = true;
} else {
// Try POST with MCP protocol request (handles Harmonic-style servers)
const postResponse = await fetch(mcpServerUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
})
});
if (postResponse.status === 401 && postResponse.headers.get('www-authenticate')?.includes('Bearer')) {
// OAuth detected via POST
requiresOauth = true;
}
}
Detection criteria:
- HTTP 401 Unauthorized response (on GET or POST)
WWW-Authenticate: Bearer header present
Why two methods?
- Standard servers (Notion, Box, Linear): Return 401 on GET for public health check endpoints
- Harmonic-style servers: Return 200 on GET (public endpoint), but 401 on POST (protected MCP protocol endpoint)
Once OAuth is detected, the service discovers endpoints using multiple methods with priority order:
Priority 1: WWW-Authenticate Header Discovery URL
Some servers provide a direct discovery URL in the WWW-Authenticate header:
// Extract discovery URL from WWW-Authenticate header
// Format: WWW-Authenticate: Bearer oauth_authorization_server="https://example.com/.well-known/oauth-authorization-server"
const wwwAuthenticate = response.headers.get('www-authenticate');
const match = wwwAuthenticate.match(/oauth_authorization_server="([^"]+)"/);
const discoveryUrl = match ? match[1] : undefined;
if (discoveryUrl) {
// Try discovery URL from header first
const metadata = await fetch(discoveryUrl).then(r => r.json());
// Contains: authorization_endpoint, token_endpoint, registration_endpoint, etc.
}
Priority 2: RFC 8414 - Authorization Server Metadata
const metadataUrl = `${authServerUrl}/.well-known/oauth-authorization-server`;
const response = await fetch(metadataUrl);
const metadata = await response.json();
// Contains: authorization_endpoint, token_endpoint, registration_endpoint, etc.
Priority 3: OpenID Connect Discovery (Final fallback):
const metadataUrl = `${authServerUrl}/.well-known/openid-configuration`;
const response = await fetch(metadataUrl);
const metadata = await response.json();
Discovery Priority Chain:
- Discovery URL from
WWW-Authenticate header (if provided)
- RFC 8414 Authorization Server Metadata
- OpenID Connect Discovery
- Give up - OAuth configuration cannot be determined
interface OAuthServerMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint?: string; // RFC 7591 Dynamic Client Registration
revocation_endpoint?: string;
scopes_supported?: string[];
response_types_supported?: string[];
grant_types_supported?: string[];
code_challenge_methods_supported?: string[]; // PKCE support
token_endpoint_auth_methods_supported?: string[];
}
Pre-registered Provider Matching
If the discovered authorization server matches a pre-registered provider pattern, the service returns the provider configuration:
// Check if auth server matches any registered provider
const provider = await this.matchOAuthProvider(metadata.issuer);
if (provider) {
return {
requiresOauth: true,
metadata,
provider: {
id: provider.id,
name: provider.name,
clientId: provider.client_id,
clientSecret: provider.client_secret, // Encrypted
authorizationEndpoint: provider.authorization_endpoint,
tokenEndpoint: provider.token_endpoint,
tokenEndpointAuthMethod: provider.token_endpoint_auth_method,
defaultScopes: JSON.parse(provider.default_scopes || '[]')
}
};
}
OAuth Detection on Server Updates
File: services/backend/src/routes/mcp/servers/update-global.ts
Endpoint: PUT /api/mcp/servers/global/:id
Purpose: Re-checks OAuth requirements whenever a global admin updates an MCP server configuration.
Update Detection Logic
When updating a global MCP server, the backend determines “effective values” for OAuth detection:
// Determine effective transport type and remotes
const effectiveTransportType = updateData.transport_type || existingServer.transport_type;
const effectiveRemotes = updateData.remotes || existingServer.remotes;
// Run OAuth detection if server is HTTP/SSE
if (effectiveTransportType === 'http' || effectiveTransportType === 'sse') {
const oauthService = new OAuthDiscoveryService(request.log);
const oauthResult = await oauthService.detectAndDiscoverOAuth(effectiveRemotes[0].url);
updateData.requires_oauth = oauthResult.requiresOauth;
}
Effective Values Logic:
- If update includes new
transport_type → Use new value
- If update doesn’t include
transport_type → Use existing server’s value
- Same logic applies to
remotes field
Why effective values? Allows partial updates while still running OAuth detection correctly.
Update Scenarios
Scenario 1: URL Change
PUT /api/mcp/servers/global/server_123
{
"remotes": [{ "url": "https://new-url.example.com" }]
}
- OAuth detection runs with new URL
requires_oauth updated based on new server’s response
Scenario 2: Transport Type Change (stdio → http)
PUT /api/mcp/servers/global/server_123
{
"transport_type": "http",
"remotes": [{ "url": "https://mcp.example.com" }]
}
- OAuth detection runs (now HTTP transport)
requires_oauth set based on detection result
Scenario 3: Transport Type Change (http → stdio)
PUT /api/mcp/servers/global/server_123
{
"transport_type": "stdio",
"packages": [{"npm": "@example/mcp-server"}]
}
- OAuth detection skipped (stdio doesn’t use OAuth)
requires_oauth explicitly set to false
Scenario 4: No Transport/URL Change
PUT /api/mcp/servers/global/server_123
{
"description": "Updated description"
}
- OAuth detection still runs with existing URL (security re-check)
- Ensures
requires_oauth reflects current server state
Error Handling
OAuth detection failures during updates are non-blocking:
try {
const oauthResult = await oauthService.detectAndDiscoverOAuth(url);
updateData.requires_oauth = oauthResult.requiresOauth;
} catch (error) {
logger.warn('OAuth detection failed, defaulting to requires_oauth=false');
updateData.requires_oauth = false;
}
Why non-blocking? Server updates should not fail due to temporary OAuth discovery issues. The update succeeds with requires_oauth=false as safe default.
Authorization Endpoint
File: services/backend/src/routes/mcp/installations/authorize.ts
Endpoint: POST /api/teams/:teamId/mcp/installations/authorize
Purpose: Initiates the OAuth 2.1 authorization flow with PKCE for MCP server installation.
Request Body
{
"server_id": "notion_mcp_server_id",
"installation_name": "My Notion Workspace",
"installation_type": "global", // or "team"
"team_config": {
"team_args": ["arg1", "arg2"],
"team_env": {"API_KEY": "value"},
"team_headers": {"X-Custom": "header"},
"team_url_query_params": {"param": "value"}
}
}
Authorization Flow Steps
Verify OAuth Requirement
Check that the MCP server has requires_oauth: true in the catalog.
Extract Server URL
Retrieve MCP server URL from remotes (HTTP/SSE) or packages (stdio) configuration.
Discover OAuth Endpoints
Call OAuthDiscoveryService.detectAndDiscoverOAuth() to get authorization endpoints.
Dynamic Client Registration or Provider Match
- If
registration_endpoint exists: Register new client via RFC 7591
- Else if pre-registered provider matches: Use provider credentials
- Else: Return error (cannot proceed)
Generate PKCE Pair
Create code verifier (128 random bytes) and code challenge (SHA256 hash).const pkce = generatePKCEPair();
// {
// code_verifier: "base64url-encoded-128-bytes",
// code_challenge: "base64url-encoded-sha256-hash",
// code_challenge_method: "S256"
// }
Generate State Parameter
Create cryptographically secure random state for CSRF protection.const state = generateState(); // 32 random bytes, base64url-encoded
Generate Resource Parameter
Create resource parameter (RFC 8707) for token audience binding.const resource = generateResourceParameter(serverId, teamId);
// "deploystack:mcp:{server_id}:{team_id}"
Create Pending Flow Record
Store temporary OAuth flow data in oauthPendingFlows table (expires in 10 minutes).await db.insert(oauthPendingFlows).values({
id: flowId,
team_id: teamId,
server_id: serverId,
created_by: userId,
oauth_state: state,
oauth_code_verifier: pkce.code_verifier,
oauth_client_id: clientId,
oauth_client_secret: clientSecret ? encrypt(clientSecret) : null,
oauth_provider_id: providerId,
oauth_token_endpoint: tokenEndpoint,
oauth_token_endpoint_auth_method: authMethod,
installation_name: "My Notion Workspace",
installation_type: "global",
team_config: JSON.stringify(teamConfig),
expires_at: new Date(Date.now() + 10 * 60 * 1000)
});
Build Authorization URL
Construct OAuth authorization URL with all parameters.const authUrl = new URL(authorizationEndpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', pkce.code_challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('resource', resource);
authUrl.searchParams.set('scope', scopes.join(' '));
authUrl.searchParams.set('prompt', 'consent');
Return Authorization URL
Frontend opens this URL in a popup window for user authorization.{
"flow_id": "abc123",
"authorization_url": "https://notion.com/oauth/authorize?...",
"requires_authorization": true,
"expires_at": "2025-12-22T10:30:00Z"
}
Callback Endpoint
File: services/backend/src/routes/mcp/installations/callback.ts
Endpoint: GET /api/teams/:teamId/mcp/oauth/callback/:flowId
Purpose: Receives authorization code from OAuth provider, exchanges it for tokens, and completes installation.
Callback Flow Steps
Validate OAuth Callback
Check for errors and validate required parameters.// Check for OAuth errors
if (query.error) {
return reply.type('text/html').send(errorPage);
}
// Validate required parameters
if (!query.state || !query.code) {
return reply.code(400).send({ error: 'Missing state or code' });
}
Find Pending Flow
Retrieve pending flow by flowId, teamId, and state parameter.const [flow] = await db
.select()
.from(oauthPendingFlows)
.where(
and(
eq(oauthPendingFlows.id, flowId),
eq(oauthPendingFlows.team_id, teamId),
eq(oauthPendingFlows.oauth_state, query.state)
)
)
.limit(1);
if (!flow) {
return reply.code(404).send({ error: 'Flow not found or state invalid' });
}
Check Flow Expiration
Ensure flow hasn’t expired (10-minute window).if (flow.expires_at < new Date()) {
await db.delete(oauthPendingFlows).where(eq(oauthPendingFlows.id, flow.id));
return reply.code(400).send({ error: 'Flow expired. Please try again.' });
}
Exchange Code for Tokens
Use PKCE verifier to exchange authorization code for access/refresh tokens.const tokenService = new OAuthTokenService(logger);
const tokenResponse = await tokenService.exchangeCodeForToken({
code: query.code,
codeVerifier: flow.oauth_code_verifier,
clientId: flow.oauth_client_id,
redirectUri,
tokenEndpoint: flow.oauth_token_endpoint,
clientSecret: flow.oauth_client_secret ? decrypt(flow.oauth_client_secret) : null,
tokenEndpointAuthMethod: flow.oauth_token_endpoint_auth_method
});
Create Installation
Create the MCP server installation record (not pending anymore).const installationId = nanoid();
await db.insert(mcpServerInstallations).values({
id: installationId,
team_id: flow.team_id,
server_id: flow.server_id,
created_by: flow.created_by,
installation_name: flow.installation_name,
installation_type: flow.installation_type,
team_args: teamConfig.team_args ? JSON.stringify(teamConfig.team_args) : null,
team_env: teamConfig.team_env ? JSON.stringify(teamConfig.team_env) : null,
team_headers: teamConfig.team_headers ? JSON.stringify(teamConfig.team_headers) : null,
team_url_query_params: teamConfig.team_url_query_params ? JSON.stringify(teamConfig.team_url_query_params) : null,
oauth_pending: false, // Installation complete
status: 'connecting',
status_message: 'Authenticated successfully, waiting for satellite to connect'
});
Per-User Instance Creation: OAuth callback creates the user’s instance with status=‘connecting’. For multi-user teams:
- Installing user’s instance created with their OAuth credentials
- Other team members’ instances created with status=‘awaiting_user_config’ (they must authenticate separately)
- Each user authenticates independently with their own OAuth account
For instance lifecycle details, see Instance Lifecycle. Encrypt and Store Tokens
Encrypt access and refresh tokens using AES-256-GCM before storing.const encryptedAccessToken = encrypt(tokenResponse.access_token, logger);
const encryptedRefreshToken = tokenResponse.refresh_token
? encrypt(tokenResponse.refresh_token, logger)
: null;
await db.insert(mcpOauthTokens).values({
id: nanoid(),
installation_id: installationId,
user_id: flow.created_by,
team_id: flow.team_id,
access_token: encryptedAccessToken,
refresh_token: encryptedRefreshToken,
token_type: tokenResponse.token_type || 'Bearer',
expires_at: new Date(Date.now() + tokenResponse.expires_in * 1000),
scope: tokenResponse.scope || null
});
Delete Pending Flow
Remove temporary flow record to prevent reuse.await db.delete(oauthPendingFlows).where(eq(oauthPendingFlows.id, flow.id));
Notify Satellites
Create satellite commands for immediate configuration update.const satelliteCommandService = new SatelliteCommandService(db, logger);
await satelliteCommandService.notifyMcpInstallation(
installationId,
flow.team_id,
flow.created_by
);
Return Success Page
Render HTML page that posts message to opener window and closes popup.<script>
if (window.opener) {
window.opener.postMessage({
type: 'oauth_success',
installation_id: 'abc123'
}, 'https://cloud.deploystack.io');
}
setTimeout(() => window.close(), 500);
</script>
OAuthTokenService
File: services/backend/src/services/OAuthTokenService.ts
Purpose: Handles token exchange and refresh operations with OAuth servers.
Token Exchange with PKCE
Exchanges authorization code for access/refresh tokens using PKCE verification:
async exchangeCodeForToken(params: TokenExchangeParams): Promise<TokenResponse> {
const requestBody = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
code_verifier: params.codeVerifier // PKCE verification
});
// Handle different authentication methods
switch (params.tokenEndpointAuthMethod) {
case 'client_secret_basic':
// HTTP Basic Auth header
const credentials = Buffer.from(`${params.clientId}:${params.clientSecret}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
break;
case 'client_secret_post':
// Client secret in body (GitHub, most providers)
requestBody.set('client_id', params.clientId);
requestBody.set('client_secret', params.clientSecret);
break;
case 'none':
default:
// Public client - PKCE only
requestBody.set('client_id', params.clientId);
break;
}
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: requestBody.toString()
});
return await response.json();
}
Token endpoint authentication methods:
none - Public client (PKCE only, no client secret)
client_secret_post - Client secret in request body (GitHub, most OAuth providers)
client_secret_basic - HTTP Basic Auth header (enterprise providers)
Token Refresh
Refreshes expired access tokens using refresh token:
async refreshToken(params: TokenRefreshParams): Promise<TokenResponse> {
const requestBody = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: params.refreshToken
});
// Same authentication method handling as token exchange
// ...
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: requestBody.toString()
});
return await response.json();
}
Update Refreshed Tokens
Updates database with newly refreshed encrypted tokens:
async updateRefreshedTokens(tokenId: string, newTokens: TokenResponse, db: AnyDatabase) {
const encryptedAccessToken = encrypt(newTokens.access_token, this.logger);
const encryptedRefreshToken = newTokens.refresh_token
? encrypt(newTokens.refresh_token, this.logger)
: undefined; // Keep existing if not rotated
const expiresAt = newTokens.expires_in
? new Date(Date.now() + newTokens.expires_in * 1000)
: null;
await db
.update(mcpOauthTokens)
.set({
access_token: encryptedAccessToken,
...(encryptedRefreshToken !== undefined && { refresh_token: encryptedRefreshToken }),
expires_at: expiresAt,
scope: newTokens.scope || undefined,
updated_at: new Date()
})
.where(eq(mcpOauthTokens.id, tokenId));
}
Note: Some OAuth providers rotate refresh tokens (issue new refresh token with each refresh). The service handles this by conditionally updating the refresh token field.
OAuthClientRegistrationService
File: services/backend/src/services/OAuthClientRegistrationService.ts
Purpose: Implements RFC 7591 (OAuth 2.0 Dynamic Client Registration Protocol).
Dynamic Client Registration
Registers a new OAuth client with MCP server’s registration endpoint:
async registerClient(
registrationEndpoint: string,
request: ClientRegistrationRequest
): Promise<ClientRegistrationResponse> {
const registrationBody = {
client_name: 'DeployStack',
redirect_uris: [redirectUri],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none' // Public client (PKCE)
};
const response = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(registrationBody)
});
const registration = await response.json();
// Returns: { client_id, client_secret?, redirect_uris, ... }
return registration;
}
Registration response:
{
client_id: "dynamically-generated-client-id",
client_secret: "optional-client-secret", // Only for confidential clients
redirect_uris: ["https://api.deploystack.io/oauth/callback"],
grant_types: ["authorization_code", "refresh_token"],
token_endpoint_auth_method: "none"
}
When DCR is used:
- MCP server supports
registration_endpoint in OAuth metadata
- Client ID is generated dynamically per installation
- No pre-registration required with OAuth provider
When Pre-registered Provider is used:
- MCP server does NOT support
registration_endpoint
- Pre-registered provider configured in
mcpOauthProviders table
- Uses fixed client ID and client secret
- Example: GitHub OAuth Apps for GitHub MCP server
Database Schema
mcpOauthProviders Table
Pre-registered OAuth providers for MCP servers that don’t support Dynamic Client Registration.
CREATE TABLE mcpOauthProviders (
id TEXT PRIMARY KEY,
-- Provider identity
name TEXT NOT NULL, -- "GitHub", "Google"
slug TEXT NOT NULL UNIQUE, -- "github", "google"
icon_url TEXT,
-- Authorization server matching
auth_server_patterns TEXT NOT NULL, -- JSON array of regex patterns
-- OAuth credentials (pre-registered with provider)
client_id TEXT NOT NULL,
client_secret TEXT, -- Encrypted (NULL for public clients)
-- OAuth endpoints
authorization_endpoint TEXT NOT NULL,
token_endpoint TEXT NOT NULL,
-- OAuth configuration
default_scopes TEXT, -- JSON array
pkce_required BOOLEAN NOT NULL DEFAULT true,
token_endpoint_auth_method TEXT NOT NULL DEFAULT 'client_secret_post',
-- Status
enabled BOOLEAN NOT NULL DEFAULT true,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Example provider record:
{
"id": "github_oauth_provider",
"name": "GitHub",
"slug": "github",
"auth_server_patterns": "[\"^https://github\\\\.com/login/oauth\"]",
"client_id": "Ov23liABCDEF12345",
"client_secret": "encrypted:abc123...", // Encrypted
"authorization_endpoint": "https://github.com/login/oauth/authorize",
"token_endpoint": "https://github.com/login/oauth/access_token",
"default_scopes": "[\"repo\", \"read:user\"]",
"pkce_required": true,
"token_endpoint_auth_method": "client_secret_post",
"enabled": true
}
oauthPendingFlows Table
Temporary storage for OAuth flows during authorization (expires in 10 minutes).
CREATE TABLE oauthPendingFlows (
id TEXT PRIMARY KEY, -- flow_id (nanoid)
-- Foreign Keys
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES mcpServers(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES authUser(id) ON DELETE CASCADE,
-- OAuth Flow State
oauth_state TEXT NOT NULL, -- CSRF protection
oauth_code_verifier TEXT NOT NULL, -- PKCE verifier
-- OAuth Client (Dynamic or Pre-registered)
oauth_client_id TEXT NOT NULL,
oauth_client_secret TEXT, -- Encrypted (if provided)
oauth_provider_id TEXT REFERENCES mcpOauthProviders(id) ON DELETE SET NULL,
oauth_token_endpoint TEXT NOT NULL,
oauth_token_endpoint_auth_method TEXT NOT NULL,
-- Installation Data (stored temporarily)
installation_name TEXT NOT NULL,
installation_type TEXT NOT NULL DEFAULT 'global',
team_config TEXT, -- JSON: team_args, team_env, team_headers, etc.
-- Expiration
expires_at TIMESTAMP NOT NULL, -- 10 minutes from creation
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Important: This table is cleaned up automatically after OAuth flow completes or expires. Records should never exist for more than 10 minutes.
mcpOauthTokens Table
Encrypted OAuth tokens for MCP server installations.
CREATE TABLE mcpOauthTokens (
id TEXT PRIMARY KEY,
-- Foreign Keys
installation_id TEXT NOT NULL REFERENCES mcpServerInstallations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES authUser(id) ON DELETE CASCADE,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- Token Data (AES-256-GCM encrypted)
access_token TEXT NOT NULL,
refresh_token TEXT,
-- Token Metadata
token_type TEXT NOT NULL DEFAULT 'Bearer',
expires_at TIMESTAMP,
scope TEXT,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Encryption format: iv:authTag:encryptedData (all hex-encoded)
- IV: 16 bytes (128 bits)
- Auth Tag: 16 bytes (128 bits)
- Encrypted Data: Variable length
Index: (installation_id, user_id, team_id) for fast token lookups by satellite.
Token Lifecycle
Token Issuance
- User authorizes application at OAuth provider
- OAuth provider redirects to callback with authorization code
- Backend exchanges code for access/refresh tokens using PKCE
- Tokens encrypted using AES-256-GCM
- Encrypted tokens stored in
mcpOauthTokens table
- Installation status set to
connecting
Automatic Token Refresh
File: services/backend/src/jobs/refresh-oauth-tokens.ts
Cron Schedule: Every 5 minutes
Refresh Criteria:
- Token has
refresh_token (NOT NULL)
- Token has
expires_at timestamp (NOT NULL)
- Token expires within next 10 minutes
- Token not already expired
Refresh Process:
Find Expiring Tokens
Query tokens expiring in the next 10 minutes.SELECT t.*, i.*, s.*
FROM mcpOauthTokens t
INNER JOIN mcpServerInstallations i ON t.installation_id = i.id
INNER JOIN mcpServers s ON i.server_id = s.id
WHERE t.refresh_token IS NOT NULL
AND t.expires_at IS NOT NULL
AND t.expires_at < NOW() + INTERVAL '10 minutes'
AND t.expires_at > NOW()
Discover OAuth Endpoints
Re-discover OAuth endpoints for each MCP server (ensures current endpoints).
Decrypt Refresh Token
Decrypt stored refresh token using AES-256-GCM.
Call Token Endpoint
Exchange refresh token for new access token.const newTokens = await tokenService.refreshToken({
refreshToken: decryptedRefreshToken,
clientId: installation.oauth_client_id || 'deploystack',
tokenEndpoint: discovery.metadata.token_endpoint,
clientSecret: clientSecret, // If using pre-registered provider
tokenEndpointAuthMethod: 'none' // Or from provider config
});
Encrypt and Update
Encrypt new tokens and update database.await tokenService.updateRefreshedTokens(token.id, newTokens, db);
Handle Failures
If refresh fails, set installation status to requires_reauth.await db
.update(mcpServerInstallations)
.set({
status: 'requires_reauth',
status_message: `OAuth token refresh failed: ${error.message}. Please re-authenticate.`,
status_updated_at: new Date()
})
.where(eq(mcpServerInstallations.id, installation.id));
Per-User Instance Impact: Token refresh failures only affect the specific user’s instance. Other team members’ instances remain unaffected even if one user’s OAuth token expires.
Logging:
INFO: Found 3 tokens that need refreshing
INFO: Refreshing token (tokenId: abc123, serverId: notion_mcp)
INFO: Token refreshed successfully (tokenId: abc123, newExpiresIn: 3600)
INFO: OAuth token refresh job completed (totalTokens: 3, successCount: 3, failureCount: 0)
Token Expiration Handling
When automatic token refresh fails (server offline, invalid refresh token, token revoked), the installation enters requires_reauth status. Users can recover through self-service re-authentication.
Automatic Refresh Failure:
- Token refresh job attempts refresh
- Refresh fails (network error, invalid_grant, server offline)
- Installation status →
requires_reauth
- Status message explains why re-auth is needed
User-Initiated Re-Authentication:
Users can re-authenticate existing installations without reinstalling:
Endpoint: POST /api/teams/:teamId/mcp/installations/:installationId/reauth
Permission: mcp.installations.view (both team_admin and team_user)
Why Both Roles: OAuth tokens are per-user credentials, not team-level configuration. Each user authenticates with their own account.
Flow:
- Frontend detects
requires_reauth status and shows “Re-authenticate” button
- User clicks button → Backend starts OAuth flow (same as initial authorization)
- OAuth provider redirects to authorization page
- User authorizes → Callback exchanges code for new tokens
- Backend updates existing token record (doesn’t create new installation)
- Installation status →
connecting → online
- Satellite receives updated tokens via configuration sync
Key Difference from Initial Authorization:
- Initial: Creates new installation + new token record
- Re-auth: Updates existing installation + existing token record
Database Impact:
- Pending flow created with
installation_id reference (links to existing installation)
- Callback detects
installation_id and performs UPDATE instead of INSERT
- Preserves team configuration (env vars, args, headers)
Security: Same PKCE flow, state validation, and token encryption as initial authorization
Token Revocation
When user deletes an MCP server installation:
- Installation deleted from
mcpServerInstallations (CASCADE)
- Tokens automatically deleted from
mcpOauthTokens (CASCADE foreign key)
- Future enhancement: Call OAuth provider’s revocation endpoint
- Satellite receives configuration update removing the installation
Security Implementation
PKCE (Proof Key for Code Exchange)
Required for all OAuth flows to prevent authorization code interception attacks.
PKCE Generation:
function generatePKCEPair() {
// 1. Generate code verifier (128 random bytes)
const codeVerifier = crypto.randomBytes(128).toString('base64url');
// 2. Generate code challenge (SHA256 hash)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return {
code_verifier: codeVerifier,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
};
}
Authorization request:
GET /oauth/authorize?
response_type=code
&client_id=abc123
&redirect_uri=https://api.deploystack.io/oauth/callback
&code_challenge=ABCD1234...
&code_challenge_method=S256
&state=xyz789
Token exchange:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=authorization_code_here
&redirect_uri=https://api.deploystack.io/oauth/callback
&code_verifier=original_verifier_here
&client_id=abc123
Security: OAuth server verifies SHA256(code_verifier) == code_challenge before issuing tokens.
State Parameter
Purpose: CSRF protection during OAuth flow.
Generation:
function generateState(): string {
return crypto.randomBytes(32).toString('base64url');
}
Flow:
- Backend generates random state before redirecting to OAuth provider
- State stored in
oauthPendingFlows table
- OAuth provider includes state in callback URL
- Backend verifies state matches stored value
- If mismatch → Reject callback (potential CSRF attack)
Resource Parameter
Purpose: Token audience binding (RFC 8707) to prevent token misuse.
Generation:
function generateResourceParameter(serverId: string, teamId: string): string {
return `deploystack:mcp:${serverId}:${teamId}`;
}
Benefits:
- Tokens bound to specific MCP server and team
- Prevents token reuse across different installations
- OAuth provider includes resource in issued token
Token Encryption
Algorithm: AES-256-GCM (Authenticated Encryption with Associated Data)
Encryption:
function encrypt(text: string, logger?: FastifyBaseLogger): string {
const key = getEncryptionKey(); // From DEPLOYSTACK_ENCRYPTION_SECRET
const iv = crypto.randomBytes(16); // 128-bit IV
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// Set AAD for extra security
const aad = Buffer.from('deploystack-global-settings', 'utf8');
cipher.setAAD(aad);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encryptedData
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
Decryption:
function decrypt(encryptedData: string, logger?: FastifyBaseLogger): string {
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
const key = getEncryptionKey();
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAAD(Buffer.from('deploystack-global-settings', 'utf8'));
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
Key Derivation:
function getEncryptionKey(): Buffer {
const secret = process.env.DEPLOYSTACK_ENCRYPTION_SECRET || 'fallback';
const salt = 'deploystack-global-settings-salt';
return crypto.scryptSync(secret, salt, 32); // 256-bit key
}
Security Features:
- AES-256: Industry-standard symmetric encryption
- GCM mode: Authenticated encryption prevents tampering
- Random IV: Each encryption uses unique initialization vector
- AAD: Additional authenticated data binds encryption context
- Scrypt: Key derivation function resistant to brute-force attacks
Environment Variable:
DEPLOYSTACK_ENCRYPTION_SECRET="your-32-character-secret-key-here"
Production requirement: Must be at least 32 characters for security.
HTTPS Requirements
All OAuth endpoints require HTTPS:
- Authorization endpoint
- Token endpoint
- Callback endpoint (redirect URI)
Why: OAuth flows transmit sensitive data (authorization codes, tokens) that must be protected from interception.
Local development exception: http://localhost allowed for testing.
OAuth Discovery Process
Step-by-Step Discovery Flow
Try GET Request (Fast Path)
Send GET request to MCP server URL to detect OAuth requirement.const getResponse = await fetch(mcpServerUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'DeployStack/1.0'
}
});
Check GET Response for OAuth
Look for 401 status and WWW-Authenticate header.if (getResponse.status === 401) {
const wwwAuth = getResponse.headers.get('www-authenticate');
if (wwwAuth?.toLowerCase().includes('bearer')) {
requiresOauth = true;
// Extract optional discovery URL from header
const match = wwwAuth.match(/oauth_authorization_server="([^"]+)"/);
discoveryUrl = match ? match[1] : undefined;
}
}
Try POST Request (Harmonic-style Servers)
If GET returns non-401, try POST with MCP protocol request.if (!requiresOauth) {
const postResponse = await fetch(mcpServerUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
})
});
if (postResponse.status === 401) {
const wwwAuth = postResponse.headers.get('www-authenticate');
if (wwwAuth?.toLowerCase().includes('bearer')) {
requiresOauth = true;
// Extract optional discovery URL from header
const match = wwwAuth.match(/oauth_authorization_server="([^"]+)"/);
discoveryUrl = match ? match[1] : undefined;
}
}
}
Why POST? Servers like Harmonic AI allow GET for public health checks but require OAuth on POST endpoints. Try Discovery URL from Header (Priority 1)
If WWW-Authenticate header includes discovery URL, try it first.if (discoveryUrl) {
// Discovery URL example: "https://example.com/.well-known/oauth-authorization-server"
const metadata = await fetch(discoveryUrl).then(r => r.json());
if (metadata.authorization_endpoint && metadata.token_endpoint) {
return metadata; // Success!
}
}
Fetch Authorization Server Metadata (Priority 2 - RFC 8414)
If no discovery URL or it failed, try RFC 8414.const issuerUrl = new URL(mcpServerUrl);
const issuer = `${issuerUrl.protocol}//${issuerUrl.host}`;
const rfc8414Url = `${issuer}/.well-known/oauth-authorization-server`;
const serverMetadata = await fetch(rfc8414Url).then(r => r.json());
Fallback to OpenID Connect Discovery (Priority 3)
If RFC 8414 fails, try OpenID Connect as final fallback.const oidcUrl = `${issuer}/.well-known/openid-configuration`;
const serverMetadata = await fetch(oidcUrl).then(r => r.json());
Validate Metadata
Ensure required endpoints are present.if (!serverMetadata.authorization_endpoint || !serverMetadata.token_endpoint) {
throw new Error('Missing required OAuth endpoints');
}
Check PKCE Support
Verify server supports S256 code challenge method.const pkceSupported = serverMetadata.code_challenge_methods_supported?.includes('S256');
if (!pkceSupported) {
logger.warn('OAuth server may not support PKCE S256');
}
Match Pre-registered Provider (Optional)
Check if authorization server matches known provider.const provider = await this.matchOAuthProvider(serverMetadata.issuer);
if (provider) {
// Use pre-registered credentials
return { requiresOauth: true, metadata: serverMetadata, provider };
}
Return Discovery Result
Complete discovery with metadata and optional provider.return {
requiresOauth: true,
metadata: {
authorization_endpoint: "https://auth.example.com/oauth/authorize",
token_endpoint: "https://auth.example.com/oauth/token",
registration_endpoint: "https://auth.example.com/oauth/register",
scopes_supported: ["read", "write"],
code_challenge_methods_supported: ["S256"]
},
provider: null // Or pre-registered provider object
};
Detection Methods by Server Type
Different MCP servers implement OAuth protection at different endpoint levels. DeployStack’s hybrid detection approach handles all configurations:
Standard Servers (Notion, Box, Linear)
Behavior: Return 401 on GET requests to base URL
GET https://mcp.notion.com/
→ 401 Unauthorized
WWW-Authenticate: Bearer realm="notion-mcp"
Detection: Immediate OAuth detection via GET request (fast path)
Harmonic-style Servers (Harmonic AI)
Behavior: Allow GET for public health checks, require OAuth on POST endpoints
GET https://mcp.api.harmonic.ai/
→ 200 OK (public endpoint)
POST https://mcp.api.harmonic.ai/
Content-Type: application/json
{ "jsonrpc": "2.0", "method": "tools/list", "id": 1 }
→ 401 Unauthorized
WWW-Authenticate: Bearer oauth_authorization_server="https://..."
Detection: GET returns 200, POST returns 401 - requires hybrid detection
Why this pattern? Public health check endpoints let monitoring systems verify server status without authentication, while actual MCP protocol operations require OAuth.
Servers with Discovery URL
Behavior: Include oauth_authorization_server in WWW-Authenticate header
WWW-Authenticate: Bearer realm="mcp-server",
oauth_authorization_server="https://example.com/.well-known/oauth-authorization-server"
Detection: Discovery URL extracted from header and used as first priority for metadata discovery
Benefit: Faster discovery - no need to guess or try multiple well-known endpoint patterns
Error Handling
Discovery failures:
- GET and POST both return non-401: Server does not require OAuth
- Discovery URL from header fails: Try RFC 8414 Authorization Server Metadata
- Authorization server metadata not found: Try OpenID Connect discovery
- All discovery methods fail: Return error to user - OAuth configuration cannot be determined
- Network timeout: Retry with exponential backoff (3 attempts)
- Invalid JSON: Log error and return OAuth not supported
Complete Fallback Chain:
- Discovery URL from
WWW-Authenticate header (if provided)
- RFC 8414 Authorization Server Metadata
- OpenID Connect Discovery
- Give up and return error
Integration Points
Backend → Database
OAuth detection triggers:
-
Server Creation:
POST /mcp/servers/global
- OAuth detection runs on initial creation
requires_oauth stored in mcpServers table
-
Server Updates:
PUT /mcp/servers/global/:id
- OAuth detection re-runs on every update
requires_oauth updated to reflect current state
- Ensures accuracy even if server’s OAuth config changes
Why re-detect on updates?
- Remote servers might enable/disable OAuth
- URLs might change to different endpoints
- Transport types might switch between stdio/http/sse
- Security verification ensures flag accuracy
Frontend → Backend
Installation initiation:
// Frontend calls authorization endpoint
const response = await fetch('/api/teams/:teamId/mcp/installations/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
server_id: 'notion_mcp_server',
installation_name: 'My Notion Workspace',
installation_type: 'global',
team_config: {
team_args: [],
team_env: {},
team_headers: {},
team_url_query_params: {}
}
})
});
const { authorization_url, flow_id } = await response.json();
// Frontend opens popup window
const popup = window.open(authorization_url, 'oauth', 'width=600,height=700');
// Frontend listens for completion message
window.addEventListener('message', (event) => {
if (event.data.type === 'oauth_success') {
console.log('Installation ID:', event.data.installation_id);
// Refresh installations list
} else if (event.data.type === 'oauth_error') {
console.error('OAuth error:', event.data.error);
}
});
Backend → Satellite
Satellite retrieves OAuth tokens during configuration fetch:
The satellite calls /api/satellites/config which includes OAuth tokens for installations:
// Backend response includes decrypted tokens
{
"installations": [
{
"id": "installation_123",
"server_id": "notion_mcp",
"installation_name": "My Notion Workspace",
"requires_oauth": true,
"oauth_token": {
"access_token": "decrypted-access-token-here", // Decrypted by backend
"token_type": "Bearer",
"expires_at": "2025-12-22T12:00:00Z",
"scope": "read write"
},
"team_headers": {
"X-Custom-Header": "value"
}
}
]
}
Important: Backend decrypts tokens before sending to satellite over HTTPS. Satellite never stores encrypted tokens.
Satellite implementation: See OAuth Token Injection documentation.
Satellite → MCP Server
Token injection in HTTP/SSE requests:
Satellite adds Authorization header when connecting to OAuth-enabled MCP servers:
// Satellite constructs headers for HTTP MCP server
const headers = {
'Content-Type': 'application/json',
'MCP-Protocol-Version': '1.0',
...config.team_headers, // Custom headers from team configuration
'Authorization': `Bearer ${oauthToken.access_token}` // OAuth token
};
// Send request to MCP server
const response = await fetch(mcpServerUrl, {
method: 'POST',
headers,
body: JSON.stringify({ method: 'tools/list' })
});
Header priority: OAuth Authorization header added last to prevent override by team headers.
Testing and Debugging
Manual Testing with Real MCP Servers
Notion MCP Server (Standard Detection):
- Add Notion to catalog:
https://mcp.notion.com/
- Backend detects OAuth requirement via GET request (fast path)
- Install as user → Opens Notion OAuth page
- Authorize → Callback completes installation
- Check
mcpOauthTokens table for encrypted tokens
- Verify satellite receives decrypted token in config
Harmonic AI MCP Server (Hybrid Detection):
- Add Harmonic to catalog:
https://mcp.api.harmonic.ai
- Backend tries GET first (returns 200 - public endpoint)
- Backend tries POST with MCP protocol request (returns 401 - OAuth required)
- Extracts discovery URL from
WWW-Authenticate header
- Install as user → Opens Harmonic OAuth page
- Authorize → Callback completes installation
- Verify logs show “OAuth detected via POST”
Box MCP Server:
- Add Box to catalog:
https://mcp.box.com/
- Follow same flow as Notion
- Verify PKCE S256 is used (check logs)
- Test token refresh by manually updating
expires_at to past
Testing Dynamic Client Registration
MCP servers with DCR support:
- Notion: ✅ Supports RFC 7591 registration endpoint
- Box: ✅ Supports RFC 7591 registration endpoint
- Linear: ✅ Supports RFC 7591 registration endpoint
Testing DCR flow:
- Ensure no pre-registered provider matches
- Check logs for “Registering dynamic OAuth client”
- Verify
oauth_client_id is dynamically generated
- Check that client can refresh tokens using generated client ID
Testing Pre-registered Providers
Setup test provider:
INSERT INTO mcpOauthProviders (id, name, slug, auth_server_patterns, client_id, client_secret, authorization_endpoint, token_endpoint, default_scopes, token_endpoint_auth_method, enabled)
VALUES (
'test_github_provider',
'GitHub (Test)',
'github-test',
'["^https://github\\.com/login/oauth"]',
'Ov23liYourClientId',
'encrypted:your-encrypted-secret', -- Use encrypt() function
'https://github.com/login/oauth/authorize',
'https://github.com/login/oauth/access_token',
'["repo", "read:user"]',
'client_secret_post',
true
);
Test flow:
- Add GitHub MCP server requiring OAuth
- Install → Should match provider by auth server pattern
- Check logs for “Using pre-registered OAuth provider: GitHub (Test)”
- Verify client_id from provider is used instead of DCR
Testing OAuth Detection on Updates
Test Case 1: Update URL to OAuth-enabled server
# Start with non-OAuth server
PUT /api/mcp/servers/global/server_123
{
"remotes": [{ "url": "https://public-mcp.example.com" }]
}
# → requires_oauth: false
# Update to OAuth-enabled server
PUT /api/mcp/servers/global/server_123
{
"remotes": [{ "url": "https://mcp.notion.com" }]
}
# → OAuth detection runs → requires_oauth: true
Test Case 2: Security re-check on metadata updates
# Update description only (URL unchanged)
PUT /api/mcp/servers/global/server_123
{
"description": "New description"
}
# → OAuth detection still runs with existing URL
# → Verifies OAuth status hasn't changed
Test Case 3: Transport type change
# Change from stdio to HTTP
PUT /api/mcp/servers/global/server_123
{
"transport_type": "http",
"remotes": [{ "url": "https://mcp.example.com" }]
}
# → OAuth detection runs → requires_oauth updated
# Change from HTTP to stdio
PUT /api/mcp/servers/global/server_123
{
"transport_type": "stdio"
}
# → requires_oauth: false (stdio doesn't use OAuth)
Common Issues
Issue: “OAuth not detected” for Harmonic-style servers
- Cause: Server returns 200 on GET, only protects POST endpoints
- Solution: Fixed in commit
2d3bf3d7 - Now tries POST with MCP protocol request if GET returns non-401
- Verify: Check logs for “Server returned non-401 on GET, trying POST with MCP protocol request”
Issue: “OAuth provider not configured” error
- Cause: MCP server doesn’t support DCR and no pre-registered provider matches
- Fix: Add provider to
mcpOauthProviders table with matching auth server pattern
Issue: Discovery URL in header not being used
- Cause: WWW-Authenticate header parsing failed
- Debug: Check logs for “OAuth discovery URL found in WWW-Authenticate header”
- Format: Header must contain
oauth_authorization_server="https://..."
Issue: Tokens not refreshing automatically
- Cause: Cron job not running or refresh token missing
- Fix: Check
refreshExpiringOAuthTokens cron job logs, verify refresh_token field is not NULL
Issue: “Flow expired” error during callback
- Cause: User took more than 10 minutes to authorize
- Fix: Increase expiry in
authorize.ts or inform user to complete authorization faster
Issue: Installation stuck in “connecting” status after OAuth
- Cause: Satellite hasn’t polled configuration yet
- Fix: Check satellite logs, verify satellite commands created, wait for next config poll
Issue: Token decryption error
- Cause:
DEPLOYSTACK_ENCRYPTION_SECRET changed between encryption and decryption
- Fix: Ensure encryption secret is consistent across deployments
Log Analysis
Successful OAuth flow with GET detection (Standard servers):
DEBUG: Making test request to check OAuth requirement (url: https://mcp.notion.com/, method: GET)
INFO: OAuth detected via GET (url: https://mcp.notion.com/, method: GET)
INFO: OAuth required, starting discovery (url: https://mcp.notion.com/)
INFO: Initiating OAuth authorization flow (serverId: notion_mcp, teamId: team_abc)
INFO: Registering dynamic OAuth client (registrationEndpoint: https://mcp.notion.com/oauth/register)
INFO: Dynamic client registration successful (clientId: dyn_12345, hasClientSecret: false)
INFO: OAuth authorization initiated successfully (flowId: flow_xyz, authUrl: https://notion.com/oauth/...)
INFO: OAuth callback received (flowId: flow_xyz, code: auth_code_123)
INFO: Token exchange successful (tokenType: Bearer, expiresIn: 3600, hasRefreshToken: true)
INFO: OAuth flow completed successfully - installation created (installationId: inst_abc)
Successful OAuth flow with POST detection (Harmonic-style servers):
DEBUG: Making test request to check OAuth requirement (url: https://mcp.api.harmonic.ai, method: GET)
DEBUG: Server returned non-401 status (url: https://mcp.api.harmonic.ai, method: GET, status: 200)
DEBUG: Server returned non-401 on GET, trying POST with MCP protocol request (url: https://mcp.api.harmonic.ai, method: POST)
INFO: OAuth requirement detected (401 + WWW-Authenticate: Bearer) (url: https://mcp.api.harmonic.ai, method: POST, discoveryUrl: https://auth.harmonic.ai/.well-known/oauth-authorization-server)
INFO: OAuth detected via POST (url: https://mcp.api.harmonic.ai, method: POST)
INFO: OAuth discovery URL found in WWW-Authenticate header (url: https://mcp.api.harmonic.ai, discoveryUrl: https://auth.harmonic.ai/.well-known/oauth-authorization-server)
DEBUG: Trying discovery URL from WWW-Authenticate header (issuer: https://auth.harmonic.ai, discoveryUrl: https://auth.harmonic.ai/.well-known/oauth-authorization-server)
INFO: Successfully discovered OAuth metadata via WWW-Authenticate header (issuer: https://auth.harmonic.ai, discoveryUrl: https://auth.harmonic.ai/.well-known/oauth-authorization-server)
...
Failed token refresh:
ERROR: Token refresh failed (status: 400, error: invalid_grant)
WARN: OAuth refresh failed, installation status set to requires_reauth (installation_id: inst_abc)