import path from "path" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Context, Option, Schema } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" export const Tokens = Schema.Struct({ accessToken: Schema.mutableKey(Schema.String), refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), scope: Schema.mutableKey(Schema.optional(Schema.String)), }) export type Tokens = Schema.Schema.Type export const ClientInfo = Schema.Struct({ clientId: Schema.mutableKey(Schema.String), clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), }) export type ClientInfo = Schema.Schema.Type export const Entry = Schema.Struct({ tokens: Schema.mutableKey(Schema.optional(Tokens)), clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)), codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)), oauthState: Schema.mutableKey(Schema.optional(Schema.String)), serverUrl: Schema.mutableKey(Schema.optional(Schema.String)), }) export type Entry = Schema.Schema.Type const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) type AuthData = Record const filepath = path.join(Global.Path.data, "mcp-auth.json") const lockKey = `mcp-auth:${filepath}` export interface Interface { readonly all: () => Effect.Effect> readonly get: (mcpName: string) => Effect.Effect readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect readonly remove: (mcpName: string) => Effect.Effect readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect readonly clearCodeVerifier: (mcpName: string) => Effect.Effect readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect readonly getOAuthState: (mcpName: string) => Effect.Effect readonly clearOAuthState: (mcpName: string) => Effect.Effect readonly isTokenExpired: (mcpName: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/McpAuth") {} export const use = serviceUse(Service) export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const flock = yield* EffectFlock.Service const read = Effect.fn("McpAuth.read")(function* () { return yield* fs.readJson(filepath).pipe( Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), Effect.catch(() => Effect.succeed({} as AuthData)), ) }) const all = Effect.fn("McpAuth.all")(function* () { return yield* read().pipe(flock.withLock(lockKey), Effect.orDie) }) const mutate = Effect.fn("McpAuth.mutate")(function* (update: (data: AuthData) => AuthData | undefined) { yield* Effect.gen(function* () { const next = update(yield* read()) if (!next) return yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie) }).pipe(flock.withLock(lockKey), Effect.orDie) }) const get = Effect.fn("McpAuth.get")(function* (mcpName: string) { const data = yield* all() return data[mcpName] }) const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) { const entry = yield* get(mcpName) if (!entry) return undefined if (!entry.serverUrl) return undefined if (entry.serverUrl !== serverUrl) return undefined return entry }) const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) { yield* mutate((data) => ({ ...data, [mcpName]: serverUrl ? { ...entry, serverUrl } : entry, })) }) const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) { yield* mutate((data) => { const next = { ...data } delete next[mcpName] return next }) }) const updateField = (field: K, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable, serverUrl?: string) { yield* mutate((data) => { const entry = data[mcpName] ?? {} entry[field] = value if (serverUrl) entry.serverUrl = serverUrl return { ...data, [mcpName]: entry } }) }) const clearField = (field: keyof Entry, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { yield* mutate((data) => { const entry = data[mcpName] if (!entry) return undefined delete entry[field] return { ...data, [mcpName]: entry } }) }) const updateTokens = updateField("tokens", "updateTokens") const updateClientInfo = updateField("clientInfo", "updateClientInfo") const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier") const updateOAuthState = updateField("oauthState", "updateOAuthState") const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier") const clearOAuthState = clearField("oauthState", "clearOAuthState") const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) { const entry = yield* get(mcpName) return entry?.oauthState }) const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) { const entry = yield* get(mcpName) if (!entry?.tokens) return null if (!entry.tokens.expiresAt) return false return entry.tokens.expiresAt < Date.now() / 1000 }) return Service.of({ all, get, getForUrl, set, remove, updateTokens, updateClientInfo, updateCodeVerifier, clearCodeVerifier, updateOAuthState, getOAuthState, clearOAuthState, isTokenExpired, }) }), ) export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer)) export * as McpAuth from "./auth"