Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions packages/opencode/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { makeRuntime } from "@/effect/run-service"

export namespace McpAuth {
export const Tokens = z.object({
Expand Down Expand Up @@ -170,32 +169,4 @@ export namespace McpAuth {
)

export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))

const { runPromise } = makeRuntime(Service, defaultLayer)

// Async facades for backward compat (used by McpOAuthProvider, CLI)

export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))

export const getForUrl = async (mcpName: string, serverUrl: string) =>
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))

export const all = async () => runPromise((svc) => svc.all())

export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
runPromise((svc) => svc.set(mcpName, entry, serverUrl))

export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))

export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))

export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))

export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))

export const updateOAuthState = async (mcpName: string, oauthState: string) =>
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
}
42 changes: 2 additions & 40 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import open from "open"
import { Cause, Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectBridge, type Shape as EffectBridgeShape } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"

Expand Down Expand Up @@ -400,6 +399,7 @@ export namespace MCP {
log.info("oauth redirect requested", { key, url: url.toString() })
},
},
(fn) => Effect.runPromise(fn(auth)),
)
}

Expand Down Expand Up @@ -917,6 +917,7 @@ export namespace MCP {
capturedUrl = url
},
},
(fn) => Effect.runPromise(fn(auth)),
)

const transport = new StreamableHTTPClientTransport(url, { authProvider })
Expand Down Expand Up @@ -1089,43 +1090,4 @@ export namespace MCP {
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
)

const { runPromise } = makeRuntime(Service, defaultLayer)

// --- Async facade functions ---

export const status = async () => runPromise((svc) => svc.status())

export const tools = async () => runPromise((svc) => svc.tools())

export const prompts = async () => runPromise((svc) => svc.prompts())

export const resources = async () => runPromise((svc) => svc.resources())

export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))

export const connect = async (name: string) => runPromise((svc) => svc.connect(name))

export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))

export const startAuth = async (mcpName: string) => {
// The /:name/auth route serializes this with c.json, so the public result
// must stay plain data. authenticate() consumes the connected client via the
// internal startAuth; never expose the live (cyclic) Client here.
const { authorizationUrl, oauthState } = await runPromise((svc) => svc.startAuth(mcpName))
return { authorizationUrl, oauthState }
}

export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))

export const finishAuth = async (mcpName: string, authorizationCode: string) =>
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))

export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))

export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))

export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))

export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
}
71 changes: 42 additions & 29 deletions packages/opencode/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ import type {
} from "@modelcontextprotocol/sdk/shared/auth.js"
import { McpAuth } from "./auth"
import { Log } from "@opencode-ai/core/util/log"
import type { Effect } from "effect"

const log = Log.create({ service: "mcp.oauth" })

const OAUTH_CALLBACK_PORT = 19876
const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"

type AuthRunner = <A>(fn: (auth: McpAuth.Interface) => Effect.Effect<A>) => Promise<A>

const appAuthRunner: AuthRunner = async (fn) => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(McpAuth.Service.use(fn))
}

export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
Expand All @@ -30,6 +38,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
private serverUrl: string,
private config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks,
private runAuth: AuthRunner = appAuthRunner,
) {}

