chore: merge dev into v2 (#35591)

Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
Aiden Cline 2026-07-06 16:05:29 -05:00 committed by GitHub
commit 9e0d3976e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
332 changed files with 24739 additions and 4586 deletions

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.13",
"version": "1.17.14",
"name": "opencode",
"type": "module",
"license": "MIT",
@ -59,7 +59,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
@ -86,6 +86,7 @@
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",

View file

@ -132,6 +132,8 @@ export const TuiThreadCommand = cmd({
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
const headers = external ? ServerAuth.headers() : undefined
const url = (await client.call("server", network)).url
try {
@ -139,6 +141,7 @@ export const TuiThreadCommand = cmd({
url,
sessionID: args.session,
directory: cwd,
headers,
})
} catch (error) {
UI.error(errorMessage(error))
@ -154,10 +157,10 @@ export const TuiThreadCommand = cmd({
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
await Effect.runPromise(
run({
client: createOpencodeClient({ baseUrl: url, directory: cwd }),
api: OpenCode.make({ baseUrl: url }),
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
api: OpenCode.make({ baseUrl: url, headers }),
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)

View file

@ -45,6 +45,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),

View file

@ -0,0 +1,37 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import open from "open"
export interface Interface {
readonly open: (url: string) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpBrowser") {}
const layer = Layer.succeed(
Service,
Service.of({
open: Effect.fn("McpBrowser.open")(function* (url: string) {
const subprocess = yield* Effect.tryPromise({
try: () => open(url),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
yield* Effect.callback<void, Error>((resume) => {
const timer = setTimeout(() => resume(Effect.void), 500)
subprocess.on("error", (error) => {
clearTimeout(timer)
resume(Effect.fail(error))
})
subprocess.on("exit", (code) => {
if (code === null || code === 0) return
clearTimeout(timer)
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
})
})
}),
}),
)
export const node = LayerNode.make({ service: Service, layer, deps: [] })
export * as McpBrowser from "./browser"

View file

@ -1,7 +1,6 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { type Tool } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
@ -27,7 +26,6 @@ import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import open from "open"
import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@ -35,6 +33,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { McpCatalog } from "./catalog"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { McpBrowser } from "./browser"
const DEFAULT_TIMEOUT = 30_000
const CLIENT_OPTIONS = {
@ -154,11 +153,19 @@ export interface ServerInstructions {
tools: string[]
}
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
export interface McpTool {
/** Shared cached definition; consumers must copy rather than mutate it. */
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
}
export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>>
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly tools: () => Effect.Effect<Record<string, McpTool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@ -200,6 +207,7 @@ const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const auth = yield* McpAuth.Service
const events = yield* EventV2Bridge.Service
const browser = yield* McpBrowser.Service
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
@ -656,7 +664,7 @@ const layer = Layer.effect(
}
const tools = Effect.fn("MCP.tools")(function* () {
const result: Record<string, Tool> = {}
const result: Record<string, McpTool> = {}
const s = yield* InstanceState.get(state)
const cfg = yield* cfgSvc.get()
@ -672,9 +680,8 @@ const layer = Layer.effect(
continue
}
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const mcpTool of listed) {
const key = McpCatalog.toolName(clientName, mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
for (const def of listed) {
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout }
}
}
return result
@ -891,22 +898,7 @@ const layer = Layer.effect(
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
onAuthorization?.(result.authorizationUrl)
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
Effect.flatMap((subprocess) =>
Effect.callback<void, Error>((resume) => {
const timer = setTimeout(() => resume(Effect.void), 500)
subprocess.on("error", (err) => {
clearTimeout(timer)
resume(Effect.fail(err))
})
subprocess.on("exit", (code) => {
if (code !== null && code !== 0) {
clearTimeout(timer)
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
}
})
}),
),
yield* browser.open(result.authorizationUrl).pipe(
Effect.catch(() => {
return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
}),
@ -1006,7 +998,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node],
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node],
})
export * as MCP from "."

View file

@ -213,6 +213,11 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
)
}
export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
const hidden = disabled(Object.keys(tools), ruleset)
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
}
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
export * as Permission from "."

View file

@ -72,6 +72,10 @@ type SelectableItem = Item & {
}
}
}
type CopilotEndpoint = "chat" | "responses" | "messages"
type CopilotModel = Omit<Model, "api"> & {
api: Model["api"] & { endpoint?: CopilotEndpoint }
}
const decodeModels = Schema.decodeUnknownSync(schema)
const decodeItem = Schema.decodeUnknownOption(item)
@ -86,17 +90,25 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const endpoint: CopilotEndpoint | undefined = isMsgApi
? "messages"
: remote.supported_endpoints?.includes("/responses")
? "responses"
: remote.supported_endpoints?.includes("/chat/completions")
? "chat"
: undefined
const prices = remote.billing?.token_prices
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
const model: Model = {
const model: CopilotModel = {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
...(endpoint ? { endpoint } : {}),
},
// API response wins
status: "active",

View file

@ -218,8 +218,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
"github-copilot": () =>
Effect.succeed({
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
async getModel(sdk: any, modelID: string, _options?: Record<string, any>, model?: Model) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
if (model && "endpoint" in model.api) {
if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID)
if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID)
}
const match = /^gpt-(\d+)/.exec(modelID)
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
return sdk.chat(modelID)

View file

@ -1240,9 +1240,6 @@ export function smallOptions(model: Provider.Model) {
return mergeDeep(base, small)
}
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
if (model.providerID === "openrouter" && small.reasoning?.effort === "low") {
return { reasoning: { effort: "none" } }
}
if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}

View file

@ -137,8 +137,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const directory = ctx.query.directory ? yield* InstanceState.directory : undefined
const all = yield* sessions.listGlobal({
directory: ctx.query.directory,
directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,

View file

@ -18,6 +18,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Option, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@ -61,8 +62,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const scope = yield* Scope.Scope
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
const directory = ctx.query.directory ? yield* InstanceState.directory : undefined
return yield* session.list({
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
directory: ctx.query.scope === "project" ? undefined : directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,

View file

@ -192,6 +192,8 @@ const layer = Layer.effect(
status: "error",
input: match.part.state.input,
error: errorMessage(error),
// Keep metadata streamed while running so failures retain progress detail (e.g. execute's child calls).
metadata: match.part.state.metadata,
time: { start: match.part.state.time.start, end: Date.now() },
},
})

View file

@ -1237,6 +1237,7 @@ const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(RuntimeFlags.Service, flags),
)
if (lastUser.format?.type === "json_schema") {

View file

@ -3,6 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
@ -21,6 +22,7 @@ import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record"
import { RuntimeFlags } from "@/effect/runtime-flags"
const MCP_RESOURCE_TOOLS = {
list: "list_mcp_resources",
@ -52,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
@ -90,6 +93,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
permission: input.session.permission,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
@ -381,7 +385,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
if (flags.experimentalCodeMode) return tools
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
const execute = item.execute
if (!execute) continue

View file

@ -0,0 +1,310 @@
import * as Tool from "./tool"
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { Session } from "@/session/session"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
export const CODE_MODE_TOOL = "execute"
const DESCRIPTION = "Run a confined orchestration script with access to connected MCP tools."
export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: "Script body executed by the confined interpreter.",
}),
})
type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
type Metadata = {
toolCalls: CallEntry[]
error?: boolean
}
type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
type CatalogEntry = {
path: string
key: string
server: string
local: string
tool: MCP.McpTool
}
function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server =
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
server,
local,
tool: mcpTools[key]!,
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
return groups
}
export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string {
return CodeMode.make({
tools: toolTree(
[...groupByServer(mcpTools, servers).values()].flat(),
() => () => Effect.fail(toolError("Tool preview is not executable.")),
),
}).instructions()
}
const lastSegment = (uri: string) => {
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
return segment.length > 0 ? segment : undefined
}
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown {
const text: string[] = []
let files = 0
let images = 0
const push = (attachment: Attachment) => {
files += 1
if (attachment.mime.startsWith("image/")) images += 1
collect(attachment)
}
for (const block of result.content) {
switch (block.type) {
case "text":
text.push(block.text)
break
case "image":
case "audio":
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
break
case "resource": {
if ("text" in block.resource) {
text.push(block.resource.text)
break
}
const mime = block.resource.mimeType ?? "application/octet-stream"
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
break
}
case "resource_link":
// A link is a reference, not fetchable media; hand it to the program instead of the attachment channel.
text.push(`${block.name}: ${block.uri}`)
break
}
}
if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent
if (text.length > 0) return text.join("\n")
if (files > 0) {
const noun = files === images ? "image" : "file"
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
}
return null
}
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) {
const tree: Record<string, Record<string, SandboxTool.Definition>> = {}
for (const entry of catalog) {
const namespace = (tree[entry.server] ??= {})
namespace[entry.local] = SandboxTool.make({
description: entry.tool.def.description ?? "",
input: entry.tool.def.inputSchema as SandboxTool.JsonSchema,
output: entry.tool.def.outputSchema as SandboxTool.JsonSchema | undefined,
run: run(entry),
})
}
return tree
}
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
plugin: Plugin.Interface
entry: CatalogEntry
args: Record<string, unknown>
callID: string
ctx: Tool.Context
}) {
yield* input.plugin.trigger(
"tool.execute.before",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
{ args: input.args },
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": input.entry.key,
"tool.call_id": input.callID,
"session.id": input.ctx.sessionID,
"message.id": input.ctx.messageID,
},
}),
)
yield* input.plugin.trigger(
"tool.execute.after",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args },
result,
)
return result
})
export const CodeModeTool = Tool.define(
CODE_MODE_TOOL,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const agents = yield* Agent.Service
const sessions = yield* Session.Service
const plugin = yield* Plugin.Service
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
description: DESCRIPTION,
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
if (ctx.abort.aborted) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: [], error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
const agent = yield* agents.get(ctx.agent)
const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
const catalog = [...groupByServer(mcpTools, servers).values()].flat()
const calls: CallEntry[] = []
const attachments: Attachment[] = []
const publish = () =>
ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
let childCalls = 0
const callTool = (entry: CatalogEntry) => (input: unknown) =>
Effect.gen(function* () {
childCalls += 1
const result = yield* invokeChildTool({
plugin,
entry,
args: (input ?? {}) as Record<string, unknown>,
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
ctx,
})
return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment))
}).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
}),
)
const runtime = CodeMode.make({
tools: toolTree(catalog, callTool),
onToolCallStart: ({ index, name, input }) =>
Effect.suspend(() => {
const shown = (() => {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
return Object.keys(value).length > 0 ? value : undefined
}
return { input }
})()
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return publish()
}),
onToolCallEnd: ({ index, outcome }) =>
Effect.suspend(() => {
const current = calls[index]
if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
return publish()
}),
})
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const cancelled = (): CodeMode.Result => ({
ok: false,
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
toolCalls: calls.map((call) => ({ name: call.tool })),
})
const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled)))
const logs = result.logs ?? []
const withLogs = (text: string) => {
if (logs.length === 0) return text
return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
}
if (!result.ok) {
if (ctx.abort.aborted) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls, error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
return yield* Effect.fail(new Error(withLogs([result.error.message, ...hints].join("\n"))))
}
// The interpreter validates returned values as plain JSON, so stringify cannot throw;
// it yields undefined only for a program that returns undefined.
const output =
typeof result.value === "string"
? result.value
: (JSON.stringify(result.value, null, 2) ?? String(result.value))
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls },
output: withLogs(output),
...(attachments.length > 0 ? { attachments } : {}),
} satisfies Tool.ExecuteResult<Metadata>
}, Effect.orDie),
}
return init
}),
)

