From 3af0bb1b2f2b0ce2c628d7670f34c6207c19f286 Mon Sep 17 00:00:00 2001 From: Wilson Heres Date: Sun, 14 Jun 2026 21:28:10 -0500 Subject: [PATCH] fix(acp): clean up session mcp servers --- packages/opencode/src/acp/service.ts | 147 +++++++++++++++--- packages/opencode/src/mcp/index.ts | 30 +++- .../routes/instance/httpapi/groups/mcp.ts | 12 ++ .../routes/instance/httpapi/handlers/mcp.ts | 13 ++ .../opencode/test/acp/service-session.test.ts | 125 ++++++++++++++- packages/opencode/test/mcp/lifecycle.test.ts | 117 ++++++++++++++ .../test/server/httpapi-exercise/index.ts | 5 + .../test/server/httpapi-mcp-oauth.test.ts | 3 +- .../opencode/test/server/httpapi-mcp.test.ts | 7 +- .../server/httpapi-public-openapi.test.ts | 1 + packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 32 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 36 +++++ 14 files changed, 505 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 36e8375f5cc4..88a70fd4a54f 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -71,6 +71,14 @@ export type Interface = { export class Service extends Context.Service()("@opencode/ACP/Service") {} +type McpRegistration = { + readonly key: string + readonly nameKey: string + readonly directory: string + readonly name: string + readonly config: ReturnType +} + export function make(input: { sdk: OpencodeClient connection?: ServiceConnection @@ -81,7 +89,8 @@ export function make(input: { }): Interface { const session = input.session ?? makeSessionService() const directoryService = input.directory ?? makeDirectoryService(input.sdk) - const registeredMcp = new Map>() + const registeredMcp = new Map>() + const activeMcp = new Map() const sessionSnapshots = new Map() const events = input.connection ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) @@ -190,7 +199,7 @@ export function make(input: { }) sessionSnapshots.set(state.id, snapshot) - yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) + yield* registerMcpServers(input.sdk, registeredMcp, activeMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) const response = { @@ -227,7 +236,7 @@ export function make(input: { }) sessionSnapshots.set(state.id, snapshot) - yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) + yield* registerMcpServers(input.sdk, registeredMcp, activeMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) yield* replayMessages(events, messages) @@ -312,7 +321,7 @@ export function make(input: { }) sessionSnapshots.set(state.id, snapshot) - yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* registerMcpServers(input.sdk, registeredMcp, activeMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) yield* replayMessages(events, messages) @@ -338,10 +347,13 @@ export function make(input: { const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) { const removed = yield* session.remove(params.sessionId) + if (!removed) return {} + + const registeredServers = registeredMcp.get(params.sessionId) ?? new Map() registeredMcp.delete(params.sessionId) sessionSnapshots.delete(params.sessionId) - if (!removed) return {} + yield* removeMcpServers(input.sdk, registeredMcp, activeMcp, registeredServers) yield* abortBackingSession(removed) return {} }) @@ -381,7 +393,7 @@ export function make(input: { }) sessionSnapshots.set(state.id, snapshot) - yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* registerMcpServers(input.sdk, registeredMcp, activeMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) yield* replayMessages(events, messages) @@ -900,39 +912,44 @@ function sendAvailableCommands( function registerMcpServers( sdk: OpencodeClient, - registered: Map>, + registered: Map>, + active: Map, directory: string, sessionId: string, servers: readonly McpServer[], ) { const started = performance.now() - const current = registered.get(sessionId) ?? new Set() + const current = registered.get(sessionId) ?? new Map() registered.set(sessionId, current) const pending = new Set() return Effect.all( servers - .map((server) => ({ server, config: mcpConfig(server) })) - .filter((entry) => { - const key = mcpRegistrationKey(entry.server.name, entry.config) - if (current.has(key) || pending.has(key)) return false - pending.add(key) + .map((server) => mcpRegistration(directory, server.name, mcpConfig(server))) + .filter((registration) => { + if (current.has(registration.key) || pending.has(registration.key)) return false + pending.add(registration.key) return true }) - .map((entry) => + .map((registration) => request( () => sdk.mcp.add( { - directory, - name: entry.server.name, - config: entry.config, + directory: registration.directory, + name: registration.name, + config: registration.config, }, { throwOnError: true }, ), "mcp", ).pipe( - Effect.tap(() => Effect.sync(() => current.add(mcpRegistrationKey(entry.server.name, entry.config)))), + Effect.tap(() => + Effect.sync(() => { + current.set(registration.key, registration) + active.set(registration.nameKey, registration.key) + }), + ), Effect.ignore, ), ), @@ -949,8 +966,98 @@ function registerMcpServers( ) } -function mcpRegistrationKey(name: string, config: ReturnType) { - return `${name}:${stableStringify(config)}` +function removeMcpServers( + sdk: OpencodeClient, + registered: Map>, + active: Map, + owned: ReadonlyMap, +) { + const pending = new Set() + return Effect.all( + [...owned.values()] + .filter((registration) => { + if (pending.has(registration.nameKey)) return false + if (active.get(registration.nameKey) !== registration.key) return false + if (hasMcpRegistration(registered, registration.key)) return false + pending.add(registration.nameKey) + return true + }) + .map((registration) => { + const replacement = replacementMcpRegistration(registered, registration.nameKey) + return request( + () => + sdk.mcp.remove( + { + directory: registration.directory, + name: registration.name, + }, + { throwOnError: true }, + ), + "mcp", + ).pipe( + Effect.catch((error) => + Effect.logError("failed to remove ACP MCP server", { + error, + directory: registration.directory, + name: registration.name, + }).pipe(Effect.andThen(Effect.sync(() => active.delete(registration.nameKey)))), + ), + Effect.andThen( + replacement + ? restoreMcpServer(sdk, active, replacement) + : Effect.sync(() => active.delete(registration.nameKey)), + ), + ) + }), + { concurrency: "unbounded" }, + ).pipe(Effect.asVoid) +} + +function restoreMcpServer(sdk: OpencodeClient, active: Map, registration: McpRegistration) { + return request( + () => + sdk.mcp.add( + { + directory: registration.directory, + name: registration.name, + config: registration.config, + }, + { throwOnError: true }, + ), + "mcp", + ).pipe( + Effect.tap(() => Effect.sync(() => active.set(registration.nameKey, registration.key))), + Effect.catch((error) => + Effect.logError("failed to restore ACP MCP server", { + error, + directory: registration.directory, + name: registration.name, + }).pipe(Effect.andThen(Effect.sync(() => active.delete(registration.nameKey)))), + ), + ) +} + +function replacementMcpRegistration(registered: Map>, nameKey: string) { + return [...registered.values()].flatMap((items) => [...items.values()]).findLast((item) => item.nameKey === nameKey) +} + +function hasMcpRegistration(registered: Map>, key: string) { + return [...registered.values()].some((items) => items.has(key)) +} + +function mcpNameKey(directory: string, name: string) { + return stableStringify([directory, name]) +} + +function mcpRegistration(directory: string, name: string, config: ReturnType): McpRegistration { + const nameKey = mcpNameKey(directory, name) + return { + key: `${nameKey}:${stableStringify(config)}`, + nameKey, + directory, + name, + config, + } } function mcpConfig(server: McpServer) { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 08d58118c992..2df35f1f92d1 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -165,6 +165,7 @@ export interface Interface { readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect + readonly remove: (name: string) => Effect.Effect, NotFoundError> readonly getPrompt: ( clientName: string, name: string, @@ -593,7 +594,6 @@ export const layer = Layer.effect( s.status[name] = result.status if (!result.mcpClient) { yield* closeClient(s, name) - delete s.clients[name] return result.status } @@ -616,10 +616,35 @@ export const layer = Layer.effect( yield* requireMcpConfig(name) const s = yield* InstanceState.get(state) yield* closeClient(s, name) - delete s.clients[name] s.status[name] = { status: "disabled" } }) + const remove = Effect.fn("MCP.remove")(function* (name: string) { + const s = yield* InstanceState.get(state) + const configured = s.config[name] + if (!configured) return yield* new NotFoundError({ name }) + + delete s.config[name] + pendingOAuthTransports.delete(name) + + const cfg = yield* cfgSvc.get() + const staticConfig = cfg.mcp?.[name] + if (!staticConfig || !isMcpConfigured(staticConfig)) { + yield* closeClient(s, name) + delete s.status[name] + return yield* status() + } + + if (staticConfig.enabled === false) { + yield* closeClient(s, name) + s.status[name] = { status: "disabled" } + return yield* status() + } + + yield* createAndStore(name, staticConfig) + return yield* status() + }) + function requestTimeout(s: State, name: string, configured: McpEntry | undefined, fallback?: number) { const staticTimeout = configured && isMcpConfigured(configured) ? configured.timeout : undefined return s.config[name]?.timeout ?? staticTimeout ?? fallback @@ -923,6 +948,7 @@ export const layer = Layer.effect( add, connect, disconnect, + remove, getPrompt, readResource, startAuth, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73e4..8022bdf78b4c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -36,6 +36,7 @@ export const McpPaths = { authAuthenticate: "/mcp/:name/auth/authenticate", connect: "/mcp/:name/connect", disconnect: "/mcp/:name/disconnect", + remove: "/mcp/:name", } as const export const McpApi = HttpApi.make("mcp") @@ -136,6 +137,17 @@ export const McpApi = HttpApi.make("mcp") description: "Disconnect an MCP server.", }), ), + HttpApiEndpoint.delete("remove", McpPaths.remove, { + params: { name: Schema.String }, + query: WorkspaceRoutingQuery, + success: described(StatusMap, "MCP server removed successfully"), + error: McpServerNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.remove", + description: "Remove a dynamically added MCP server.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index cdf0cc1e70eb..152913a5f948 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -98,6 +98,18 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return true }) + const remove = Effect.fn("McpHttpApi.remove")(function* (ctx: { params: { name: string } }) { + return yield* mcp + .remove(ctx.params.name) + .pipe( + Effect.catchTag("MCP.NotFoundError", (error) => + Effect.fail( + new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }), + ), + ), + ) + }) + return handlers .handle("status", status) .handle("add", add) @@ -107,5 +119,6 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler .handle("authRemove", authRemove) .handle("connect", connect) .handle("disconnect", disconnect) + .handle("remove", remove) }), ) diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 890719072abf..9863d012c977 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -148,6 +148,13 @@ describe("ACP service sessions", () => { ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] + const mcpRemoves: string[] = [] + const mcpOperations: Array<{ + readonly action: "add" | "remove" + readonly directory: string + readonly name: string + readonly command?: readonly string[] + }> = [] const aborts: string[] = [] const forks: string[] = [] const prompts: unknown[] = [] @@ -235,10 +242,21 @@ describe("ACP service sessions", () => { }, }, mcp: { - add: (input: { name?: string }) => { + add: (input: { directory?: string; name?: string; config?: { command?: readonly string[] } }) => { if (input.name) mcpAdds.push(input.name) + mcpOperations.push({ + action: "add", + directory: input.directory ?? "", + name: input.name ?? "", + command: input.config?.command, + }) return Promise.resolve({ data: {} }) }, + remove: (input: { directory?: string; name?: string }) => { + if (input.name) mcpRemoves.push(`${input.directory ?? ""}:${input.name}`) + mcpOperations.push({ action: "remove", directory: input.directory ?? "", name: input.name ?? "" }) + return Promise.resolve({ data: true }) + }, }, } as unknown as OpencodeClient const connection = { @@ -262,6 +280,8 @@ describe("ACP service sessions", () => { service: ACPService.make({ sdk, connection, usage }), updates, mcpAdds, + mcpRemoves, + mcpOperations, aborts, forks, prompts, @@ -426,6 +446,109 @@ describe("ACP service sessions", () => { expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({}) }) + it("removes ACP MCP servers when closing their owning session", async () => { + const { service, mcpRemoves } = makeService() + const created = await Effect.runPromise( + service.newSession({ + cwd: "/workspace", + mcpServers: [ + { name: "tools", command: "node", args: ["server.js"], env: [] }, + { name: "tools", command: "node", args: ["server.js"], env: [] }, + ], + }), + ) + + expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({}) + + expect(mcpRemoves).toEqual(["/workspace:tools"]) + }) + + it("keeps shared ACP MCP servers registered until the last owning session closes", async () => { + const { service, mcpRemoves } = makeService() + const server = { name: "tools", command: "node", args: ["server.js"], env: [] } + + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_a", mcpServers: [server] })) + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_b", mcpServers: [server] })) + + expect(await Effect.runPromise(service.closeSession({ sessionId: "ses_a" }))).toEqual({}) + expect(mcpRemoves).toEqual([]) + + expect(await Effect.runPromise(service.closeSession({ sessionId: "ses_b" }))).toEqual({}) + expect(mcpRemoves).toEqual(["/workspace:tools"]) + }) + + it("restores the remaining same-name ACP MCP config when closing the active owner", async () => { + const { service, mcpOperations } = makeService() + + await Effect.runPromise( + service.loadSession({ + cwd: "/workspace", + sessionId: "ses_a", + mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }], + }), + ) + await Effect.runPromise( + service.loadSession({ + cwd: "/workspace", + sessionId: "ses_b", + mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }], + }), + ) + + await Effect.runPromise(service.closeSession({ sessionId: "ses_b" })) + expect(mcpOperations).toEqual([ + { action: "add", directory: "/workspace", name: "tools", command: ["node", "one.js"] }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "two.js"] }, + { action: "remove", directory: "/workspace", name: "tools" }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "one.js"] }, + ]) + + await Effect.runPromise(service.closeSession({ sessionId: "ses_a" })) + expect(mcpOperations).toEqual([ + { action: "add", directory: "/workspace", name: "tools", command: ["node", "one.js"] }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "two.js"] }, + { action: "remove", directory: "/workspace", name: "tools" }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "one.js"] }, + { action: "remove", directory: "/workspace", name: "tools" }, + ]) + }) + + it("restores the previous active same-name ACP MCP config", async () => { + const { service, mcpOperations } = makeService() + + await Effect.runPromise( + service.loadSession({ + cwd: "/workspace", + sessionId: "ses_a", + mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }], + }), + ) + await Effect.runPromise( + service.loadSession({ + cwd: "/workspace", + sessionId: "ses_b", + mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }], + }), + ) + await Effect.runPromise( + service.loadSession({ + cwd: "/workspace", + sessionId: "ses_c", + mcpServers: [{ name: "tools", command: "node", args: ["three.js"], env: [] }], + }), + ) + + await Effect.runPromise(service.closeSession({ sessionId: "ses_c" })) + + expect(mcpOperations).toEqual([ + { action: "add", directory: "/workspace", name: "tools", command: ["node", "one.js"] }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "two.js"] }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "three.js"] }, + { action: "remove", directory: "/workspace", name: "tools" }, + { action: "add", directory: "/workspace", name: "tools", command: ["node", "two.js"] }, + ]) + }) + it("cancel aborts the backing session and keeps the ACP session", async () => { const { service, aborts } = makeService() const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 34304624e680..d6291a6956cd 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -539,6 +539,91 @@ it.instance( }, ) +it.instance( + "remove() deletes dynamic-only servers from status and tools", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "dynamic-server" + const dynamicState = getOrCreateClientState("dynamic-server") + dynamicState.tools = [ + { name: "dynamic_tool", description: "dynamic", inputSchema: { type: "object", properties: {} } }, + ] + + yield* mcp.add("dynamic-server", { + type: "local", + command: ["echo", "dynamic"], + }) + + expect((yield* mcp.status())["dynamic-server"]?.status).toBe("connected") + expect(Object.keys(yield* mcp.tools()).some((key) => key.includes("dynamic_tool"))).toBe(true) + + const removedStatus = yield* mcp.remove("dynamic-server") + + expect(removedStatus["dynamic-server"]).toBeUndefined() + expect((yield* mcp.status())["dynamic-server"]).toBeUndefined() + expect(dynamicState.closed).toBe(true) + expect(Object.keys(yield* mcp.tools()).some((key) => key.includes("dynamic_tool"))).toBe(false) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "remove() restores same-name static config after dynamic override", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "shared-server" + const staticState = getOrCreateClientState("shared-server") + staticState.tools = [ + { name: "static_tool", description: "static", inputSchema: { type: "object", properties: {} } }, + ] + + expect((yield* mcp.status())["shared-server"]?.status).toBe("connected") + expect(Object.keys(yield* mcp.tools()).some((key) => key.includes("static_tool"))).toBe(true) + + clientStates.delete("shared-server") + const dynamicState = getOrCreateClientState("shared-server") + dynamicState.tools = [ + { name: "dynamic_tool", description: "dynamic", inputSchema: { type: "object", properties: {} } }, + ] + + yield* mcp.add("shared-server", { + type: "local", + command: ["echo", "dynamic"], + }) + + expect(staticState.closed).toBe(true) + expect(Object.keys(yield* mcp.tools()).some((key) => key.includes("dynamic_tool"))).toBe(true) + + clientStates.delete("shared-server") + const restoredState = getOrCreateClientState("shared-server") + restoredState.tools = [ + { name: "static_tool", description: "restored", inputSchema: { type: "object", properties: {} } }, + ] + + const removedStatus = yield* mcp.remove("shared-server") + + expect(dynamicState.closed).toBe(true) + expect(removedStatus["shared-server"]?.status).toBe("connected") + expect((yield* mcp.status())["shared-server"]?.status).toBe("connected") + expect(Object.keys(yield* mcp.tools()).some((key) => key.includes("static_tool"))).toBe(true) + expect(Object.keys(yield* mcp.tools()).some((key) => key.includes("dynamic_tool"))).toBe(false) + }), + ), + { + config: { + mcp: { + "shared-server": { + type: "local", + command: ["echo", "static"], + }, + }, + }, + }, +) + // ======================================================================== // Test: add() closes existing client before replacing // ======================================================================== @@ -995,6 +1080,38 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "remove() on static-only server fails without closing it", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "static-only" + const staticState = getOrCreateClientState("static-only") + + expect((yield* mcp.status())["static-only"]?.status).toBe("connected") + + const exit = yield* mcp.remove("static-only").pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "static-only" }) + } + expect(staticState.closed).toBe(false) + expect((yield* mcp.status())["static-only"]?.status).toBe("connected") + }), + ), + { + config: { + mcp: { + "static-only": { + type: "local", + command: ["echo", "static"], + }, + }, + }, + }, +) + // ======================================================================== // Test: tools() with no MCP servers configured // ======================================================================== diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 3860d742ddc9..bba7b3e7d7da 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -431,6 +431,11 @@ const scenarios: Scenario[] = [ .mutating() .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) .json(404, object, "status"), + http.protected + .delete("/mcp/{name}", "mcp.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(404, object, "status"), http.protected.get("/pty/shells", "pty.shells").json(200, array), http.protected.get("/pty", "pty.list").json(200, array), http.protected diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index d3ca4ae6835b..9ad2c6b5f6b3 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -27,7 +27,8 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate")) .handle("authRemove", () => Effect.die("unexpected MCP authRemove")) .handle("connect", () => Effect.die("unexpected MCP connect")) - .handle("disconnect", () => Effect.die("unexpected MCP disconnect")), + .handle("disconnect", () => Effect.die("unexpected MCP disconnect")) + .handle("remove", () => Effect.die("unexpected MCP remove")), ), ) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 42a4398ba720..6b2f0ac4fd60 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -81,7 +81,7 @@ describe("mcp HttpApi", () => { ) it.instance( - "serves add, connect, and disconnect endpoints", + "serves add, connect, disconnect, and remove endpoints", () => Effect.gen(function* () { const tmp = yield* TestInstance @@ -112,6 +112,10 @@ describe("mcp HttpApi", () => { const disconnected = yield* request(handler, "/mcp/demo/disconnect", tmp.directory, { method: "POST" }) expect(disconnected.status).toBe(200) expect(yield* json(disconnected)).toBe(true) + + const removed = yield* request(handler, "/mcp/added", tmp.directory, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(yield* json>(removed)).not.toHaveProperty("added") }), { config: { @@ -203,6 +207,7 @@ describe("mcp HttpApi", () => { { method: "DELETE", route: "/mcp/missing/auth" }, { method: "POST", route: "/mcp/missing/connect" }, { method: "POST", route: "/mcp/missing/disconnect" }, + { method: "DELETE", route: "/mcp/missing" }, ]) { const response = yield* request(handler, input.route, tmp.directory, { method: input.method, diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index d13d19c506ef..aa25d3b409fc 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -279,6 +279,7 @@ describe("PublicApi OpenAPI v2 errors", () => { ["delete", "/mcp/{name}/auth"], ["post", "/mcp/{name}/connect"], ["post", "/mcp/{name}/disconnect"], + ["delete", "/mcp/{name}"], ] as const) { expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe( "McpServerNotFoundError", diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 08828018a4a3..6bdbeaf5826c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -119,6 +119,7 @@ const mcp = Layer.succeed( add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, + remove: () => Effect.succeed({}), getPrompt: () => Effect.succeed(undefined), readResource: () => Effect.succeed(undefined), startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e12518..1f3a73103b58 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -43,6 +43,7 @@ const mcp = Layer.succeed( add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, + remove: () => Effect.succeed({}), getPrompt: () => Effect.succeed(undefined), readResource: () => Effect.succeed(undefined), startAuth: () => Effect.die("unexpected MCP auth"), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0b72e620ae5d..4d072a8e6a81 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -107,6 +107,8 @@ import type { McpDisconnectResponses, McpLocalConfig, McpRemoteConfig, + McpRemoveErrors, + McpRemoveResponses, McpStatusErrors, McpStatusResponses, MoveSessionDestination, @@ -2450,6 +2452,36 @@ export class Mcp extends HeyApiClient { }) } + /** + * Remove a dynamically added MCP server. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}", + ...options, + ...params, + }) + } + private _auth?: Auth2 get auth(): Auth2 { return (this._auth ??= new Auth2({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 093c1894a8ab..2485ee9de498 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6727,6 +6727,42 @@ export type McpDisconnectResponses = { export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type McpRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}" +} + +export type McpRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpRemoveError = McpRemoveErrors[keyof McpRemoveErrors] + +export type McpRemoveResponses = { + /** + * MCP server removed successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpRemoveResponse = McpRemoveResponses[keyof McpRemoveResponses] + export type ProjectListData = { body?: never path?: never