mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(acp): register MCP servers per session
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user