Selaa lähdekoodia

feat(httpapi): bridge mcp control endpoints (#24403)

Kit Langton 20 tuntia sitten
vanhempi
sitoutus
a14c22d4e9

+ 19 - 10
.opencode/skills/effect/SKILL.md

@@ -1,21 +1,30 @@
 ---
 ---
 name: effect
 name: effect
-description: Answer questions about the Effect framework
+description: Work with Effect v4 / effect-smol TypeScript code in this repo
 ---
 ---
 
 
 # Effect
 # Effect
 
 
-This codebase uses Effect, a framework for writing typescript.
+This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
 
 
-## How to Answer Effect Questions
+## Source Of Truth
 
 
-1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
-   `.opencode/references/effect-smol` in this project NOT the skill folder.
-2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
-3. Provide responses based on the actual Effect source code and documentation
+Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
+
+1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
+2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
+3. Also inspect existing repo code for local house style before introducing new patterns.
+4. Prefer answers and implementations backed by specific source files or nearby repo examples.
 
 
 ## Guidelines
 ## Guidelines
 
 
-- Always use the explore agent with the cloned repository when answering Effect-related questions
-- Reference specific files and patterns found in the Effect codebase
-- Do not answer from memory - always verify against the source
+- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
+- Use `Effect.gen(function* () { ... })` for multi-step workflows.
+- Use `Effect.fn("Name")` or `Effect.fnUntraced(...)` for named effects when adding reusable service methods or important workflows.
+- Prefer Effect `Schema` for API and domain data shapes. Use branded schemas for IDs and `Schema.TaggedErrorClass` for typed domain errors when modeling new error surfaces.
+- Keep HTTP handlers thin: decode input, read request context, call services, and map transport errors. Put business rules in services.
+- In Effect service code, prefer Effect-aware platform abstractions and dependencies over ad hoc promises where the surrounding code already does so.
+- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
+- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
+- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
+- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.

+ 173 - 17
packages/opencode/specs/effect/http-api.md

@@ -178,8 +178,8 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `config`                  | `bridged`         | read, providers, update                                                                            |
 | `config`                  | `bridged`         | read, providers, update                                                                            |
 | `project`                 | `bridged`         | list, current, git init, update                                                                    |
 | `project`                 | `bridged`         | list, current, git init, update                                                                    |
 | `file`                    | `bridged` partial | find text/file/symbol, list/content/status                                                         |
 | `file`                    | `bridged` partial | find text/file/symbol, list/content/status                                                         |
-| `mcp`                     | `bridged` partial | status only                                                                                        |
-| `workspace`               | `bridged`         | list, get, enter                                                                                   |
+| `mcp`                     | `bridged` partial | status, add, connect/disconnect; OAuth remains                                                     |
+| `workspace`               | `bridged` partial | adaptor/list/status; create/remove/session-restore remain                                           |
 | top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                                          |
 | top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                                          |
 | experimental JSON routes  | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
 | experimental JSON routes  | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
 | `session`                 | `later/special`   | large stateful surface plus streaming                                                              |
 | `session`                 | `later/special`   | large stateful surface plus streaming                                                              |
@@ -188,24 +188,180 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `pty`                     | `special`         | websocket                                                                                          |
 | `pty`                     | `special`         | websocket                                                                                          |
 | `tui`                     | `special`         | UI bridge                                                                                          |
 | `tui`                     | `special`         | UI bridge                                                                                          |
 
 
+## Full Route Checklist
+
+This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
+
+### Top-Level Instance Routes
+
+- [x] `POST /instance/dispose` - dispose active instance after response.
+- [x] `GET /path` - current directory and worktree paths.
+- [x] `GET /vcs` - current VCS status.
+- [x] `GET /vcs/diff` - VCS diff summary.
+- [x] `GET /command` - command catalog.
+- [x] `GET /agent` - agent catalog.
+- [x] `GET /skill` - skill catalog.
+- [x] `GET /lsp` - LSP status.
+- [x] `GET /formatter` - formatter status.
+
+### Config Routes
+
+- [x] `GET /config` - read config.
+- [x] `PATCH /config` - update config and dispose active instance after response.
+- [x] `GET /config/providers` - config provider summary.
+
+### Project Routes
+
+- [x] `GET /project` - list projects.
+- [x] `GET /project/current` - current project.
+- [x] `POST /project/git/init` - initialize git and reload active instance after response.
+- [x] `PATCH /project/:projectID` - update project metadata.
+
+### Provider Routes
+
+- [x] `GET /provider` - list providers.
+- [x] `GET /provider/auth` - list provider auth methods.
+- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
+- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
+
+### Question Routes
+
+- [x] `GET /question` - list questions.
+- [x] `POST /question/:requestID/reply` - reply to question.
+- [x] `POST /question/:requestID/reject` - reject question.
+
+### Permission Routes
+
+- [x] `GET /permission` - list permission requests.
+- [x] `POST /permission/:requestID/reply` - reply to permission request.
+
+### File Routes
+
+- [x] `GET /find` - text search.
+- [x] `GET /find/file` - file search.
+- [x] `GET /find/symbol` - symbol search.
+- [x] `GET /file` - list directory entries.
+- [x] `GET /file/content` - read file content.
+- [x] `GET /file/status` - file status.
+
+### MCP Routes
+
+- [x] `GET /mcp` - MCP status.
+- [x] `POST /mcp` - add MCP server at runtime.
+- [ ] `POST /mcp/:name/auth` - start MCP OAuth.
+- [ ] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
+- [ ] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
+- [ ] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
+- [x] `POST /mcp/:name/connect` - connect MCP server.
+- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
+
+### Experimental Routes
+
+- [x] `GET /experimental/console` - active Console provider metadata.
+- [x] `GET /experimental/console/orgs` - switchable Console orgs.
+- [ ] `POST /experimental/console/switch` - switch active Console org.
+- [x] `GET /experimental/tool/ids` - tool IDs.
+- [ ] `GET /experimental/tool` - tools for provider/model.
+- [x] `GET /experimental/worktree` - list worktrees.
+- [x] `POST /experimental/worktree` - create worktree.
+- [x] `DELETE /experimental/worktree` - remove worktree.
+- [x] `POST /experimental/worktree/reset` - reset worktree.
+- [ ] `GET /experimental/session` - global session list.
+- [x] `GET /experimental/resource` - MCP resources.
+
+### Workspace Routes
+
+- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
+- [ ] `POST /experimental/workspace` - create workspace.
+- [x] `GET /experimental/workspace` - list workspaces.
+- [x] `GET /experimental/workspace/status` - workspace status.
+- [ ] `DELETE /experimental/workspace/:id` - remove workspace.
+- [ ] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
+
+### Sync Routes
+
+- [ ] `POST /sync/start` - start workspace sync.
+- [ ] `POST /sync/replay` - replay sync events.
+- [ ] `POST /sync/history` - list sync event history.
+
+### Session Routes
+
+- [ ] `GET /session` - list sessions.
+- [ ] `GET /session/status` - session status map.
+- [ ] `GET /session/:sessionID` - get session.
+- [ ] `GET /session/:sessionID/children` - get child sessions.
+- [ ] `GET /session/:sessionID/todo` - get session todos.
+- [ ] `POST /session` - create session.
+- [ ] `DELETE /session/:sessionID` - delete session.
+- [ ] `PATCH /session/:sessionID` - update session metadata.
+- [ ] `POST /session/:sessionID/init` - run project init command.
+- [ ] `POST /session/:sessionID/fork` - fork session.
+- [ ] `POST /session/:sessionID/abort` - abort session.
+- [ ] `POST /session/:sessionID/share` - share session.
+- [ ] `GET /session/:sessionID/diff` - session diff.
+- [ ] `DELETE /session/:sessionID/share` - unshare session.
+- [ ] `POST /session/:sessionID/summarize` - summarize session.
+- [ ] `GET /session/:sessionID/message` - list session messages.
+- [ ] `GET /session/:sessionID/message/:messageID` - get message.
+- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
+- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
+- [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
+- [ ] `POST /session/:sessionID/message` - prompt with streaming response.
+- [ ] `POST /session/:sessionID/prompt_async` - async prompt.
+- [ ] `POST /session/:sessionID/command` - run command.
+- [ ] `POST /session/:sessionID/shell` - run shell command.
+- [ ] `POST /session/:sessionID/revert` - revert message.
+- [ ] `POST /session/:sessionID/unrevert` - restore reverted messages.
+- [ ] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
+
+### Event Routes
+
+- [ ] `GET /event` - SSE event stream; replace with raw Effect HTTP, not `HttpApi`.
+
+### PTY Routes
+
+- [ ] `GET /pty` - list PTY sessions.
+- [ ] `POST /pty` - create PTY session.
+- [ ] `GET /pty/:ptyID` - get PTY session.
+- [ ] `PUT /pty/:ptyID` - update PTY session.
+- [ ] `DELETE /pty/:ptyID` - remove PTY session.
+- [ ] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
+
+### TUI Routes
+
+- [ ] `POST /tui/append-prompt` - append prompt.
+- [ ] `POST /tui/open-help` - open help.
+- [ ] `POST /tui/open-sessions` - open sessions.
+- [ ] `POST /tui/open-themes` - open themes.
+- [ ] `POST /tui/open-models` - open models.
+- [ ] `POST /tui/submit-prompt` - submit prompt.
+- [ ] `POST /tui/clear-prompt` - clear prompt.
+- [ ] `POST /tui/execute-command` - execute command.
+- [ ] `POST /tui/show-toast` - show toast.
+- [ ] `POST /tui/publish` - publish TUI event.
+- [ ] `POST /tui/select-session` - select session.
+- [ ] `GET /tui/control/next` - get next TUI request.
+- [ ] `POST /tui/control/response` - submit TUI control response.
+
 ## Remaining PR Plan
 ## Remaining PR Plan
 
 
 Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
 Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
 
 
-1. Bridge `PATCH /project/:projectID`.
-2. Bridge MCP add/connect/disconnect routes.
-3. Bridge MCP OAuth routes: start, callback, authenticate, remove.
-4. Bridge experimental console switch and tool list routes.
-5. Bridge experimental global session list.
-6. Bridge sync start/replay/history routes.
-7. Bridge session read routes: list, status, get, children, todo, diff, messages.
-8. Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
-9. Bridge session share/summary/message/part mutation routes.
-10. Replace event SSE with non-Hono Effect HTTP.
-11. Replace pty websocket/control routes with non-Hono Effect HTTP.
-12. Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
-13. Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
-14. Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
+1. [x] Bridge `PATCH /project/:projectID`.
+2. [x] Bridge MCP add/connect/disconnect routes.
+3. [ ] Bridge MCP OAuth routes: start, callback, authenticate, remove.
+4. [ ] Bridge experimental console switch and tool list routes.
+5. [ ] Bridge experimental global session list.
+6. [ ] Bridge workspace create/remove/session-restore routes.
+7. [ ] Bridge sync start/replay/history routes.
+8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
+9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
+10. [ ] Bridge session share/summary/message/part mutation routes.
+11. [ ] Replace event SSE with non-Hono Effect HTTP.
+12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
+13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
+14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
+15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
 
 
 ## Checklist
 ## Checklist
 
 
@@ -216,7 +372,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
 - [x] Attach auth middleware in route modules.
 - [x] Attach auth middleware in route modules.
 - [x] Support `auth_token` as a query security scheme.
 - [x] Support `auth_token` as a query security scheme.
 - [x] Add bridge-level auth and instance tests.
 - [x] Add bridge-level auth and instance tests.
-- [ ] Complete exact Hono route inventory.
+- [x] Complete exact Hono route inventory.
 - [x] Resolve implemented-but-unmounted route groups.
 - [x] Resolve implemented-but-unmounted route groups.
 - [x] Port remaining top-level JSON reads.
 - [x] Port remaining top-level JSON reads.
 - [ ] Generate SDK/OpenAPI from Effect routes.
 - [ ] Generate SDK/OpenAPI from Effect routes.

+ 57 - 1
packages/opencode/src/server/routes/instance/httpapi/mcp.ts

@@ -1,10 +1,20 @@
 import { MCP } from "@/mcp"
 import { MCP } from "@/mcp"
+import { ConfigMCP } from "@/config/mcp"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "./auth"
 import { Authorization } from "./auth"
 
 
+const AddPayload = Schema.Struct({
+  name: Schema.String,
+  config: ConfigMCP.Info,
+}).annotate({ identifier: "McpAddInput" })
+
+const StatusMap = Schema.Record(Schema.String, MCP.Status)
+
 export const McpPaths = {
 export const McpPaths = {
   status: "/mcp",
   status: "/mcp",
+  connect: "/mcp/:name/connect",
+  disconnect: "/mcp/:name/disconnect",
 } as const
 } as const
 
 
 export const McpApi = HttpApi.make("mcp")
 export const McpApi = HttpApi.make("mcp")
@@ -20,6 +30,34 @@ export const McpApi = HttpApi.make("mcp")
             description: "Get the status of all Model Context Protocol (MCP) servers.",
             description: "Get the status of all Model Context Protocol (MCP) servers.",
           }),
           }),
         ),
         ),
