Skip to content

Commit e3bfd4c

Browse files
authored
fix(mcp): refresh credentials on reauthentication (anomalyco#33717)
1 parent c288226 commit e3bfd4c

3 files changed

Lines changed: 147 additions & 21 deletions

File tree

packages/opencode/src/mcp/index.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
2222
import { InstallationVersion } from "@opencode-ai/core/installation/version"
2323
import { withTimeout } from "@/util/timeout"
2424
import { FSUtil } from "@opencode-ai/core/fs-util"
25-
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
25+
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
2626
import { McpOAuthCallback } from "./oauth-callback"
2727
import { McpAuth } from "./auth"
2828
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -109,7 +109,7 @@ export type Status = Schema.Schema.Type<typeof Status>
109109

110110
// Store transports for OAuth servers to allow finishing auth
111111
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
112-
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
112+
const pendingOAuthTransports = new Map<string, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
113113

114114
// Prompt cache types
115115
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
@@ -301,7 +301,7 @@ export const layer = Layer.effect(
301301
})
302302
.pipe(Effect.ignore, Effect.as(undefined))
303303
} else {
304-
pendingOAuthTransports.set(key, transport)
304+
pendingOAuthTransports.set(key, { transport })
305305
lastStatus = { status: "needs_auth" as const }
306306
return events
307307
.publish(TuiEvent.ToastShow, {
@@ -819,7 +819,7 @@ export const layer = Layer.effect(
819819
.join("")
820820
yield* auth.updateOAuthState(mcpName, oauthState)
821821
let capturedUrl: URL | undefined
822-
const authProvider = new McpOAuthProvider(
822+
const authProvider = new McpOAuthPendingProvider(
823823
mcpName,
824824
mcpConfig.url,
825825
{
@@ -845,15 +845,16 @@ export const layer = Layer.effect(
845845
return yield* Effect.tryPromise({
846846
try: () => {
847847
const client = createClient(directory)
848-
return client
849-
.connect(transport)
850-
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
848+
return client.connect(transport).then(async () => {
849+
await authProvider.commit()
850+
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
851+
})
851852
},
852853
catch: (error) => error,
853854
}).pipe(
854855
Effect.catch((error) => {
855856
if (error instanceof UnauthorizedError && capturedUrl) {
856-
pendingOAuthTransports.set(mcpName, transport)
857+
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
857858
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
858859
}
859860
return Effect.die(error)
@@ -924,11 +925,11 @@ export const layer = Layer.effect(
924925

925926
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
926927
yield* requireMcpConfig(mcpName)
927-
const transport = pendingOAuthTransports.get(mcpName)
928-
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
928+
const pending = pendingOAuthTransports.get(mcpName)
929+
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
929930

930931
const result = yield* Effect.tryPromise({
931-
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
932+
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
932933
catch: (error) => {
933934
return error
934935
},
@@ -938,6 +939,7 @@ export const layer = Layer.effect(
938939
return { status: "failed", error: "OAuth completion failed" } satisfies Status
939940
}
940941

942+
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
941943
yield* auth.clearCodeVerifier(mcpName)
942944
pendingOAuthTransports.delete(mcpName)
943945

packages/opencode/src/mcp/oauth-provider.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
2525

2626
export class McpOAuthProvider implements OAuthClientProvider {
2727
constructor(
28-
private mcpName: string,
29-
private serverUrl: string,
30-
private config: McpOAuthConfig,
28+
protected mcpName: string,
29+
protected serverUrl: string,
30+
protected config: McpOAuthConfig,
3131
private callbacks: McpOAuthCallbacks,
32-
private auth: McpAuth.Interface,
32+
protected auth: McpAuth.Interface,
3333
) {}
3434

3535
get redirectUrl(): string {
@@ -53,7 +53,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
5353
}
5454

5555
async clientInformation(): Promise<OAuthClientInformation | undefined> {
56-
// Check config first (pre-registered client)
5756
if (this.config.clientId) {
5857
return {
5958
client_id: this.config.clientId,
@@ -164,10 +163,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
164163

165164
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
166165
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
167-
if (!entry) {
168-
return
169-
}
170-
166+
if (!entry) return
171167
switch (type) {
172168
case "all":
173169
await Effect.runPromise(this.auth.remove(this.mcpName))
@@ -184,6 +180,63 @@ export class McpOAuthProvider implements OAuthClientProvider {
184180
}
185181
}
186182

183+
export class McpOAuthPendingProvider extends McpOAuthProvider {
184+
private pendingClientInfo?: OAuthClientInformationFull
185+
private pendingTokens?: OAuthTokens
186+
187+
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
188+
if (!this.config.clientId) return this.pendingClientInfo
189+
return {
190+
client_id: this.config.clientId,
191+
client_secret: this.config.clientSecret,
192+
}
193+
}
194+
195+
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
196+
this.pendingClientInfo = info
197+
}
198+
199+
override async tokens(): Promise<OAuthTokens | undefined> {
200+
return this.pendingTokens
201+
}
202+
203+
override async saveTokens(tokens: OAuthTokens): Promise<void> {
204+
this.pendingTokens = tokens
205+
}
206+
207+
override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
208+
if (type === "all" || type === "client") this.pendingClientInfo = undefined
209+
if (type === "all" || type === "tokens") this.pendingTokens = undefined
210+
}
211+
212+
async commit(): Promise<void> {
213+
if (!this.pendingTokens) return
214+
await Effect.runPromise(
215+
this.auth.set(
216+
this.mcpName,
217+
{
218+
tokens: {
219+
accessToken: this.pendingTokens.access_token,
220+
refreshToken: this.pendingTokens.refresh_token,
221+
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
222+
scope: this.pendingTokens.scope,
223+
},
224+
clientInfo:
225+
this.pendingClientInfo && !this.config.clientId
226+
? {
227+
clientId: this.pendingClientInfo.client_id,
228+
clientSecret: this.pendingClientInfo.client_secret,
229+
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
230+
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
231+
}
232+
: undefined,
233+
},
234+
this.serverUrl,
235+
),
236+
)
237+
}
238+
}
239+
187240
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
188241

189242
/**

packages/opencode/test/mcp/oauth-auto-connect.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ let simulateAuthFlow = true
2323
let connectSucceedsImmediately = false
2424
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
2525
let listToolsCalls = 0
26+
let finishAuthFails = false
27+
let finishAuthStoresCredentials = false
2628

2729
// Mock the transport constructors to simulate OAuth auto-auth on 401
2830
void 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")
133150
const { Config } = await import("../../src/config/config")
134151
const { McpAuth } = await import("../../src/mcp/auth")
135152
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
153+
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
136154
const { FSUtil } = await import("@opencode-ai/core/fs-util")
137155
const { 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+
230301
mcpTest.instance(
231302
"auth status only reports credentials stored for the configured server URL",
232303
() =>

0 commit comments

Comments
 (0)