Compare commits

..
Author SHA1 Message Date
Claude 8766c80910 Revert "perf: lazy-load CLI commands via dynamic import for code splitting"
This reverts commit e320484774.
2026-04-16 13:11:30 +00:00
Claude e320484774 perf: lazy-load CLI commands via dynamic import for code splitting
Replace 23 static command imports with lazy-loaded dynamic imports.
Each command's heavy dependencies (Provider, Session, MCP, TUI, AI SDKs)
are now only loaded when that specific command is invoked.

Combined with the Rollup tree-shaking pre-pass, this produces 45 chunks
instead of a single bundle. The entry chunk (what loads on startup) drops
from ~9.8MB to ~120KB — a 98.8% reduction in startup load.

- Add `lazyCmd` helper to `cli/cmd/cmd.ts` for type-safe lazy commands
- Inline all builder options (yargs metadata) in `index.ts`
- Dynamic `import()` in handlers defers heavy module loading
- `opencode --help` / `--version` no longer loads AI SDK, MCP, TUI, etc.

https://claude.ai/code/session_01R7zMpXjsq1R6uR7xpyJ14i
2026-04-16 13:03:44 +00:00
Claude d1a9ef8053 perf: add Rollup tree-shaking pre-pass for export * as barrels
Bun's bundler (and esbuild) cannot tree-shake `export * as X from "./mod"`
barrels — see evanw/esbuild#1420. Rollup can, using AST-level analysis to
drop unused exports and their transitive imports.

This adds a Rollup pre-pass to the build pipeline that runs before Bun's
compile step. Rollup resolves internal source, eliminates dead code across
the 57 `export * as` barrels, and writes tree-shaken ESM to a temp directory.
Bun then compiles the pre-processed output into the final binary.

- Add `script/treeshake-prepass.ts` with Bun.Transpiler-based Rollup plugin
- Integrate into `script/build.ts` (skippable via `--skip-treeshake`)
- Add `rollup` as devDependency
- Pre-pass completes in ~5s, negligible vs full multi-platform build

Current bundle savings are modest (~0.7%) because the single-entrypoint
architecture means most code paths are reachable. Savings scale with:
- Per-command entry points or lazy loading
- Remaining 50 `export namespace` → `export * as` migrations
- Future code splitting

