feat(acp): register MCP servers per session

This commit is contained in:
Shoubhit Dash
2026-09-30 19:45:26 +05:30
parent 2f0a2b607d
commit 704ef1c693
4 changed files with 81 additions and 23 deletions
+26 -8
View File
@@ -1,5 +1,6 @@
import { isDeepStrictEqual } from "node:util"
import {
isMcpServerNotFoundError,
isSessionNotFoundError,
type CommandInfo,
type ModelRef,
@@ -97,7 +98,7 @@ export function make(input: {
readonly connection: ACPConnection.Connection
}): Interface {
const sessions = new Map<string, Attached>()
const registeredMcp = new Map<string, Set<string>>()
const registeredMcp = new Map<string, Map<string, string>>()
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
@@ -280,6 +281,7 @@ export function make(input: {
if (!isSessionNotFoundError(error)) throw error
})
await turn?.turn.catch(() => {})
await releaseMcpServers(input.client, registeredMcp, params.sessionId)
detach(params.sessionId)
return {}
},
@@ -480,21 +482,21 @@ async function messages(client: OpenCodeClient, sessionID: string) {
async function registerMcpServers(
client: OpenCodeClient,
registered: Map<string, Set<string>>,
registered: Map<string, Map<string, string>>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
const current = registered.get(session.id) ?? new Map<string, string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
const key = stableStringify(config)
if (current.get(server.name) === key) return []
current.set(server.name, key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
client.session.mcp.add({ sessionID: session.id, server: server.name, config }).catch((error) => {
if (current.get(server.name) === key) current.delete(server.name)
throw error
}),
]
@@ -502,6 +504,22 @@ async function registerMcpServers(
)
}
async function releaseMcpServers(
client: OpenCodeClient,
registered: Map<string, Map<string, string>>,
sessionID: string,
) {
const servers = Array.from(registered.get(sessionID)?.keys() ?? [])
registered.delete(sessionID)
await Promise.all(
servers.map((server) =>
client.session.mcp.remove({ sessionID, server }).catch((error) => {
if (!isSessionNotFoundError(error) && !isMcpServerNotFoundError(error)) throw error
}),
),
)
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
+13 -13
View File
@@ -317,7 +317,7 @@ describe("acp service directory behavior", () => {
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
test("registers MCP configs per session and deduplicates identical re-registrations", async () => {
const local: McpServer = {
name: "tools",
command: "bun",
@@ -332,7 +332,7 @@ describe("acp service directory behavior", () => {
headers: [{ name: "Authorization", value: "Bearer x" }],
}
let created = 0
const mcp = "/api/experimental/mcp/"
const mcp = /^\/api\/experimental\/session\/(ses_\d+)\/mcp\/(\w+)$/
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -342,7 +342,7 @@ describe("acp service directory behavior", () => {
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.method === "PUT" && request.path.startsWith(mcp)) {
if (request.method === "PUT" && mcp.test(request.path)) {
return new Response(null, { status: 204 })
}
return undefined
@@ -354,9 +354,14 @@ describe("acp service directory behavior", () => {
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith(mcp))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
const adds = fixture.requests.filter((request) => request.method === "PUT" && mcp.test(request.path))
expect(adds.map((request) => request.path.match(mcp)?.slice(1))).toEqual([
["ses_1", "tools"],
["ses_1", "docs"],
["ses_1", "tools"],
["ses_2", "tools"],
])
expect(adds.filter((request) => request.path.endsWith("/mcp/tools")).map((request) => request.body)).toEqual([
{
config: {
type: "local",
@@ -379,7 +384,7 @@ describe("acp service directory behavior", () => {
},
},
])
expect(adds.find((request) => request.path === `${mcp}docs`)?.body).toEqual({
expect(adds.find((request) => request.path.endsWith("/mcp/docs"))?.body).toEqual({
config: {
type: "remote",
url: "https://example.com/mcp",
@@ -387,12 +392,7 @@ describe("acp service directory behavior", () => {
oauth: false,
},
})
expect(adds.map((request) => request.query)).toEqual([
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
])
expect(adds.map((request) => request.query)).toEqual([{}, {}, {}, {}])
})
})
@@ -295,6 +295,46 @@ describe("acp service lifecycle", () => {
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
})
test("releases session MCP servers on close and leaves deletion cleanup to the server", async () => {
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_${created}`) })
}
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.path.startsWith("/api/experimental/session/")) return new Response(null, { status: 204 })
if (request.method === "POST" && request.path.endsWith("/interrupt"))
return Response.json({ interrupted: false })
if (request.method === "DELETE" && request.path === "/api/session/ses_2") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const server = { name: "ctx", command: "bun", args: ["ctx.ts"], env: [] }
const mcpRequests = () =>
fixture.requests
.filter((request) => request.path.startsWith("/api/experimental/session/"))
.map((request) => `${request.method} ${request.path}`)
const closed = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [server] })
const deleted = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [server] })
await fixture.service.closeSession({ sessionId: closed.sessionId })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: closed.sessionId, mcpServers: [server] })
await fixture.service.deleteSession({ sessionId: deleted.sessionId })
expect(mcpRequests()).toEqual([
"PUT /api/experimental/session/ses_1/mcp/ctx",
"PUT /api/experimental/session/ses_2/mcp/ctx",
"DELETE /api/experimental/session/ses_1/mcp/ctx",
"PUT /api/experimental/session/ses_1/mcp/ctx",
])
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
+2 -2
View File
@@ -24,7 +24,7 @@ describe("acp service", () => {
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/experimental/mcp/docs" && request.method === "PUT")
if (url.pathname === "/api/experimental/session/ses_acp/mcp/docs" && request.method === "PUT")
return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
@@ -54,7 +54,7 @@ describe("acp service", () => {
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/experimental/mcp/docs",
path: "/api/experimental/session/ses_acp/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},