Skip to content

Commit 4c2dc3f

Browse files
committed
feat(auth-timeout): make callback timeout configurable
1 parent 929c97c commit 4c2dc3f

File tree

7 files changed

+196
-8
lines changed

7 files changed

+196
-8
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ To bypass authentication, or to emit custom headers on all requests to your remo
135135
]
136136
```
137137

138+
* 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.
139+
140+
```json
141+
"args": [
142+
"mcp-remote",
143+
"https://remote.mcp.server/sse",
144+
"--auth-timeout",
145+
"60"
146+
]
147+
```
148+
138149
### Transport Strategies
139150

140151
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: 4 additions & 3 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,8 +160,8 @@ 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)
163+
.then(({ serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, authTimeoutMs }) => {
164+
return runClient(serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, authTimeoutMs)
164165
})
165166
.catch((error) => {
166167
console.error('Fatal error:', error)

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
@@ -41,6 +41,8 @@ export interface OAuthCallbackServerOptions {
4141
path: string
4242
/** Event emitter to signal when auth code is received */
4343
events: EventEmitter
44+
/** Timeout in milliseconds for the auth callback server's long poll */
45+
authTimeoutMs?: number
4446
}
4547

4648
// optional tatic OAuth client information

src/lib/utils.test.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,153 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { parseCommandLineArgs, setupOAuthCallbackServerWithLongPoll } from './utils'
3+
import { EventEmitter } from 'events'
4+
import express from 'express'
25

36
// All sanitizeUrl tests have been moved to the strict-url-sanitise package
7+
8+
describe('parseCommandLineArgs', () => {
9+
const mockUsage = 'Usage: test <url>'
10+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
11+
throw new Error('process.exit called')
12+
})
13+
14+
beforeEach(() => {
15+
vi.clearAllMocks()
16+
})
17+
18+
afterEach(() => {
19+
mockExit.mockReset()
20+
})
21+
22+
describe('--auth-timeout parsing', () => {
23+
it('should use default timeout of 30000ms when no --auth-timeout flag is provided', async () => {
24+
const args = ['https://example.com']
25+
const result = await parseCommandLineArgs(args, mockUsage)
26+
27+
expect(result.authTimeoutMs).toBe(30000)
28+
})
29+
30+
it('should parse valid timeout in seconds and convert to milliseconds', async () => {
31+
const args = ['https://example.com', '--auth-timeout', '60']
32+
const result = await parseCommandLineArgs(args, mockUsage)
33+
34+
expect(result.authTimeoutMs).toBe(60000)
35+
})
36+
37+
it('should parse another valid timeout value', async () => {
38+
const args = ['https://example.com', '--auth-timeout', '120']
39+
const result = await parseCommandLineArgs(args, mockUsage)
40+
41+
expect(result.authTimeoutMs).toBe(120000)
42+
})
43+
44+
it('should use default timeout when invalid timeout value is provided', async () => {
45+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
46+
47+
const args = ['https://example.com', '--auth-timeout', 'invalid']
48+
const result = await parseCommandLineArgs(args, mockUsage)
49+
50+
expect(result.authTimeoutMs).toBe(30000)
51+
expect(consoleSpy).toHaveBeenCalledWith(
52+
expect.stringContaining('Warning: Ignoring invalid auth timeout value: invalid. Must be a positive number.')
53+
)
54+
55+
consoleSpy.mockRestore()
56+
})
57+
58+
it('should use default timeout when negative timeout value is provided', async () => {
59+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
60+
61+
const args = ['https://example.com', '--auth-timeout', '-30']
62+
const result = await parseCommandLineArgs(args, mockUsage)
63+
64+
expect(result.authTimeoutMs).toBe(30000)
65+
expect(consoleSpy).toHaveBeenCalledWith(
66+
expect.stringContaining('Warning: Ignoring invalid auth timeout value: -30. Must be a positive number.')
67+
)
68+
69+
consoleSpy.mockRestore()
70+
})
71+
72+
it('should use default timeout when zero timeout value is provided', async () => {
73+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
74+
75+
const args = ['https://example.com', '--auth-timeout', '0']
76+
const result = await parseCommandLineArgs(args, mockUsage)
77+
78+
expect(result.authTimeoutMs).toBe(30000)
79+
expect(consoleSpy).toHaveBeenCalledWith(
80+
expect.stringContaining('Warning: Ignoring invalid auth timeout value: 0. Must be a positive number.')
81+
)
82+
83+
consoleSpy.mockRestore()
84+
})
85+
86+
it('should use default timeout when --auth-timeout flag has no value', async () => {
87+
const args = ['https://example.com', '--auth-timeout']
88+
const result = await parseCommandLineArgs(args, mockUsage)
89+
90+
expect(result.authTimeoutMs).toBe(30000)
91+
})
92+
93+
it('should log when using custom timeout', async () => {
94+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
95+
96+
const args = ['https://example.com', '--auth-timeout', '45']
97+
const result = await parseCommandLineArgs(args, mockUsage)
98+
99+
expect(result.authTimeoutMs).toBe(45000)
100+
expect(consoleSpy).toHaveBeenCalledWith(
101+
expect.stringContaining('Using auth callback timeout: 45 seconds')
102+
)
103+
104+
consoleSpy.mockRestore()
105+
})
106+
})
107+
})
108+
109+
describe('setupOAuthCallbackServerWithLongPoll', () => {
110+
let server: any
111+
let events: EventEmitter
112+
113+
beforeEach(() => {
114+
events = new EventEmitter()
115+
})
116+
117+
afterEach(() => {
118+
if (server) {
119+
server.close()
120+
server = null
121+
}
122+
})
123+
124+
it('should use custom timeout when authTimeoutMs is provided', async () => {
125+
const customTimeout = 5000
126+
const result = setupOAuthCallbackServerWithLongPoll({
127+
port: 0, // Use any available port
128+
path: '/oauth/callback',
129+
events,
130+
authTimeoutMs: customTimeout
131+
})
132+
133+
server = result.server
134+
135+
// Test that the server was created
136+
expect(server).toBeDefined()
137+
expect(typeof result.waitForAuthCode).toBe('function')
138+
})
139+
140+
it('should use default timeout when authTimeoutMs is not provided', async () => {
141+
const result = setupOAuthCallbackServerWithLongPoll({
142+
port: 0, // Use any available port
143+
path: '/oauth/callback',
144+
events
145+
})
146+
147+
server = result.server
148+
149+
// Test that the server was created with defaults
150+
expect(server).toBeDefined()
151+
expect(typeof result.waitForAuthCode).toBe('function')
152+
})
153+
})

src/lib/utils.ts

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

396396
// If auth completes while we're waiting, send the response immediately
397397
authCompletedPromise
@@ -617,6 +617,19 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
617617
log(`Using authorize resource: ${authorizeResource}`)
618618
}
619619

620+
// Parse auth timeout
621+
let authTimeoutMs = 30000 // Default 30 seconds
622+
const authTimeoutIndex = args.indexOf('--auth-timeout')
623+
if (authTimeoutIndex !== -1 && authTimeoutIndex < args.length - 1) {
624+
const timeoutSeconds = parseInt(args[authTimeoutIndex + 1], 10)
625+
if (!isNaN(timeoutSeconds) && timeoutSeconds > 0) {
626+
authTimeoutMs = timeoutSeconds * 1000
627+
log(`Using auth callback timeout: ${timeoutSeconds} seconds`)
628+
} else {
629+
log(`Warning: Ignoring invalid auth timeout value: ${args[authTimeoutIndex + 1]}. Must be a positive number.`)
630+
}
631+
}
632+
620633
if (!serverUrl) {
621634
log(usage)
622635
process.exit(1)
@@ -691,6 +704,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
691704
staticOAuthClientMetadata,
692705
staticOAuthClientInfo,
693706
authorizeResource,
707+
authTimeoutMs,
694708
}
695709
}
696710

src/proxy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ async function runProxy(
3636
staticOAuthClientMetadata: StaticOAuthClientMetadata,
3737
staticOAuthClientInfo: StaticOAuthClientInformationFull,
3838
authorizeResource: string,
39+
authTimeoutMs: number,
3940
) {
4041
// Set up event emitter for auth flow
4142
const events = new EventEmitter()
@@ -44,7 +45,7 @@ async function runProxy(
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({
@@ -155,6 +156,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://se
155156
staticOAuthClientMetadata,
156157
staticOAuthClientInfo,
157158
authorizeResource,
159+
authTimeoutMs,
158160
}) => {
159161
return runProxy(
160162
serverUrl,
@@ -165,6 +167,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://se
165167
staticOAuthClientMetadata,
166168
staticOAuthClientInfo,
167169
authorizeResource,
170+
authTimeoutMs,
168171
)
169172
},
170173
)

0 commit comments

Comments
 (0)