diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md index 4758146377..78216ab01c 100644 --- a/.opencode/skills/effect/SKILL.md +++ b/.opencode/skills/effect/SKILL.md @@ -1,21 +1,30 @@ --- name: effect -description: Answer questions about the Effect framework +description: Work with Effect v4 / effect-smol TypeScript code in this repo --- # 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 -- 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. diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index e5a64d9209..2be0261eac 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/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 | | `project` | `bridged` | list, current, git init, update | | `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 | | 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 | @@ -188,24 +188,180 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `pty` | `special` | websocket | | `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 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 @@ -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] Support `auth_token` as a query security scheme. - [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] Port remaining top-level JSON reads. - [ ] Generate SDK/OpenAPI from Effect routes. diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts index 34d4e09e2d..81ca68e2cf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts @@ -1,10 +1,20 @@ import { MCP } from "@/mcp" +import { ConfigMCP } from "@/config/mcp" import { Effect, Layer, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" 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 = { status: "/mcp", + connect: "/mcp/:name/connect", + disconnect: "/mcp/:name/disconnect", } as const 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.", }), ), + 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( OpenApi.annotations({ @@ -45,6 +83,24 @@ export const mcpHandlers = Layer.unwrap( 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)) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 8d341b8a05..ab8632b5c6 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/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.formatter, (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 diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 3da1dc9333..68144503b0 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -11,12 +11,13 @@ void Log.init({ print: false }) const context = Context.empty() as Context.Context -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( new Request(`http://localhost${route}`, { - headers: { - "x-opencode-directory": directory, - }, + ...init, + headers, }), context, ) @@ -45,4 +46,41 @@ describe("mcp HttpApi", () => { expect(response.status).toBe(200) 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) + }) })