View file

@ -51,6 +51,9 @@ import { Job } from "@/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MCP } from "@/mcp"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { McpCatalog } from "@/mcp/catalog"
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
@ -74,6 +77,7 @@ export interface Interface {
providerID: ProviderV2.ID
modelID: ModelV2.ID
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) => Effect.Effect<Tool.Def[]>
}
@ -87,6 +91,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const mcp = yield* MCP.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool
@ -105,6 +110,8 @@ const layer = Layer.effect(
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const agent = yield* Agent.Service
const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined
const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@ -211,6 +218,7 @@ const layer = Layer.effect(
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}),
})
return {
@ -230,6 +238,7 @@ const layer = Layer.effect(
tool.search,
tool.skill,
tool.patch,
...(tool.execute ? [tool.execute] : []),
...(flags.experimentalLspTool ? [tool.lsp] : []),
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
],
@ -263,6 +272,17 @@ const layer = Layer.effect(
return ["Available agent types and the tools they have access to:", description].join("\n")
})
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: {
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) {
if (!codeMode) return
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
const tools = Permission.visibleTools(yield* mcp.tools(), ruleset)
if (Object.keys(tools).length === 0) return
return codeMode.describeCatalog(tools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize))
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) {
@ -277,8 +297,13 @@ const layer = Layer.effect(
return true
})
const codeModeDescription = filtered.some((tool) => tool.id === "execute")
? yield* describeCodeMode(input)
: undefined
const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription)
return yield* Effect.forEach(
filtered,
visible,
Effect.fnUntraced(function* (tool: Tool.Def) {
const output = {
description: tool.description,
@ -292,7 +317,11 @@ const layer = Layer.effect(
: undefined
return {
id: tool.id,
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === "execute" ? codeModeDescription : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
@ -412,6 +441,7 @@ export const node = LayerNode.make({
Format.node,
Truncate.node,
RuntimeFlags.node,
MCP.node,
Database.node,
Ripgrep.node,
],

View file

@ -0,0 +1,26 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
if (process.argv.includes("--hang")) {
const pidFile = process.env.MCP_LIFECYCLE_PID_FILE
if (!pidFile) throw new Error("MCP_LIFECYCLE_PID_FILE is required")
await Bun.write(pidFile, String(process.pid))
await new Promise(() => {})
}
const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, () =>
Promise.resolve({
tools: [
{
name: "current_directory",
description: process.cwd(),
inputSchema: { type: "object", properties: {} },
},
],
}),
)
await server.connect(new StdioServerTransport())

View file

@ -1,126 +1,101 @@
import { describe, expect, mock, beforeEach } from "bun:test"
import { describe, expect } from "bun:test"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { MCP } from "../../src/mcp/index"
// Track what options were passed to each transport constructor
const transportCalls: Array<{
type: "streamable" | "sse"
url: string
options: { authProvider?: unknown; requestInit?: RequestInit }
}> = []
// Mock the transport constructors to capture their arguments
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: class MockStreamableHTTP {
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
transportCalls.push({
type: "streamable",
url: url.toString(),
options: options ?? {},
})
}
async start() {
throw new Error("Mock transport cannot connect")
}
},
}))
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: class MockSSE {
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
transportCalls.push({
type: "sse",
url: url.toString(),
options: options ?? {},
})
}
async start() {
throw new Error("Mock transport cannot connect")
}
},
}))
beforeEach(() => {
transportCalls.length = 0
})
// Import MCP after mocking
const { MCP } = await import("../../src/mcp/index")
const it = testEffect(LayerNode.compile(MCP.node))
const serve = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Headers[] = []
const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
port: 0,
fetch(request) {
requests.push(new Headers(request.headers))
return transport.handleRequest(request)
},
})
return {
requests,
url: http.url.toString(),
close: async () => {
await http.stop(true)
await protocol.close()
},
}
}),
(server) => Effect.promise(server.close),
)
describe("mcp.headers", () => {
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
Effect.gen(function* () {
const server = yield* serve
const mcp = yield* MCP.Service
yield* mcp
.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
})
.pipe(Effect.catch(() => Effect.void))
// Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
for (const call of transportCalls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
const result = yield* mcp.add("test-server", {
type: "remote",
url: server.url,
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
})
// OAuth should be enabled by default, so authProvider should exist
expect(call.options.authProvider).toBeDefined()
},
})
expect(result.status).toMatchObject({ "test-server": { status: "connected" } })
expect(server.requests.length).toBeGreaterThan(0)
for (const headers of server.requests) {
expect(headers.get("authorization")).toBe("Bearer test-token")
expect(headers.get("x-custom-header")).toBe("custom-value")
}
}),
)
it.instance("headers are passed to transports when oauth is explicitly disabled", () =>
Effect.gen(function* () {
const server = yield* serve
const mcp = yield* MCP.Service
yield* mcp
.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
})
.pipe(Effect.catch(() => Effect.void))
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
for (const call of transportCalls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
const result = yield* mcp.add("test-server-no-oauth", {
type: "remote",
url: server.url,
oauth: false,
headers: {
Authorization: "Bearer test-token",
})
// OAuth is disabled, so no authProvider
expect(call.options.authProvider).toBeUndefined()
},
})
expect(result.status).toMatchObject({ "test-server-no-oauth": { status: "connected" } })
expect(server.requests.length).toBeGreaterThan(0)
for (const headers of server.requests) {
expect(headers.get("authorization")).toBe("Bearer test-token")
}
}),
)
it.instance("no requestInit when headers are not provided", () =>
Effect.gen(function* () {
const server = yield* serve
const mcp = yield* MCP.Service
yield* mcp
.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
})
.pipe(Effect.catch(() => Effect.void))
const result = yield* mcp.add("test-server-no-headers", {
type: "remote",
url: server.url,
})
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
for (const call of transportCalls) {
// No headers means requestInit should be undefined
expect(call.options.requestInit).toBeUndefined()
expect(result.status).toMatchObject({ "test-server-no-headers": { status: "connected" } })
expect(server.requests.length).toBeGreaterThan(0)
for (const headers of server.requests) {
expect(headers.has("authorization")).toBe(false)
expect(headers.has("x-custom-header")).toBe(false)
}
}),
)

