Skip to content

Commit 01e2336

Browse files
authored
Merge pull request #123 from peterkarman1/petek/feat/auth-timeout
feat(auth-timeout): make callback timeout configurable
2 parents 01e240f + faf10b6 commit 01e2336

File tree

7 files changed

+201
-10
lines changed

7 files changed

+201
-10
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ You can specify multiple `--ignore-tool` flags to ignore different patterns. Exa
153153
- `*account` - ignores all tools ending with "account" (e.g., `getAccount`, `updateAccount`)
154154
- `exactTool` - ignores only the tool named exactly "exactTool"
155155

156+
* To change the timeout for the OAuth callback (by default `30` seconds), add the `--auth-timeout` flag with a value in seconds. This is useful if the authentication process on the server side takes a long time.
157+
158+
```json
159+
"args": [
160+
"mcp-remote",
161+
"https://remote.mcp.server/sse",
162+
"--auth-timeout",
163+
"60"
164+
]
165+
```
166+
156167
### Transport Strategies
157168

158169
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.

src/client.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ async function runClient(
3636
host: string,
3737
staticOAuthClientMetadata: StaticOAuthClientMetadata,
3838
staticOAuthClientInfo: StaticOAuthClientInformationFull,
39+
authTimeoutMs: number,
3940
) {
4041
// Set up event emitter for auth flow
4142
const events = new EventEmitter()
@@ -44,7 +45,7 @@ async function runClient(
4445
const serverUrlHash = getServerUrlHash(serverUrl)
4546

4647
// Create a lazy auth coordinator
47-
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events)
48+
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events, authTimeoutMs)
4849

