Skip to content

Commit 796b652

Browse files
authored
fix(httpapi): preserve mcp oauth error parity (#24706)
1 parent 4d74849 commit 796b652

4 files changed

Lines changed: 122 additions & 12 deletions

File tree

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const AuthCallbackPayload = Schema.Struct({
2020
const AuthRemoveResponse = Schema.Struct({
2121
success: Schema.Literal(true),
2222
}).annotate({ identifier: "McpAuthRemoveResponse" })
23+
class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
24+
{ error: Schema.String },
25+
{ httpApiStatus: 400 },
26+
) {}
2327

2428
export const McpPaths = {
2529
status: "/mcp",
@@ -57,7 +61,7 @@ export const McpApi = HttpApi.make("mcp")
5761
HttpApiEndpoint.post("authStart", McpPaths.auth, {
5862
params: { name: Schema.String },
5963
success: AuthStartResponse,
60-
error: HttpApiError.BadRequest,
64+
error: UnsupportedOAuthError,
6165
}).annotateMerge(
6266
OpenApi.annotations({
6367
identifier: "mcp.auth.start",
@@ -80,7 +84,7 @@ export const McpApi = HttpApi.make("mcp")
8084
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
8185
params: { name: Schema.String },
8286
success: MCP.Status,
83-
error: HttpApiError.BadRequest,
87+
error: UnsupportedOAuthError,
8488
}).annotateMerge(
8589
OpenApi.annotations({
8690
identifier: "mcp.auth.authenticate",
@@ -149,7 +153,9 @@ export const mcpHandlers = Layer.unwrap(
149153
})
150154

151155
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
152-
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
156+
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
157+
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
158+
}
153159
return yield* mcp.startAuth(ctx.params.name)
154160
})
155161

@@ -161,7 +167,9 @@ export const mcpHandlers = Layer.unwrap(
161167
})
162168

163169
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
164-
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
170+
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
171+
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
172+
}
165173
return yield* mcp.authenticate(ctx.params.name)
166174
})
167175

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ import { lazy } from "@/util/lazy"
88
import { Effect } from "effect"
99
import { jsonRequest, runRequest } from "./trace"
1010