+        HttpApiEndpoint.post("add", McpPaths.status, {
+          payload: AddPayload,
+          success: StatusMap,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "mcp.add",
+            summary: "Add MCP server",
+            description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
+          }),
+        ),
+        HttpApiEndpoint.post("connect", McpPaths.connect, {
+          params: { name: Schema.String },
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "mcp.connect",
+            description: "Connect an MCP server.",
+          }),
+        ),
+        HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
+          params: { name: Schema.String },
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "mcp.disconnect",
+            description: "Disconnect an MCP server.",
+          }),
+        ),
       )
       )
       .annotateMerge(
       .annotateMerge(
         OpenApi.annotations({
         OpenApi.annotations({
@@ -45,6 +83,24 @@ export const mcpHandlers = Layer.unwrap(
       return yield* mcp.status()
       return yield* mcp.status()
     })
     })
 
 
-    return HttpApiBuilder.group(McpApi, "mcp", (handlers) => handlers.handle("status", status))
+    const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
+      const payload = Schema.decodeUnknownSync(AddPayload)(ctx.payload)
+      const result = (yield* mcp.add(payload.name, payload.config)).status
+      return Schema.decodeUnknownSync(StatusMap)("status" in result ? { [payload.name]: result } : result)
+    })
+
+    const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
+      yield* mcp.connect(ctx.params.name)
+      return true
+    })
+
+    const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
+      yield* mcp.disconnect(ctx.params.name)
+      return true
+    })
+
+    return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
+      handlers.handle("status", status).handle("add", add).handle("connect", connect).handle("disconnect", disconnect),
+    )
   }),
   }),
 ).pipe(Layer.provide(MCP.defaultLayer))
 ).pipe(Layer.provide(MCP.defaultLayer))