https://claude.ai/code/session_01R7zMpXjsq1R6uR7xpyJ14i
2026-04-16 12:37:49 +00:00
13 changed files with 501 additions and 289 deletions
+1
View File
@@ -450,6 +450,7 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"rollup": "4.60.1",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
+1
View File
@@ -1,4 +1,5 @@
research
.rollup-tmp
dist
dist-*
gen
+1
View File
@@ -69,6 +69,7 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"rollup": "4.60.1",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
+21 -1
View File
@@ -5,6 +5,7 @@ import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { treeshakePrepass } from "./treeshake-prepass"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -50,9 +51,23 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const skipTreeshake = process.argv.includes("--skip-treeshake")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
// Run Rollup tree-shaking pre-pass on the main entrypoint.
// Bun/esbuild can't tree-shake `export * as X` barrels (evanw/esbuild#1420).
// Rollup can — it does AST-level analysis to drop unused exports and their
// transitive imports. Workers are excluded since they're separate bundles.
const rollupTmpDir = path.join(dir, ".rollup-tmp")
let treeshakenEntry: string | undefined
if (!skipTreeshake) {
const entryMap = await treeshakePrepass(["./src/index.ts"], rollupTmpDir)
treeshakenEntry = entryMap.get("index")
} else {
console.log("[treeshake] Skipped (--skip-treeshake)")
}
const createEmbeddedWebUIBundle = async () => {
console.log(`Building Web UI to embed in the binary`)
const appDir = path.join(import.meta.dirname, "../../app")
@@ -213,7 +228,7 @@ for (const item of targets) {
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: [
"./src/index.ts",
treeshakenEntry ?? "./src/index.ts",
parserWorker,
workerPath,
rgPath,
@@ -270,4 +285,9 @@ if (Script.release) {
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
}
// Clean up Rollup temp directory
if (fs.existsSync(rollupTmpDir)) {
fs.rmSync(rollupTmpDir, { recursive: true })
}
export { binaries }
@@ -0,0 +1,187 @@
#!/usr/bin/env bun
/**
* Rollup tree-shaking pre-pass for the opencode build.
*
* Bun's bundler cannot tree-shake `export * as X from "./mod"` barrels
* (nor can esbuild — see evanw/esbuild#1420). Rollup can.
*
* This script runs Rollup on the source entrypoints to eliminate unused
* exports and their transitive imports, then writes the tree-shaken ESM
* to .rollup-tmp/ for Bun to compile into the final binary.
*
* Usage:
* bun script/treeshake-prepass.ts [entrypoints...]
*
* If no entrypoints are given, defaults to ./src/index.ts.
* Output goes to .rollup-tmp/ preserving the entry filename.
*/
import { rollup, type Plugin as RollupPlugin } from "rollup"
import path from "path"
import fs from "fs"
const dir = path.resolve(import.meta.dirname, "..")
const srcDir = path.join(dir, "src")
// Path alias mappings from tsconfig.json
const aliases: Record<string, string> = {
"@/": path.join(srcDir, "/"),
"@tui/": path.join(srcDir, "cli/cmd/tui/"),
}
// Conditional imports from package.json "#imports"
const hashImports: Record<string, string> = {
"#db": path.join(srcDir, "storage/db.bun.ts"),
"#pty": path.join(srcDir, "pty/pty.bun.ts"),
"#hono": path.join(srcDir, "server/adapter.bun.ts"),
}
function resolveWithAliases(source: string, importerDir: string): string | null {
// Handle hash imports
if (hashImports[source]) return hashImports[source]
// Handle path aliases
for (const [alias, target] of Object.entries(aliases)) {
if (source.startsWith(alias)) {
return target + source.slice(alias.length)
}
}
// Handle relative imports
if (source.startsWith(".")) {
return path.resolve(importerDir, source)
}
return null
}
// Binary/asset extensions that Bun imports natively but Rollup can't parse
const assetExtensions = new Set([".wav", ".wasm", ".node", ".png", ".jpg", ".gif", ".svg", ".css"])
function tryResolveFile(base: string): string | null {
// Try exact file, then .ts, then .tsx, then /index.ts, then /index.tsx
for (const suffix of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) {
const p = base + suffix
if (fs.existsSync(p) && fs.statSync(p).isFile()) return p
}
// Bun.Transpiler rewrites .ts → .js in import paths, so try .ts for .js
if (base.endsWith(".js")) {
const tsBase = base.slice(0, -3)
for (const suffix of [".ts", ".tsx"]) {
const p = tsBase + suffix
if (fs.existsSync(p) && fs.statSync(p).isFile()) return p
}
}
return null
}
/**
* Rollup plugin that resolves TypeScript paths and transpiles TS/TSX.
* Uses Bun.Transpiler for speed — no separate TS compilation step.
*/
const bunTranspilePlugin: RollupPlugin = {
name: "bun-transpile",
resolveId(source, importer) {
if (!importer) return null
const importerDir = path.dirname(importer)
const resolved = resolveWithAliases(source, importerDir)
if (!resolved) return null // external (node_modules, node builtins)
const file = tryResolveFile(resolved)
if (file) return file
// If it's a local import we can't resolve (generated file, missing, etc.),
// mark it external so Bun handles it later
return { id: source, external: true }
},
load(id) {
if (id.endsWith(".ts") || id.endsWith(".tsx")) {
return fs.readFileSync(id, "utf-8")
}
// Handle non-JS assets that Bun imports natively
if (id.endsWith(".txt")) {
const content = fs.readFileSync(id, "utf-8")
return `export default ${JSON.stringify(content)};`
}
if (id.endsWith(".json")) {
const content = fs.readFileSync(id, "utf-8")
return `export default ${content};`
}
if (id.endsWith(".sql")) {
const content = fs.readFileSync(id, "utf-8")
return `export default ${JSON.stringify(content)};`
}
// Binary assets — return a placeholder (Bun handles the real import)
const ext = path.extname(id)
if (assetExtensions.has(ext)) {
return `export default "asset:${path.basename(id)}";`
}
return null
},
transform(code, id) {
if (!id.endsWith(".ts") && !id.endsWith(".tsx")) return null
const loader = id.endsWith(".tsx") ? "tsx" : "ts"
const t = new Bun.Transpiler({ loader, tsconfig: JSON.stringify({ compilerOptions: { jsx: "preserve" } }) })
return { code: t.transformSync(code), map: null }
},
}
export async function treeshakePrepass(entrypoints: string[], outDir: string) {
const absEntries = entrypoints.map((e) => path.resolve(dir, e))
const startTime = performance.now()
console.log(`[treeshake] Running Rollup pre-pass on ${absEntries.length} entrypoint(s)...`)
const bundle = await rollup({
input: absEntries,
plugins: [bunTranspilePlugin],
treeshake: {
moduleSideEffects: false, // equivalent to sideEffects: false
},
// Mark everything that isn't local source as external.
// Bun handles node_modules resolution + bundling in the compile step.
external: (id) => {
if (id.startsWith(".") || id.startsWith("/") || id.startsWith("@/") || id.startsWith("@tui/") || id.startsWith("#"))
return false
return true
},
logLevel: "warn",
})
fs.mkdirSync(outDir, { recursive: true })
const { output } = await bundle.write({
dir: outDir,
format: "esm",
preserveModules: false,
entryFileNames: "[name].js",
})
await bundle.close()
const elapsed = (performance.now() - startTime).toFixed(0)
const totalSize = output.reduce((sum, chunk) => sum + ("code" in chunk ? chunk.code.length : 0), 0)
console.log(`[treeshake] Done in ${elapsed}ms — ${output.length} chunks, ${(totalSize / 1024).toFixed(0)}KB total`)
// Return a mapping of original entry basenames to output paths
const entryMap = new Map<string, string>()
for (const chunk of output) {
if (chunk.type === "chunk" && chunk.isEntry) {
entryMap.set(chunk.name, path.join(outDir, chunk.fileName))
}
}
return entryMap
}
// CLI mode: run directly
if (import.meta.main) {
const args = process.argv.slice(2)
const entries = args.length > 0 ? args : ["./src/index.ts"]
const outDir = path.join(dir, ".rollup-tmp")
const result = await treeshakePrepass(entries, outDir)
for (const [name, out] of result) {
console.log(` ${name}${path.relative(dir, out)}`)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "@/mcp/auth"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config"
import { Instance } from "../../project/instance"
+122 -121
View File
@@ -4,140 +4,141 @@ import { Global } from "../global"
import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const Tokens = z.object({
accessToken: z.string(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
scope: z.string().optional(),
})
export type Tokens = z.infer<typeof Tokens>
export namespace McpAuth {
export const Tokens = z.object({
accessToken: z.string(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
scope: z.string().optional(),
})
export type Tokens = z.infer<typeof Tokens>
export const ClientInfo = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
clientIdIssuedAt: z.number().optional(),
clientSecretExpiresAt: z.number().optional(),
})
export type ClientInfo = z.infer<typeof ClientInfo>
export const ClientInfo = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
clientIdIssuedAt: z.number().optional(),
clientSecretExpiresAt: z.number().optional(),
})
export type ClientInfo = z.infer<typeof ClientInfo>
export const Entry = z.object({
tokens: Tokens.optional(),
clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(),
oauthState: z.string().optional(),
serverUrl: z.string().optional(),
})
export type Entry = z.infer<typeof Entry>
export const Entry = z.object({
tokens: Tokens.optional(),
clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(),
oauthState: z.string().optional(),
serverUrl: z.string().optional(),
})
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 {
readonly all: () => Effect.Effect<Record<string, Entry>>
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
readonly remove: (mcpName: string) => Effect.Effect<void>
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
}
export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>>
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
readonly remove: (mcpName: string) => Effect.Effect<void>
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
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(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const all = Effect.fn("McpAuth.all")(function* () {
return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
)
})
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) {
const data = yield* all()
if (serverUrl) entry.serverUrl = serverUrl
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
})
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
const data = yield* all()
delete data[mcpName]
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
})
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
const entry = (yield* get(mcpName)) ?? {}
entry[field] = value
yield* set(mcpName, entry, serverUrl)
const all = Effect.fn("McpAuth.all")(function* () {
return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
)
})
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
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) {
delete entry[field]
yield* set(mcpName, entry)
}
if (!entry) return undefined
if (!entry.serverUrl) return undefined
if (entry.serverUrl !== serverUrl) return undefined
return 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 set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
const data = yield* all()
if (serverUrl) entry.serverUrl = serverUrl
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
})
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
const entry = yield* get(mcpName)
return entry?.oauthState
})
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
const data = yield* all()
delete data[mcpName]
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
})
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
})
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
const entry = (yield* get(mcpName)) ?? {}
entry[field] = value
yield* set(mcpName, entry, serverUrl)
})
return Service.of({
all,
get,
getForUrl,
set,
remove,
updateTokens,
updateClientInfo,
updateCodeVerifier,
clearCodeVerifier,
updateOAuthState,
getOAuthState,
clearOAuthState,
isTokenExpired,
})
}),
)
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
const entry = yield* get(mcpName)
if (entry) {
delete entry[field]
yield* set(mcpName, entry)
}
})
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export * as McpAuth from "./auth"
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(AppFileSystem.defaultLayer))
}
+2 -2
View File
@@ -19,8 +19,8 @@ import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { McpOAuthProvider } from "./oauth-provider"
import * as McpOAuthCallback from "./oauth-callback"
import * as McpAuth from "./auth"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
+160 -159
View File
@@ -56,176 +56,177 @@ interface PendingAuth {
timeout: ReturnType<typeof setTimeout>
}
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
const mcpNameToState = new Map<string, string>()
export namespace McpOAuthCallback {
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
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) {
for (const [name, state] of mcpNameToState) {
if (state === oauthState) {
mcpNameToState.delete(name)
break
}
}
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
if (url.pathname !== currentPath) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
if (server) return
const running = await isPortInUse(port)
if (running) {
log.info("oauth callback server already running on another instance", { port })
return
}
currentPort = port
currentPath = path
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(currentPort, () => {
log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve()
})
server!.on("error", reject)
})
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
function cleanupStateIndex(oauthState: string) {
for (const [name, state] of mcpNameToState) {
if (state === oauthState) {
mcpNameToState.delete(name)
break
}
}, CALLBACK_TIMEOUT_MS)
}
}
pendingAuths.set(oauthState, { resolve, reject, timeout })
})
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
if (url.pathname !== currentPath) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
export function cancelPending(mcpName: string): void {
// Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName
const pending = pendingAuths.get(key)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
}
}
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
if (server) return
const running = await isPortInUse(port)
if (running) {
log.info("oauth callback server already running on another instance", { port })
return
}
currentPort = port
currentPath = path
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(currentPort, () => {
log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve()
})
server!.on("error", reject)
})
socket.on("error", () => {
resolve(false)
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
}
}, CALLBACK_TIMEOUT_MS)
pendingAuths.set(oauthState, { resolve, reject, timeout })
})
})
}
export async function stop(): Promise<void> {
if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}
for (const [_name, pending] of pendingAuths) {
clearTimeout(pending.timeout)
pending.reject(new Error("OAuth callback server stopped"))
export function cancelPending(mcpName: string): void {
// Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName
const pending = pendingAuths.get(key)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
}
}
pendingAuths.clear()
mcpNameToState.clear()
}
export function isRunning(): boolean {
return server !== undefined
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => {
resolve(false)
})
})
}
export async function stop(): Promise<void> {
if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}
for (const [_name, pending] of pendingAuths) {
clearTimeout(pending.timeout)
pending.reject(new Error("OAuth callback server stopped"))
}
pendingAuths.clear()
mcpNameToState.clear()
}
export function isRunning(): boolean {
return server !== undefined
}
}
export * as McpOAuthCallback from "./oauth-callback"
+1 -1
View File
@@ -6,7 +6,7 @@ import type {
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js"
import { Effect } from "effect"
import * as McpAuth from "./auth"
import { McpAuth } from "./auth"
import { Log } from "../util"
const log = Log.create({ service: "mcp.oauth" })
+1 -1
View File
@@ -641,7 +641,7 @@ test(
// ========================================================================
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
const oauthState = "abc123hexstate"
@@ -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 () => {
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()
@@ -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 () => {
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()
@@ -104,7 +104,7 @@ beforeEach(() => {
const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
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 { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>