Skip to content

Commit 0beb4de

Browse files
authored
fix(httpapi): return mcp server not found errors (#28817)
1 parent 51da348 commit 0beb4de

8 files changed

Lines changed: 158 additions & 72 deletions

File tree

packages/opencode/src/mcp/index.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export const Failed = NamedError.create("MCPFailed", {
6767
name: Schema.String,
6868
})
6969

70+
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
71+
name: Schema.String,
72+
}) {}
73+
7074
type MCPClient = Client
7175

7276
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
@@ -242,8 +246,8 @@ export interface Interface {
242246
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
243247
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
244248
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
245-
readonly connect: (name: string) => Effect.Effect<void>
246-
readonly disconnect: (name: string) => Effect.Effect<void>
249+
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
250+
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
247251
readonly getPrompt: (
248252
clientName: string,
249253
name: string,
@@ -253,11 +257,11 @@ export interface Interface {
253257
clientName: string,
254258
resourceUri: string,
255259
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
256-
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
257-
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
258-
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
260+
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError>
261+
readonly authenticate: (mcpName: string) => Effect.Effect<Status, NotFoundError>
262+
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
259263
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
260-
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
264+
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
261265
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
262266
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
263267
}
@@ -642,15 +646,12 @@ export const layer = Layer.effect(
642646
})
643647

644648
const connect = Effect.fn("MCP.connect")(function* (name: string) {
645-
const mcp = yield* getMcpConfig(name)
646-
if (!mcp) {
647-
log.error("MCP config not found or invalid", { name })
648-
return
649-
}
649+
const mcp = yield* requireMcpConfig(name)
650650
yield* createAndStore(name, { ...mcp, enabled: true })
651651
})
652652

653653
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
654+
yield* requireMcpConfig(name)
654655
const s = yield* InstanceState.get(state)
655656
yield* closeClient(s, name)
656657
delete s.clients[name]
@@ -759,9 +760,14 @@ export const layer = Layer.effect(
759760
return mcpConfig
760761
})
761762

762-
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
763+
const requireMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
763764
const mcpConfig = yield* getMcpConfig(mcpName)
764-
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
765+
if (!mcpConfig) return yield* new NotFoundError({ name: mcpName })
766+
return mcpConfig
767+
})
768+
769+
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
770+
const mcpConfig = yield* requireMcpConfig(mcpName)
765771
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
766772
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
767773
const url = remoteURL(mcpName, mcpConfig.url)
@@ -825,11 +831,9 @@ export const layer = Layer.effect(
825831
const result = yield* startAuth(mcpName)
826832
if (!result.authorizationUrl) {
827833
const client = "client" in result ? result.client : undefined
828-
const mcpConfig = yield* getMcpConfig(mcpName)
829-
if (!mcpConfig) {
830-
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
831-
return { status: "failed", error: "MCP config not found after auth" } as Status
832-
}
834+
const mcpConfig = yield* requireMcpConfig(mcpName).pipe(
835+
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
836+
)
833837

834838
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
835839
if (!client || !listed) {
@@ -880,6 +884,7 @@ export const layer = Layer.effect(
880884
})
881885

882886
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
887+
yield* requireMcpConfig(mcpName)
883888
const transport = pendingOAuthTransports.get(mcpName)
884889
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
885890

@@ -898,8 +903,7 @@ export const layer = Layer.effect(
898903
yield* auth.clearCodeVerifier(mcpName)
899904
pendingOAuthTransports.delete(mcpName)
900905

901-
const mcpConfig = yield* getMcpConfig(mcpName)
902-
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
906+
const mcpConfig = yield* requireMcpConfig(mcpName)
903907

904908
return yield* createAndStore(mcpName, mcpConfig)
905909
})
@@ -912,8 +916,7 @@ export const layer = Layer.effect(
912916
})
913917

914918
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
915-
const mcpConfig = yield* getMcpConfig(mcpName)
916-
if (!mcpConfig) return false
919+
const mcpConfig = yield* requireMcpConfig(mcpName)
917920
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
918921
})
919922

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionN
140140
{ httpApiStatus: 404 },
141141
) {}
142142