File diff suppressed because it is too large Load diff

View file

@ -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`])
}),
)

View file

@ -1,152 +1,138 @@
import { expect, mock, beforeEach } from "bun:test"
import { EventEmitter } from "events"
import { expect } from "bun:test"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Layer, Option } from "effect"
import { Config } from "../../src/config/config"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { McpAuth } from "../../src/mcp/auth"
import { McpBrowser } from "../../src/mcp/browser"
import { MCP } from "../../src/mcp/index"
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track open() calls and control failure behavior
let openShouldFail = false
let openCalledWith: string | undefined
let openDeferred: Deferred.Deferred<string> | undefined
const browsers = new Map<string, { opened: Deferred.Deferred<string>; fail: boolean }>()
void mock.module("open", () => ({
default: async (url: string) => {
openCalledWith = url
if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore))
// Return a mock subprocess that emits an error if openShouldFail is true
const subprocess = new EventEmitter()
if (openShouldFail) {
// Emit error asynchronously like a real subprocess would
setTimeout(() => {
subprocess.emit("error", new Error("spawn xdg-open ENOENT"))
}, 10)
}
return subprocess
},
}))
// Mock UnauthorizedError
class MockUnauthorizedError extends Error {
constructor() {
super("Unauthorized")
this.name = "UnauthorizedError"
}
}
// Track what options were passed to each transport constructor
const transportCalls: Array<{
type: "streamable" | "sse"
url: string
options: { authProvider?: unknown; requestInit?: RequestInit }
}> = []
// Mock the transport constructors
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: class MockStreamableHTTP {
url: string
authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
constructor(
url: URL,
options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise<void> }; requestInit?: RequestInit },
) {
this.url = url.toString()
this.authProvider = options?.authProvider
transportCalls.push({
type: "streamable",
url: url.toString(),
options: options ?? {},
})
}
async start() {
// Simulate OAuth redirect by calling the authProvider's redirectToAuthorization
if (this.authProvider?.redirectToAuthorization) {
await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?client_id=test"))
}
throw new MockUnauthorizedError()
}
async finishAuth(_code: string) {
// Mock successful auth completion
}
},
}))
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: class MockSSE {
constructor(url: URL) {
transportCalls.push({
type: "sse",
url: url.toString(),
options: {},
})
}
async start() {
throw new Error("Mock SSE transport cannot connect")
}
},
}))
// Mock the MCP SDK Client to trigger OAuth flow
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
getServerCapabilities() {
return { tools: {} }
}
},
}))
// Mock UnauthorizedError in the auth module
void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
UnauthorizedError: MockUnauthorizedError,
}))
beforeEach(() => {
openShouldFail = false
openCalledWith = undefined
openDeferred = undefined
transportCalls.length = 0
})
// 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 { 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 browserLayer = Layer.succeed(
McpBrowser.Service,
McpBrowser.Service.of({
open: (url) =>
Effect.gen(function* () {
const browser = browsers.get(new URL(url).origin)
if (!browser) return yield* Effect.fail(new Error(`Unexpected browser URL: ${url}`))
Deferred.doneUnsafe(browser.opened, Effect.succeed(url))
if (browser.fail) return yield* Effect.fail(new Error("spawn xdg-open ENOENT"))
yield* Effect.tryPromise({
try: () => fetch(url).then((response) => response.body?.cancel()),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
}),
}),
)
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
const config = (name: string, headers?: Record<string, string>) => ({
mcp: {
[name]: {
type: "remote" as const,
url: "https://example.com/mcp",
headers,
},
},
})
const mcpTest = testEffect(
LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node]), [
[McpBrowser.node, browserLayer],
]),
)
const serveOAuthMcp = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Array<{ pathname: string; headers: Headers }> = []
const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const url = new URL(request.url)
requests.push({ pathname: url.pathname, headers: new Headers(request.headers) })
if (url.pathname === "/mcp") {
if (request.headers.get("authorization") === "Bearer test-access-token") {
return transport.handleRequest(request)
}
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource/mcp", scope="mcp"`,
},
})
}
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
return Response.json({
resource: `${url.origin}/mcp`,
authorization_servers: [url.origin],
scopes_supported: ["mcp"],
})
}
if (url.pathname === "/.well-known/oauth-authorization-server") {
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
registration_endpoint: `${url.origin}/register`,
scopes_supported: ["mcp"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
token_endpoint_auth_methods_supported: ["none"],
code_challenge_methods_supported: ["S256"],
})
}
if (url.pathname === "/register") {
const metadata = await request.json()
if (!metadata || typeof metadata !== "object") return new Response("Invalid metadata", { status: 400 })
return Response.json({ ...metadata, client_id: "test-client" }, { status: 201 })
}
if (url.pathname === "/authorize") {
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
redirect.searchParams.set("code", "test-code")
const state = url.searchParams.get("state")
if (state) redirect.searchParams.set("state", state)
return Response.redirect(redirect, 302)
}
if (url.pathname === "/token") {
return Response.json({ access_token: "test-access-token", token_type: "Bearer", scope: "mcp" })
}
return new Response("Not found", { status: 404 })
},
})
return {
requests,
url: new URL("/mcp", http.url).toString(),
close: async () => {
await http.stop(true)
await protocol.close()
},
}
}),
(server) => Effect.promise(server.close),
)
const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
const trackBrowserOpen = Effect.gen(function* () {
const opened = yield* Deferred.make<string>()
openDeferred = opened
yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined)))
return opened
})
const trackBrowserOpen = (url: string, fail = false) =>
Effect.gen(function* () {
const origin = new URL(url).origin
const opened = yield* Deferred.make<string>()
browsers.set(origin, { opened, fail })
yield* Effect.addFinalizer(() => Effect.sync(() => browsers.delete(origin)))
return opened
})
const trackBrowserOpenFailed = Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
@ -160,84 +146,80 @@ const trackBrowserOpenFailed = Effect.gen(function* () {
return event
})
const authenticateScoped = (name: string, onAuthorization?: (authorizationUrl: string) => void) =>
const addServer = Effect.fnUntraced(function* (name: string, url: string, headers?: Record<string, string>) {
const mcp = yield* MCP.Service
const result = yield* mcp.add(name, { type: "remote", url, headers })
expect(result.status).toMatchObject({ [name]: { status: "needs_auth" } })
return mcp
})
mcpTest.instance("BrowserOpenFailed event is published when browser launch fails", () =>
Effect.gen(function* () {
const mcp = yield* service
yield* mcp.authenticate(name, onAuthorization).pipe(
Effect.ignore,
Effect.catchCause(() => Effect.void),
Effect.forkScoped,
yield* withCallbackStop
const server = yield* serveOAuthMcp
yield* trackBrowserOpen(server.url, true)
const event = yield* trackBrowserOpenFailed
const mcp = yield* addServer("test-oauth-server", server.url)
yield* mcp.authenticate("test-oauth-server").pipe(Effect.ignore, Effect.forkScoped)
const failure = yield* awaitWithTimeout(
Deferred.await(event),
"Timed out waiting for BrowserOpenFailed event",
"5 seconds",
)
})
mcpTest.instance(
"BrowserOpenFailed event is published when open() throws",
() =>
Effect.gen(function* () {
yield* withCallbackStop
openShouldFail = true
const event = yield* trackBrowserOpenFailed
yield* authenticateScoped("test-oauth-server")
const failure = yield* awaitWithTimeout(
Deferred.await(event),
"Timed out waiting for BrowserOpenFailed event",
"5 seconds",
)
expect(failure.mcpName).toBe("test-oauth-server")
expect(failure.url).toContain("https://")
}),
{ config: config("test-oauth-server") },
expect(failure.mcpName).toBe("test-oauth-server")
expect(failure.url).toStartWith(new URL("/authorize", server.url).toString())
}),
)
mcpTest.instance(
"BrowserOpenFailed event is NOT published when open() succeeds",
() =>
Effect.gen(function* () {
yield* withCallbackStop
openShouldFail = false
mcpTest.instance("BrowserOpenFailed event is not published when browser launch succeeds", () =>
Effect.gen(function* () {
yield* withCallbackStop
const server = yield* serveOAuthMcp
const opened = yield* trackBrowserOpen
const event = yield* trackBrowserOpenFailed
yield* authenticateScoped("test-oauth-server-2")
const opened = yield* trackBrowserOpen(server.url)
const event = yield* trackBrowserOpenFailed
const mcp = yield* addServer("test-oauth-server-2", server.url)
const status = yield* awaitWithTimeout(
mcp.authenticate("test-oauth-server-2"),
"Timed out completing OAuth authentication",
"5 seconds",
)
const url = yield* Deferred.await(opened)
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
expect(failure).toEqual(Option.none())
expect(openCalledWith).toBeDefined()
}),
{ config: config("test-oauth-server-2") },
expect(status).toEqual({ status: "connected" })
expect(failure).toEqual(Option.none())
expect(new URL(url).origin).toBe(new URL(server.url).origin)
}),
)
mcpTest.instance(
"open() is called with the authorization URL",
() =>
Effect.gen(function* () {
yield* withCallbackStop
openShouldFail = false
openCalledWith = undefined
mcpTest.instance("browser launch receives the discovered authorization URL", () =>
Effect.gen(function* () {
yield* withCallbackStop
const server = yield* serveOAuthMcp
const opened = yield* trackBrowserOpen
const event = yield* trackBrowserOpenFailed
const authorization = yield* Deferred.make<string>()
yield* authenticateScoped("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url)))
const opened = yield* trackBrowserOpen(server.url)
const authorization = yield* Deferred.make<string>()
const mcp = yield* addServer("test-oauth-server-3", server.url, { "X-Custom-Header": "custom-value" })
const status = yield* awaitWithTimeout(
mcp.authenticate("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))),
"Timed out completing OAuth authentication",
"5 seconds",
)
const url = yield* Deferred.await(opened)
const authorizationUrl = yield* Deferred.await(authorization)
const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
const authorizationUrl = yield* awaitWithTimeout(
Deferred.await(authorization),
"Timed out waiting for authorization URL",
"5 seconds",
)
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
expect(failure).toEqual(Option.none())
expect(authorizationUrl).toBe(url)
expect(typeof url).toBe("string")
expect(url).toContain("https://")
expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" })
}),
{ config: config("test-oauth-server-3", { "X-Custom-Header": "custom-value" }) },
expect(status).toEqual({ status: "connected" })
expect(authorizationUrl).toBe(url)
expect(new URL(url).pathname).toBe("/authorize")
expect(new URL(url).searchParams.get("client_id")).toBe("test-client")
expect(
server.requests.some(
(request) => request.pathname === "/mcp" && request.headers.get("x-custom-header") === "custom-value",
),
).toBe(true)
}),
)

View file

@ -187,6 +187,44 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "mai-code-1-flash-picker",
name: "MAI-Code-1-Flash",
version: "mai-code-1-flash-picker",
supported_endpoints: ["/responses"],
capabilities: {
family: "oswe-vscode-modelD",
limits: {
max_context_window_tokens: 256000,
max_output_tokens: 128000,
max_prompt_tokens: 128000,
},
supports: {
streaming: true,
structured_outputs: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch
const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mai-code-1-flash-picker"]
expect("endpoint" in model.api ? model.api.endpoint : undefined).toBe("responses")
})
test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(

View file

@ -4638,12 +4638,12 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
}
})
test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weakest effort is low", () => {
test("ProviderTransform.smallOptions preserves the weakest OpenRouter reasoning effort", () => {
expect(
ProviderTransform.smallOptions({
providerID: "openrouter",
api: {
id: "anthropic/claude-sonnet-4.6",
id: "google/gemini-3.5-flash",
npm: "@openrouter/ai-sdk-provider",
},
variants: {
@ -4652,7 +4652,7 @@ test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weak
high: { reasoning: { effort: "high" } },
},
} as any),
).toEqual({ reasoning: { effort: "none" } })
).toEqual({ reasoning: { effort: "low" } })
})
describe("ProviderTransform.smallOptions - google thinking controls", () => {

View file

@ -1630,33 +1630,21 @@ const scenarios: Scenario[] = [
const session = yield* ctx.session({ title: "Summarize session" })
yield* ctx.message(session.id, { text: "summarize this work" })
const summary = [
"## Goal",
"## Objective",
"- Exercise session summarize.",
"",
"## Constraints & Preferences",
"## Important Details",
"- Use fake LLM.",
"",
"## Progress",
"### Done",
"- Summary generated.",
"",
"### In Progress",
"- (none)",
"",
"### Blocked",
"- (none)",
"",
"## Key Decisions",
"- Keep route local.",
"- Test fixture: test/server/httpapi-exercise/index.ts.",
"",
"## Next Steps",
"- (none)",
"## Work State",
"- Completed: Summary generated.",
"- Active: (none)",
"- Blocked: (none)",
"",
"## Critical Context",
"- Test fixture.",
"",
"## Relevant Files",
"- test/server/httpapi-exercise/index.ts: scenario",
"## Next Move",
"1. (none)",
].join("\n")
yield* ctx.llmText(summary)
yield* ctx.llmText(summary)

View file

@ -21,6 +21,7 @@ import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
@ -879,6 +880,78 @@ describe("session HttpApi", () => {
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"lists sessions created through an equivalent directory hint",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const hint = test.directory + path.sep
const headers = { "x-opencode-directory": hint, "content-type": "application/json" }
const created = yield* requestJson<Session.Info>(SessionPaths.create, {
method: "POST",
headers,
body: JSON.stringify({ title: "hinted" }),
})
const query = new URLSearchParams({ directory: hint, roots: "true" })
const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?${query}`, { headers })
expect(listed.map((item) => item.id)).toContain(created.id)
const globalQuery = new URLSearchParams({ directory: hint })
const global = yield* requestJson<Session.Info[]>(`${ExperimentalPaths.session}?${globalQuery}`, { headers })
expect(global.map((item) => item.id)).toContain(created.id)
}),
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
)
it.instance(
"lists Windows sessions for equivalent directory spellings",
() =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const test = yield* TestInstance
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
const created = yield* requestJson<Session.Info>(SessionPaths.create, {
method: "POST",
headers,
body: JSON.stringify({ title: "windows spelling" }),
})
const forwardSlashes = test.directory.replaceAll("\\", "/")
const lowercaseDrive = test.directory.replace(/^[A-Z]:/, (drive) => drive.toLowerCase())
const trailingSeparator = `${test.directory}\\`
for (const spelling of [forwardSlashes, lowercaseDrive, trailingSeparator]) {
const query = new URLSearchParams({ directory: spelling, roots: "true" })
const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?${query}`, { headers })
expect({ spelling, ids: listed.map((item) => item.id) }).toEqual({ spelling, ids: [created.id] })
}
}),
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
{ timeout: 15000 },
)
it.instance(
"lists Windows sessions created through the global worktree sentinel",
() =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const globalWorktreeSentinel = "/"
const headers = { "x-opencode-directory": globalWorktreeSentinel, "content-type": "application/json" }
const driveRootSession = yield* requestJson<Session.Info>(SessionPaths.create, {
method: "POST",
headers,
body: JSON.stringify({ title: "created at drive root" }),
})
expect(driveRootSession.directory).toMatch(/^[A-Za-z]:\\$/)
const query = new URLSearchParams({ directory: globalWorktreeSentinel, roots: "true" })
const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?${query}`, { headers })
expect(listed.map((item) => item.id)).toContain(driveRootSession.id)
}),
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
{ timeout: 15000 },
)
it.instance(
"serves paginated message link headers",
() =>

View file

@ -1430,8 +1430,8 @@ describe("session.compaction.process", () => {
expect(captured).toContain("<previous-summary>")
expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured).toContain("## Constraints & Preferences")
expect(captured).toContain("## Progress")
expect(captured).toContain("## Important Details")
expect(captured).toContain("## Work State")
}).pipe(withCompaction({ llm: stub.llmLayer }))
},
{ git: true },

View file

@ -832,6 +832,80 @@ describe("session.llm.stream", () => {
},
)
const cerebrasFixture = { providerID: "cerebras", modelID: "gpt-oss-120b" }
it.instance(
"replays Cerebras assistant reasoning using the provider-supported field",
() =>
Effect.gen(function* () {
const fixture = loadFixture(cerebrasFixture.providerID, cerebrasFixture.modelID)
const request = waitRequest(
"/chat/completions",
new Response(createChatStream("Hello"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
)
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(cerebrasFixture.providerID),
ModelV2.ID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-cerebras-reasoning")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("msg_user-cerebras-reasoning"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderV2.ID.make(cerebrasFixture.providerID), modelID: resolved.id },
} satisfies SessionV1.User
yield* drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Previous answer" },
],
},
{ role: "user", content: "Continue" },
] satisfies ModelMessage[],
tools: {},
})
const capture = yield* Effect.promise(() => request)
const messages = capture.body.messages as Array<Record<string, unknown>>
const assistant = messages.find((msg) => msg.role === "assistant")
expect(assistant?.reasoning).toBe("thinking")
expect(assistant && "reasoning_content" in assistant).toBe(false)
}),
{
config: () => ({
enabled_providers: [cerebrasFixture.providerID],
provider: {
[cerebrasFixture.providerID]: {
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
},
},
}),
},
)
const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" }
it.instance(
"service stream cancellation cancels provider response body promptly",

View file

@ -0,0 +1,331 @@
import { beforeAll, describe, expect, test } from "bun:test"
import { CodeModeTool, describeCatalog } from "@/tool/code-mode"
import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
CallToolRequestSchema,
LATEST_PROTOCOL_VERSION,
ListToolsRequestSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Layer } from "effect"
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
const SERVER = "fixtures"
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode-int"),
messageID: MessageID.make("msg_code-mode-int"),
agent: "build",
abort: new AbortController().signal,
callID: "call_code_mode_int",
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
// Avoid the SDK Client here; other MCP tests mock it process-globally.
class RawJsonRpcClient {
private nextId = 1
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
constructor(private transport: InMemoryTransport) {}
async connect() {
this.transport.onmessage = (message) => {
const msg = message as { id?: number; result?: unknown; error?: { message: string } }
if (msg.id === undefined) return
const entry = this.pending.get(msg.id)
if (!entry) return
this.pending.delete(msg.id)
if (msg.error) entry.reject(new Error(msg.error.message))
else entry.resolve(msg.result)
}
await this.transport.start()
await this.request("initialize", {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
})
await this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" })
}
private request(method: string, params: unknown): Promise<any> {
const id = this.nextId++
const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }))
void this.transport.send({ jsonrpc: "2.0", id, method, params } as never)
return result
}
listTools() {
return this.request("tools/list", {})
}
callTool(params: { name: string; arguments?: Record<string, unknown> }, _schema?: unknown, _options?: unknown) {
return this.request("tools/call", params)
}
}
const TOOL_DEFS: MCPToolDef[] = [
{
name: "get_text",
description: "Greet someone and return the greeting as text",
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
},
{
name: "add",
description: "Add two numbers and return the structured sum",
inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
outputSchema: { type: "object", properties: { sum: { type: "number" } }, required: ["sum"] },
},
{
name: "screenshot",
description: "Capture a screenshot and return it as an image",
inputSchema: { type: "object", properties: {} },
},
{
name: "boom",
description: "A tool that always fails",
inputSchema: { type: "object", properties: {} },
},
] as MCPToolDef[]
function handleCall(name: string, args: Record<string, unknown>) {
switch (name) {
case "get_text":
return { content: [{ type: "text", text: `hello ${args.name}` }] }
case "add": {
const sum = (args.a as number) + (args.b as number)
return { content: [{ type: "text", text: String(sum) }], structuredContent: { sum } }
}
case "screenshot":
return { content: [{ type: "image", data: PNG, mimeType: "image/png" }] }
case "boom":
return { content: [{ type: "text", text: "kaboom" }], isError: true }
default:
return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true }
}
}
let tool: Awaited<ReturnType<typeof buildTool>>["tool"]
let description: string
async function buildTool() {
const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
server.setRequestHandler(CallToolRequestSchema, async (req) =>
handleCall(req.params.name, (req.params.arguments ?? {}) as Record<string, unknown>),
)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
const client = new RawJsonRpcClient(clientTransport)
await client.connect()
const listed = (await client.listTools()).tools as MCPToolDef[]
const mcpTools: Record<string, MCP.McpTool> = {}
for (const def of listed) {
mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client }
}
const layer = Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"],
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", permission: [] } as any) }),
Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }),
Layer.mock(MCP.Service, {
tools: () => Effect.succeed(mcpTools),
clients: () => Effect.succeed({ [SERVER]: {} as any }),
}),
)
return {
tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))),
description: describeCatalog(mcpTools, [SERVER]),
}
}
const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
// Program failures die at the tool boundary; recover the defect for message assertions.
const runFailed = async (code: string) => {
const exit = await Effect.runPromise(tool.execute({ code }, ctx).pipe(Effect.exit))
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
return Cause.squash(exit.cause) as Error
}
beforeAll(async () => {
const built = await buildTool()
tool = built.tool
description = built.description
})
describe("code mode integration (real MCP server)", () => {
test("the appended catalog inlines full signatures with real MCP schemas", () => {
expect(description).toContain("Available tools (COMPLETE list")
expect(description).toContain("- fixtures (4 tools)")
expect(description).toContain(
"tools.fixtures.add(input: {\n a: number,\n b: number,\n}): Promise<{\n sum: number,\n}>",
)
expect(description).toContain("tools.fixtures.get_text(input: {\n name: string,\n}): Promise<unknown>")
expect(description).toContain("// Add two numbers and return the structured sum")
expect(description).not.toContain("$codemode")
expect(description).toContain("## Workflow")
expect(description).toContain("Do not infer or normalize tool names")
expect(description).toContain("bracket notation and quotes are part of the path")
expect(description).not.toContain("total_count")
})
test("calls a text tool and receives its text as the native result", async () => {
const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r")
expect(out.output).toBe("hello world")
expect(out.metadata.toolCalls).toEqual([
{ tool: "fixtures.get_text", status: "completed", input: { name: "world" } },
])
expect(out.attachments).toBeUndefined()
})
test("exposes structured data natively from a tool with an outputSchema", async () => {
const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.sum")
expect(out.output).toBe("5")
})
test("composes multiple structured calls and returns a plain object", async () => {
const out = await run(`
const first = await tools.fixtures.add({ a: 1, b: 2 })
const second = await tools.fixtures.add({ a: first.sum, b: 10 })
return { total: second.sum }
`)
expect(JSON.parse(out.output)).toEqual({ total: 13 })
expect(out.metadata.toolCalls).toEqual([
{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } },
{ tool: "fixtures.add", status: "completed", input: { a: 3, b: 10 } },
])
})
test("an image result becomes an execute attachment and a marker in the sandbox", async () => {
const out = await run("return await tools.fixtures.screenshot({})")
expect(out.output).toBe("[1 image attached to the result]")
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }])
})
test("image bytes never enter the sandbox or the model-facing output", async () => {
const out = await run(`
const shot = await tools.fixtures.screenshot({})
return { sawMarker: typeof shot === 'string' && shot.includes('attached'), value: shot }
`)
expect(JSON.parse(out.output)).toEqual({
sawMarker: true,
value: "[1 image attached to the result]",
})
expect(out.output).not.toContain(PNG)
expect(out.attachments).toHaveLength(1)
})
test("attachments accumulate even when the program returns something else", async () => {
const out = await run("await tools.fixtures.screenshot({}); return 'captured'")
expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1)
})
test("runs calls in parallel and accumulates every attachment", async () => {
const out = await run(`
const both = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})])
return 'two shots: ' + both.length
`)
expect(out.output).toBe("two shots: 2")
expect(out.attachments).toHaveLength(2)
expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"])
})
test("propagates an MCP isError into the program as a catchable error", async () => {
const out = await run("try { await tools.fixtures.boom({}) } catch (e) { return 'caught: ' + e.message }")
expect(out.output).toBe("caught: kaboom")
})
test("an uncaught MCP error surfaces as a failed execution", async () => {
const error = await runFailed("await tools.fixtures.boom({}); return 'unreachable'")
expect(error.message).toContain("kaboom")
})
test("console output is captured and appended as a Logs section after the result", async () => {
const out = await run(`
console.log("looking up", { name: "world" })
const r = await tools.fixtures.get_text({ name: "world" })
console.warn("got", r)
return r
`)
expect(out.output).toBe('hello world\n\nLogs:\nlooking up {"name":"world"}\n[warn] got hello world')
expect(out.metadata.error).toBeUndefined()
})
test("console output is preserved on the error path", async () => {
const error = await runFailed(`
console.log("before the throw")
await tools.fixtures.boom({})
return "unreachable"
`)
expect(error.message).toContain("kaboom")
expect(error.message).toContain("Logs:\nbefore the throw")
})
test("a program that logs nothing gets no Logs section", async () => {
const out = await run("return 'quiet'")
expect(out.output).toBe("quiet")
expect(out.output).not.toContain("Logs:")
})
test("console does not consume the tool-call metadata (logging is not a tool call)", async () => {
const out = await run("console.log('hi'); console.error('bye'); return 'ok'")
expect(out.output).toBe("ok\n\nLogs:\nhi\n[error] bye")
expect(out.metadata.toolCalls).toEqual([])
})
test("asks permission for each MCP call, keyed by the flat catalog name", async () => {
const asked: string[] = []
const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) }
await Effect.runPromise(
tool.execute(
{
code: `
await tools.fixtures.add({ a: 1, b: 1 })
await tools.fixtures.get_text({ name: 'x' })
return 'done'
`,
},
permCtx,
),
)
expect(asked).toEqual(["fixtures_add", "fixtures_get_text"])
})
test("streams running/completed metadata for child calls over a real transport", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
await Effect.runPromise(
tool.execute({ code: "await tools.fixtures.add({ a: 1, b: 2 }); return 'done'" }, recordingCtx),
)
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "fixtures.add", status: "running", input: { a: 1, b: 2 } }],
})
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }],
})
})
})

View file

@ -0,0 +1,730 @@
import { describe, expect, test } from "bun:test"
import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Cause, Effect, Exit, Layer, Schema } from "effect"
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode"),
messageID: MessageID.make("msg_code-mode"),
agent: "build",
abort: new AbortController().signal,
callID: "call_code_mode",
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
function mcpTool(
name: string,
handler: (args: Record<string, unknown>) => unknown,
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
outputSchema?: Record<string, unknown>,
): MCP.McpTool {
return {
def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
client: {
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
} as unknown as MCP.McpTool["client"],
}
}
function harness(input: {
mcpTools: Record<string, MCP.McpTool>
servers: string[]
permission?: PermissionV1.Rule[]
trigger?: Plugin.Interface["trigger"]
}) {
return Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.mock(Agent.Service, {
get: () => Effect.succeed({ name: "build", permission: input.permission ?? [] } as any),
}),
Layer.mock(Session.Service, {
get: () => Effect.succeed({ permission: [] } as any),
}),
Layer.mock(MCP.Service, {
tools: () => Effect.succeed(input.mcpTools),
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
}),
)
}
function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) {
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
}
function build(
mcpTools: Record<string, MCP.McpTool>,
servers?: string[],
permission?: PermissionV1.Rule[],
trigger?: Plugin.Interface["trigger"],
) {
const names = serverNames(mcpTools, servers)
return Effect.runPromise(
CodeModeTool.pipe(
Effect.flatMap(Tool.init),
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
),
)
}
function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) {
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
}
// Program failures die at the tool boundary; recover the defect for message assertions.
async function failure(effect: Effect.Effect<unknown>) {
const exit = await Effect.runPromise(effect.pipe(Effect.exit))
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
return Cause.squash(exit.cause) as Error
}
describe("code mode execute", () => {
test("defines execute input with an Effect schema", async () => {
const decode = Schema.decodeUnknownEffect(Parameters)
await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" })
await expect(Effect.runPromise(decode({}))).rejects.toThrow()
expect(Schema.toJsonSchemaDocument(Parameters).schema).toMatchObject({
properties: {
code: {
description: "Script body executed by the confined interpreter.",
},
},
})
})
test("groups multi-underscore server names by longest matching prefix", () => {
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
expect(description).toContain("- my_server (1 tool)")
expect(description).toContain("tools.my_server.do_thing(")
})
test("groupByServer uses the whole key as the server name when it has no underscore", () => {
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
expect(description).toContain("- standalone (1 tool)")
expect(description).toContain("tools.standalone.standalone(")
})
test("describeCatalog carries the raw MCP schemas for rendering", () => {
const description = describeFor(
{
weather_current: mcpTool(
"current",
() => "",
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
),
},
["weather"],
)
expect(description).toContain(
"tools.weather.current(input: {\n city: string,\n}): Promise<{\n tempC: number,\n}>",
)
})
test("the static base description carries no catalog; the registry appends it", async () => {
const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") })
expect(tool.id).toBe(CODE_MODE_TOOL)
expect(tool.description).toBe("Run a confined orchestration script with access to connected MCP tools.")
expect(tool.description).not.toContain("Available tools")
expect(tool.description).not.toContain("list_issues")
})
test("small catalogs inline every full signature in the appended catalog", () => {
const description = describeFor({
github_create_issue: mcpTool("create_issue", () => "", {
type: "object",
properties: { title: { type: "string" }, body: { type: "string" } },
required: ["title"],
}),
github_list_issues: mcpTool("list_issues", () => ""),
linear_search: mcpTool("search", () => ""),
})
expect(description).toContain("Available tools (COMPLETE list")
expect(description).toContain("- github (2 tools)")
expect(description).toContain("- linear (1 tool)")
expect(description).toContain(
"tools.github.create_issue(input: {\n title: string,\n body?: string,\n}): Promise<unknown>",
)
expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("tools.linear.search(")
expect(description).toContain("tools.linear.search(input: {}): Promise<unknown>")
expect(description).not.toContain("$codemode")
expect(description).not.toContain("Browse one namespace")
expect(description).toContain("## Workflow")
expect(description).toContain("1. Pick a tool from the list under `## Available tools`")
expect(description).not.toContain("JSON.parse(res)")
expect(description).toContain("check that it is a non-null object and not an array")
expect(description).toContain("Return only the fields you need")
expect(description).not.toContain("total_count")
})
test("signatures render the declared outputSchema as the return type", () => {
const description = describeFor({
weather_current: mcpTool(
"current",
() => "",
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{
type: "object",
properties: { tempC: { type: "number" }, summary: { type: "string" } },
required: ["tempC"],
},
),
})
expect(description).toContain(
"tools.weather.current(input: {\n city: string,\n}): Promise<{\n tempC: number,\n summary?: string,\n}>",
)
})
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
const tools: Record<string, MCP.McpTool> = {}
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
for (let i = 0; i < 150; i++) {
tools[`alpha_op_${i}`] = {
def: {
name: `op_${i}`,
description: `${filler}${i}`,
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
} as MCPToolDef,
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
}
}
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
type: "object",
properties: { topic: { type: "string", description: "Subject to look up" } },
required: ["topic"],
})
const description = describeFor(tools, ["alpha", "zeta"])
expect(description).toContain("Available tools (PARTIAL - ")
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
expect(description).toContain("- zeta (1 tool)\n")
expect(description).toContain(
"tools.zeta.only_tool(input: {\n /** Subject to look up */\n topic: string,\n}): Promise<unknown>",
)
expect(description).toContain("tools.$codemode.search(")
expect(description).toContain(" limit?: number,\n offset?: number,")
expect(description).toContain(" remaining: number,\n next: {")
expect(description).toContain(" offset: number,\n } | null,")
expect(description).toContain(
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
)
expect(description).toContain(
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)
expect(description).not.toContain("total_count")
expect(description).toContain("tools.alpha.op_0(")
expect(description).not.toContain("tools.alpha.op_99(")
const tool = await build(tools, ["alpha", "zeta"])
const out = await Effect.runPromise(
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3, offset: 0 })" }, ctx),
)
const result = JSON.parse(out.output)
expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool")
expect(result).toMatchObject({ remaining: 0, next: null })
expect(result.items[0].signature).toContain("tools.")
const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature
expect(signature).toContain("tools.zeta.only_tool(input: {\n")
expect(signature).toContain(" /** Subject to look up */\n topic: string")
expect(description).toContain("/** Subject to look up */")
expect(out.metadata.toolCalls).toEqual([
{ tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3, offset: 0 } },
])
})
test("runs plain JavaScript and returns the value as text", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx))
expect(output.output).toBe("3")
expect(output.metadata.toolCalls).toEqual([])
})
test("Object.keys(tools) enumerates the MCP server and CodeMode namespaces", async () => {
const tool = await build({
github_list_issues: mcpTool("list_issues", () => ""),
linear_search: mcpTool("search", () => ""),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" },
ctx,
),
)
expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear", "$codemode"], count: 3 })
})
test("calls a namespaced MCP tool and flows its text result back into the program", async () => {
const seen: Record<string, unknown>[] = []
const tool = await build({
greeter_hello: mcpTool("hello", (args) => {
seen.push(args)
return { content: [{ type: "text", text: `hello ${args.name}` }] }
}),
})
const output = await Effect.runPromise(
tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx),
)
expect(seen).toEqual([{ name: "world" }])
expect(output.output).toBe("HELLO WORLD")
expect(output.metadata.toolCalls).toEqual([
{ tool: "greeter.hello", status: "completed", input: { name: "world" } },
])
})
test("exposes structured content as native data and composes multiple calls", async () => {
const tool = await build({
math_add: mcpTool("add", (args) => ({
content: [],
structuredContent: { sum: (args.a as number) + (args.b as number) },
})),
})
const output = await Effect.runPromise(
tool.execute(
{
code: `
const first = await tools.math.add({ a: 1, b: 2 })
const second = await tools.math.add({ a: first.sum, b: 10 })
return { total: second.sum }
`,
},
ctx,
),
)
expect(JSON.parse(output.output)).toEqual({ total: 13 })
expect(output.metadata.toolCalls).toEqual([
{ tool: "math.add", status: "completed", input: { a: 1, b: 2 } },
{ tool: "math.add", status: "completed", input: { a: 3, b: 10 } },
])
})
test("runs tool calls in parallel with Promise.all", async () => {
const tool = await build({
echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })),
echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" },
ctx,
),
)
expect(output.output).toBe("12")
expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"])
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
})
test("a program failure fails the tool with a readable error", async () => {
const tool = await build({})
const error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx))
expect(error.message).toBe("Uncaught: boom")
})
test("reports an unknown tool as a failed execution", async () => {
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
const error = await failure(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(error.message).toContain("Unknown tool 'known.missing'")
})
test("propagates an MCP tool error into the program as a catchable failure", async () => {
const tool = await build({
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })),
})
const output = await Effect.runPromise(
tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx),
)
expect(output.output).toBe("caught: server exploded")
})
test("asks permission before each child tool call", async () => {
const asked: unknown[] = []
const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) }
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) })
await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx),
)
expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"])
})
test("a denied permission fails the child call with a catchable message, not the whole execute", async () => {
const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) }
const called: string[] = []
const tool = await build({
a_tool: mcpTool("a", () => {
called.push("a")
return { content: [{ type: "text", text: "ok" }] }
}),
})
const output = await Effect.runPromise(
tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx),
)
expect(output.output).toBe("denied: permission denied by user")
expect(output.metadata.error).toBeUndefined()
expect(called).toEqual([])
expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
})
test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => {
const events: { name: string; input: any; output: any }[] = []
const trigger = ((name: unknown, input: unknown, output: unknown) =>
Effect.sync(() => {
events.push({ name: name as string, input, output })
return output
})) as Plugin.Interface["trigger"]
const tool = await build(
{
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
},
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx),
)
expect(out.output).toBe("done")
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
["tool.execute.before", "a_tool", "call_code_mode/1"],
["tool.execute.after", "a_tool", "call_code_mode/1"],
["tool.execute.before", "b_tool", "call_code_mode/2"],
["tool.execute.after", "b_tool", "call_code_mode/2"],
])
const [before, after] = events
expect(before!.input.sessionID).toBe(ctx.sessionID)
expect(before!.output).toEqual({ args: { x: 1 } })
expect(after!.input.args).toEqual({ x: 1 })
expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
})
test("a failing before hook fails only that child call as a catchable in-program error", async () => {
const trigger = ((name: unknown, input: any, output: unknown) => {
if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded"))
return Effect.succeed(output)
}) as Plugin.Interface["trigger"]
const called: string[] = []
const record = (name: string) => () => {
called.push(name)
return { content: [{ type: "text", text: "ok" }] }
}
const tool = await build(
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute(
{
code: `
let caught
try { await tools.a.tool({}) } catch (e) { caught = e.message }
const r = await tools.b.tool({})
return caught + " / " + r
`,
},
ctx,
),
)
expect(out.metadata.error).toBeUndefined()
expect(out.output).toBe("hook exploded / ok")
expect(called).toEqual(["b"])
})
test("streams live per-call metadata as a call starts and finishes", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) })
await Effect.runPromise(
tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx),
)
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }],
})
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }],
})
})
test("marks a failed child call as error in the live metadata", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
const tool = await build({
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })),
})
await Effect.runPromise(
tool.execute(
{ code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" },
recordingCtx,
),
)
expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] })
})
test("accumulates stripped media as execute attachments the sandbox never sees", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({
content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }],
structuredContent: { name: "shot.png" },
})),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
expect(JSON.parse(out.output)).toEqual({ name: "shot.png" })
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
expect(out.output).not.toContain("PNGDATA")
})
test("a media-only result returns a text marker so the program knows it succeeded", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
expect(out.output).toBe("[1 image attached to the result]")
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
})
test("media-only markers distinguish all-image from mixed attachments", async () => {
const tool = await build({
media_images: mcpTool("images", () => ({
content: [
{ type: "image", data: "PNG1", mimeType: "image/png" },
{ type: "image", data: "PNG2", mimeType: "image/png" },
],
})),
media_mixed: mcpTool("mixed", () => ({
content: [
{ type: "image", data: "PNG3", mimeType: "image/png" },
{ type: "resource", resource: { uri: "file:///tmp/report.pdf", mimeType: "application/pdf", blob: "PDF1" } },
],
})),
})
const out = await Effect.runPromise(
tool.execute(
{
code: `
const images = await tools.media.images({})
const mixed = await tools.media.mixed({})
return { images, mixed }
`,
},
ctx,
),
)
expect(JSON.parse(out.output)).toEqual({
images: "[2 images attached to the result]",
mixed: "[2 files attached to the result]",
})
expect(out.output).not.toContain("PNG")
expect(out.attachments).toEqual([
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG1" },
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG2" },
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG3" },
{ type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF1", filename: "report.pdf" },
])
})
test("resource links flow to the program as text, never as attachments", async () => {
const tool = await build({
docs_find: mcpTool("find", () => ({
content: [
{
type: "resource_link",
uri: "https://example.com/guide.pdf",
name: "guide.pdf",
mimeType: "application/pdf",
},
{ type: "resource_link", uri: "file:///tmp/notes.md", name: "notes.md" },
],
})),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.docs.find({})" }, ctx))
expect(out.output).toBe("guide.pdf: https://example.com/guide.pdf\nnotes.md: file:///tmp/notes.md")
expect(out.attachments).toBeUndefined()
})
test("attachments still flow when the program returns something else entirely", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
})
const out = await Effect.runPromise(tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx))
expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1)
})
test("isolates the sandbox from host globals", async () => {
const tool = await build({})
const error = await failure(tool.execute({ code: "return process.env" }, ctx))
expect(error.message).toContain("process")
})
test("cancelling via ctx.abort interrupts the running program", async () => {
const controller = new AbortController()
const tool = await build({
host_trigger: mcpTool("trigger", () => {
controller.abort()
return new Promise(() => {})
}),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "try { await tools.host.trigger({}) } catch {} while (true) {}" },
{ ...ctx, abort: controller.signal },
),
)
expect(output.output).toBe("Execution cancelled.")
expect(output.metadata.error).toBe(true)
expect(output.metadata.toolCalls).toEqual([{ tool: "host.trigger", status: "running" }])
})
test("a pre-aborted signal cancels before the program runs", async () => {
const controller = new AbortController()
controller.abort()
const ran: string[] = []
const tool = await build({ host_touch: mcpTool("touch", () => (ran.push("called"), "ok")) })
const output = await Effect.runPromise(
tool.execute({ code: "return await tools.host.touch({})" }, { ...ctx, abort: controller.signal }),
)
expect(output.output).toBe("Execution cancelled.")
expect(ran).toEqual([])
})
test("leaves oversized results to OpenCode's native tool-output truncation", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx))
expect(output.metadata.error).toBeUndefined()
expect(output.output).not.toContain("[result truncated:")
expect(output.output.length).toBeGreaterThanOrEqual(40_000)
})
test("appends logs after the result on success and after the message on error", async () => {
const tool = await build({})
const ok = await Effect.runPromise(
tool.execute({ code: "console.log('step one'); console.warn('careful'); return 'done'" }, ctx),
)
expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")
const error = await failure(tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx))
expect(error.message).toContain("Uncaught: boom")
expect(error.message).toContain("Logs:\nbefore the throw")
})
})
describe("code mode permission visibility", () => {
const deny = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "deny" })
const askRule = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "ask" })
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
test("a hard-denied tool never enters the catalog or its search index", () => {
const mcpTools = {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")])
expect(description).toContain("tools.github.list_issues(")
expect(description).not.toContain("create_issue")
expect(description).toContain("- github (1 tool)")
})
test("an ask-level tool stays fully visible in the catalog", () => {
const mcpTools = {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")])
expect(description).toContain("tools.github.create_issue(")
expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("- github (2 tools)")
})
test("a hard-denied tool is not dispatchable: the program gets the unknown-tool diagnostic", async () => {
const called: string[] = []
const tool = await build(
{
github_create_issue: mcpTool("create_issue", () => {
called.push("create_issue")
return ok()
}),
github_list_issues: mcpTool("list_issues", ok),
},
["github"],
[deny("github_create_issue")],
)
const denied = await failure(tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx))
expect(denied.message).toContain("Unknown tool 'github.create_issue'")
expect(denied.message).not.toContain("permission")
expect(called).toEqual([])
const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx))
expect(allowed.metadata.error).toBeUndefined()
expect(allowed.output).toBe("ok")
})
test("an ask-level tool remains callable and still prompts via ctx.ask", async () => {
const asked: string[] = []
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
const tool = await build(
{ github_list_issues: mcpTool("list_issues", ok) },
["github"],
[askRule("github_list_issues")],
)
const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx))
expect(out.output).toBe("ok")
expect(asked).toEqual(["github_list_issues"])
})
test("Permission.visibleTools hides only hard denies, matching Permission.disabled", () => {
const tools = { a_tool: 1, b_tool: 2, c_tool: 3 }
const visible = Permission.visibleTools(tools, [
deny("a_tool"),
askRule("b_tool"),
{ permission: "c_tool", pattern: "something", action: "deny" },
])
expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"])
})
})

