Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Kit Langton
6af06c06c5 refactor: switch mcp siblings to self-reexport imports 2026-04-16 11:24:31 -04:00
Kit Langton
43e2617e72 feat: unwrap McpAuth, McpOAuthCallback namespaces to flat exports + barrel 2026-04-16 08:28:13 -04:00
8 changed files with 281 additions and 283 deletions

View file

@ -5,7 +5,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { UI } from "../ui" import { UI } from "../ui"
import { MCP } from "../../mcp" import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth" import { McpAuth } from "@/mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider" import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config" import { Config } from "../../config"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"

View file

@ -4,35 +4,34 @@ import { Global } from "../global"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export namespace McpAuth { export const Tokens = z.object({
export const Tokens = z.object({
accessToken: z.string(), accessToken: z.string(),
refreshToken: z.string().optional(), refreshToken: z.string().optional(),
expiresAt: z.number().optional(), expiresAt: z.number().optional(),
scope: z.string().optional(), scope: z.string().optional(),
}) })
export type Tokens = z.infer<typeof Tokens> export type Tokens = z.infer<typeof Tokens>
export const ClientInfo = z.object({ export const ClientInfo = z.object({
clientId: z.string(), clientId: z.string(),
clientSecret: z.string().optional(), clientSecret: z.string().optional(),
clientIdIssuedAt: z.number().optional(), clientIdIssuedAt: z.number().optional(),
clientSecretExpiresAt: z.number().optional(), clientSecretExpiresAt: z.number().optional(),
}) })
export type ClientInfo = z.infer<typeof ClientInfo> export type ClientInfo = z.infer<typeof ClientInfo>
export const Entry = z.object({ export const Entry = z.object({
tokens: Tokens.optional(), tokens: Tokens.optional(),
clientInfo: ClientInfo.optional(), clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(), codeVerifier: z.string().optional(),
oauthState: z.string().optional(), oauthState: z.string().optional(),
serverUrl: z.string().optional(), serverUrl: z.string().optional(),
}) })
export type Entry = z.infer<typeof Entry> export type Entry = z.infer<typeof Entry>
const filepath = path.join(Global.Path.data, "mcp-auth.json") const filepath = path.join(Global.Path.data, "mcp-auth.json")
export interface Interface { export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>> readonly all: () => Effect.Effect<Record<string, Entry>>
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined> readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined> readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
@ -46,11 +45,11 @@ export namespace McpAuth {
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined> readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void> readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null> readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {} export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
@ -138,7 +137,7 @@ export namespace McpAuth {
isTokenExpired, isTokenExpired,
}) })
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
} export * as McpAuth from "./auth"

View file

@ -19,8 +19,8 @@ import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { McpOAuthProvider } from "./oauth-provider" import { McpOAuthProvider } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback" import * as McpOAuthCallback from "./oauth-callback"
import { McpAuth } from "./auth" import * as McpAuth from "./auth"
import { BusEvent } from "../bus/bus-event" import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/cli/cmd/tui/event"

View file

