Skip to content

Authentication Error Token exchange failed: HTTP 400 Solution #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import * as fsSync from 'fs'
import path from 'path'
import os from 'os'
import { OAuthClientInformationFull, OAuthClientInformationFullSchema } from '@modelcontextprotocol/sdk/shared/auth.js'
import { OAuthCallbackServerOptions } from './types'
import { getConfigFilePath, readJsonFile } from './mcp-auth-config'
import express from 'express'
import net from 'net'
import crypto from 'crypto'
import fs from 'fs/promises'
import * as fs from 'fs/promises'

// Connection constants
export const REASON_AUTH_NEEDED = 'authentication-needed'
export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport'
export const SHORT_TIMEOUT_DURATION = 50000

// Transport strategy types
export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first'
Expand All @@ -27,6 +31,29 @@ export function log(str: string, ...rest: unknown[]) {
console.error(`[${pid}] ${str}`, ...rest)
}

/**
* Clears MCP auth files after short timeout
* Used for clients like Cursor or Claude Desktop with short timeouts
*/
function clearMcpAuthFiles() {
try {
const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth')
const versionDir = path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`)

log('Short timeout reached, clearing MCP auth files for current version')
// Check if the version directory exists
if (fsSync.existsSync(versionDir)) {
// Delete only the current version directory and its contents
fsSync.rmSync(versionDir, { recursive: true, force: true })
log(`MCP auth directory for version ${MCP_REMOTE_VERSION} cleared successfully`)
} else {
log(`No MCP directory found for version ${MCP_REMOTE_VERSION}, nothing to clear`)
}
} catch (error) {
log('Error clearing MCP auth files:', error)
}
}

/**
* Creates a bidirectional proxy between two transports
* @param params The transport connections to proxy between
Expand Down Expand Up @@ -100,6 +127,7 @@ export type AuthInitializer = () => Promise<{
* @param authInitializer Function to initialize authentication when needed
* @param transportStrategy Strategy for selecting transport type ('sse-only', 'http-only', 'sse-first', 'http-first')
* @param recursionReasons Set of reasons for recursive calls (internal use)
* @param shortTimeout Whether to use a short timeout (for clients like Cursor or Claude Desktop)
* @returns The connected transport
*/
export async function connectToRemoteServer(
Expand All @@ -109,7 +137,8 @@ export async function connectToRemoteServer(
headers: Record<string, string>,
authInitializer: AuthInitializer,
transportStrategy: TransportStrategy = 'http-first',
recursionReasons: Set<string> = new Set(),
shortTimeout: boolean = false,
recursionReasons: Set<string> = new Set()
): Promise<Transport> {
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
const url = new URL(serverUrl)
Expand Down Expand Up @@ -199,14 +228,24 @@ export async function connectToRemoteServer(
headers,
authInitializer,
sseTransport ? 'http-only' : 'sse-only',
recursionReasons,
shortTimeout,
recursionReasons
)
} else if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
log('Authentication required. Initializing auth...')

// Initialize authentication on-demand
const { waitForAuthCode, skipBrowserAuth } = await authInitializer()

// Set up short timeout if enabled
let shortTimeoutTimer: NodeJS.Timeout | null = null
if (shortTimeout) {
log(`Short timeout enabled, will clear auth files after ${SHORT_TIMEOUT_DURATION / 1000} seconds`)
shortTimeoutTimer = setTimeout(() => {
clearMcpAuthFiles()
}, SHORT_TIMEOUT_DURATION)
}

if (skipBrowserAuth) {
log('Authentication required but skipping browser auth - using shared auth')
} else {
Expand All @@ -217,6 +256,11 @@ export async function connectToRemoteServer(
const code = await waitForAuthCode()

try {
// Clear the timeout if auth completes successfully
if (shortTimeoutTimer) {
clearTimeout(shortTimeoutTimer)
}

log('Completing authorization...')
await transport.finishAuth(code)

Expand All @@ -231,8 +275,13 @@ export async function connectToRemoteServer(
log(`Recursively reconnecting for reason: ${REASON_AUTH_NEEDED}`)

// Recursively call connectToRemoteServer with the updated recursion tracking
return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons)
return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, shortTimeout)
} catch (authError) {
// Clear the timeout if auth fails
if (shortTimeoutTimer) {
clearTimeout(shortTimeoutTimer)
}

log('Authorization error:', authError)
throw authError
}
Expand Down Expand Up @@ -435,6 +484,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
const serverUrl = args[0]
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
const allowHttp = args.includes('--allow-http')
const shortTimeout = args.includes('--short-timeout')

// Parse transport strategy
let transportStrategy: TransportStrategy = 'http-first' // Default
Expand Down Expand Up @@ -505,7 +555,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
})
}

return { serverUrl, callbackPort, headers, transportStrategy }
return { serverUrl, callbackPort, headers, transportStrategy, shortTimeout }
}

/**
Expand Down
7 changes: 4 additions & 3 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ async function runProxy(
callbackPort: number,
headers: Record<string, string>,
transportStrategy: TransportStrategy = 'http-first',
shortTimeout: boolean = false,
) {
// Set up event emitter for auth flow
const events = new EventEmitter()
Expand Down Expand Up @@ -78,7 +79,7 @@ async function runProxy(

try {
// Connect to remote server with lazy authentication
const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy)
const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy, shortTimeout)

// Set up bidirectional proxy between local and remote transports
mcpProxy({
Expand Down Expand Up @@ -136,8 +137,8 @@ to the CA certificate file. If using claude_desktop_config.json, this might look

// Parse command-line arguments and run the proxy
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
return runProxy(serverUrl, callbackPort, headers, transportStrategy)
.then(({ serverUrl, callbackPort, headers, transportStrategy, shortTimeout }) => {
return runProxy(serverUrl, callbackPort, headers, transportStrategy, shortTimeout)
})
.catch((error) => {
log('Fatal error:', error)
Expand Down
Loading