View file

@ -19,6 +19,8 @@ import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MCP } from "@/mcp"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
@ -55,6 +57,42 @@ const replacements = [
] as const
const it = testEffect(LayerNode.compile(root, replacements))
const withCodeMode = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () =>
Effect.succeed({
weather_current: {
def: {
name: "current",
description: "current weather",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
} as MCPToolDef,
client: {} as MCP.McpTool["client"],
},
}),
clients: () => Effect.succeed({ weather: {} as any }),
}),
],
]),
)
const withEmptyCodeMode = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
}),
],
]),
)
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
afterEach(async () => {
@ -71,6 +109,47 @@ describe("tool.registry", () => {
}),
)
it.instance("does not expose execute unless code mode is enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("execute")
}),
)
withCodeMode.instance("exposes execute when code mode is enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const ids = yield* registry.ids()
const tools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: yield* agents.defaultInfo(),
})
const execute = tools.find((tool) => tool.id === "execute")
expect(ids).toContain("execute")
expect(tools.map((tool) => tool.id)).toContain("execute")
expect(execute?.description).toContain("tools.weather.current(input: {\n city: string,\n})")
}),
)
withEmptyCodeMode.instance("does not expose execute when code mode has no visible tools", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: yield* agents.defaultInfo(),
})
expect(tools.map((tool) => tool.id)).not.toContain("execute")
}),
)
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service