@ -56,25 +56,24 @@ interface PendingAuth {
timeout: ReturnType<typeof setTimeout> timeout: ReturnType<typeof setTimeout>
} }
export namespace McpOAuthCallback { let server: ReturnType<typeof createServer> | undefined
let server: ReturnType<typeof createServer> | undefined const pendingAuths = new Map<string, PendingAuth>()
const pendingAuths = new Map<string, PendingAuth>() // Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can // find the right entry in pendingAuths (which is keyed by oauthState).
// find the right entry in pendingAuths (which is keyed by oauthState). const mcpNameToState = new Map<string, string>()
const mcpNameToState = new Map<string, string>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function cleanupStateIndex(oauthState: string) { function cleanupStateIndex(oauthState: string) {
for (const [name, state] of mcpNameToState) { for (const [name, state] of mcpNameToState) {
if (state === oauthState) { if (state === oauthState) {
mcpNameToState.delete(name) mcpNameToState.delete(name)
break break
} }
} }
} }
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`) const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
if (url.pathname !== currentPath) { if (url.pathname !== currentPath) {
@ -137,9 +136,9 @@ export namespace McpOAuthCallback {
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS) res.end(HTML_SUCCESS)
} }
export async function ensureRunning(redirectUri?: string): Promise<void> { export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided) // Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri) const { port, path } = parseRedirectUri(redirectUri)
@ -168,9 +167,9 @@ export namespace McpOAuthCallback {
}) })
server!.on("error", reject) server!.on("error", reject)
}) })
} }
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> { export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState) if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -183,9 +182,9 @@ export namespace McpOAuthCallback {
pendingAuths.set(oauthState, { resolve, reject, timeout }) pendingAuths.set(oauthState, { resolve, reject, timeout })
}) })
} }
export function cancelPending(mcpName: string): void { export function cancelPending(mcpName: string): void {
// Look up the oauthState for this mcpName via the reverse index // Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName) const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName const key = oauthState ?? mcpName
@ -196,9 +195,9 @@ export namespace McpOAuthCallback {
mcpNameToState.delete(mcpName) mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled")) pending.reject(new Error("Authorization cancelled"))
} }
} }
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> { export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const socket = createConnection(port, "127.0.0.1") const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => { socket.on("connect", () => {
@ -209,9 +208,9 @@ export namespace McpOAuthCallback {
resolve(false) resolve(false)
}) })
}) })
} }
export async function stop(): Promise<void> { export async function stop(): Promise<void> {
if (server) { if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve())) await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined server = undefined
@ -224,9 +223,9 @@ export namespace McpOAuthCallback {
} }
pendingAuths.clear() pendingAuths.clear()
mcpNameToState.clear() mcpNameToState.clear()
}
export function isRunning(): boolean {
return server !== undefined
}
} }
export function isRunning(): boolean {
return server !== undefined
}
export * as McpOAuthCallback from "./oauth-callback"

View file

@ -6,7 +6,7 @@ import type {
OAuthClientInformationFull, OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js" } from "@modelcontextprotocol/sdk/shared/auth.js"
import { Effect } from "effect" import { Effect } from "effect"
import { McpAuth } from "./auth" import * as McpAuth from "./auth"
import { Log } from "../util" import { Log } from "../util"
const log = Log.create({ service: "mcp.oauth" }) const log = Log.create({ service: "mcp.oauth" })

View file

@ -641,7 +641,7 @@ test(
// ======================================================================== // ========================================================================
test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => { test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => {
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const McpOAuthCallback = await import("../../src/mcp/oauth-callback")
// Register a pending auth with an oauthState key, associated to an mcpName // Register a pending auth with an oauthState key, associated to an mcpName
const oauthState = "abc123hexstate" const oauthState = "abc123hexstate"

View file

@ -158,7 +158,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
test("state() generates a new state when none is saved", async () => { test("state() generates a new state when none is saved", async () => {
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
const { McpAuth } = await import("../../src/mcp/auth") const McpAuth = await import("../../src/mcp/auth")
await using tmp = await tmpdir() await using tmp = await tmpdir()
@ -199,7 +199,7 @@ test("state() generates a new state when none is saved", async () => {
test("state() returns existing state when one is saved", async () => { test("state() returns existing state when one is saved", async () => {
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
const { McpAuth } = await import("../../src/mcp/auth") const McpAuth = await import("../../src/mcp/auth")
await using tmp = await tmpdir() await using tmp = await tmpdir()

View file

@ -104,7 +104,7 @@ beforeEach(() => {
const { MCP } = await import("../../src/mcp/index") const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime") const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Bus } = await import("../../src/bus") const { Bus } = await import("../../src/bus")
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const McpOAuthCallback = await import("../../src/mcp/oauth-callback")
const { Instance } = await import("../../src/project/instance") const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture") const { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never> const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>