11+
const UnsupportedOAuthError = z
12+
.object({
13+
error: z.string(),
14+
})
15+
.meta({ ref: "McpUnsupportedOAuthError" })
16+
17+
const unsupportedOAuthErrorResponse = {
18+
description: "MCP server does not support OAuth",
19+
content: {
20+
"application/json": {
21+
schema: resolver(UnsupportedOAuthError),
22+
},
23+
},
24+
}
25+
1126
export const McpRoutes = lazy(() =>
1227
new Hono()
1328
.get(
@@ -85,7 +100,8 @@ export const McpRoutes = lazy(() =>
85100
},
86101
},
87102
},
88-
...errors(400, 404),
103+
400: unsupportedOAuthErrorResponse,
104+
...errors(404),
89105
},
90106
}),
91107
async (c) => {
@@ -157,7 +173,8 @@ export const McpRoutes = lazy(() =>
157173
},
158174
},
159175
},
160-
...errors(400, 404),
176+
400: unsupportedOAuthErrorResponse,
177+
...errors(404),
161178
},
162179
}),
163180
async (c) => {

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

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
import { afterEach, describe, expect, test } from "bun:test"
2-
import { Context } from "effect"
2+
import type { UpgradeWebSocket } from "hono/ws"
3+
import { Context, Effect, FileSystem, Layer, Path } from "effect"
4+
import { NodeFileSystem, NodePath } from "@effect/platform-node"
5+
import { Flag } from "@opencode-ai/core/flag/flag"
36
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
47
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp"
58
import { Instance } from "../../src/project/instance"
9+
import { InstanceRoutes } from "../../src/server/routes/instance"
610
import * as Log from "@opencode-ai/core/util/log"
711
import { resetDatabase } from "../fixture/db"
8-
import { tmpdir } from "../fixture/fixture"
12+
import { provideInstance, tmpdir } from "../fixture/fixture"
13+
import { testEffect } from "../lib/effect"
914

1015
void Log.init({ print: false })
1116

17+
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
1218
const context = Context.empty() as Context.Context<unknown>
19+
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
20+
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
21+
22+
function app(experimental: boolean) {
23+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
24+
return InstanceRoutes(websocket)
25+
}
1326

1427
function request(route: string, directory: string, init?: RequestInit) {
1528
const headers = new Headers(init?.headers)
@@ -23,7 +36,51 @@ function request(route: string, directory: string, init?: RequestInit) {
2336
)
2437
}
2538

39+
function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
40+
return Effect.gen(function* () {
41+
const fs = yield* FileSystem.FileSystem
42+
const path = yield* Path.Path
43+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
44+
45+
yield* fs.writeFileString(
46+
path.join(dir, "opencode.json"),
47+
JSON.stringify({
48+
$schema: "https://opencode.ai/config.json",
49+
formatter: false,
50+
lsp: false,
51+
mcp: {
52+
demo: {
53+
type: "local",
54+
command: ["echo", "demo"],
55+
enabled: false,
56+
},
57+
},
58+
}),
59+
)
60+
yield* Effect.addFinalizer(() =>
61+
Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore),
62+
)
63+
64+
return yield* self(dir).pipe(provideInstance(dir))
65+
})
66+
}
67+
68+
const readResponse = Effect.fnUntraced(function* (input: {
69+
app: ReturnType<typeof InstanceRoutes>
70+
path: string
71+
headers: HeadersInit
72+
}) {
73+
const response = yield* Effect.promise(() =>
74+
Promise.resolve(input.app.request(input.path, { method: "POST", headers: input.headers })),
75+
)
76+
return {
77+
status: response.status,
78+
body: yield* Effect.promise(() => response.text()),
79+
}
80+
})
81+
2682
afterEach(async () => {
83+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
2784
await Instance.disposeAll()
2885
await resetDatabase()
2986
})
@@ -107,4 +164,28 @@ describe("mcp HttpApi", () => {
107164
expect(removed.status).toBe(200)
108165
expect(await removed.json()).toEqual({ success: true })
109166
})
167+
168+
it.live(
169+
"matches legacy unsupported OAuth error responses",
170+
withMcpProject((dir) =>
171+
Effect.gen(function* () {
172+
const headers = { "x-opencode-directory": dir }
173+
const legacy = app(false)
174+
const httpapi = app(true)
175+
176+
yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) =>
177+
Effect.gen(function* () {
178+
const legacyResponse = yield* readResponse({ app: legacy, path, headers })
179+
const httpapiResponse = yield* readResponse({ app: httpapi, path, headers })
180+
181+
expect(legacyResponse).toEqual({
182+
status: 400,
183+
body: JSON.stringify({ error: "MCP server demo does not support OAuth" }),
184+
})
185+
expect(httpapiResponse).toEqual(legacyResponse)
186+
}),
187+
)
188+
}),
189+
),
190+
)
110191
})

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2129,6 +2129,10 @@ export type McpStatus =
21292129
| McpStatusNeedsAuth
21302130
| McpStatusNeedsClientRegistration
21312131

2132+
export type McpUnsupportedOAuthError = {
2133+
error: string
2134+
}
2135+
21322136
export type Path = {
21332137
home: string
21342138
state: string
@@ -4907,9 +4911,9 @@ export type McpAuthStartData = {
49074911

49084912
export type McpAuthStartErrors = {
49094913
/**
4910-
* Bad request
4914+
* MCP server does not support OAuth
49114915
*/
4912-
400: BadRequestError
4916+
400: McpUnsupportedOAuthError
49134917
/**
49144918
* Not found
49154919
*/
@@ -4985,9 +4989,9 @@ export type McpAuthAuthenticateData = {
49854989

49864990
export type McpAuthAuthenticateErrors = {
49874991
/**
4988-
* Bad request
4992+
* MCP server does not support OAuth
49894993
*/
4990-
400: BadRequestError
4994+
400: McpUnsupportedOAuthError
49914995
/**
49924996
* Not found
49934997
*/

0 commit comments

Comments
 (0)