@@ -19,6 +19,27 @@ import {
1919interface MockState {
2020 finishAuthCalls : number ;
2121 finishAuthError : Error | undefined ;
22+ finishAuthInstanceId : number | undefined ;
23+ finishAuthResourceMetadataUrl : string | undefined ;
24+ finishAuthScope : string | undefined ;
25+ redirectingInstanceId : number | undefined ;
26+ preFailureResourceMetadataUrl : string | undefined ;
27+ preFailureScope : string | undefined ;
28+ challengeResourceMetadataUrl : string | undefined ;
29+ challengeScope : string | undefined ;
30+ boundTransport :
31+ | {
32+ instanceId : number ;
33+ resourceMetadataUrl ?: string ;
34+ scope ?: string ;
35+ sessionId ?: string ;
36+ }
37+ | undefined ;
38+ stampSessionId : string | undefined ;
39+ sessionStamped : boolean ;
40+ callSessionIds : ( string | undefined ) [ ] ;
41+ callOAuthError : Error | undefined ;
42+ callbackCloseThrows : boolean ;
2243 connectFailuresLeft : number ;
2344 connectCalls : number ;
2445 /** Thrown once, before any redirect, then cleared. */
@@ -61,6 +82,20 @@ function freshMockState(): MockState {
6182 return {
6283 finishAuthCalls : 0 ,
6384 finishAuthError : new Error ( "finishAuth exploded" ) ,
85+ finishAuthInstanceId : undefined ,
86+ finishAuthResourceMetadataUrl : undefined ,
87+ finishAuthScope : undefined ,
88+ redirectingInstanceId : undefined ,
89+ preFailureResourceMetadataUrl : undefined ,
90+ preFailureScope : undefined ,
91+ challengeResourceMetadataUrl : undefined ,
92+ challengeScope : undefined ,
93+ boundTransport : undefined ,
94+ stampSessionId : undefined ,
95+ sessionStamped : false ,
96+ callSessionIds : [ ] ,
97+ callOAuthError : undefined ,
98+ callbackCloseThrows : false ,
6499 connectFailuresLeft : 0 ,
65100 connectCalls : 0 ,
66101 connectOAuthError : undefined ,
@@ -193,18 +228,44 @@ function waitForGate(signal: AbortSignal | undefined): Promise<void> {
193228}
194229
195230await mockMcpClientModule ( mock , {
196- connect : async ( provider ) => {
231+ connect : async ( provider , transport ) => {
197232 mock . connectCalls += 1 ;
233+ if ( transport !== undefined ) mock . boundTransport = transport ;
198234 if ( mock . connectOAuthError !== undefined ) {
235+ if (
236+ transport !== undefined &&
237+ mock . preFailureResourceMetadataUrl !== undefined
238+ ) {
239+ transport . resourceMetadataUrl = mock . preFailureResourceMetadataUrl ;
240+ if ( mock . preFailureScope !== undefined )
241+ transport . scope = mock . preFailureScope ;
242+ }
199243 const error = mock . connectOAuthError ;
200244 mock . connectOAuthError = undefined ;
201245 throw error ;
202246 }
203247 if ( mock . connectFailuresLeft > 0 ) {
204248 mock . connectFailuresLeft -= 1 ;
249+ if (
250+ transport !== undefined &&
251+ mock . challengeResourceMetadataUrl !== undefined
252+ ) {
253+ transport . resourceMetadataUrl = mock . challengeResourceMetadataUrl ;
254+ if ( mock . challengeScope !== undefined )
255+ transport . scope = mock . challengeScope ;
256+ mock . redirectingInstanceId = transport . instanceId ;
257+ }
205258 await emitRedirects ( provider ) ;
206259 throw new UnauthorizedError ( "authorization required" ) ;
207260 }
261+ if (
262+ transport !== undefined &&
263+ mock . stampSessionId !== undefined &&
264+ ! mock . sessionStamped
265+ ) {
266+ transport . sessionId = mock . stampSessionId ;
267+ mock . sessionStamped = true ;
268+ }
208269 } ,
209270 listTools : async ( ) => {
210271 if ( mock . listFailuresLeft > 0 ) {
@@ -218,8 +279,23 @@ await mockMcpClientModule(mock, {
218279 }
219280 return { tools : [ ] } ;
220281 } ,
221- callTool : async ( ) => {
282+ callTool : async ( sessionId ) => {
222283 mock . callToolCalls += 1 ;
284+ mock . callSessionIds . push ( sessionId ) ;
285+ if ( mock . callOAuthError !== undefined ) {
286+ if (
287+ mock . boundTransport !== undefined &&
288+ mock . preFailureResourceMetadataUrl !== undefined
289+ ) {
290+ mock . boundTransport . resourceMetadataUrl =
291+ mock . preFailureResourceMetadataUrl ;
292+ if ( mock . preFailureScope !== undefined )
293+ mock . boundTransport . scope = mock . preFailureScope ;
294+ }
295+ const error = mock . callOAuthError ;
296+ mock . callOAuthError = undefined ;
297+ throw error ;
298+ }
223299 if ( mock . callFailuresLeft > 0 ) {
224300 mock . callFailuresLeft -= 1 ;
225301 if ( mock . callRedirectsLeft > 0 ) {
@@ -237,6 +313,9 @@ await mockMcpClientModule(mock, {
237313await mockMcpTransportModule ( mock , {
238314 finishAuth : async ( self ) => {
239315 mock . finishAuthCalls += 1 ;
316+ mock . finishAuthInstanceId = self . instanceId ;
317+ mock . finishAuthResourceMetadataUrl = self . resourceMetadataUrl ;
318+ mock . finishAuthScope = self . scope ;
240319 mock . exchangedCodeVerifier = self . provider ?. codeVerifier ?.( ) ;
241320 if ( mock . finishAuthError !== undefined ) throw mock . finishAuthError ;
242321 } ,
@@ -248,7 +327,9 @@ await mockMcpCallbackServerModule({
248327 await waitForGate ( signal ) ;
249328 return "code" ;
250329 } ,
251- close : ( ) => undefined ,
330+ close : ( ) => {
331+ if ( mock . callbackCloseThrows ) throw new Error ( "callback close threw" ) ;
332+ } ,
252333} ) ;
253334
254335await mockMcpOAuthProviderModule (
@@ -734,6 +815,71 @@ describe("HTTP MCP re-auth loop prevention", () => {
734815 expect ( mock . finishAuthCalls ) . toBe ( 0 ) ;
735816 } ) ;
736817
818+ const defaultDiscovery =
819+ "https://mcp.linear.app/.well-known/oauth-protected-resource" ;
820+ const challengeMetadata =
821+ "https://resource.linear.app/.well-known/oauth-protected-resource/mcp" ;
822+
823+ test ( "finishAuth uses the redirecting transport's 401 resource metadata" , async ( ) => {
824+ mock . finishAuthError = undefined ;
825+ mock . preFailureResourceMetadataUrl = defaultDiscovery ;
826+ mock . preFailureScope = "legacy" ;
827+ mock . challengeResourceMetadataUrl = challengeMetadata ;
828+ mock . challengeScope = "mcp:read" ;
829+ mock . connectOAuthError = new InvalidScopeError ( "scope not allowed" ) ;
830+ mock . connectFailuresLeft = 1 ;
831+
832+ const result = await connectWithAuthPrompt ( ) ;
833+
834+ expect ( result . ok ) . toBe ( true ) ;
835+ expect ( mock . finishAuthCalls ) . toBe ( 1 ) ;
836+ expect ( mock . redirectingInstanceId ) . toBeDefined ( ) ;
837+ expect ( mock . finishAuthInstanceId ) . toBe ( mock . redirectingInstanceId ) ;
838+ expect ( mock . finishAuthResourceMetadataUrl ) . toBe ( challengeMetadata ) ;
839+ expect ( mock . finishAuthScope ) . toBe ( "mcp:read" ) ;
840+ expect ( mock . finishAuthResourceMetadataUrl ) . not . toBe ( defaultDiscovery ) ;
841+ } ) ;
842+
843+ test ( "open-session code exchange does not retry the wiped mcp-session-id" , async ( ) => {
844+ mock . finishAuthError = undefined ;
845+ mock . stampSessionId = "sess-wiped" ;
846+ mock . preFailureResourceMetadataUrl = defaultDiscovery ;
847+ mock . preFailureScope = "legacy" ;
848+ mock . challengeResourceMetadataUrl = challengeMetadata ;
849+ mock . challengeScope = "mcp:read" ;
850+ const connected = await connectMCPServer ( config , {
851+ onAuthURL : ( ) => {
852+ mock . authURLCount += 1 ;
853+ } ,
854+ } ) ;
855+ expect ( connected . ok ) . toBe ( true ) ;
856+ if ( ! connected . ok ) return ;
857+
858+ mock . callOAuthError = new InvalidScopeError ( "scope not allowed" ) ;
859+ mock . connectFailuresLeft = 1 ;
860+
861+ await expect (
862+ callText ( connected . client , "ping" , { } , new AbortController ( ) . signal ) ,
863+ ) . resolves . toBe ( "" ) ;
864+
865+ expect ( mock . finishAuthInstanceId ) . toBe ( mock . redirectingInstanceId ) ;
866+ expect ( mock . finishAuthResourceMetadataUrl ) . toBe ( challengeMetadata ) ;
867+ expect ( mock . finishAuthScope ) . toBe ( "mcp:read" ) ;
868+ expect ( mock . callSessionIds [ 0 ] ) . toBe ( "sess-wiped" ) ;
869+ expect ( mock . callSessionIds . at ( - 1 ) ) . toBeUndefined ( ) ;
870+ } ) ;
871+
872+ test ( "a sync callback close throw stays a failed connect result" , async ( ) => {
873+ mock . callbackCloseThrows = true ;
874+ mock . connectOAuthError = new Error ( "dial failed" ) ;
875+
876+ const result = await connectMCPServer ( config , { } ) ;
877+
878+ expect ( result . ok ) . toBe ( false ) ;
879+ if ( result . ok ) return ;
880+ expect ( result . error ) . toBe ( "dial failed" ) ;
881+ } ) ;
882+
737883 test ( "reconnect during in-flight waitForCode does not emit a second prompt" , async ( ) => {
738884 mock . connectFailuresLeft = Number . POSITIVE_INFINITY ;
739885 mock . callbackGate = new Promise ( ( ) => undefined ) ;
0 commit comments