Skip to content
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,27 @@ You can specify multiple `--ignore-tool` flags to ignore different patterns. Exa
]
```

* To automatically refresh access tokens before they expire, use the auto-refresh flags:
* `--enable-auto-refresh` / `--disable-auto-refresh` – turn the background refresher on or off (enabled by default for both the proxy and the CLI so they keep working in the background, opt out when you explicitly need to disable it).
* `--refresh-lead <seconds>` – how early to refresh before expiry (default `600`, i.e. 10 minutes).
* `--refresh-interval <seconds>` – how often to scan stored tokens (default `60`).
* `--refresh-backoff <seconds>` – how long to wait before retrying after a failure (default `300`).


```json
"args": [
"mcp-remote",
"https://remote.mcp.server/sse",
"--enable-auto-refresh",
"--refresh-lead",
"300",
"--refresh-interval",
"30"
]
```

The refresher scans all OAuth sessions stored under `~/.mcp-auth`, renews access tokens using their refresh tokens, and logs the outcome so long-running hosts keep working without forcing a browser re-auth.

### Transport Strategies

MCP Remote supports different transport strategies when connecting to an MCP server. This allows you to control whether it uses Server-Sent Events (SSE) or HTTP transport, and in what order it tries them.
Expand Down
25 changes: 24 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@ import { EventEmitter } from 'events'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, connectToRemoteServer, TransportStrategy } from './lib/utils'
import {
parseCommandLineArgs,
setupSignalHandlers,
log,
MCP_REMOTE_VERSION,
connectToRemoteServer,
TransportStrategy,
AutoRefreshOptions,
} from './lib/utils'
import { StaticOAuthClientInformationFull, StaticOAuthClientMetadata } from './lib/types'
import { createLazyAuthCoordinator } from './lib/coordination'
import { TokenRefreshManager } from './lib/token-refresh-manager'

/**
* Main function to run the client
Expand All @@ -30,6 +39,7 @@ async function runClient(
staticOAuthClientInfo: StaticOAuthClientInformationFull,
authTimeoutMs: number,
serverUrlHash: string,
autoRefresh: AutoRefreshOptions,
) {
// Set up event emitter for auth flow
const events = new EventEmitter()
Expand All @@ -48,6 +58,14 @@ async function runClient(
serverUrlHash,
})

const refreshManager = new TokenRefreshManager({
enabled: autoRefresh.enabled,
intervalMs: autoRefresh.intervalMs,
leadTimeMs: autoRefresh.leadTimeMs,
failureBackoffMs: autoRefresh.backoffMs,
})
refreshManager.start()

// Create the client
const client = new Client(
{
Expand Down Expand Up @@ -103,6 +121,7 @@ async function runClient(

// Set up cleanup handler
const cleanup = async () => {
refreshManager.stop()
log('\nClosing connection...')
await client.close()
// If auth was initialized and server was created, close it
Expand Down Expand Up @@ -134,13 +153,15 @@ async function runClient(

// log('Listening for messages. Press Ctrl+C to exit.')
log('Exiting OK...')
refreshManager.stop()
// Only close the server if it was initialized
if (server) {
server.close()
}
process.exit(0)
} catch (error) {
log('Fatal error:', error)
refreshManager.stop()
// Only close the server if it was initialized
if (server) {
server.close()
Expand All @@ -162,6 +183,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts <https://s
staticOAuthClientInfo,
authTimeoutMs,
serverUrlHash,
autoRefresh,
}) => {
return runClient(
serverUrl,
Expand All @@ -173,6 +195,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts <https://s
staticOAuthClientInfo,
authTimeoutMs,
serverUrlHash,
autoRefresh,
)
},
)
Expand Down
185 changes: 185 additions & 0 deletions src/lib/mcp-auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path'
import os from 'os'
import fs from 'fs/promises'
import { log, MCP_REMOTE_VERSION } from './utils'
import { StaticOAuthClientInformationFull, StaticOAuthClientMetadata } from './types'

/**
* MCP Remote Authentication Configuration
Expand All @@ -17,6 +18,11 @@ import { log, MCP_REMOTE_VERSION } from './utils'
* - Format: OAuthClientInformation object with client_id and other registration details
* - {server_hash}_tokens.json: Contains OAuth access and refresh tokens
* - Format: OAuthTokens object with access_token, refresh_token, and expiration information
* - {server_hash}_token_state.json: Derived metadata such as issuedAt/expiresAt and refresh attempt status
* - Format: TokenState object maintained by the auto-refresh feature
* - {server_hash}_server.json: Registration metadata (server URL, callback host/port, static client info)
* - Used by background processes like the token refresh manager to reconstruct providers
* - {server_hash}_refresh_lock.json: Lightweight lock file ensuring only one process refreshes tokens at a time
* - {server_hash}_code_verifier.txt: Contains the PKCE code verifier for the current OAuth flow
* - Format: Plain text string used for PKCE verification
*
Expand All @@ -32,6 +38,91 @@ export interface LockfileData {
timestamp: number
}

/**
* Tracks derived token metadata so we know when tokens were issued/expires and the status of
* the most recent refresh attempt.
*/
export interface TokenState {
issuedAt: number
expiresAt?: number
lastRefreshAttempt?: number
lastRefreshError?: string
}

/**
* Persistent registration details for a specific server configuration. Stored in `{hash}_server.json`
* so background tasks (like the refresh manager) know how to reconstruct the OAuth client provider.
*/
export interface ServerRegistration {
serverUrl: string
host: string
callbackPort?: number
authorizeResource?: string
staticOAuthClientMetadata?: StaticOAuthClientMetadata
staticOAuthClientInfo?: StaticOAuthClientInformationFull
}

/**
* Representation of the refresh lock file, ensuring only one process attempts a token refresh at a time.
*/
export interface RefreshLockData {
pid: number
expiresAt: number
}

/**
* Zod-like schema used to validate persisted token state objects.
*/
const tokenStateSchema = {
async parseAsync(data: any) {
if (typeof data !== 'object' || data === null) return undefined
if (typeof data.issuedAt !== 'number') return undefined

const state: TokenState = {
issuedAt: data.issuedAt,
expiresAt: typeof data.expiresAt === 'number' ? data.expiresAt : undefined,
lastRefreshAttempt: typeof data.lastRefreshAttempt === 'number' ? data.lastRefreshAttempt : undefined,
lastRefreshError: typeof data.lastRefreshError === 'string' ? data.lastRefreshError : undefined,
}

return state
},
}

/**
* Schema for validating stored server registration payloads.
*/
const serverRegistrationSchema = {
async parseAsync(data: any) {
if (typeof data !== 'object' || data === null) return undefined
if (typeof data.serverUrl !== 'string' || typeof data.host !== 'string') {
return undefined
}

const registration: ServerRegistration = {
serverUrl: data.serverUrl,
host: data.host,
callbackPort: typeof data.callbackPort === 'number' ? data.callbackPort : undefined,
authorizeResource: typeof data.authorizeResource === 'string' ? data.authorizeResource : undefined,
staticOAuthClientMetadata: data.staticOAuthClientMetadata,
staticOAuthClientInfo: data.staticOAuthClientInfo,
}

return registration
},
}

/**
* Schema for validating refresh lock files.
*/
const refreshLockSchema = {
async parseAsync(data: any) {
if (typeof data !== 'object' || data === null) return undefined
if (typeof data.pid !== 'number' || typeof data.expiresAt !== 'number') return undefined
return data as RefreshLockData
},
}

/**
* Creates a lockfile for the given server
* @param serverUrlHash The hash of the server URL
Expand Down Expand Up @@ -77,6 +168,45 @@ export async function deleteLockfile(serverUrlHash: string): Promise<void> {
await deleteConfigFile(serverUrlHash, 'lock.json')
}

/**
* Saves persistent information about a registered server
* @param serverUrlHash The hash identifying the server configuration
* @param registration The registration metadata to store
*/
export async function saveServerRegistration(serverUrlHash: string, registration: ServerRegistration): Promise<void> {
await writeJsonFile(serverUrlHash, 'server.json', registration)
}

/**
* Reads server registration data if available
* @param serverUrlHash The hash identifying the server configuration
* @returns The stored registration metadata, if present
*/
export async function readServerRegistration(serverUrlHash: string): Promise<ServerRegistration | undefined> {
return await readJsonFile<ServerRegistration>(serverUrlHash, 'server.json', serverRegistrationSchema)
}

/**
* Lists server hashes that currently have token files on disk
* @returns An array of server hashes with stored tokens
*/
export async function listServerHashesWithTokens(): Promise<string[]> {
try {
const configDir = getConfigDir()
const entries = await fs.readdir(configDir)

return entries
.filter((filename) => filename.endsWith('_tokens.json'))
.map((filename) => filename.replace(/_tokens\.json$/, ''))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return []
}
log('Error listing stored tokens:', error)
return []
}
}

/**
* Gets the configuration directory path
* @returns The path to the configuration directory
Expand Down Expand Up @@ -171,6 +301,31 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat
}
}

/**
* Writes token metadata for a server (e.g., issued/expiry timestamps)
* @param serverUrlHash The hash identifying the server configuration
* @param state The token state fields to persist/merge
*/
export async function writeTokenState(serverUrlHash: string, state: Partial<TokenState>): Promise<void> {
const current = await readTokenState(serverUrlHash)
const nextState: TokenState = {
issuedAt: state.issuedAt ?? current?.issuedAt ?? Date.now(),
expiresAt: state.expiresAt ?? current?.expiresAt,
lastRefreshAttempt: state.lastRefreshAttempt ?? current?.lastRefreshAttempt,
lastRefreshError: state.lastRefreshError ?? current?.lastRefreshError,
}
await writeJsonFile(serverUrlHash, 'token_state.json', nextState)
}

/**
* Reads token metadata for a server
* @param serverUrlHash The hash identifying the server configuration
* @returns The stored token state, if available
*/
export async function readTokenState(serverUrlHash: string): Promise<TokenState | undefined> {
return await readJsonFile<TokenState>(serverUrlHash, 'token_state.json', tokenStateSchema)
}

/**
* Reads a text file
* @param serverUrlHash The hash of the server URL
Expand Down Expand Up @@ -204,3 +359,33 @@ export async function writeTextFile(serverUrlHash: string, filename: string, tex
throw error
}
}

/**
* Attempts to acquire a refresh lock for a server. Returns true if acquired.
* @param serverUrlHash The hash identifying the server configuration
* @param ttlMs The duration in milliseconds before the lock expires automatically
*/
export async function tryAcquireRefreshLock(serverUrlHash: string, ttlMs: number): Promise<boolean> {
const existingLock = await readJsonFile<RefreshLockData>(serverUrlHash, 'refresh_lock.json', refreshLockSchema)
const now = Date.now()

if (existingLock && existingLock.expiresAt > now && existingLock.pid !== process.pid) {
return false
}

const newLock: RefreshLockData = {
pid: process.pid,
expiresAt: now + ttlMs,
}

await writeJsonFile(serverUrlHash, 'refresh_lock.json', newLock)
return true
}

/**
* Releases the refresh lock for a server
* @param serverUrlHash The hash identifying the server configuration
*/
export async function releaseRefreshLock(serverUrlHash: string): Promise<void> {
await deleteConfigFile(serverUrlHash, 'refresh_lock.json')
}
16 changes: 14 additions & 2 deletions src/lib/node-oauth-client-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import type { OAuthProviderOptions, StaticOAuthClientMetadata } from './types'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteConfigFile } from './mcp-auth-config'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteConfigFile, writeTokenState } from './mcp-auth-config'
import { StaticOAuthClientInformationFull } from './types'
import { log, debugLog, MCP_REMOTE_VERSION } from './utils'
import { sanitizeUrl } from 'strict-url-sanitise'
Expand All @@ -28,6 +28,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
private staticOAuthClientInfo: StaticOAuthClientInformationFull
private authorizeResource: string | undefined
private _state: string
addClientAuthentication?: OAuthClientProvider['addClientAuthentication']

/**
* Creates a new NodeOAuthClientProvider
Expand Down Expand Up @@ -157,6 +158,13 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
})

await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens)

const issuedAt = Date.now()
const expiresAt = typeof tokens.expires_in === 'number' ? issuedAt + tokens.expires_in * 1000 : undefined
await writeTokenState(this.serverUrlHash, {
issuedAt,
expiresAt,
})
}

/**
Expand Down Expand Up @@ -213,6 +221,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
await Promise.all([
deleteConfigFile(this.serverUrlHash, 'client_info.json'),
deleteConfigFile(this.serverUrlHash, 'tokens.json'),
deleteConfigFile(this.serverUrlHash, 'token_state.json'),
deleteConfigFile(this.serverUrlHash, 'code_verifier.txt'),
])
debugLog('All credentials invalidated')
Expand All @@ -224,7 +233,10 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
break

case 'tokens':
await deleteConfigFile(this.serverUrlHash, 'tokens.json')
await Promise.all([
deleteConfigFile(this.serverUrlHash, 'tokens.json'),
deleteConfigFile(this.serverUrlHash, 'token_state.json'),
])
debugLog('OAuth tokens invalidated')
break

Expand Down
Loading
Loading