feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - System prompt injection for routing - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
78
packages/opencode/test/mcp/auth.test.ts
Normal file
78
packages/opencode/test/mcp/auth.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { McpAuth } from "../../src/mcp/auth"
|
||||
|
||||
function authFile() {
|
||||
let raw = ""
|
||||
let activeWrites = 0
|
||||
let sawOverlap = false
|
||||
|
||||
const layer = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readJson: (file) =>
|
||||
file.endsWith("mcp-auth.json")
|
||||
? Effect.try({
|
||||
try: () => {
|
||||
if (!raw) throw new Error("mcp-auth.json missing")
|
||||
return JSON.parse(raw)
|
||||
},
|
||||
catch: (cause) => new FSUtil.FileSystemError({ method: "readJson", cause }),
|
||||
})
|
||||
: fs.readJson(file),
|
||||
writeJson: (file, value, mode) =>
|
||||
file.endsWith("mcp-auth.json")
|
||||
? Effect.promise(async () => {
|
||||
activeWrites++
|
||||
sawOverlap = sawOverlap || activeWrites > 1
|
||||
raw = ""
|
||||
await sleep(10)
|
||||
const next = JSON.stringify(value, null, 2)
|
||||
raw = sawOverlap ? `${next}\n}` : next
|
||||
activeWrites--
|
||||
})
|
||||
: fs.writeJson(file, value, mode),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
return { layer, raw: () => raw }
|
||||
}
|
||||
|
||||
function authService(layer: Layer.Layer<FSUtil.Service>) {
|
||||
return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe(
|
||||
Effect.provide(McpAuth.layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(layer))),
|
||||
)
|
||||
}
|
||||
|
||||
test("serializes concurrent auth file updates across service instances", async () => {
|
||||
const file = authFile()
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const first = yield* authService(file.layer)
|
||||
const second = yield* authService(file.layer)
|
||||
|
||||
yield* Effect.all(
|
||||
[
|
||||
first.updateTokens("posthog", { accessToken: "access-token" }, "https://mcp.posthog.com/mcp"),
|
||||
second.updateClientInfo("posthog", { clientId: "client-id" }, "https://mcp.posthog.com/mcp"),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const entry = yield* first.get("posthog")
|
||||
expect(entry?.tokens?.accessToken).toBe("access-token")
|
||||
expect(entry?.clientInfo?.clientId).toBe("client-id")
|
||||
expect(entry?.serverUrl).toBe("https://mcp.posthog.com/mcp")
|
||||
expect(() => JSON.parse(file.raw())).not.toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
126
packages/opencode/test/mcp/headers.test.ts
Normal file
126
packages/opencode/test/mcp/headers.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, mock, beforeEach } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// Track what options were passed to each transport constructor
|
||||
const transportCalls: Array<{
|
||||
type: "streamable" | "sse"
|
||||
url: string
|
||||
options: { authProvider?: unknown; requestInit?: RequestInit }
|
||||
}> = []
|
||||
|
||||
// Mock the transport constructors to capture their arguments
|
||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: class MockStreamableHTTP {
|
||||
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
|
||||
transportCalls.push({
|
||||
type: "streamable",
|
||||
url: url.toString(),
|
||||
options: options ?? {},
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
throw new Error("Mock transport cannot connect")
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: class MockSSE {
|
||||
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
|
||||
transportCalls.push({
|
||||
type: "sse",
|
||||
url: url.toString(),
|
||||
options: options ?? {},
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
throw new Error("Mock transport cannot connect")
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
transportCalls.length = 0
|
||||
})
|
||||
|
||||
// Import MCP after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const it = testEffect(MCP.defaultLayer)
|
||||
|
||||
describe("mcp.headers", () => {
|
||||
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp
|
||||
.add("test-server", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
// Both transports should have been created with headers
|
||||
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
for (const call of transportCalls) {
|
||||
expect(call.options.requestInit).toBeDefined()
|
||||
expect(call.options.requestInit?.headers).toEqual({
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
})
|
||||
// OAuth should be enabled by default, so authProvider should exist
|
||||
expect(call.options.authProvider).toBeDefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("headers are passed to transports when oauth is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp
|
||||
.add("test-server-no-oauth", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
oauth: false,
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
for (const call of transportCalls) {
|
||||
expect(call.options.requestInit).toBeDefined()
|
||||
expect(call.options.requestInit?.headers).toEqual({
|
||||
Authorization: "Bearer test-token",
|
||||
})
|
||||
// OAuth is disabled, so no authProvider
|
||||
expect(call.options.authProvider).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("no requestInit when headers are not provided", () =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp
|
||||
.add("test-server-no-headers", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
for (const call of transportCalls) {
|
||||
// No headers means requestInit should be undefined
|
||||
expect(call.options.requestInit).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
1181
packages/opencode/test/mcp/lifecycle.test.ts
Normal file
1181
packages/opencode/test/mcp/lifecycle.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
274
packages/opencode/test/mcp/oauth-auto-connect.test.ts
Normal file
274
packages/opencode/test/mcp/oauth-auto-connect.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { expect, mock, beforeEach } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// Mock UnauthorizedError to match the SDK's class
|
||||
class MockUnauthorizedError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? "Unauthorized")
|
||||
this.name = "UnauthorizedError"
|
||||
}
|
||||
}
|
||||
|
||||
// Track what options were passed to each transport constructor
|
||||
const transportCalls: Array<{
|
||||
type: "streamable" | "sse"
|
||||
url: string
|
||||
options: { authProvider?: unknown }
|
||||
}> = []
|
||||
|
||||
// Controls whether the mock transport simulates a 401 that triggers the SDK
|
||||
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
|
||||
let simulateAuthFlow = true
|
||||
let connectSucceedsImmediately = false
|
||||
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
||||
let listToolsCalls = 0
|
||||
|
||||
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: class MockStreamableHTTP {
|
||||
authProvider:
|
||||
| {
|
||||
state?: () => Promise<string>
|
||||
redirectToAuthorization?: (url: URL) => Promise<void>
|
||||
saveCodeVerifier?: (v: string) => Promise<void>
|
||||
}
|
||||
| undefined
|
||||
constructor(url: URL, options?: { authProvider?: unknown }) {
|
||||
this.authProvider = options?.authProvider as typeof this.authProvider
|
||||
transportCalls.push({
|
||||
type: "streamable",
|
||||
url: url.toString(),
|
||||
options: options ?? {},
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
if (connectSucceedsImmediately) return
|
||||
|
||||
// Simulate what the real SDK transport does on 401:
|
||||
// It calls auth() which eventually calls provider.state(), then
|
||||
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
||||
if (simulateAuthFlow && this.authProvider) {
|
||||
// The SDK calls provider.state() to get the OAuth state parameter
|
||||
if (this.authProvider.state) {
|
||||
await this.authProvider.state()
|
||||
}
|
||||
// The SDK calls saveCodeVerifier before redirecting
|
||||
if (this.authProvider.saveCodeVerifier) {
|
||||
await this.authProvider.saveCodeVerifier("test-verifier")
|
||||
}
|
||||
// The SDK calls redirectToAuthorization to redirect the user
|
||||
if (this.authProvider.redirectToAuthorization) {
|
||||
await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=test"))
|
||||
}
|
||||
throw new MockUnauthorizedError()
|
||||
}
|
||||
throw new MockUnauthorizedError()
|
||||
}
|
||||
async finishAuth(_code: string) {}
|
||||
},
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: class MockSSE {
|
||||
constructor(url: URL, options?: { authProvider?: unknown }) {
|
||||
transportCalls.push({
|
||||
type: "sse",
|
||||
url: url.toString(),
|
||||
options: options ?? {},
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
throw new Error("Mock SSE transport cannot connect")
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the MCP SDK Client
|
||||
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: class MockClient {
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
await transport.start()
|
||||
}
|
||||
|
||||
setNotificationHandler() {}
|
||||
|
||||
getServerCapabilities() {
|
||||
return serverCapabilities
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
listToolsCalls++
|
||||
return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] }
|
||||
}
|
||||
|
||||
async listResources() {
|
||||
return { resources: [{ name: "docs", uri: "docs://readme" }] }
|
||||
}
|
||||
|
||||
async close() {}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock UnauthorizedError in the auth module so instanceof checks work
|
||||
void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
UnauthorizedError: MockUnauthorizedError,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
transportCalls.length = 0
|
||||
simulateAuthFlow = true
|
||||
connectSucceedsImmediately = false
|
||||
serverCapabilities = { tools: {} }
|
||||
listToolsCalls = 0
|
||||
})
|
||||
|
||||
// Import modules after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||
|
||||
const mcpTest = testEffect(
|
||||
Layer.mergeAll(
|
||||
MCP.layer.pipe(
|
||||
Layer.provide(McpAuth.defaultLayer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
),
|
||||
McpAuth.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const config = (name: string) => ({
|
||||
mcp: {
|
||||
[name]: {
|
||||
type: "remote" as const,
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mcpTest.instance(
|
||||
"first connect to OAuth server shows needs_auth instead of failed",
|
||||
() =>
|
||||
MCP.Service.use((mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp.add("test-oauth", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
|
||||
const serverStatus = result.status as Record<string, { status: string; error?: string }>
|
||||
|
||||
// The server should be detected as needing auth, NOT as failed.
|
||||
// Before the fix, provider.state() would throw a plain Error
|
||||
// ("No OAuth state saved for MCP server: test-oauth") which was
|
||||
// not caught as UnauthorizedError, causing status to be "failed".
|
||||
expect(serverStatus["test-oauth"]).toBeDefined()
|
||||
expect(serverStatus["test-oauth"].status).toBe("needs_auth")
|
||||
}),
|
||||
),
|
||||
{ config: config("test-oauth") },
|
||||
)
|
||||
|
||||
mcpTest.instance("state() generates a new state when none is saved", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* McpAuth.Service
|
||||
const provider = new McpOAuthProvider(
|
||||
"test-state-gen",
|
||||
"https://example.com/mcp",
|
||||
{},
|
||||
{ onRedirect: async () => {} },
|
||||
auth,
|
||||
)
|
||||
|
||||
const entryBefore = yield* McpAuth.use.get("test-state-gen")
|
||||
expect(entryBefore?.oauthState).toBeUndefined()
|
||||
|
||||
// state() should generate and return a new state, not throw
|
||||
const state = yield* Effect.promise(() => provider.state())
|
||||
expect(typeof state).toBe("string")
|
||||
expect(state.length).toBe(64) // 32 bytes as hex
|
||||
|
||||
// The generated state should be persisted
|
||||
const entryAfter = yield* McpAuth.use.get("test-state-gen")
|
||||
expect(entryAfter?.oauthState).toBe(state)
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance("state() returns existing state when one is saved", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* McpAuth.Service
|
||||
const provider = new McpOAuthProvider(
|
||||
"test-state-existing",
|
||||
"https://example.com/mcp",
|
||||
{},
|
||||
{ onRedirect: async () => {} },
|
||||
auth,
|
||||
)
|
||||
|
||||
// Pre-save a state
|
||||
const existingState = "pre-saved-state-value"
|
||||
yield* McpAuth.use.updateOAuthState("test-state-existing", existingState)
|
||||
|
||||
// state() should return the existing state
|
||||
const state = yield* Effect.promise(() => provider.state())
|
||||
expect(state).toBe(existingState)
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"authenticate() stores a connected client when auth completes without redirect",
|
||||
() =>
|
||||
MCP.Service.use((mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const added = yield* mcp.add("test-oauth-connect", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
const before = added.status as Record<string, { status: string; error?: string }>
|
||||
expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
|
||||
|
||||
simulateAuthFlow = false
|
||||
connectSucceedsImmediately = true
|
||||
|
||||
const result = yield* mcp.authenticate("test-oauth-connect")
|
||||
expect(result.status).toBe("connected")
|
||||
|
||||
const after = yield* mcp.status()
|
||||
expect(after["test-oauth-connect"]?.status).toBe("connected")
|
||||
}),
|
||||
),
|
||||
{ config: config("test-oauth-connect") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"authenticate() connects a resource-only server without listing tools",
|
||||
() =>
|
||||
MCP.Service.use((mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const added = yield* mcp.add("test-oauth-resources", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
const before = added.status as Record<string, { status: string }>
|
||||
expect(before["test-oauth-resources"]?.status).toBe("needs_auth")
|
||||
|
||||
simulateAuthFlow = false
|
||||
connectSucceedsImmediately = true
|
||||
serverCapabilities = { resources: {} }
|
||||
|
||||
const result = yield* mcp.authenticate("test-oauth-resources")
|
||||
expect(result.status).toBe("connected")
|
||||
expect(listToolsCalls).toBe(0)
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
|
||||
}),
|
||||
),
|
||||
{ config: config("test-oauth-resources") },
|
||||
)
|
||||
237
packages/opencode/test/mcp/oauth-browser.test.ts
Normal file
237
packages/opencode/test/mcp/oauth-browser.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { expect, mock, beforeEach } from "bun:test"
|
||||
import { EventEmitter } from "events"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// Track open() calls and control failure behavior
|
||||
let openShouldFail = false
|
||||
let openCalledWith: string | undefined
|
||||
let openDeferred: Deferred.Deferred<string> | undefined
|
||||
|
||||
void mock.module("open", () => ({
|
||||
default: async (url: string) => {
|
||||
openCalledWith = url
|
||||
if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore))
|
||||
|
||||
// Return a mock subprocess that emits an error if openShouldFail is true
|
||||
const subprocess = new EventEmitter()
|
||||
if (openShouldFail) {
|
||||
// Emit error asynchronously like a real subprocess would
|
||||
setTimeout(() => {
|
||||
subprocess.emit("error", new Error("spawn xdg-open ENOENT"))
|
||||
}, 10)
|
||||
}
|
||||
return subprocess
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock UnauthorizedError
|
||||
class MockUnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized")
|
||||
this.name = "UnauthorizedError"
|
||||
}
|
||||
}
|
||||
|
||||
// Track what options were passed to each transport constructor
|
||||
const transportCalls: Array<{
|
||||
type: "streamable" | "sse"
|
||||
url: string
|
||||
options: { authProvider?: unknown; requestInit?: RequestInit }
|
||||
}> = []
|
||||
|
||||
// Mock the transport constructors
|
||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: class MockStreamableHTTP {
|
||||
url: string
|
||||
authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
|
||||
constructor(
|
||||
url: URL,
|
||||
options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise<void> }; requestInit?: RequestInit },
|
||||
) {
|
||||
this.url = url.toString()
|
||||
this.authProvider = options?.authProvider
|
||||
transportCalls.push({
|
||||
type: "streamable",
|
||||
url: url.toString(),
|
||||
options: options ?? {},
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
// Simulate OAuth redirect by calling the authProvider's redirectToAuthorization
|
||||
if (this.authProvider?.redirectToAuthorization) {
|
||||
await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?client_id=test"))
|
||||
}
|
||||
throw new MockUnauthorizedError()
|
||||
}
|
||||
async finishAuth(_code: string) {
|
||||
// Mock successful auth completion
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: class MockSSE {
|
||||
constructor(url: URL) {
|
||||
transportCalls.push({
|
||||
type: "sse",
|
||||
url: url.toString(),
|
||||
options: {},
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
throw new Error("Mock SSE transport cannot connect")
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the MCP SDK Client to trigger OAuth flow
|
||||
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: class MockClient {
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
await transport.start()
|
||||
}
|
||||
|
||||
getServerCapabilities() {
|
||||
return { tools: {} }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock UnauthorizedError in the auth module
|
||||
void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
UnauthorizedError: MockUnauthorizedError,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
openShouldFail = false
|
||||
openCalledWith = undefined
|
||||
openDeferred = undefined
|
||||
transportCalls.length = 0
|
||||
})
|
||||
|
||||
// Import modules after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||
const mcpTest = testEffect(
|
||||
MCP.layer.pipe(
|
||||
Layer.provide(McpAuth.defaultLayer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
),
|
||||
)
|
||||
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
|
||||
|
||||
const config = (name: string, headers?: Record<string, string>) => ({
|
||||
mcp: {
|
||||
[name]: {
|
||||
type: "remote" as const,
|
||||
url: "https://example.com/mcp",
|
||||
headers,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
|
||||
const trackBrowserOpen = Effect.gen(function* () {
|
||||
const opened = yield* Deferred.make<string>()
|
||||
openDeferred = opened
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined)))
|
||||
return opened
|
||||
})
|
||||
|
||||
const trackBrowserOpenFailed = Effect.gen(function* () {
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const event = yield* Deferred.make<{ mcpName: string; url: string }>()
|
||||
const unsubscribe = yield* events.listen((evt) => {
|
||||
if (evt.type === MCP.BrowserOpenFailed.type)
|
||||
Deferred.doneUnsafe(event, Effect.succeed(evt.data as { mcpName: string; url: string }))
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
return event
|
||||
})
|
||||
|
||||
const authenticateScoped = (name: string) =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
yield* mcp.authenticate(name).pipe(
|
||||
Effect.ignore,
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
})
|
||||
|
||||
mcpTest.instance(
|
||||
"BrowserOpenFailed event is published when open() throws",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withCallbackStop
|
||||
openShouldFail = true
|
||||
|
||||
const event = yield* trackBrowserOpenFailed
|
||||
yield* authenticateScoped("test-oauth-server")
|
||||
|
||||
const failure = yield* awaitWithTimeout(
|
||||
Deferred.await(event),
|
||||
"Timed out waiting for BrowserOpenFailed event",
|
||||
"5 seconds",
|
||||
)
|
||||
|
||||
expect(failure.mcpName).toBe("test-oauth-server")
|
||||
expect(failure.url).toContain("https://")
|
||||
}),
|
||||
{ config: config("test-oauth-server") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"BrowserOpenFailed event is NOT published when open() succeeds",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withCallbackStop
|
||||
openShouldFail = false
|
||||
|
||||
const opened = yield* trackBrowserOpen
|
||||
const event = yield* trackBrowserOpenFailed
|
||||
yield* authenticateScoped("test-oauth-server-2")
|
||||
|
||||
yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
|
||||
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
|
||||
|
||||
expect(failure).toEqual(Option.none())
|
||||
expect(openCalledWith).toBeDefined()
|
||||
}),
|
||||
{ config: config("test-oauth-server-2") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"open() is called with the authorization URL",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withCallbackStop
|
||||
openShouldFail = false
|
||||
openCalledWith = undefined
|
||||
|
||||
const opened = yield* trackBrowserOpen
|
||||
const event = yield* trackBrowserOpenFailed
|
||||
yield* authenticateScoped("test-oauth-server-3")
|
||||
|
||||
const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
|
||||
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
|
||||
|
||||
expect(failure).toEqual(Option.none())
|
||||
expect(typeof url).toBe("string")
|
||||
expect(url).toContain("https://")
|
||||
expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" })
|
||||
}),
|
||||
{ config: config("test-oauth-server-3", { "X-Custom-Header": "custom-value" }) },
|
||||
)
|
||||
34
packages/opencode/test/mcp/oauth-callback.test.ts
Normal file
34
packages/opencode/test/mcp/oauth-callback.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect, describe, afterEach } from "bun:test"
|
||||
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
||||
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
|
||||
|
||||
describe("parseRedirectUri", () => {
|
||||
test("returns defaults when no URI provided", () => {
|
||||
const result = parseRedirectUri()
|
||||
expect(result.port).toBe(19876)
|
||||
expect(result.path).toBe("/mcp/oauth/callback")
|
||||
})
|
||||
|
||||
test("parses port and path from URI", () => {
|
||||
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
|
||||
expect(result.port).toBe(8080)
|
||||
expect(result.path).toBe("/oauth/callback")
|
||||
})
|
||||
|
||||
test("returns defaults for invalid URI", () => {
|
||||
const result = parseRedirectUri("not-a-valid-url")
|
||||
expect(result.port).toBe(19876)
|
||||
expect(result.path).toBe("/mcp/oauth/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpOAuthCallback.ensureRunning", () => {
|
||||
afterEach(async () => {
|
||||
await McpOAuthCallback.stop()
|
||||
})
|
||||
|
||||
test("starts server with custom redirectUri port and path", async () => {
|
||||
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
|
||||
expect(McpOAuthCallback.isRunning()).toBe(true)
|
||||
})
|
||||
})
|
||||
61
packages/opencode/test/mcp/oauth-provider.test.ts
Normal file
61
packages/opencode/test/mcp/oauth-provider.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
|
||||
import type { McpAuth } from "../../src/mcp/auth"
|
||||
|
||||
// Stub auth — only synchronous getters are exercised in these tests
|
||||
const stubAuth = {} as McpAuth.Interface
|
||||
|
||||
const makeProvider = (config: ConstructorParameters<typeof McpOAuthProvider>[2]) =>
|
||||
new McpOAuthProvider("test-server", "https://mcp.example.com/mcp", config, { onRedirect: async () => {} }, stubAuth)
|
||||
|
||||
describe("McpOAuthProvider.redirectUrl", () => {
|
||||
test("defaults to 127.0.0.1:19876/mcp/oauth/callback", () => {
|
||||
const provider = makeProvider({})
|
||||
expect(provider.redirectUrl).toBe(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`)
|
||||
})
|
||||
|
||||
test("uses callbackPort when set", () => {
|
||||
const provider = makeProvider({ callbackPort: 6620 })
|
||||
expect(provider.redirectUrl).toBe(`http://127.0.0.1:6620${OAUTH_CALLBACK_PATH}`)
|
||||
})
|
||||
|
||||
test("redirectUri takes precedence over callbackPort", () => {
|
||||
const provider = makeProvider({
|
||||
callbackPort: 6620,
|
||||
redirectUri: "http://127.0.0.1:9999/custom/callback",
|
||||
})
|
||||
expect(provider.redirectUrl).toBe("http://127.0.0.1:9999/custom/callback")
|
||||
})
|
||||
|
||||
test("uses explicit redirectUri when set without callbackPort", () => {
|
||||
const provider = makeProvider({ redirectUri: "http://127.0.0.1:8080/oauth/callback" })
|
||||
expect(provider.redirectUrl).toBe("http://127.0.0.1:8080/oauth/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpOAuthProvider.clientMetadata", () => {
|
||||
test("includes redirect_uris from redirectUrl", () => {
|
||||
const provider = makeProvider({ callbackPort: 6620 })
|
||||
expect(provider.clientMetadata.redirect_uris).toEqual([`http://127.0.0.1:6620${OAUTH_CALLBACK_PATH}`])
|
||||
})
|
||||
|
||||
test("includes scope when set in config", () => {
|
||||
const provider = makeProvider({ scope: "openid offline_access" })
|
||||
expect(provider.clientMetadata.scope).toBe("openid offline_access")
|
||||
})
|
||||
|
||||
test("omits scope when not set in config", () => {
|
||||
const provider = makeProvider({})
|
||||
expect(provider.clientMetadata.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sets token_endpoint_auth_method to client_secret_post when clientSecret provided", () => {
|
||||
const provider = makeProvider({ clientSecret: "secret" })
|
||||
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("client_secret_post")
|
||||
})
|
||||
|
||||
test("sets token_endpoint_auth_method to none when no clientSecret", () => {
|
||||
const provider = makeProvider({})
|
||||
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user