143+
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
144+
"McpServerNotFoundError",
145+
{
146+
name: Schema.String,
147+
message: Schema.String,
148+
},
149+
{ httpApiStatus: 404 },
150+
) {}
151+
143152
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
144153
{
145154
name: Schema.Literal("NotFoundError"),

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MCP } from "@/mcp"
22
import { ConfigMCP } from "@/config/mcp"
33
import { Schema } from "effect"
44
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
5+
import { McpServerNotFoundError } from "../errors"
56
import { Authorization } from "../middleware/authorization"
67
import { InstanceContextMiddleware } from "../middleware/instance-context"
78
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
@@ -67,7 +68,7 @@ export const McpApi = HttpApi.make("mcp")
6768
params: { name: Schema.String },
6869
query: WorkspaceRoutingQuery,
6970
success: described(AuthStartResponse, "OAuth flow started"),
70-
error: [UnsupportedOAuthError, HttpApiError.NotFound],
71+
error: [UnsupportedOAuthError, McpServerNotFoundError],
7172
}).annotateMerge(
7273
OpenApi.annotations({
7374
identifier: "mcp.auth.start",
@@ -80,7 +81,7 @@ export const McpApi = HttpApi.make("mcp")
8081
query: WorkspaceRoutingQuery,
8182
payload: AuthCallbackPayload,
8283
success: described(MCP.Status, "OAuth authentication completed"),
83-
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
84+
error: [HttpApiError.BadRequest, McpServerNotFoundError],
8485
}).annotateMerge(
8586
OpenApi.annotations({
8687
identifier: "mcp.auth.callback",
@@ -93,7 +94,7 @@ export const McpApi = HttpApi.make("mcp")
9394
params: { name: Schema.String },
9495
query: WorkspaceRoutingQuery,
9596
success: described(MCP.Status, "OAuth authentication completed"),
96-
error: [UnsupportedOAuthError, HttpApiError.NotFound],
97+
error: [UnsupportedOAuthError, McpServerNotFoundError],
9798
}).annotateMerge(
9899
OpenApi.annotations({
99100
identifier: "mcp.auth.authenticate",
@@ -105,7 +106,7 @@ export const McpApi = HttpApi.make("mcp")
105106
params: { name: Schema.String },
106107
query: WorkspaceRoutingQuery,
107108
success: described(AuthRemoveResponse, "OAuth credentials removed"),
108-
error: HttpApiError.NotFound,
109+
error: McpServerNotFoundError,
109110
}).annotateMerge(
110111
OpenApi.annotations({
111112
identifier: "mcp.auth.remove",
@@ -117,6 +118,7 @@ export const McpApi = HttpApi.make("mcp")
117118
params: { name: Schema.String },
118119
query: WorkspaceRoutingQuery,
119120
success: described(Schema.Boolean, "MCP server connected successfully"),
121+
error: McpServerNotFoundError,
120122
}).annotateMerge(
121123
OpenApi.annotations({
122124
identifier: "mcp.connect",
@@ -127,6 +129,7 @@ export const McpApi = HttpApi.make("mcp")
127129
params: { name: Schema.String },
128130
query: WorkspaceRoutingQuery,
129131
success: described(Schema.Boolean, "MCP server disconnected successfully"),
132+
error: McpServerNotFoundError,
130133
}).annotateMerge(
131134
OpenApi.annotations({
132135
identifier: "mcp.disconnect",

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

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MCP } from "@/mcp"
22
import { Effect, Schema } from "effect"
33
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
44
import { InstanceHttpApi } from "../api"
5+
import { McpServerNotFoundError } from "../errors"
56
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
67

78
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
@@ -20,38 +21,74 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
2021
})
2122

2223
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
23-
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
24-
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
25-
}
26-
return yield* mcp.startAuth(ctx.params.name)
24+
return yield* Effect.gen(function* () {
25+
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
26+
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
27+
}
28+
return yield* mcp.startAuth(ctx.params.name)
29+
}).pipe(
30+
Effect.catchTag("MCP.NotFoundError", (error) =>
31+
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
32+
),
33+
)
2734
})
2835

2936
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
3037
params: { name: string }
3138
payload: typeof AuthCallbackPayload.Type
3239
}) {
33-
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
40+
return yield* mcp
41+
.finishAuth(ctx.params.name, ctx.payload.code)
42+
.pipe(
43+
Effect.catchTag("MCP.NotFoundError", (error) =>
44+
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
45+
),
46+
)
3447
})
3548

3649
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
37-
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
38-
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
39-
}
40-
return yield* mcp.authenticate(ctx.params.name)
50+
return yield* Effect.gen(function* () {
51+
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
52+
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
53+
}
54+
return yield* mcp.authenticate(ctx.params.name)
55+
}).pipe(
56+
Effect.catchTag("MCP.NotFoundError", (error) =>
57+
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
58+
),
59+
)
4160
})
4261

4362
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
63+
const status = yield* mcp.status()
64+
if (!(ctx.params.name in status))
65+
return yield* new McpServerNotFoundError({
66+
name: ctx.params.name,
67+
message: `MCP server not found: ${ctx.params.name}`,
68+
})
4469
yield* mcp.removeAuth(ctx.params.name)
4570
return { success: true as const }
4671
})
4772

