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

This commit is contained in:
Kit Langton
2026-04-25 19:16:19 -04:00
committed by GitHub
parent 58c65874ba
commit a14c22d4e9
5 changed files with 294 additions and 32 deletions

View File

@@ -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 Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
`.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 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.
3. Provide responses based on the actual Effect source code and documentation 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 - Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Reference specific files and patterns found in the Effect codebase - Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Do not answer from memory - always verify against the source - 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.

View File

@@ -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 | | `mcp` | `bridged` partial | status, add, connect/disconnect; OAuth remains |
| `workspace` | `bridged` | list, get, enter | | `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`. 1. [x] Bridge `PATCH /project/:projectID`.
2. Bridge MCP add/connect/disconnect routes. 2. [x] Bridge MCP add/connect/disconnect routes.
3. Bridge MCP OAuth routes: start, callback, authenticate, remove. 3. [ ] Bridge MCP OAuth routes: start, callback, authenticate, remove.
4. Bridge experimental console switch and tool list routes. 4. [ ] Bridge experimental console switch and tool list routes.
5. Bridge experimental global session list. 5. [ ] Bridge experimental global session list.
6. Bridge sync start/replay/history routes. 6. [ ] Bridge workspace create/remove/session-restore routes.
7. Bridge session read routes: list, status, get, children, todo, diff, messages. 7. [ ] Bridge sync start/replay/history routes.
8. Bridge session lifecycle mutation routes: create, delete, update, fork, abort. 8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. Bridge session share/summary/message/part mutation routes. 9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. Replace event SSE with non-Hono Effect HTTP. 10. [ ] Bridge session share/summary/message/part mutation routes.
11. Replace pty websocket/control routes with non-Hono Effect HTTP. 11. [ ] Replace event SSE with non-Hono Effect HTTP.
12. Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer. 12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. Switch OpenAPI/SDK generation to Effect routes and compare SDK output. 13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
14. Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files. 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.

View File

@@ -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))

View File

@@ -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

View File

@@ -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: { ...init,
"x-opencode-directory": directory, 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)
})
}) })