get redirectUrl(): string {
Expand Down Expand Up @@ -62,7 +71,7 @@ export class McpOAuthProvider implements OAuthClientProvider {

// Check stored client info (from dynamic registration)
// Use getForUrl to validate credentials are for the current server URL
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
const entry = await this.runAuth((auth) => auth.getForUrl(this.mcpName, this.serverUrl))
if (entry?.clientInfo) {
// Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
Expand All @@ -80,15 +89,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
}

async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await McpAuth.updateClientInfo(
this.mcpName,
{
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
},
this.serverUrl,
await this.runAuth((auth) =>
auth.updateClientInfo(
this.mcpName,
{
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
},
this.serverUrl,
),
)
log.info("saved dynamically registered client", {
mcpName: this.mcpName,
Expand All @@ -98,7 +109,7 @@ export class McpOAuthProvider implements OAuthClientProvider {

async tokens(): Promise<OAuthTokens | undefined> {
// Use getForUrl to validate tokens are for the current server URL
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
const entry = await this.runAuth((auth) => auth.getForUrl(this.mcpName, this.serverUrl))
if (!entry?.tokens) return undefined

return {
Expand All @@ -113,15 +124,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
}

async saveTokens(tokens: OAuthTokens): Promise<void> {
await McpAuth.updateTokens(
this.mcpName,
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
},
this.serverUrl,
await this.runAuth((auth) =>
auth.updateTokens(
this.mcpName,
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in != null ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
},
this.serverUrl,
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
log.info("saved oauth tokens", { mcpName: this.mcpName })
}
Expand All @@ -132,23 +145,23 @@ export class McpOAuthProvider implements OAuthClientProvider {
}

async saveCodeVerifier(codeVerifier: string): Promise<void> {
await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
await this.runAuth((auth) => auth.updateCodeVerifier(this.mcpName, codeVerifier))
}

async codeVerifier(): Promise<string> {
const entry = await McpAuth.get(this.mcpName)
const entry = await this.runAuth((auth) => auth.get(this.mcpName))
if (!entry?.codeVerifier) {
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
}
return entry.codeVerifier
}

async saveState(state: string): Promise<void> {
await McpAuth.updateOAuthState(this.mcpName, state)
await this.runAuth((auth) => auth.updateOAuthState(this.mcpName, state))
}

async state(): Promise<string> {
const entry = await McpAuth.get(this.mcpName)
const entry = await this.runAuth((auth) => auth.get(this.mcpName))
if (entry?.oauthState) {
return entry.oauthState
}
Expand All @@ -160,28 +173,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
await McpAuth.updateOAuthState(this.mcpName, newState)
await this.runAuth((auth) => auth.updateOAuthState(this.mcpName, newState))
return newState
}

async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
log.info("invalidating credentials", { mcpName: this.mcpName, type })
const entry = await McpAuth.get(this.mcpName)
const entry = await this.runAuth((auth) => auth.get(this.mcpName))
if (!entry) {
return
}

switch (type) {
case "all":
await McpAuth.remove(this.mcpName)
await this.runAuth((auth) => auth.remove(this.mcpName))
break
case "client":
delete entry.clientInfo
await McpAuth.set(this.mcpName, entry)
await this.runAuth((auth) => auth.set(this.mcpName, entry))
break
case "tokens":
delete entry.tokens
await McpAuth.set(this.mcpName, entry)
await this.runAuth((auth) => auth.set(this.mcpName, entry))
break
}
}
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/test/effect/legacy-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,47 @@ test("Config service does not expose Promise facades", async () => {
for (const facade of facades) expect(text).not.toContain(facade)
})

test("MCP services do not expose Promise facades", async () => {
const services = {
"mcp/index.ts": [
"status",
"tools",
"prompts",
"resources",
"add",
"connect",
"disconnect",
"startAuth",
"authenticate",
"finishAuth",
"removeAuth",
"supportsOAuth",
"hasStoredTokens",
"getAuthStatus",
],
"mcp/auth.ts": [
"get",
"getForUrl",
"all",
"set",
"remove",
"updateTokens",
"updateClientInfo",
"updateCodeVerifier",
"updateOAuthState",
],
}

for (const [file, facades] of Object.entries(services)) {
const text = await readFile(path.join(srcRoot, file), "utf8")
expect(text).not.toMatch(/\bfrom\s+["']@\/effect\/run-service["']/)
expect(text).not.toMatch(/\bmakeRuntime\s*\(\s*Service\s*,\s*defaultLayer\s*\)/)
for (const facade of facades) {
expect(text).not.toMatch(new RegExp(`\\bexport\\s+const\\s+${facade}\\b`))
}
}
})

test("Project, Vcs, and Worktree services do not expose Promise facades", async () => {
const services = {
"project/project.ts": [
Expand Down
13 changes: 9 additions & 4 deletions packages/opencode/test/mcp/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ beforeEach(() => {
const { MCP } = await import("../../src/mcp/index")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
const { makeRuntime } = await import("../../src/effect/run-service")
const mcpRuntime = makeRuntime(MCP.Service, MCP.defaultLayer)
const MCPFacade = {
add: (name: string, mcpConfig: any) => mcpRuntime.runPromise((mcp) => mcp.add(name, mcpConfig)),
}

test("headers are passed to transports when oauth is enabled (default)", async () => {
await using tmp = await tmpdir({
Expand Down Expand Up @@ -73,7 +78,7 @@ test("headers are passed to transports when oauth is enabled (default)", async (
directory: tmp.path,
fn: async () => {
// Trigger MCP initialization - it will fail to connect but we can check the transport options
await MCP.add("test-server", {
await MCPFacade.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Expand Down Expand Up @@ -106,7 +111,7 @@ test("headers are passed to transports when oauth is explicitly disabled", async
fn: async () => {
transportCalls.length = 0

await MCP.add("test-server-no-oauth", {
await MCPFacade.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
Expand Down Expand Up @@ -137,7 +142,7 @@ test("no requestInit when headers are not provided", async () => {
fn: async () => {
transportCalls.length = 0

await MCP.add("test-server-no-headers", {
await MCPFacade.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
}).catch(() => {})
Expand All @@ -160,7 +165,7 @@ test("invalid remote url returns failed status without constructing transports",
fn: async () => {
transportCalls.length = 0

const result = await MCP.add("bad-url", {
const result = await MCPFacade.add("bad-url", {
type: "remote",
url: "not a valid url",
})
Expand Down
Loading
Loading