Skip to content

Commit 533495a

Browse files
authored
test(mcp): migrate OAuth auto-connect tests (#27356)
1 parent f0635e3 commit 533495a

1 file changed

Lines changed: 122 additions & 168 deletions

File tree

Lines changed: 122 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { test, expect, mock, beforeEach } from "bun:test"
2-
import { Effect } from "effect"
1+
import { expect, mock, beforeEach } from "bun:test"
2+
import { Effect, Layer } from "effect"
3+
import { testEffect } from "../lib/effect"
34

45
// Mock UnauthorizedError to match the SDK's class
56
class MockUnauthorizedError extends Error {
@@ -111,172 +112,125 @@ beforeEach(() => {
111112

112113
// Import modules after mocking
113114
const { MCP } = await import("../../src/mcp/index")
114-
const { Instance } = await import("../../src/project/instance")
115-
const { WithInstance } = await import("../../src/project/with-instance")
116-
const { tmpdir } = await import("../fixture/fixture")
117-
118-
test("first connect to OAuth server shows needs_auth instead of failed", async () => {
119-
await using tmp = await tmpdir({
120-
init: async (dir) => {
121-
await Bun.write(
122-
`${dir}/opencode.json`,
123-
JSON.stringify({
124-
$schema: "https://opencode.ai/config.json",
125-
mcp: {
126-
"test-oauth": {
127-
type: "remote",
128-
url: "https://example.com/mcp",
129-
},
130-
},
131-
}),
132-
)
133-
},
134-
})
135-
136-
await WithInstance.provide({
137-
directory: tmp.path,
138-
fn: async () => {
139-
const result = await Effect.runPromise(
140-
MCP.Service.use((mcp) =>
141-
mcp.add("test-oauth", {
142-
type: "remote",
143-
url: "https://example.com/mcp",
144-
}),
145-
).pipe(Effect.provide(MCP.defaultLayer)),
146-
)
147-
148-
const serverStatus = result.status as Record<string, { status: string; error?: string }>
149-
150-
// The server should be detected as needing auth, NOT as failed.
151-
// Before the fix, provider.state() would throw a plain Error
152-
// ("No OAuth state saved for MCP server: test-oauth") which was
153-
// not caught as UnauthorizedError, causing status to be "failed".
154-
expect(serverStatus["test-oauth"]).toBeDefined()
155-
expect(serverStatus["test-oauth"].status).toBe("needs_auth")
156-
},
157-
})
158-
})
159-
160-
test("state() generates a new state when none is saved", async () => {
161-
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
162-
const { McpAuth } = await import("../../src/mcp/auth")
163-
164-
await using tmp = await tmpdir()
165-
166-
await WithInstance.provide({
167-
directory: tmp.path,
168-
fn: async () => {
169-
const auth = await Effect.runPromise(
170-
Effect.gen(function* () {
171-
return yield* McpAuth.Service
172-
}).pipe(Effect.provide(McpAuth.defaultLayer)),
173-
)
174-
const provider = new McpOAuthProvider(
175-
"test-state-gen",
176-
"https://example.com/mcp",
177-
{},
178-
{ onRedirect: async () => {} },
179-
auth,
180-
)
181-
182-
const entryBefore = await Effect.runPromise(
183-
McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
184-
)
185-
expect(entryBefore?.oauthState).toBeUndefined()
186-
187-
// state() should generate and return a new state, not throw
188-
const state = await provider.state()
189-
expect(typeof state).toBe("string")
190-
expect(state.length).toBe(64) // 32 bytes as hex
191-
192-
// The generated state should be persisted
193-
const entryAfter = await Effect.runPromise(
194-
McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
195-
)
196-
expect(entryAfter?.oauthState).toBe(state)
197-
},
198-
})
199-
})
200-
201-
test("state() returns existing state when one is saved", async () => {
202-
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
203-
const { McpAuth } = await import("../../src/mcp/auth")
204-
205-
await using tmp = await tmpdir()
206-
207-
await WithInstance.provide({
208-
directory: tmp.path,
209-
fn: async () => {
210-
const auth = await Effect.runPromise(
211-
Effect.gen(function* () {
212-
return yield* McpAuth.Service
213-
}).pipe(Effect.provide(McpAuth.defaultLayer)),
214-
)
215-
const provider = new McpOAuthProvider(
216-
"test-state-existing",
217-
"https://example.com/mcp",
218-
{},
219-
{ onRedirect: async () => {} },
220-
auth,
221-
)
222-
223-
// Pre-save a state
224-
const existingState = "pre-saved-state-value"
225-
await Effect.runPromise(
226-
McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState)).pipe(
227-
Effect.provide(McpAuth.defaultLayer),
228-
),
229-
)
230-
231-
// state() should return the existing state
232-
const state = await provider.state()
233-
expect(state).toBe(existingState)
115+
const { Bus } = await import("../../src/bus")
116+
const { Config } = await import("../../src/config/config")
117+
const { McpAuth } = await import("../../src/mcp/auth")
118+
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
119+
const { AppFileSystem } = await import("@opencode-ai/core/filesystem")
120+
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
121+
122+
const mcpTest = testEffect(
123+
Layer.mergeAll(
124+
MCP.layer.pipe(
125+
Layer.provide(McpAuth.defaultLayer),
126+
Layer.provideMerge(Bus.layer),
127+
Layer.provide(Config.defaultLayer),
128+
Layer.provide(CrossSpawnSpawner.defaultLayer),
129+
Layer.provide(AppFileSystem.defaultLayer),
130+
),
131+
McpAuth.defaultLayer,
132+
),
133+
)
134+
135+
const config = (name: string) => ({
136+
mcp: {
137+
[name]: {
138+
type: "remote" as const,
139+
url: "https://example.com/mcp",
234140
},
235-
})
141+
},
236142
})
237143

238-
test("authenticate() stores a connected client when auth completes without redirect", async () => {
239-
await using tmp = await tmpdir({
240-
init: async (dir) => {
241-
await Bun.write(
242-
`${dir}/opencode.json`,
243-
JSON.stringify({
244-
$schema: "https://opencode.ai/config.json",
245-
mcp: {
246-
"test-oauth-connect": {
247-
type: "remote",
248-
url: "https://example.com/mcp",
249-
},
250-
},
251-
}),
252-
)
253-
},
254-
})
255-
256-
await WithInstance.provide({
257-
directory: tmp.path,
258-
fn: async () => {
259-
await Effect.runPromise(
260-
MCP.Service.use((mcp) =>
261-
Effect.gen(function* () {
262-
const added = yield* mcp.add("test-oauth-connect", {
263-
type: "remote",
264-
url: "https://example.com/mcp",
265-
})
266-
const before = added.status as Record<string, { status: string; error?: string }>
267-
expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
268-
269-
simulateAuthFlow = false
270-
connectSucceedsImmediately = true
271-
272-
const result = yield* mcp.authenticate("test-oauth-connect")
273-
expect(result.status).toBe("connected")
274-
275-
const after = yield* mcp.status()
276-
expect(after["test-oauth-connect"]?.status).toBe("connected")
277-
}),
278-
).pipe(Effect.provide(MCP.defaultLayer)),
279-
)
280-
},
281-
})
282-
})
144+
mcpTest.instance(
145+
"first connect to OAuth server shows needs_auth instead of failed",
146+
() =>
147+
MCP.Service.use((mcp) =>
148+
Effect.gen(function* () {
149+
const result = yield* mcp.add("test-oauth", {
150+
type: "remote",
151+
url: "https://example.com/mcp",
152+
})
153+
154+
const serverStatus = result.status as Record<string, { status: string; error?: string }>
155+
156+
// The server should be detected as needing auth, NOT as failed.
157+
// Before the fix, provider.state() would throw a plain Error
158+
// ("No OAuth state saved for MCP server: test-oauth") which was
159+
// not caught as UnauthorizedError, causing status to be "failed".
160+
expect(serverStatus["test-oauth"]).toBeDefined()
161+
expect(serverStatus["test-oauth"].status).toBe("needs_auth")
162+
}),
163+
),
164+
{ config: config("test-oauth") },
165+
)
166+
167+
mcpTest.instance("state() generates a new state when none is saved", () =>
168+
Effect.gen(function* () {
169+
const auth = yield* McpAuth.Service
170+
const provider = new McpOAuthProvider(
171+
"test-state-gen",
172+
"https://example.com/mcp",
173+
{},
174+
{ onRedirect: async () => {} },
175+
auth,
176+
)
177+
178+
const entryBefore = yield* McpAuth.Service.use((auth) => auth.get("test-state-gen"))
179+
expect(entryBefore?.oauthState).toBeUndefined()
180+
181+
// state() should generate and return a new state, not throw
182+
const state = yield* Effect.promise(() => provider.state())
183+
expect(typeof state).toBe("string")
184+
expect(state.length).toBe(64) // 32 bytes as hex
185+
186+
// The generated state should be persisted
187+
const entryAfter = yield* McpAuth.Service.use((auth) => auth.get("test-state-gen"))
188+
expect(entryAfter?.oauthState).toBe(state)
189+
}),
190+
)
191+
192+
mcpTest.instance("state() returns existing state when one is saved", () =>
193+
Effect.gen(function* () {
194+
const auth = yield* McpAuth.Service
195+
const provider = new McpOAuthProvider(
196+
"test-state-existing",
197+
"https://example.com/mcp",
198+
{},
199+
{ onRedirect: async () => {} },
200+
auth,
201+
)
202+
203+
// Pre-save a state
204+
const existingState = "pre-saved-state-value"
205+
yield* McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState))
206+
207+
// state() should return the existing state
208+
const state = yield* Effect.promise(() => provider.state())
209+
expect(state).toBe(existingState)
210+
}),
211+
)
212+
213+
mcpTest.instance(
214+
"authenticate() stores a connected client when auth completes without redirect",
215+
() =>
216+
MCP.Service.use((mcp) =>
217+
Effect.gen(function* () {
218+
const added = yield* mcp.add("test-oauth-connect", {
219+
type: "remote",
220+
url: "https://example.com/mcp",
221+
})
222+
const before = added.status as Record<string, { status: string; error?: string }>
223+
expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
224+
225+
simulateAuthFlow = false
226+
connectSucceedsImmediately = true
227+
228+
const result = yield* mcp.authenticate("test-oauth-connect")
229+
expect(result.status).toBe("connected")
230+
231+
const after = yield* mcp.status()
232+
expect(after["test-oauth-connect"]?.status).toBe("connected")
233+
}),
234+
),
235+
{ config: config("test-oauth-connect") },
236+
)

0 commit comments

Comments
 (0)