4873
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
49-
yield* mcp.connect(ctx.params.name)
74+
yield* mcp
75+
.connect(ctx.params.name)
76+
.pipe(
77+
Effect.catchTag("MCP.NotFoundError", (error) =>
78+
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
79+
),
80+
)
5081
return true
5182
})
5283

5384
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
54-
yield* mcp.disconnect(ctx.params.name)
85+
yield* mcp
86+
.disconnect(ctx.params.name)
87+
.pipe(
88+
Effect.catchTag("MCP.NotFoundError", (error) =>
89+
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
90+
),
91+
)
5592
return true
5693
})
5794

packages/opencode/test/mcp/lifecycle.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, mock, beforeEach } from "bun:test"
2-
import { Effect, Exit } from "effect"
2+
import { Cause, Effect, Exit } from "effect"
33
import type { MCP as MCPNS } from "../../src/mcp/index"
44
import { testEffect } from "../lib/effect"
55

@@ -635,12 +635,15 @@ it.instance(
635635
// ========================================================================
636636

637637
it.instance(
638-
"connect() on nonexistent server does not throw",
638+
"connect() on nonexistent server fails with NotFoundError",
639639
() =>
640640
MCP.Service.use((mcp: MCPNS.Interface) =>
641641
Effect.gen(function* () {
642-
// Should not throw
643-
yield* mcp.connect("nonexistent")
642+
const exit = yield* mcp.connect("nonexistent").pipe(Effect.exit)
643+
expect(Exit.isFailure(exit)).toBe(true)
644+
if (Exit.isFailure(exit)) {
645+
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
646+
}
644647
const status = yield* mcp.status()
645648
expect(status["nonexistent"]).toBeUndefined()
646649
}),
@@ -653,12 +656,15 @@ it.instance(
653656
// ========================================================================
654657

655658
it.instance(
656-
"disconnect() on nonexistent server does not throw",
659+
"disconnect() on nonexistent server fails with NotFoundError",
657660
() =>
658661
MCP.Service.use((mcp: MCPNS.Interface) =>
659662
Effect.gen(function* () {
660-
yield* mcp.disconnect("nonexistent")
661-
// Should complete without error
663+
const exit = yield* mcp.disconnect("nonexistent").pipe(Effect.exit)
664+
expect(Exit.isFailure(exit)).toBe(true)
665+
if (Exit.isFailure(exit)) {
666+
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
667+
}
662668
}),
663669
),
664670
{ config: { mcp: {} } },

packages/opencode/test/server/httpapi-exercise/index.ts

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -329,58 +329,37 @@ const scenarios: Scenario[] = [
329329
http.protected
330330
.post("/mcp/{name}/auth", "mcp.auth.start")
331331
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
332-
.json(
333-
400,
334-
(body) => {
335-
object(body)
336-
check(typeof body.error === "string", "unsupported MCP OAuth response should include error")
337-
},
338-
"status",
339-
),
332+
.json(404, object, "status"),
340333
http.protected
341334
.delete("/mcp/{name}/auth", "mcp.auth.remove")
342335
.mutating()
343336
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
344-
.json(200, (body) => {
345-
object(body)
346-
check(body.success === true, "MCP auth removal should return success")
347-
}),
337+
.json(404, object, "status"),
348338
http.protected
349339
.post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
350340
.at((ctx) => ({
351341
path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
352342
headers: ctx.headers(),
353343
}))
354-
.json(
355-
400,
356-
(body) => {
357-
object(body)
358-
check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error")
359-
},
360-
"status",
361-
),
344+
.json(404, object, "status"),
362345
http.protected
363346
.post("/mcp/{name}/auth/callback", "mcp.auth.callback")
364347
.at((ctx) => ({
365348
path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
366349
headers: ctx.headers(),
367-
body: { code: 1 },
350+
body: { code: "code" },
368351
}))
369-
.status(400),
352+
.json(404, object, "status"),
370353
http.protected
371354
.post("/mcp/{name}/connect", "mcp.connect")
372355
.mutating()
373356
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
374-
.json(200, (body) => {
375-
check(body === true, "missing MCP connect should remain a no-op success")
376-
}),
357+
.json(404, object, "status"),
377358
http.protected
378359
.post("/mcp/{name}/disconnect", "mcp.disconnect")
379360
.mutating()
380361
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
381-
.json(200, (body) => {
382-
check(body === true, "missing MCP disconnect should remain a no-op success")
383-
}),
362+
.json(404, object, "status"),
384363
http.protected.get("/pty/shells", "pty.shells").json(200, array),
385364
http.protected.get("/pty", "pty.list").json(200, array),
386365
http.protected

0 commit comments

Comments
 (0)