Skip to content

Commit e13e604

Browse files
authored
feat(httpapi): bridge mcp oauth endpoints (anomalyco#24405)
1 parent 313d696 commit e13e604

4 files changed

Lines changed: 121 additions & 9 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
178178
| `config` | `bridged` | read, providers, update |
179179
| `project` | `bridged` | list, current, git init, update |
180180
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
181-
| `mcp` | `bridged` partial | status, add, connect/disconnect; OAuth remains |
182-
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
181+
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
182+
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
183183
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
184184
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
185185
| `session` | `later/special` | large stateful surface plus streaming |
@@ -248,10 +248,10 @@ This checklist tracks bridge parity only. Checked routes are available through t
248248

249249
- [x] `GET /mcp` - MCP status.
250250
- [x] `POST /mcp` - add MCP server at runtime.
251-
- [ ] `POST /mcp/:name/auth` - start MCP OAuth.
252-
- [ ] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
253-
- [ ] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
254-
- [ ] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
251+
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
252+
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
253+
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
254+
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
255255
- [x] `POST /mcp/:name/connect` - connect MCP server.
256256
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
257257

@@ -349,7 +349,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
349349

350350
1. [x] Bridge `PATCH /project/:projectID`.
351351
2. [x] Bridge MCP add/connect/disconnect routes.
352-
3. [ ] Bridge MCP OAuth routes: start, callback, authenticate, remove.
352+
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
353353
4. [ ] Bridge experimental console switch and tool list routes.
354354
5. [ ] Bridge experimental global session list.
355355
6. [ ] Bridge workspace create/remove/session-restore routes.

packages/opencode/src/server/routes/instance/httpapi/mcp.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { MCP } from "@/mcp"
22
import { ConfigMCP } from "@/config/mcp"
33
import { Effect, Layer, Schema } from "effect"
4-
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
4+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
55
import { Authorization } from "./auth"
66

77
const AddPayload = Schema.Struct({
@@ -10,9 +10,22 @@ const AddPayload = Schema.Struct({
1010
}).annotate({ identifier: "McpAddInput" })
1111

1212
const StatusMap = Schema.Record(Schema.String, MCP.Status)
13+
const AuthStartResponse = Schema.Struct({
14+
authorizationUrl: Schema.String,
15+
oauthState: Schema.String,
16+
}).annotate({ identifier: "McpAuthStartResponse" })
17+
const AuthCallbackPayload = Schema.Struct({
18+
code: Schema.String,
19+
}).annotate({ identifier: "McpAuthCallbackInput" })
20+
const AuthRemoveResponse = Schema.Struct({
21+
success: Schema.Literal(true),
22+
}).annotate({ identifier: "McpAuthRemoveResponse" })
1323

1424
export const McpPaths = {
1525
status: "/mcp",
26+
auth: "/mcp/:name/auth",
27+
authCallback: "/mcp/:name/auth/callback",
28+
authAuthenticate: "/mcp/:name/auth/authenticate",
1629
connect: "/mcp/:name/connect",
1730
disconnect: "/mcp/:name/disconnect",
1831
} as const
@@ -40,6 +53,47 @@ export const McpApi = HttpApi.make("mcp")
4053
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
4154
}),
4255
),
56+
HttpApiEndpoint.post("authStart", McpPaths.auth, {
57+
params: { name: Schema.String },
58+
success: AuthStartResponse,
59+
}).annotateMerge(
60+
OpenApi.annotations({
61+
identifier: "mcp.auth.start",
62+
summary: "Start MCP OAuth",
63+
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
64+
}),
65+
),
66+
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
67+
params: { name: Schema.String },
68+
payload: AuthCallbackPayload,
69+
success: MCP.Status,
70+
}).annotateMerge(
71+
OpenApi.annotations({
72+
identifier: "mcp.auth.callback",
73+
summary: "Complete MCP OAuth",
74+
description: "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
75+
}),
76+
),
77+
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
78+
params: { name: Schema.String },
79+
success: MCP.Status,
80+
}).annotateMerge(
81+
OpenApi.annotations({
82+
identifier: "mcp.auth.authenticate",
83+
summary: "Authenticate MCP OAuth",
84+
description: "Start OAuth flow and wait for callback (opens browser).",
85+
}),
86+
),
87+
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
88+
params: { name: Schema.String },
89+
success: AuthRemoveResponse,
90+
}).annotateMerge(
91+
OpenApi.annotations({
92+
identifier: "mcp.auth.remove",
93+
summary: "Remove MCP OAuth",
94+
description: "Remove OAuth credentials for an MCP server.",
95+
}),
96+
),
4397
HttpApiEndpoint.post("connect", McpPaths.connect, {
4498
params: { name: Schema.String },
4599
success: Schema.Boolean,
@@ -89,6 +143,28 @@ export const mcpHandlers = Layer.unwrap(
89143
return Schema.decodeUnknownSync(StatusMap)("status" in result ? { [payload.name]: result } : result)
90144
})
91145

146+
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
147+
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
148+
return yield* mcp.startAuth(ctx.params.name)
149+
})
150+
151+
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
152+
params: { name: string }
153+
payload: typeof AuthCallbackPayload.Type
154+
}) {
155+
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
156+
})
157+
158+
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
159+
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
160+
return yield* mcp.authenticate(ctx.params.name)
161+
})
162+
163+
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
164+
yield* mcp.removeAuth(ctx.params.name)
165+
return { success: true as const }
166+
})
167+
92168
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
93169
yield* mcp.connect(ctx.params.name)
94170
return true
@@ -100,7 +176,15 @@ export const mcpHandlers = Layer.unwrap(
100176
})
101177

102178
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
103-
handlers.handle("status", status).handle("add", add).handle("connect", connect).handle("disconnect", disconnect),
179+
handlers
180+
.handle("status", status)
181+
.handle("add", add)
182+
.handle("authStart", authStart)
183+
.handle("authCallback", authCallback)
184+
.handle("authAuthenticate", authAuthenticate)
185+
.handle("authRemove", authRemove)
186+
.handle("connect", connect)
187+
.handle("disconnect", disconnect),
104188
)
105189
}),
106190
).pipe(Layer.provide(MCP.defaultLayer))

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
8080
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
8181
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
8282
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
83+
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
84+
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
85+
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
86+
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
8387
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
8488
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
8589
}

packages/opencode/test/server/httpapi-mcp.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,28 @@ describe("mcp HttpApi", () => {
8383
expect(disconnected.status).toBe(200)
8484
expect(await disconnected.json()).toBe(true)
8585
})
86+
87+
test("serves deterministic OAuth endpoints", async () => {
88+
await using tmp = await tmpdir({
89+
config: {
90+
mcp: {
91+
demo: {
92+
type: "local",
93+
command: ["echo", "demo"],
94+
enabled: false,
95+
},
96+
},
97+
},
98+
})
99+
100+
const start = await request("/mcp/demo/auth", tmp.path, { method: "POST" })
101+
expect(start.status).toBe(400)
102+
103+
const authenticate = await request("/mcp/demo/auth/authenticate", tmp.path, { method: "POST" })
104+
expect(authenticate.status).toBe(400)
105+
106+
const removed = await request("/mcp/demo/auth", tmp.path, { method: "DELETE" })
107+
expect(removed.status).toBe(200)
108+
expect(await removed.json()).toEqual({ success: true })
109+
})
86110
})

0 commit comments

Comments
 (0)