rework acp to compensate for changes in Zed IDE (#4050)

This commit is contained in:
Aiden Cline
2025-11-07 11:57:12 -08:00
committed by GitHub
parent b46c3f2a26
commit 73cd8a334c
10 changed files with 621 additions and 361 deletions

View File

@@ -20,299 +20,321 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import type { ACPConfig, ACPSessionState } from "./types"
import { Provider } from "../provider/provider"
import { SessionPrompt } from "../session/prompt"
import { Installation } from "@/installation"
import { SessionLock } from "@/session/lock"
import { Bus } from "@/bus"
import { MessageV2 } from "@/session/message-v2"
import { Storage } from "@/storage/storage"
import { Command } from "@/command"
import { Agent as Agents } from "@/agent/agent"
import { Permission } from "@/permission"
import { SessionCompaction } from "@/session/compaction"
import { Config } from "@/config/config"
import { MCP } from "@/mcp"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { OpencodeClient } from "@opencode-ai/sdk"
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
export async function init() {
const model = await defaultModel({})
export async function init({ sdk }: { sdk: OpencodeClient }) {
const model = await defaultModel({ sdk })
return {
create: (connection: AgentSideConnection, config: ACPConfig) => {
if (!config.defaultModel) {
config.defaultModel = model
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
if (!fullConfig.defaultModel) {
fullConfig.defaultModel = model
}
return new Agent(connection, config)
return new Agent(connection, fullConfig)
},
}
}
export class Agent implements ACPAgent {
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
private config: ACPConfig
private sdk: OpencodeClient
private sessionManager
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
constructor(connection: AgentSideConnection, config: ACPConfig) {
this.connection = connection
this.config = config
this.setupEventSubscriptions()
this.sdk = config.sdk
this.sessionManager = new ACPSessionManager(this.sdk)
}
private setupEventSubscriptions() {
private setupEventSubscriptions(session: ACPSessionState) {
const sessionId = session.id
const directory = session.cwd
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
Bus.subscribe(Permission.Event.Updated, async (event) => {
const acpSession = this.sessionManager.get(event.properties.sessionID)
if (!acpSession) return
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId: acpSession.id,
toolCall: {
toolCallId: permission.callID ?? permission.id,
status: "pending",
title: permission.title,
rawInput: permission.metadata,
kind: toToolKind(permission.type),
locations: toLocations(permission.type, permission.metadata),
},
options,
})
.catch((error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
Permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
Permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
})
return
}
Permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: res.outcome.optionId as "once" | "always" | "reject",
})
} catch (err) {
if (!(err instanceof Permission.RejectedError)) {
log.error("unexpected error when handling permission", { error: err })
throw err
}
}
})
Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => {
const props = event.properties
const { part } = props
const acpSession = this.sessionManager.get(part.sessionID)
if (!acpSession) return
const message = await Storage.read<MessageV2.Info>([
"message",
part.sessionID,
part.messageID,
]).catch(() => undefined)
if (!message || message.role !== "assistant") return
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((err) => {
log.error("failed to send tool pending to ACP", { error: err })
})
break
case "running":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
break
case "completed":
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled"
? "completed"
: (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
this.config.sdk.event.subscribe({ query: { directory } }).then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
case "permission.updated":
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId,
toolCall: {
toolCallId: permission.callID ?? permission.id,
status: "pending",
title: permission.title,
rawInput: permission.metadata,
kind: toToolKind(permission.type),
locations: toLocations(permission.type, permission.metadata),
},
options,
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.config.sdk.postSessionIdPermissionsPermissionId({
path: { id: permission.sessionID, permissionID: permission.id },
body: {
response: "reject",
},
query: { directory },
})
.catch((err) => {
log.error("failed to send session update for todo", { error: err })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.config.sdk.postSessionIdPermissionsPermissionId({
path: { id: permission.sessionID, permissionID: permission.id },
body: {
response: "reject",
},
query: { directory },
})
return
}
await this.config.sdk.postSessionIdPermissionsPermissionId({
path: { id: permission.sessionID, permissionID: permission.id },
body: {
response: res.outcome.optionId as "once" | "always" | "reject",
},
query: { directory },
})
} catch (err) {
log.error("unexpected error when handling permission", { error: err })
} finally {
break
}
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
case "message.part.updated":
log.info("message part updated", { event: event.properties })
try {
const props = event.properties
const { part } = props
const message = await this.config.sdk.session
.message({
throwOnError: true,
path: {
id: part.sessionID,
messageID: part.messageID,
},
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
break
case "error":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
query: { directory },
})
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
})
if (!message || message.info.role !== "assistant") return
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((err) => {
log.error("failed to send tool pending to ACP", { error: err })
})
break
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
break
case "completed":
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
},
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
break
}
} else if (part.type === "text") {
const delta = props.delta
if (delta && part.synthetic !== true) {
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send text to ACP", { error: err })
})
}
} else if (part.type === "reasoning") {
const delta = props.delta
if (delta) {
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send reasoning to ACP", { error: err })
})
]
if (kind === "edit") {
const input = part.state.input
const filePath =
typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText =
typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z
.array(Todo.Info)
.safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled"
? "completed"
: (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
},
})
.catch((err) => {
log.error("failed to send session update for todo", { error: err })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
}
}
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
break
case "error":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
},
},
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
break
}
} else if (part.type === "text") {
const delta = props.delta
if (delta && part.synthetic !== true) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send text to ACP", { error: err })
})
}
} else if (part.type === "reasoning") {
const delta = props.delta
if (delta) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send reasoning to ACP", { error: err })
})
}
}
} finally {
break
}
}
}
})
@@ -364,19 +386,26 @@ export namespace ACP {
}
async newSession(params: NewSessionRequest) {
const directory = params.cwd
try {
const model = await defaultModel(this.config)
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const model = await defaultModel(this.config, directory)
// Store ACP session state
const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const sessionId = state.id
log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
log.info("creating_session", { mcpServers: params.mcpServers.length })
const load = await this.loadSession({
cwd: params.cwd,
cwd: directory,
mcpServers: params.mcpServers,
sessionId: session.id,
sessionId,
})
this.setupEventSubscriptions(state)
return {
sessionId: session.id,
sessionId,
models: load.models,
modes: load.modes,
_meta: {},
@@ -393,26 +422,47 @@ export namespace ACP {
}
async loadSession(params: LoadSessionRequest) {
const model = await defaultModel(this.config)
const directory = params.cwd
const model = await defaultModel(this.config, directory)
const sessionId = params.sessionId
const providers = await Provider.list()
const entries = Object.entries(providers).sort((a, b) => {
const nameA = a[1].info.name.toLowerCase()
const nameB = b[1].info.name.toLowerCase()
const providers = await this.sdk.config
.providers({ throwOnError: true, query: { directory } })
.then((x) => x.data.providers)
const entries = providers.sort((a, b) => {
const nameA = a.name.toLowerCase()
const nameB = b.name.toLowerCase()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
const availableModels = entries.flatMap(([providerID, provider]) => {
const models = Provider.sort(Object.values(provider.info.models))
const availableModels = entries.flatMap((provider) => {
const models = Provider.sort(Object.values(provider.models))
return models.map((model) => ({
modelId: `${providerID}/${model.id}`,
name: `${provider.info.name}/${model.name}`,
modelId: `${provider.id}/${model.id}`,
name: `${provider.name}/${model.name}`,
}))
})
const availableCommands = (await Command.list()).map((command) => ({
const agents = await this.config.sdk.app
.agents({
throwOnError: true,
query: {
directory,
},
})
.then((resp) => resp.data)
const commands = await this.config.sdk.command
.list({
throwOnError: true,
query: {
directory,
},
})
.then((resp) => resp.data)
const availableCommands = commands.map((command) => ({
name: command.name,
description: command.description ?? "",
}))
@@ -423,7 +473,7 @@ export namespace ACP {
description: "compact the session",
})
const availableModes = (await Agents.list())
const availableModes = agents
.filter((agent) => agent.mode !== "subagent")
.map((agent) => ({
id: agent.name,
@@ -459,7 +509,18 @@ export namespace ACP {
await Promise.all(
Object.entries(mcpServers).map(async ([key, mcp]) => {
await MCP.add(key, mcp)
await this.sdk.mcp
.add({
throwOnError: true,
query: { directory },
body: {
name: key,
config: mcp,
},
})
.catch((error) => {
log.error("failed to add mcp server", { name: key, error })
})
}),
)
@@ -489,12 +550,8 @@ export namespace ACP {
async setSessionModel(params: SetSessionModelRequest) {
const session = this.sessionManager.get(params.sessionId)
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`)
}
const parsed = Provider.parseModel(params.modelId)
const model = await Provider.getModel(parsed.providerID, parsed.modelID)
const model = Provider.parseModel(params.modelId)
this.sessionManager.setModel(session.id, {
providerID: model.providerID,
@@ -507,31 +564,32 @@ export namespace ACP {
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
const session = this.sessionManager.get(params.sessionId)
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`)
}
await Agents.get(params.modeId).then((agent) => {
if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
})
this.sessionManager.get(params.sessionId)
await this.config.sdk.app
.agents({ throwOnError: true })
.then((x) => x.data)
.then((agent) => {
if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
})
this.sessionManager.setMode(params.sessionId, params.modeId)
}
async prompt(params: PromptRequest) {
const sessionID = params.sessionId
const acpSession = this.sessionManager.get(sessionID)
if (!acpSession) {
throw new Error(`Session not found: ${sessionID}`)
}
const session = this.sessionManager.get(sessionID)
const directory = session.cwd
const current = acpSession.model
const model = current ?? (await defaultModel(this.config))
const current = session.model
const model = current ?? (await defaultModel(this.config, directory))
if (!current) {
this.sessionManager.setModel(acpSession.id, model)
this.sessionManager.setModel(session.id, model)
}
const agent = acpSession.modeId ?? "build"
const agent = session.modeId ?? "build"
const parts: SessionPrompt.PromptInput["parts"] = []
const parts: Array<
| { type: "text"; text: string }
| { type: "file"; url: string; filename: string; mime: string }
> = []
for (const part of params.prompt) {
switch (part.type) {
case "text":
@@ -545,12 +603,14 @@ export namespace ACP {
parts.push({
type: "file",
url: `data:${part.mimeType};base64,${part.data}`,
filename: "image",
mime: part.mimeType,
})
} else if (part.uri && part.uri.startsWith("http:")) {
parts.push({
type: "file",
url: part.uri,
filename: "image",
mime: part.mimeType,
})
}
@@ -581,7 +641,7 @@ export namespace ACP {
const cmd = (() => {
const text = parts
.filter((p) => p.type === "text")
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("")
.trim()
@@ -598,36 +658,50 @@ export namespace ACP {
}
if (!cmd) {
await SessionPrompt.prompt({
sessionID,
model: {
providerID: model.providerID,
modelID: model.modelID,
await this.sdk.session.prompt({
path: { id: sessionID },
body: {
model: {
providerID: model.providerID,
modelID: model.modelID,
},
parts,
agent,
},
query: {
directory,
},
parts,
agent,
})
return done
}
const command = await Command.get(cmd.name)
const command = await this.config.sdk.command
.list({ throwOnError: true, query: { directory } })
.then((x) => x.data.find((c) => c.name === cmd.name))
if (command) {
await SessionPrompt.command({
sessionID,
command: command.name,
arguments: cmd.args,
model: model.providerID + "/" + model.modelID,
agent,
await this.sdk.session.command({
path: { id: sessionID },
body: {
command: command.name,
arguments: cmd.args,
model: model.providerID + "/" + model.modelID,
agent,
},
query: {
directory,
},
})
return done
}
switch (cmd.name) {
case "compact":
await SessionCompaction.run({
sessionID,
providerID: model.providerID,
modelID: model.modelID,
await this.config.sdk.session.summarize({
path: { id: sessionID },
throwOnError: true,
query: {
directory,
},
})
break
}
@@ -636,7 +710,14 @@ export namespace ACP {
}
async cancel(params: CancelNotification) {
SessionLock.abort(params.sessionId)
const session = this.sessionManager.get(params.sessionId)
await this.config.sdk.session.abort({
path: { id: params.sessionId },
throwOnError: true,
query: {
directory: session.cwd,
},
})
}
}
@@ -687,12 +768,15 @@ export namespace ACP {
}
}
async function defaultModel(config: ACPConfig) {
async function defaultModel(config: ACPConfig, cwd?: string) {
const sdk = config.sdk
const configured = config.defaultModel
if (configured) return configured
const model = await Config.get()
.then((cfg) => {
const model = await sdk.config
.get({ throwOnError: true, query: { directory: cwd } })
.then((resp) => {
const cfg = resp.data
if (!cfg.model) return undefined
const parsed = Provider.parseModel(cfg.model)
return {

View File

@@ -1,66 +1,74 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import { Session } from "../session"
import { Provider } from "../provider/provider"
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types"
import { Log } from "@/util/log"
import type { OpencodeClient } from "@opencode-ai/sdk"
const log = Log.create({ service: "acp-session-manager" })
export class ACPSessionManager {
private sessions = new Map<string, ACPSessionState>()
private sdk: OpencodeClient
constructor(sdk: OpencodeClient) {
this.sdk = sdk
}
async create(
cwd: string,
mcpServers: McpServer[],
model?: ACPSessionState["model"],
): Promise<ACPSessionState> {
const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` })
const session = await this.sdk.session
.create({
body: {
title: `ACP Session ${crypto.randomUUID()}`,
},
query: {
directory: cwd,
},
throwOnError: true,
})
.then((x) => x.data)
const sessionId = session.id
const resolvedModel = model ?? (await Provider.defaultModel())
const resolvedModel = model
const state: ACPSessionState = {
id: sessionId,
parentId: session.parentID,
cwd,
mcpServers,
createdAt: new Date(),
model: resolvedModel,
}
log.info("creating_session", { state })
this.sessions.set(sessionId, state)
return state
}
get(sessionId: string) {
return this.sessions.get(sessionId)
}
async remove(sessionId: string) {
const state = this.sessions.get(sessionId)
if (!state) return
await Session.remove(sessionId).catch(() => {})
this.sessions.delete(sessionId)
}
has(sessionId: string) {
return this.sessions.has(sessionId)
get(sessionId: string): ACPSessionState {
const session = this.sessions.get(sessionId)
if (!session) {
log.error("session not found", { sessionId })
throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` }))
}
return session
}
getModel(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
const session = this.get(sessionId)
return session.model
}
setModel(sessionId: string, model: ACPSessionState["model"]) {
const session = this.sessions.get(sessionId)
if (!session) return
const session = this.get(sessionId)
session.model = model
this.sessions.set(sessionId, session)
return session
}
setMode(sessionId: string, modeId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
const session = this.get(sessionId)
session.modeId = modeId
this.sessions.set(sessionId, session)
return session

View File

@@ -1,12 +1,12 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk"
export interface ACPSessionState {
id: string
parentId?: string
cwd: string
mcpServers: McpServer[]
createdAt: Date
model: {
model?: {
providerID: string
modelID: string
}
@@ -14,6 +14,7 @@ export interface ACPSessionState {
}
export interface ACPConfig {
sdk: OpencodeClient
defaultModel?: {
providerID: string
modelID: string

View File

@@ -3,6 +3,8 @@ import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk"
const log = Log.create({ service: "acp-command" })
@@ -17,15 +19,34 @@ export const AcpCommand = cmd({
command: "acp",
describe: "Start ACP (Agent Client Protocol) server",
builder: (yargs) => {
return yargs.option("cwd", {
describe: "working directory",
type: "string",
default: process.cwd(),
})
return yargs
.option("cwd", {
describe: "working directory",
type: "string",
default: process.cwd(),
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
})
},
handler: async (opts) => {
if (opts.cwd) process.chdir(opts["cwd"])
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const server = Server.listen({
port: args.port,
hostname: args.hostname,
})
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
})
const input = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
@@ -50,10 +71,10 @@ export const AcpCommand = cmd({
})
const stream = ndJsonStream(input, output)
const agent = await ACP.init()
const agent = await ACP.init({ sdk })
new AgentSideConnection((conn) => {
return agent.create(conn, {})
return agent.create(conn, { sdk })
}, stream)
log.info("setup connection")

View File

@@ -92,13 +92,28 @@ export namespace MCP {
export async function add(name: string, mcp: Config.Mcp) {
const s = await state()
const result = await create(name, mcp)
if (!result) return
if (!result) {
const status = {
status: "failed" as const,
error: "unknown error",
}
s.status[name] = status
return {
status,
}
}
if (!result.mcpClient) {
s.status[name] = result.status
return
return {
status: s.status,
}
}
s.clients[name] = result.mcpClient
s.status[name] = result.status
return {
status: s.status,
}
}
async function create(key: string, mcp: Config.Mcp) {
@@ -207,8 +222,12 @@ export namespace MCP {
}
}
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => {
log.error("create() failed to get tools from client", { key, error: err })
return undefined
})
if (!result) {
log.info("create() tools() returned nothing, closing client", { key })
await mcpClient.close().catch((error) => {
log.error("Failed to close MCP client", {
error,
@@ -227,6 +246,7 @@ export namespace MCP {
}
}
log.info("create() successfully created client", { key, toolCount: Object.keys(result).length })
return {
mcpClient,
status,
@@ -238,13 +258,18 @@ export namespace MCP {
}
export async function clients() {
return state().then((state) => state.clients)
const s = await state()
log.info("clients() called", { clientCount: Object.keys(s.clients).length })
return s.clients
}
export async function tools() {
const result: Record<string, Tool> = {}
const s = await state()
for (const [clientName, client] of Object.entries(await clients())) {
log.info("tools() called", { clientCount: Object.keys(s.clients).length })
const clientsSnapshot = await clients()
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
log.info("tools() fetching tools for client", { clientName })
const tools = await client.tools().catch((e) => {
log.error("failed to get tools", { clientName, error: e.message })
const failedStatus = {
@@ -255,14 +280,17 @@ export namespace MCP {
delete s.clients[clientName]
})
if (!tools) {
log.info("tools() no tools returned for client", { clientName })
continue
}
log.info("tools() got tools for client", { clientName, toolCount: Object.keys(tools).length })
for (const [toolName, tool] of Object.entries(tools)) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = tool
}
}
log.info("tools() final result", { toolCount: Object.keys(result).length })
return result
}
}

View File

@@ -1359,6 +1359,36 @@ export namespace Server {
return c.json(await MCP.status())
},
)
.post(
"/mcp",
describeRoute({
description: "Add MCP server dynamically",
operationId: "mcp.add",
responses: {
200: {
description: "MCP server added successfully",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
name: z.string(),
config: Config.Mcp,
}),
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config)
return c.json(result.status)
},
)
.get(
"/lsp",
describeRoute({