@@ -23,6 +23,8 @@ let simulateAuthFlow = true
2323let connectSucceedsImmediately = false
2424let serverCapabilities : { tools ?: object ; resources ?: object } = { tools : { } }
2525let listToolsCalls = 0
26+ let finishAuthFails = false
27+ let finishAuthStoresCredentials = false
2628
2729// Mock the transport constructors to simulate OAuth auto-auth on 401
2830void mock . module ( "@modelcontextprotocol/sdk/client/streamableHttp.js" , ( ) => ( {
@@ -32,6 +34,10 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
3234 state ?: ( ) => Promise < string >
3335 redirectToAuthorization ?: ( url : URL ) => Promise < void >
3436 saveCodeVerifier ?: ( v : string ) => Promise < void >
37+ tokens ?: ( ) => Promise < { access_token : string } | undefined >
38+ clientInformation ?: ( ) => Promise < { client_id : string } | undefined >
39+ saveClientInformation ?: ( info : { client_id : string ; client_secret ?: string } ) => Promise < void >
40+ saveTokens ?: ( tokens : { access_token : string ; token_type : string } ) => Promise < void >
3541 }
3642 | undefined
3743 constructor ( url : URL , options ?: { authProvider ?: unknown } ) {
@@ -49,6 +55,8 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
4955 // It calls auth() which eventually calls provider.state(), then
5056 // provider.redirectToAuthorization(), then throws UnauthorizedError.
5157 if ( simulateAuthFlow && this . authProvider ) {
58+ if ( await this . authProvider . tokens ?.( ) ) throw new MockUnauthorizedError ( )
59+ if ( await this . authProvider . clientInformation ?.( ) ) throw new MockUnauthorizedError ( )
5260 // The SDK calls provider.state() to get the OAuth state parameter
5361 if ( this . authProvider . state ) {
5462 await this . authProvider . state ( )
@@ -65,7 +73,14 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
6573 }
6674 throw new MockUnauthorizedError ( )
6775 }
68- async finishAuth ( _code : string ) { }
76+ async finishAuth ( _code : string ) {
77+ if ( finishAuthFails ) throw new Error ( "Token exchange failed" )
78+ if ( finishAuthStoresCredentials ) {
79+ await this . authProvider ?. saveClientInformation ?.( { client_id : "replacement-client" } )
80+ await this . authProvider ?. saveTokens ?.( { access_token : "replacement-token" , token_type : "Bearer" } )
81+ }
82+ }
83+ async close ( ) { }
6984 } ,
7085} ) )
7186
@@ -125,6 +140,8 @@ beforeEach(() => {
125140 connectSucceedsImmediately = false
126141 serverCapabilities = { tools : { } }
127142 listToolsCalls = 0
143+ finishAuthFails = false
144+ finishAuthStoresCredentials = false
128145} )
129146
130147// Import modules after mocking
@@ -133,6 +150,7 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
133150const { Config } = await import ( "../../src/config/config" )
134151const { McpAuth } = await import ( "../../src/mcp/auth" )
135152const { McpOAuthProvider } = await import ( "../../src/mcp/oauth-provider" )
153+ const { McpOAuthCallback } = await import ( "../../src/mcp/oauth-callback" )
136154const { FSUtil } = await import ( "@opencode-ai/core/fs-util" )
137155const { CrossSpawnSpawner } = await import ( "@opencode-ai/core/cross-spawn-spawner" )
138156
@@ -227,6 +245,59 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
227245 } ) ,
228246)
229247
248+ mcpTest . instance (
249+ "failed reauthentication preserves existing credentials" ,
250+ ( ) =>
251+ Effect . gen ( function * ( ) {
252+ yield * Effect . addFinalizer ( ( ) => Effect . promise ( ( ) => McpOAuthCallback . stop ( ) ) . pipe ( Effect . ignore ) )
253+ const mcp = yield * MCP . Service
254+ const auth = yield * McpAuth . Service
255+ const name = "test-reauth-failure"
256+ const url = "https://example.com/mcp"
257+ const clientInfo = { clientId : "dynamic-client" , clientSecret : "dynamic-secret" }
258+
259+ yield * auth . updateClientInfo ( name , clientInfo , url )
260+ yield * auth . updateTokens ( name , { accessToken : "working-token" } , url )
261+ expect ( ( yield * mcp . startAuth ( name ) ) . authorizationUrl ) . toContain ( "https://auth.example.com/authorize" )
262+ finishAuthFails = true
263+
264+ expect ( yield * mcp . finishAuth ( name , "invalid-code" ) ) . toEqual ( {
265+ status : "failed" ,
266+ error : "OAuth completion failed" ,
267+ } )
268+ const entry = yield * auth . get ( name )
269+ expect ( entry ?. tokens ?. accessToken ) . toBe ( "working-token" )
270+ expect ( entry ?. clientInfo ) . toEqual ( clientInfo )
271+ } ) ,
272+ { config : config ( "test-reauth-failure" ) } ,
273+ )
274+
275+ mcpTest . instance (
276+ "successful reauthentication commits replacement credentials" ,
277+ ( ) =>
278+ Effect . gen ( function * ( ) {
279+ yield * Effect . addFinalizer ( ( ) => Effect . promise ( ( ) => McpOAuthCallback . stop ( ) ) . pipe ( Effect . ignore ) )
280+ const mcp = yield * MCP . Service
281+ const auth = yield * McpAuth . Service
282+ const name = "test-reauth-success"
283+ const url = "https://example.com/mcp"
284+
285+ yield * auth . updateClientInfo ( name , { clientId : "old-client" } , url )
286+ yield * auth . updateTokens ( name , { accessToken : "old-token" } , url )
287+ expect ( ( yield * mcp . startAuth ( name ) ) . authorizationUrl ) . toContain ( "https://auth.example.com/authorize" )
288+ expect ( ( yield * auth . get ( name ) ) ?. tokens ?. accessToken ) . toBe ( "old-token" )
289+ finishAuthStoresCredentials = true
290+ connectSucceedsImmediately = true
291+
292+ expect ( ( yield * mcp . finishAuth ( name , "valid-code" ) ) . status ) . toBe ( "connected" )
293+ const entry = yield * auth . get ( name )
294+ expect ( entry ?. tokens ?. accessToken ) . toBe ( "replacement-token" )
295+ expect ( entry ?. clientInfo ?. clientId ) . toBe ( "replacement-client" )
296+ expect ( entry ?. serverUrl ) . toBe ( url )
297+ } ) ,
298+ { config : config ( "test-reauth-success" ) } ,
299+ )
300+
230301mcpTest . instance (
231302 "auth status only reports credentials stored for the configured server URL" ,
232303 ( ) =>
0 commit comments