Просмотр исходного кода

feat(httpapi): bridge mcp oauth endpoints (#24405)

Kit Langton 4 месяцев назад
Родитель
Сommit
450128f9be

+ 7 - 7
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.

+ 86 - 2
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))

+ 4 - 0
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))
   }

+ 24 - 0
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 })
+  })
 })