+ 3 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -79,6 +79,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
     app.get(McpPaths.status, (c) => handler(c.req.raw, context))
     app.get(McpPaths.status, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.status, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
   }
   }
 
 
   return app
   return app

+ 42 - 4
packages/opencode/test/server/httpapi-mcp.test.ts

@@ -11,12 +11,13 @@ void Log.init({ print: false })
 
 
 const context = Context.empty() as Context.Context<unknown>
 const context = Context.empty() as Context.Context<unknown>
 
 
-function request(route: string, directory: string) {
+function request(route: string, directory: string, init?: RequestInit) {
+  const headers = new Headers(init?.headers)
+  headers.set("x-opencode-directory", directory)
   return ExperimentalHttpApiServer.webHandler().handler(
   return ExperimentalHttpApiServer.webHandler().handler(
     new Request(`http://localhost${route}`, {
     new Request(`http://localhost${route}`, {
-      headers: {
-        "x-opencode-directory": directory,
-      },
+      ...init,
+      headers,
     }),
     }),
     context,
     context,
   )
   )
@@ -45,4 +46,41 @@ describe("mcp HttpApi", () => {
     expect(response.status).toBe(200)
     expect(response.status).toBe(200)
     expect(await response.json()).toEqual({ demo: { status: "disabled" } })
     expect(await response.json()).toEqual({ demo: { status: "disabled" } })
   })
   })
+
+  test("serves add, connect, and disconnect endpoints", async () => {
+    await using tmp = await tmpdir({
+      config: {
+        mcp: {
+          demo: {
+            type: "local",
+            command: ["echo", "demo"],
+            enabled: false,
+          },
+        },
+      },
+    })
+
+    const added = await request(McpPaths.status, tmp.path, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        name: "added",
+        config: {
+          type: "local",
+          command: ["echo", "added"],
+          enabled: false,
+        },
+      }),
+    })
+    expect(added.status).toBe(200)
+    expect(await added.json()).toMatchObject({ added: { status: "disabled" } })
+
+    const connected = await request("/mcp/demo/connect", tmp.path, { method: "POST" })
+    expect(connected.status).toBe(200)
+    expect(await connected.json()).toBe(true)
+
+    const disconnected = await request("/mcp/demo/disconnect", tmp.path, { method: "POST" })
+    expect(disconnected.status).toBe(200)
+    expect(await disconnected.json()).toBe(true)
+  })
 })
 })