test(mcp): replace module mocks with real servers (#35450)
This commit is contained in:
@@ -1,199 +1,155 @@
|
||||
import { expect, mock, beforeEach } from "bun:test"
|
||||
import { expect } from "bun:test"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { McpAuth } from "../../src/mcp/auth"
|
||||
import { MCP } from "../../src/mcp/index"
|
||||
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
||||
import { McpOAuthPendingProvider, McpOAuthProvider } from "../../src/mcp/oauth-provider"
|
||||
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
|
||||
let finishAuthFails = false
|
||||
let finishAuthStoresCredentials = false
|
||||
|
||||
// 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>
|
||||
tokens?: () => Promise<{ access_token: string } | undefined>
|
||||
clientInformation?: () => Promise<{ client_id: string } | undefined>
|
||||
saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
|
||||
saveTokens?: (tokens: { access_token: string; token_type: 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) {
|
||||
if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
|
||||
if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
|
||||
// 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) {
|
||||
if (finishAuthFails) throw new Error("Token exchange failed")
|
||||
if (finishAuthStoresCredentials) {
|
||||
await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
|
||||
await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
|
||||
}
|
||||
}
|
||||
async close() {}
|
||||
},
|
||||
}))
|
||||
|
||||
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 {
|
||||
setRequestHandler() {}
|
||||
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
await transport.start()
|
||||
}
|
||||
|
||||
setNotificationHandler() {}
|
||||
|
||||
getServerCapabilities() {
|
||||
return serverCapabilities
|
||||
}
|
||||
|
||||
getInstructions() {}
|
||||
|
||||
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
|
||||
finishAuthFails = false
|
||||
finishAuthStoresCredentials = false
|
||||
})
|
||||
|
||||
// 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 { 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(
|
||||
LayerNode.compile(
|
||||
LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]),
|
||||
),
|
||||
)
|
||||
|
||||
const config = (name: string) => ({
|
||||
mcp: {
|
||||
[name]: {
|
||||
type: "remote" as const,
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
interface OAuthMcpOptions {
|
||||
capabilities?: "tools" | "resources"
|
||||
}
|
||||
|
||||
function serveOAuthMcp(options: OAuthMcpOptions = {}) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const capabilities = options.capabilities ?? "tools"
|
||||
const protocol = new Server(
|
||||
{ name: "oauth-auto-connect", version: "1.0.0" },
|
||||
{ capabilities: capabilities === "tools" ? { tools: {} } : { resources: {} } },
|
||||
)
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
let listToolsCalls = 0
|
||||
let requiresAuth = true
|
||||
|
||||
if (capabilities === "tools") {
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => {
|
||||
listToolsCalls++
|
||||
return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
|
||||
})
|
||||
}
|
||||
if (capabilities === "resources") {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, () =>
|
||||
Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
|
||||
)
|
||||
}
|
||||
|
||||
await protocol.connect(transport)
|
||||
const http = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
const origin = url.origin
|
||||
const mcpUrl = `${origin}/mcp`
|
||||
|
||||
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
||||
return Response.json({
|
||||
resource: mcpUrl,
|
||||
authorization_servers: [origin],
|
||||
scopes_supported: ["mcp"],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/.well-known/oauth-protected-resource") {
|
||||
return Response.json({
|
||||
resource: mcpUrl,
|
||||
authorization_servers: [origin],
|
||||
scopes_supported: ["mcp"],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
||||
return Response.json({
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
registration_endpoint: `${origin}/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
scopes_supported: ["mcp"],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/register") {
|
||||
const metadata = (await request.json()) as Record<string, unknown>
|
||||
return Response.json({ ...metadata, client_id: "replacement-client" }, { status: 201 })
|
||||
}
|
||||
if (url.pathname === "/token") {
|
||||
const body = new URLSearchParams(await request.text())
|
||||
if (body.get("code") !== "valid-code") {
|
||||
return Response.json(
|
||||
{ error: "invalid_grant", error_description: "Token exchange failed" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
return Response.json({ access_token: "replacement-token", token_type: "Bearer" })
|
||||
}
|
||||
if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 })
|
||||
|
||||
if (request.method === "GET") return new Response(null, { status: 405 })
|
||||
if (requiresAuth && request.headers.get("authorization") !== "Bearer replacement-token") {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource", scope="mcp"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
return transport.handleRequest(request)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: new URL("/mcp", http.url).toString(),
|
||||
allowAnonymous: () => {
|
||||
requiresAuth = false
|
||||
},
|
||||
listToolsCalls: () => listToolsCalls,
|
||||
close: async () => {
|
||||
await http.stop(true)
|
||||
await protocol.close()
|
||||
},
|
||||
}
|
||||
}),
|
||||
(server) => Effect.promise(server.close),
|
||||
)
|
||||
}
|
||||
|
||||
const remote = (url: string, enabled = true) => ({
|
||||
type: "remote" as const,
|
||||
url,
|
||||
enabled,
|
||||
})
|
||||
|
||||
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 stopOAuthCallback = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
|
||||
const serverStatus = result.status as Record<string, { status: string; error?: string }>
|
||||
mcpTest.instance("first connect to OAuth server shows needs_auth instead of failed", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* serveOAuthMcp()
|
||||
const mcp = yield* MCP.Service
|
||||
const result = yield* mcp.add("test-oauth", remote(server.url))
|
||||
|
||||
// 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") },
|
||||
expect((result.status as Record<string, { status: string }>)["test-oauth"]).toEqual({ status: "needs_auth" })
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance("state() generates a new state when none is saved", () =>
|
||||
mcpTest.instance("state() generates and persists a new state when none is saved", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* McpAuth.Service
|
||||
const provider = new McpOAuthProvider(
|
||||
@@ -204,17 +160,11 @@ mcpTest.instance("state() generates a new state when none is saved", () =>
|
||||
auth,
|
||||
)
|
||||
|
||||
const entryBefore = yield* McpAuth.use.get("test-state-gen")
|
||||
expect(entryBefore?.oauthState).toBeUndefined()
|
||||
expect((yield* auth.get("test-state-gen"))?.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)
|
||||
expect(state).toHaveLength(64)
|
||||
expect((yield* auth.get("test-state-gen"))?.oauthState).toBe(state)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -229,139 +179,122 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
|
||||
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)
|
||||
yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value")
|
||||
expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value")
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"failed reauthentication preserves existing credentials",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-failure"
|
||||
const url = "https://example.com/mcp"
|
||||
const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
|
||||
mcpTest.instance("pending provider does not expose or overwrite existing credentials before commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-pending-credentials"
|
||||
const url = "https://example.com/mcp"
|
||||
const provider = new McpOAuthPendingProvider(name, url, {}, { onRedirect: async () => {} }, auth)
|
||||
|
||||
yield* auth.updateClientInfo(name, clientInfo, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||
finishAuthFails = true
|
||||
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
|
||||
|
||||
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
|
||||
status: "failed",
|
||||
error: "OAuth completion failed: Token exchange failed",
|
||||
})
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("working-token")
|
||||
expect(entry?.clientInfo).toEqual(clientInfo)
|
||||
}),
|
||||
{ config: config("test-reauth-failure") },
|
||||
expect(yield* Effect.promise(() => provider.clientInformation())).toBeUndefined()
|
||||
expect(yield* Effect.promise(() => provider.tokens())).toBeUndefined()
|
||||
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
||||
expect((yield* auth.get(name))?.clientInfo?.clientId).toBe("old-client")
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"successful reauthentication commits replacement credentials",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-success"
|
||||
const url = "https://example.com/mcp"
|
||||
mcpTest.instance("failed reauthentication preserves existing credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* stopOAuthCallback
|
||||
const server = yield* serveOAuthMcp()
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-failure"
|
||||
|
||||
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
||||
finishAuthStoresCredentials = true
|
||||
connectSucceedsImmediately = true
|
||||
yield* auth.updateClientInfo(name, { clientId: "dynamic-client", clientSecret: "dynamic-secret" }, server.url)
|
||||
yield* auth.updateTokens(name, { accessToken: "working-token" }, server.url)
|
||||
yield* mcp.add(name, remote(server.url))
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize")
|
||||
|
||||
expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("replacement-token")
|
||||
expect(entry?.clientInfo?.clientId).toBe("replacement-client")
|
||||
expect(entry?.serverUrl).toBe(url)
|
||||
}),
|
||||
{ config: config("test-reauth-success") },
|
||||
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
|
||||
status: "failed",
|
||||
error: "OAuth completion failed: Token exchange failed",
|
||||
})
|
||||
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("working-token")
|
||||
expect((yield* auth.get(name))?.clientInfo).toMatchObject({
|
||||
clientId: "dynamic-client",
|
||||
clientSecret: "dynamic-secret",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"auth status only reports credentials stored for the configured server URL",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
expect(transportCalls).toHaveLength(0)
|
||||
yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp")
|
||||
mcpTest.instance("successful reauthentication commits replacement credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* stopOAuthCallback
|
||||
const server = yield* serveOAuthMcp()
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-success"
|
||||
|
||||
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
|
||||
yield* auth.updateClientInfo(name, { clientId: "old-client" }, server.url)
|
||||
yield* auth.updateTokens(name, { accessToken: "old-token" }, server.url)
|
||||
yield* mcp.add(name, remote(server.url))
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize")
|
||||
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
||||
|
||||
yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp")
|
||||
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated")
|
||||
|
||||
yield* McpAuth.use.updateTokens(
|
||||
"test-status-url",
|
||||
{ accessToken: "expired-token", expiresAt: 1 },
|
||||
"https://example.com/mcp",
|
||||
)
|
||||
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired")
|
||||
expect(transportCalls).toHaveLength(0)
|
||||
}),
|
||||
{ config: config("test-status-url") },
|
||||
expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("replacement-token")
|
||||
expect(entry?.clientInfo?.clientId).toBe("replacement-client")
|
||||
expect(entry?.serverUrl).toBe(server.url)
|
||||
}),
|
||||
)
|
||||
|
||||
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")
|
||||
mcpTest.instance("auth status only reports credentials stored for the configured server URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.add("test-status-url", remote("https://example.com/mcp", false))
|
||||
yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp")
|
||||
|
||||
simulateAuthFlow = false
|
||||
connectSucceedsImmediately = true
|
||||
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
|
||||
|
||||
const result = yield* mcp.authenticate("test-oauth-connect")
|
||||
expect(result.status).toBe("connected")
|
||||
yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp")
|
||||
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated")
|
||||
|
||||
const after = yield* mcp.status()
|
||||
expect(after["test-oauth-connect"]?.status).toBe("connected")
|
||||
}),
|
||||
),
|
||||
{ config: config("test-oauth-connect") },
|
||||
yield* McpAuth.use.updateTokens(
|
||||
"test-status-url",
|
||||
{ accessToken: "expired-token", expiresAt: 1 },
|
||||
"https://example.com/mcp",
|
||||
)
|
||||
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired")
|
||||
}),
|
||||
)
|
||||
|
||||
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")
|
||||
mcpTest.instance("authenticate() stores a connected client when auth completes without redirect", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* stopOAuthCallback
|
||||
const server = yield* serveOAuthMcp()
|
||||
const mcp = yield* MCP.Service
|
||||
const name = "test-oauth-connect"
|
||||
const added = yield* mcp.add(name, remote(server.url))
|
||||
expect((added.status as Record<string, { status: string }>)[name]?.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://readme"])
|
||||
}),
|
||||
),
|
||||
{ config: config("test-oauth-resources") },
|
||||
server.allowAnonymous()
|
||||
expect((yield* mcp.authenticate(name)).status).toBe("connected")
|
||||
expect((yield* mcp.status())[name]?.status).toBe("connected")
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance("authenticate() connects a resource-only server without listing tools", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* stopOAuthCallback
|
||||
const server = yield* serveOAuthMcp({ capabilities: "resources" })
|
||||
const mcp = yield* MCP.Service
|
||||
const name = "test-oauth-resources"
|
||||
const added = yield* mcp.add(name, remote(server.url))
|
||||
expect((added.status as Record<string, { status: string }>)[name]?.status).toBe("needs_auth")
|
||||
|
||||
server.allowAnonymous()
|
||||
expect((yield* mcp.authenticate(name)).status).toBe("connected")
|
||||
expect(server.listToolsCalls()).toBe(0)
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual([`${name}:docs://readme`])
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user