4950
// Create the OAuth client provider
5051
const authProvider = new NodeOAuthClientProvider({
@@ -159,9 +160,20 @@ async function runClient(
159160

160161
// Parse command-line arguments and run the client
161162
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts <https://server-url> [callback-port] [--debug]')
162-
.then(({ serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo }) => {
163-
return runClient(serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo)
164-
})
163+
.then(
164+
({ serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, authTimeoutMs }) => {
165+
return runClient(
166+
serverUrl,
167+
callbackPort,
168+
headers,
169+
transportStrategy,
170+
host,
171+
staticOAuthClientMetadata,
172+
staticOAuthClientInfo,
173+
authTimeoutMs,
174+
)
175+
},
176+
)
165177
.catch((error) => {
166178
console.error('Fatal error:', error)
167179
process.exit(1)

src/lib/coordination.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ export async function waitForAuthentication(port: number): Promise<boolean> {
129129
* @param events The event emitter to use for signaling
130130
* @returns An AuthCoordinator object with an initializeAuth method
131131
*/
132-
export function createLazyAuthCoordinator(serverUrlHash: string, callbackPort: number, events: EventEmitter): AuthCoordinator {
132+
export function createLazyAuthCoordinator(
133+
serverUrlHash: string,
134+
callbackPort: number,
135+
events: EventEmitter,
136+
authTimeoutMs: number,
137+
): AuthCoordinator {
133138
let authState: { server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean } | null = null
134139

135140
return {
@@ -144,7 +149,7 @@ export function createLazyAuthCoordinator(serverUrlHash: string, callbackPort: n
144149
if (DEBUG) debugLog('Initializing auth coordination on-demand', { serverUrlHash, callbackPort })
145150

146151
// Initialize auth using the existing coordinateAuth logic
147-
authState = await coordinateAuth(serverUrlHash, callbackPort, events)
152+
authState = await coordinateAuth(serverUrlHash, callbackPort, events, authTimeoutMs)
148153
if (DEBUG) debugLog('Auth coordination completed', { skipBrowserAuth: authState.skipBrowserAuth })
149154
return authState
150155
},
@@ -162,6 +167,7 @@ export async function coordinateAuth(
162167
serverUrlHash: string,
163168
callbackPort: number,
164169
events: EventEmitter,
170+
authTimeoutMs: number,
165171
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> {
166172
if (DEBUG) debugLog('Coordinating authentication', { serverUrlHash, callbackPort })
167173

@@ -228,6 +234,7 @@ export async function coordinateAuth(
228234
port: callbackPort,
229235
path: '/oauth/callback',
230236
events,
237+
authTimeoutMs,
231238
})
232239

233240
// Get the actual port the server is running on

src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export interface OAuthCallbackServerOptions {
4343
path: string
4444
/** Event emitter to signal when auth code is received */
4545
events: EventEmitter
46+
/** Timeout in milliseconds for the auth callback server's long poll */
47+
authTimeoutMs?: number
4648
}
4749

4850
// optional tatic OAuth client information

src/lib/utils.test.ts

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { describe, it, expect, vi } from 'vitest'
2-
import { parseCommandLineArgs, shouldIncludeTool, mcpProxy } from './utils'
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { parseCommandLineArgs, shouldIncludeTool, mcpProxy, setupOAuthCallbackServerWithLongPoll } from './utils'
33
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
4+
import { EventEmitter } from 'events'
5+
import express from 'express'
46

57
// All sanitizeUrl tests have been moved to the strict-url-sanitise package
68

@@ -322,6 +324,100 @@ describe('Feature: Command Line Arguments Parsing', () => {
322324
expect(result.transportStrategy).toBe('sse-only')
323325
expect(result.ignoredTools).toEqual(['tool1', 'tool2'])
324326
})
327+
328+
it('Scenario: Use default auth timeout when not specified', async () => {
329+
// Given command line arguments without --auth-timeout flag
330+
const args = ['https://example.com/sse']
331+
const usage = 'test usage'
332+
333+
// When parsing the command line arguments
334+
const result = await parseCommandLineArgs(args, usage)
335+
336+
// Then the default auth timeout should be 30000ms
337+
expect(result.authTimeoutMs).toBe(30000)
338+
})
339+
340+
it('Scenario: Parse valid auth timeout in seconds and convert to milliseconds', async () => {
341+
// Given command line arguments with valid --auth-timeout
342+
const args = ['https://example.com/sse', '--auth-timeout', '60']
343+
const usage = 'test usage'
344+
345+
// When parsing the command line arguments
346+
const result = await parseCommandLineArgs(args, usage)
347+
348+
// Then the timeout should be converted to milliseconds
349+
expect(result.authTimeoutMs).toBe(60000)
350+
})
351+
352+
it('Scenario: Use default timeout when invalid auth timeout value is provided', async () => {
353+
// Given command line arguments with invalid --auth-timeout value
354+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
355+
const args = ['https://example.com/sse', '--auth-timeout', 'invalid']
356+
const usage = 'test usage'
357+
358+
// When parsing the command line arguments
359+
const result = await parseCommandLineArgs(args, usage)
360+
361+
// Then the default timeout should be used and warning logged
362+
expect(result.authTimeoutMs).toBe(30000)
363+
expect(consoleSpy).toHaveBeenCalledWith(
364+
expect.stringContaining('Warning: Ignoring invalid auth timeout value: invalid. Must be a positive number.'),
365+
)
366+
367+
consoleSpy.mockRestore()
368+
})
369+
370+
it('Scenario: Use default timeout when negative auth timeout value is provided', async () => {
371+
// Given command line arguments with negative --auth-timeout value
372+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
373+
const args = ['https://example.com/sse', '--auth-timeout', '-30']
374+
const usage = 'test usage'
375+
376+
// When parsing the command line arguments
377+
const result = await parseCommandLineArgs(args, usage)
378+
379+
// Then the default timeout should be used and warning logged
380+
expect(result.authTimeoutMs).toBe(30000)
381+
expect(consoleSpy).toHaveBeenCalledWith(
382+
expect.stringContaining('Warning: Ignoring invalid auth timeout value: -30. Must be a positive number.'),
383+
)
384+
385+
consoleSpy.mockRestore()
386+
})
387+
388+
it('Scenario: Use default timeout when zero auth timeout value is provided', async () => {
389+
// Given command line arguments with zero --auth-timeout value
390+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
391+
const args = ['https://example.com/sse', '--auth-timeout', '0']
392+
const usage = 'test usage'
393+
394+
// When parsing the command line arguments
395+
const result = await parseCommandLineArgs(args, usage)
396+
397+
// Then the default timeout should be used and warning logged
398+
expect(result.authTimeoutMs).toBe(30000)
399+
expect(consoleSpy).toHaveBeenCalledWith(
400+
expect.stringContaining('Warning: Ignoring invalid auth timeout value: 0. Must be a positive number.'),
401+
)
402+
403+
consoleSpy.mockRestore()
404+
})
405+
406+
it('Scenario: Log when using custom auth timeout', async () => {
407+
// Given command line arguments with custom --auth-timeout value
408+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
409+
const args = ['https://example.com/sse', '--auth-timeout', '45']
410+
const usage = 'test usage'
411+
412+
// When parsing the command line arguments
413+
const result = await parseCommandLineArgs(args, usage)
414+
415+
// Then the custom timeout should be used and logged
416+
expect(result.authTimeoutMs).toBe(45000)
417+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Using auth callback timeout: 45 seconds'))
418+
419+
consoleSpy.mockRestore()
420+
})
325421
})
326422

327423
describe('Feature: Tool Filtering with Ignore Patterns', () => {
@@ -773,3 +869,49 @@ describe('Feature: MCP Proxy', () => {
773869
)
774870
})
775871
})
872+
873+
describe('setupOAuthCallbackServerWithLongPoll', () => {
874+
let server: any
875+
let events: EventEmitter
876+
877+
beforeEach(() => {
878+
events = new EventEmitter()
879+
})
880+
881+
afterEach(() => {
882+
if (server) {
883+
server.close()
884+
server = null
885+
}
886+
})
887+
888+
it('should use custom timeout when authTimeoutMs is provided', async () => {
889+
const customTimeout = 5000
890+
const result = setupOAuthCallbackServerWithLongPoll({
891+
port: 0, // Use any available port
892+
path: '/oauth/callback',
893+
events,
894+
authTimeoutMs: customTimeout,
895+
})
896+
897+
server = result.server
898+
899+
// Test that the server was created
900+
expect(server).toBeDefined()
901+
expect(typeof result.waitForAuthCode).toBe('function')
902+
})
903+
904+
it('should use default timeout when authTimeoutMs is not provided', async () => {
905+
const result = setupOAuthCallbackServerWithLongPoll({
906+
port: 0, // Use any available port
907+
path: '/oauth/callback',
908+
events,
909+
})
910+
911+
server = result.server
912+
913+
// Test that the server was created with defaults
914+
expect(server).toBeDefined()
915+
expect(typeof result.waitForAuthCode).toBe('function')
916+
})
917+
})

src/lib/utils.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe
475475
const longPollTimeout = setTimeout(() => {
476476
log('Long poll timeout reached, responding with 202')
477477
res.status(202).send('Authentication in progress')
478-
}, 30000)
478+
}, options.authTimeoutMs || 30000)
479479

480480
// If auth completes while we're waiting, send the response immediately
481481
authCompletedPromise
@@ -716,6 +716,19 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
716716
j++
717717
}
718718

719+
// Parse auth timeout
720+
let authTimeoutMs = 30000 // Default 30 seconds
721+
const authTimeoutIndex = args.indexOf('--auth-timeout')
722+
if (authTimeoutIndex !== -1 && authTimeoutIndex < args.length - 1) {
723+
const timeoutSeconds = parseInt(args[authTimeoutIndex + 1], 10)
724+
if (!isNaN(timeoutSeconds) && timeoutSeconds > 0) {
725+
authTimeoutMs = timeoutSeconds * 1000
726+
log(`Using auth callback timeout: ${timeoutSeconds} seconds`)
727+
} else {
728+
log(`Warning: Ignoring invalid auth timeout value: ${args[authTimeoutIndex + 1]}. Must be a positive number.`)
729+
}
730+
}
731+
719732
if (!serverUrl) {
720733
log(usage)
721734
process.exit(1)
@@ -791,6 +804,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
791804
staticOAuthClientInfo,
792805
authorizeResource,
793806
ignoredTools,
807+
authTimeoutMs,
794808
}
795809
}
796810

src/proxy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ async function runProxy(
3737
staticOAuthClientInfo: StaticOAuthClientInformationFull,
3838
authorizeResource: string,
3939
ignoredTools: string[],
40+
authTimeoutMs: number,
4041
) {
4142
// Set up event emitter for auth flow
4243
const events = new EventEmitter()
@@ -45,7 +46,7 @@ async function runProxy(
4546
const serverUrlHash = getServerUrlHash(serverUrl)
4647

4748
// Create a lazy auth coordinator
48-
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events)
49+
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events, authTimeoutMs)
4950

5051
// Create the OAuth client provider
5152
const authProvider = new NodeOAuthClientProvider({
@@ -158,6 +159,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://se
158159
staticOAuthClientInfo,
159160
authorizeResource,
160161
ignoredTools,
162+
authTimeoutMs,
161163
}) => {
162164
return runProxy(
163165
serverUrl,
@@ -169,6 +171,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://se
169171
staticOAuthClientInfo,
170172
authorizeResource,
171173
ignoredTools,
174+
authTimeoutMs,
172175
)
173176
},
174177
)

0 commit comments

Comments
 (0)