From 450128f9be8f2028cbfbc361043c91c0e0943bba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Apr 2026 19:27:11 -0400 Subject: [PATCH] feat(httpapi): bridge mcp oauth endpoints (#24405) --- packages/opencode/specs/effect/http-api.md | 14 +-- .../src/server/routes/instance/httpapi/mcp.ts | 88 ++++++++++++++++++- .../src/server/routes/instance/index.ts | 4 + .../opencode/test/server/httpapi-mcp.test.ts | 24 +++++ 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 373b8d7e88..b4103f7c26 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, add, connect/disconnect; OAuth remains | -| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain | +| `mcp` | `bridged` | status, add, OAuth, connect/disconnect | +| `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 | @@ -248,10 +248,10 @@ This checklist tracks bridge parity only. Checked routes are available through t - [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/auth` - start MCP OAuth. +- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback. +- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow. +- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials. - [x] `POST /mcp/:name/connect` - connect MCP server. - [x] `POST /mcp/:name/disconnect` - disconnect MCP server. @@ -349,7 +349,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev 1. [x] Bridge `PATCH /project/:projectID`. 2. [x] Bridge MCP add/connect/disconnect routes. -3. [ ] Bridge MCP OAuth routes: start, callback, authenticate, remove. +3. [x] 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. diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts index 81ca68e2cf..e039584b8f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts @@ -1,7 +1,7 @@ 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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" const AddPayload = Schema.Struct({ @@ -10,9 +10,22 @@ const AddPayload = Schema.Struct({ }).annotate({ identifier: "McpAddInput" }) const StatusMap = Schema.Record(Schema.String, MCP.Status) +const AuthStartResponse = Schema.Struct({ + authorizationUrl: Schema.String, + oauthState: Schema.String, +}).annotate({ identifier: "McpAuthStartResponse" }) +const AuthCallbackPayload = Schema.Struct({ + code: Schema.String, +}).annotate({ identifier: "McpAuthCallbackInput" }) +const AuthRemoveResponse = Schema.Struct({ + success: Schema.Literal(true), +}).annotate({ identifier: "McpAuthRemoveResponse" }) export const McpPaths = { status: "/mcp", + auth: "/mcp/:name/auth", + authCallback: "/mcp/:name/auth/callback", + authAuthenticate: "/mcp/:name/auth/authenticate", connect: "/mcp/:name/connect", disconnect: "/mcp/:name/disconnect", } as const @@ -40,6 +53,47 @@ export const McpApi = HttpApi.make("mcp") description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", }), ), + HttpApiEndpoint.post("authStart", McpPaths.auth, { + params: { name: Schema.String }, + success: AuthStartResponse, + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.auth.start", + summary: "Start MCP OAuth", + description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + }), + ), + HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { + params: { name: Schema.String }, + payload: AuthCallbackPayload, + success: MCP.Status, + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.auth.callback", + summary: "Complete MCP OAuth", + description: "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + }), + ), + HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { + params: { name: Schema.String }, + success: MCP.Status, + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.auth.authenticate", + summary: "Authenticate MCP OAuth", + description: "Start OAuth flow and wait for callback (opens browser).", + }), + ), + HttpApiEndpoint.delete("authRemove", McpPaths.auth, { + params: { name: Schema.String }, + success: AuthRemoveResponse, + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.auth.remove", + summary: "Remove MCP OAuth", + description: "Remove OAuth credentials for an MCP server.", + }), + ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, success: Schema.Boolean, @@ -89,6 +143,28 @@ export const mcpHandlers = Layer.unwrap( return Schema.decodeUnknownSync(StatusMap)("status" in result ? { [payload.name]: result } : result) }) + const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({}) + return yield* mcp.startAuth(ctx.params.name) + }) + + const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { + params: { name: string } + payload: typeof AuthCallbackPayload.Type + }) { + return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) + }) + + const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({}) + return yield* mcp.authenticate(ctx.params.name) + }) + + const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { + yield* mcp.removeAuth(ctx.params.name) + return { success: true as const } + }) + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { yield* mcp.connect(ctx.params.name) return true @@ -100,7 +176,15 @@ export const mcpHandlers = Layer.unwrap( }) return HttpApiBuilder.group(McpApi, "mcp", (handlers) => - handlers.handle("status", status).handle("add", add).handle("connect", connect).handle("disconnect", disconnect), + handlers + .handle("status", status) + .handle("add", add) + .handle("authStart", authStart) + .handle("authCallback", authCallback) + .handle("authAuthenticate", authAuthenticate) + .handle("authRemove", authRemove) + .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 ab8632b5c6..ad686ba08c 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -80,6 +80,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { 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.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (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)) } diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 68144503b0..07d0b72ed8 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -83,4 +83,28 @@ describe("mcp HttpApi", () => { expect(disconnected.status).toBe(200) expect(await disconnected.json()).toBe(true) }) + + test("serves deterministic OAuth endpoints", async () => { + await using tmp = await tmpdir({ + config: { + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }) + + const start = await request("/mcp/demo/auth", tmp.path, { method: "POST" }) + expect(start.status).toBe(400) + + const authenticate = await request("/mcp/demo/auth/authenticate", tmp.path, { method: "POST" }) + expect(authenticate.status).toBe(400) + + const removed = await request("/mcp/demo/auth", tmp.path, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(await removed.json()).toEqual({ success: true }) + }) })