Skip to content

Commit d66adcc

Browse files
fix(mcp): authenticate() takes a token, so a remove during its listing wins
The already-authorized path listed tools and committed the client with no check, so a server removed meanwhile came back connected. It now claims a token first, like `finishAuth`, and closes its client if a newer call owns the server. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172qrhMa5TQgETASi5hxMqD
1 parent eeb4c3d commit d66adcc

2 files changed

Lines changed: 68 additions & 2 deletions

File tree

‎packages/opencode/src/mcp/index.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,12 @@ export const layer = Layer.effect(
14101410
})
14111411

14121412
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
1413+
// altimate_change start — see connect: a disconnect or remove issued while the
1414+
// already-authorized path lists tools is the later call, and wins. The browser path
1415+
// hands over to finishAuth, which takes its own token.
1416+
const seq = ++lifecycleSeq
1417+
const token = claim(yield* InstanceState.get(state), mcpName, seq)
1418+
// altimate_change end
14131419
const result = yield* startAuth(mcpName)
14141420
if (!result.authorizationUrl) {
14151421
const client = "client" in result ? result.client : undefined
@@ -1431,7 +1437,15 @@ export const layer = Layer.effect(
14311437
}
14321438

14331439
const s = yield* InstanceState.get(state)
1440+
// A newer call owns the server, and may own the OAuth state too: close this client,
1441+
// leave the rest alone. Checked again after the clear, which suspends.
1442+
const superseded = Effect.fnUntraced(function* () {
1443+
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
1444+
return s.status[mcpName] ?? ({ status: "disabled" } satisfies Status)
1445+
})
1446+
if (!isCurrent(s, mcpName, token)) return yield* superseded()
14341447
yield* auth.clearOAuthState(mcpName)
1448+
if (!isCurrent(s, mcpName, token)) return yield* superseded()
14351449
return yield* storeClient(s, mcpName, client, listing.tools, listing.meta, mcpConfig.timeout)
14361450
// altimate_change end
14371451
}

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, mock, beforeEach } from "bun:test"
2-
import { Effect, Layer } from "effect"
2+
import { Effect, Fiber, Layer } from "effect"
33
import { testEffect } from "../lib/effect"
44

55
// Mock UnauthorizedError to match the SDK's class
@@ -23,6 +23,10 @@ let simulateAuthFlow = true
2323
let connectSucceedsImmediately = false
2424
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
2525
let listToolsCalls = 0
26+
// altimate_change start — hold the tool listing open so a lifecycle call can land mid-authenticate
27+
let listToolsGate: { taken: () => void; release: Promise<void> } | undefined
28+
let closedClients = 0
29+
// altimate_change end
2630

2731
// Mock the transport constructors to simulate OAuth auto-auth on 401
2832
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -101,14 +105,26 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
101105

102106
async listTools() {
103107
listToolsCalls++
108+
// altimate_change start — see listToolsGate
109+
const gate = listToolsGate
110+
listToolsGate = undefined
111+
if (gate) {
112+
gate.taken()
113+
await gate.release
114+
}
115+
// altimate_change end
104116
return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] }
105117
}
106118

107119
async listResources() {
108120
return { resources: [{ name: "docs", uri: "docs://readme" }] }
109121
}
110122

111-
async close() {}
123+
// altimate_change start — see listToolsGate
124+
async close() {
125+
closedClients++
126+
}
127+
// altimate_change end
112128
},
113129
}))
114130

@@ -123,6 +139,10 @@ beforeEach(() => {
123139
connectSucceedsImmediately = false
124140
serverCapabilities = { tools: {} }
125141
listToolsCalls = 0
142+
// altimate_change start — see listToolsGate
143+
listToolsGate = undefined
144+
closedClients = 0
145+
// altimate_change end
126146
})
127147

128148
// Import modules after mocking
@@ -274,3 +294,35 @@ mcpTest.instance(
274294
),
275295
{ config: config("test-oauth-resources") },
276296
)
297+
298+
// altimate_change start — the already-authorized path of authenticate() commits a client after
299+
// its own async listing; a remove that lands during the listing is the later call, and wins. (review)
300+
mcpTest.instance(
301+
"a server removed while authenticate() lists its tools stays removed, and the late client is closed",
302+
() =>
303+
MCP.Service.use((mcp) =>
304+
Effect.gen(function* () {
305+
yield* mcp.add("test-oauth-removed", { type: "remote", url: "https://example.com/mcp" })
306+
307+
simulateAuthFlow = false
308+
connectSucceedsImmediately = true
309+
let taken!: () => void
310+
const wasTaken = new Promise<void>((resolve) => (taken = resolve))
311+
let release!: () => void
312+
listToolsGate = { taken, release: new Promise<void>((resolve) => (release = resolve)) }
313+
const closedBefore = closedClients
314+
315+
const authenticating = yield* Effect.forkChild(mcp.authenticate("test-oauth-removed"))
316+
yield* Effect.promise(() => wasTaken)
317+
yield* mcp.remove("test-oauth-removed")
318+
release()
319+
yield* Fiber.join(authenticating)
320+
321+
expect((yield* mcp.status())["test-oauth-removed"]?.status).not.toBe("connected")
322+
expect((yield* mcp.clients())["test-oauth-removed"]).toBeUndefined()
323+
expect(closedClients).toBe(closedBefore + 1)
324+
}),
325+
),
326+
{ config: config("test-oauth-removed") },
327+
)
328+
// altimate_change end

0 commit comments

Comments
 (0)