fix(httpapi): return mcp server not found errors (#28817)
This commit is contained in:
parent
51da3483a9
commit
0beb4de3e8
8 changed files with 158 additions and 72 deletions
|
|
@ -67,6 +67,10 @@ export const Failed = NamedError.create("MCPFailed", {
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
||||||
|
name: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
type MCPClient = Client
|
type MCPClient = Client
|
||||||
|
|
||||||
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
|
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
|
||||||
|
|
@ -242,8 +246,8 @@ export interface Interface {
|
||||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||||
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||||
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
||||||
readonly connect: (name: string) => Effect.Effect<void>
|
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||||
readonly disconnect: (name: string) => Effect.Effect<void>
|
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||||
readonly getPrompt: (
|
readonly getPrompt: (
|
||||||
clientName: string,
|
clientName: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -253,11 +257,11 @@ export interface Interface {
|
||||||
clientName: string,
|
clientName: string,
|
||||||
resourceUri: string,
|
resourceUri: string,
|
||||||
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
|
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
|
||||||
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
|
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError>
|
||||||
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
|
readonly authenticate: (mcpName: string) => Effect.Effect<Status, NotFoundError>
|
||||||
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
|
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
|
||||||
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
|
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
|
||||||
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
|
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
|
||||||
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
|
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
|
||||||
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
|
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
|
||||||
}
|
}
|
||||||
|
|
@ -642,15 +646,12 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const connect = Effect.fn("MCP.connect")(function* (name: string) {
|
const connect = Effect.fn("MCP.connect")(function* (name: string) {
|
||||||
const mcp = yield* getMcpConfig(name)
|
const mcp = yield* requireMcpConfig(name)
|
||||||
if (!mcp) {
|
|
||||||
log.error("MCP config not found or invalid", { name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* createAndStore(name, { ...mcp, enabled: true })
|
yield* createAndStore(name, { ...mcp, enabled: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||||
|
yield* requireMcpConfig(name)
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
yield* closeClient(s, name)
|
yield* closeClient(s, name)
|
||||||
delete s.clients[name]
|
delete s.clients[name]
|
||||||
|
|
@ -759,9 +760,14 @@ export const layer = Layer.effect(
|
||||||
return mcpConfig
|
return mcpConfig
|
||||||
})
|
})
|
||||||
|
|
||||||
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
|
const requireMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||||
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
|
if (!mcpConfig) return yield* new NotFoundError({ name: mcpName })
|
||||||
|
return mcpConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
|
||||||
|
const mcpConfig = yield* requireMcpConfig(mcpName)
|
||||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||||
const url = remoteURL(mcpName, mcpConfig.url)
|
const url = remoteURL(mcpName, mcpConfig.url)
|
||||||
|
|
@ -825,11 +831,9 @@ export const layer = Layer.effect(
|
||||||
const result = yield* startAuth(mcpName)
|
const result = yield* startAuth(mcpName)
|
||||||
if (!result.authorizationUrl) {
|
if (!result.authorizationUrl) {
|
||||||
const client = "client" in result ? result.client : undefined
|
const client = "client" in result ? result.client : undefined
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
const mcpConfig = yield* requireMcpConfig(mcpName).pipe(
|
||||||
if (!mcpConfig) {
|
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
|
||||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
)
|
||||||
return { status: "failed", error: "MCP config not found after auth" } as Status
|
|
||||||
}
|
|
||||||
|
|
||||||
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
||||||
if (!client || !listed) {
|
if (!client || !listed) {
|
||||||
|
|
@ -880,6 +884,7 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||||
|
yield* requireMcpConfig(mcpName)
|
||||||
const transport = pendingOAuthTransports.get(mcpName)
|
const transport = pendingOAuthTransports.get(mcpName)
|
||||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||||
|
|
||||||
|
|
@ -898,8 +903,7 @@ export const layer = Layer.effect(
|
||||||
yield* auth.clearCodeVerifier(mcpName)
|
yield* auth.clearCodeVerifier(mcpName)
|
||||||
pendingOAuthTransports.delete(mcpName)
|
pendingOAuthTransports.delete(mcpName)
|
||||||
|
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
const mcpConfig = yield* requireMcpConfig(mcpName)
|
||||||
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
|
|
||||||
|
|
||||||
return yield* createAndStore(mcpName, mcpConfig)
|
return yield* createAndStore(mcpName, mcpConfig)
|
||||||
})
|
})
|
||||||
|
|
@ -912,8 +916,7 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
|
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
const mcpConfig = yield* requireMcpConfig(mcpName)
|
||||||
if (!mcpConfig) return false
|
|
||||||
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
|
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,15 @@ export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionN
|
||||||
{ httpApiStatus: 404 },
|
{ httpApiStatus: 404 },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
|
||||||
|
"McpServerNotFoundError",
|
||||||
|
{
|
||||||
|
name: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
{ httpApiStatus: 404 },
|
||||||
|
) {}
|
||||||
|
|
||||||
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
|
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
|
||||||
{
|
{
|
||||||
name: Schema.Literal("NotFoundError"),
|
name: Schema.Literal("NotFoundError"),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { MCP } from "@/mcp"
|
||||||
import { ConfigMCP } from "@/config/mcp"
|
import { ConfigMCP } from "@/config/mcp"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { McpServerNotFoundError } from "../errors"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
|
|
@ -67,7 +68,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(AuthStartResponse, "OAuth flow started"),
|
success: described(AuthStartResponse, "OAuth flow started"),
|
||||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
error: [UnsupportedOAuthError, McpServerNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.start",
|
identifier: "mcp.auth.start",
|
||||||
|
|
@ -80,7 +81,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
payload: AuthCallbackPayload,
|
payload: AuthCallbackPayload,
|
||||||
success: described(MCP.Status, "OAuth authentication completed"),
|
success: described(MCP.Status, "OAuth authentication completed"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
error: [HttpApiError.BadRequest, McpServerNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.callback",
|
identifier: "mcp.auth.callback",
|
||||||
|
|
@ -93,7 +94,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(MCP.Status, "OAuth authentication completed"),
|
success: described(MCP.Status, "OAuth authentication completed"),
|
||||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
error: [UnsupportedOAuthError, McpServerNotFoundError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.authenticate",
|
identifier: "mcp.auth.authenticate",
|
||||||
|
|
@ -105,7 +106,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||||
error: HttpApiError.NotFound,
|
error: McpServerNotFoundError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.remove",
|
identifier: "mcp.auth.remove",
|
||||||
|
|
@ -117,6 +118,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "MCP server connected successfully"),
|
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||||
|
error: McpServerNotFoundError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.connect",
|
identifier: "mcp.connect",
|
||||||
|
|
@ -127,6 +129,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||||
|
error: McpServerNotFoundError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.disconnect",
|
identifier: "mcp.disconnect",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { MCP } from "@/mcp"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
|
import { McpServerNotFoundError } from "../errors"
|
||||||
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
|
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
|
||||||
|
|
||||||
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
|
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
|
||||||
|
|
@ -20,38 +21,74 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
|
||||||
})
|
})
|
||||||
|
|
||||||
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
||||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
return yield* Effect.gen(function* () {
|
||||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||||
}
|
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||||
return yield* mcp.startAuth(ctx.params.name)
|
}
|
||||||
|
return yield* mcp.startAuth(ctx.params.name)
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||||
|
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
||||||
params: { name: string }
|
params: { name: string }
|
||||||
payload: typeof AuthCallbackPayload.Type
|
payload: typeof AuthCallbackPayload.Type
|
||||||
}) {
|
}) {
|
||||||
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
|
return yield* mcp
|
||||||
|
.finishAuth(ctx.params.name, ctx.payload.code)
|
||||||
|
.pipe(
|
||||||
|
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||||
|
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
||||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
return yield* Effect.gen(function* () {
|
||||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||||
}
|
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||||
return yield* mcp.authenticate(ctx.params.name)
|
}
|
||||||
|
return yield* mcp.authenticate(ctx.params.name)
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||||
|
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
||||||
|
const status = yield* mcp.status()
|
||||||
|
if (!(ctx.params.name in status))
|
||||||
|
return yield* new McpServerNotFoundError({
|
||||||
|
name: ctx.params.name,
|
||||||
|
message: `MCP server not found: ${ctx.params.name}`,
|
||||||
|
})
|
||||||
yield* mcp.removeAuth(ctx.params.name)
|
yield* mcp.removeAuth(ctx.params.name)
|
||||||
return { success: true as const }
|
return { success: true as const }
|
||||||
})
|
})
|
||||||
|
|
||||||
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
||||||
yield* mcp.connect(ctx.params.name)
|
yield* mcp
|
||||||
|
.connect(ctx.params.name)
|
||||||
|
.pipe(
|
||||||
|
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||||
|
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||||
|
),
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
|
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
|
||||||
yield* mcp.disconnect(ctx.params.name)
|
yield* mcp
|
||||||
|
.disconnect(ctx.params.name)
|
||||||
|
.pipe(
|
||||||
|
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||||
|
Effect.fail(new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` })),
|
||||||
|
),
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, mock, beforeEach } from "bun:test"
|
import { expect, mock, beforeEach } from "bun:test"
|
||||||
import { Effect, Exit } from "effect"
|
import { Cause, Effect, Exit } from "effect"
|
||||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
|
@ -635,12 +635,15 @@ it.instance(
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"connect() on nonexistent server does not throw",
|
"connect() on nonexistent server fails with NotFoundError",
|
||||||
() =>
|
() =>
|
||||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// Should not throw
|
const exit = yield* mcp.connect("nonexistent").pipe(Effect.exit)
|
||||||
yield* mcp.connect("nonexistent")
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
|
||||||
|
}
|
||||||
const status = yield* mcp.status()
|
const status = yield* mcp.status()
|
||||||
expect(status["nonexistent"]).toBeUndefined()
|
expect(status["nonexistent"]).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
|
|
@ -653,12 +656,15 @@ it.instance(
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"disconnect() on nonexistent server does not throw",
|
"disconnect() on nonexistent server fails with NotFoundError",
|
||||||
() =>
|
() =>
|
||||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* mcp.disconnect("nonexistent")
|
const exit = yield* mcp.disconnect("nonexistent").pipe(Effect.exit)
|
||||||
// Should complete without error
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ config: { mcp: {} } },
|
{ config: { mcp: {} } },
|
||||||
|
|
|
||||||
|
|
@ -329,58 +329,37 @@ const scenarios: Scenario[] = [
|
||||||
http.protected
|
http.protected
|
||||||
.post("/mcp/{name}/auth", "mcp.auth.start")
|
.post("/mcp/{name}/auth", "mcp.auth.start")
|
||||||
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||||
.json(
|
.json(404, object, "status"),
|
||||||
400,
|
|
||||||
(body) => {
|
|
||||||
object(body)
|
|
||||||
check(typeof body.error === "string", "unsupported MCP OAuth response should include error")
|
|
||||||
},
|
|
||||||
"status",
|
|
||||||
),
|
|
||||||
http.protected
|
http.protected
|
||||||
.delete("/mcp/{name}/auth", "mcp.auth.remove")
|
.delete("/mcp/{name}/auth", "mcp.auth.remove")
|
||||||
.mutating()
|
.mutating()
|
||||||
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||||
.json(200, (body) => {
|
.json(404, object, "status"),
|
||||||
object(body)
|
|
||||||
check(body.success === true, "MCP auth removal should return success")
|
|
||||||
}),
|
|
||||||
http.protected
|
http.protected
|
||||||
.post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
|
.post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
|
||||||
.at((ctx) => ({
|
.at((ctx) => ({
|
||||||
path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
|
path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
|
||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
}))
|
}))
|
||||||
.json(
|
.json(404, object, "status"),
|
||||||
400,
|
|
||||||
(body) => {
|
|
||||||
object(body)
|
|
||||||
check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error")
|
|
||||||
},
|
|
||||||
"status",
|
|
||||||
),
|
|
||||||
http.protected
|
http.protected
|
||||||
.post("/mcp/{name}/auth/callback", "mcp.auth.callback")
|
.post("/mcp/{name}/auth/callback", "mcp.auth.callback")
|
||||||
.at((ctx) => ({
|
.at((ctx) => ({
|
||||||
path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
|
path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
|
||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
body: { code: 1 },
|
body: { code: "code" },
|
||||||
}))
|
}))
|
||||||
.status(400),
|
.json(404, object, "status"),
|
||||||
http.protected
|
http.protected
|
||||||
.post("/mcp/{name}/connect", "mcp.connect")
|
.post("/mcp/{name}/connect", "mcp.connect")
|
||||||
.mutating()
|
.mutating()
|
||||||
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||||
.json(200, (body) => {
|
.json(404, object, "status"),
|
||||||
check(body === true, "missing MCP connect should remain a no-op success")
|
|
||||||
}),
|
|
||||||
http.protected
|
http.protected
|
||||||
.post("/mcp/{name}/disconnect", "mcp.disconnect")
|
.post("/mcp/{name}/disconnect", "mcp.disconnect")
|
||||||
.mutating()
|
.mutating()
|
||||||
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
|
||||||
.json(200, (body) => {
|
.json(404, object, "status"),
|
||||||
check(body === true, "missing MCP disconnect should remain a no-op success")
|
|
||||||
}),
|
|
||||||
http.protected.get("/pty/shells", "pty.shells").json(200, array),
|
http.protected.get("/pty/shells", "pty.shells").json(200, array),
|
||||||
http.protected.get("/pty", "pty.list").json(200, array),
|
http.protected.get("/pty", "pty.list").json(200, array),
|
||||||
http.protected
|
http.protected
|
||||||
|
|
|
||||||
|
|
@ -192,4 +192,36 @@ describe("mcp HttpApi", () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"returns typed not found errors for missing MCP servers",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tmp = yield* TestInstance
|
||||||
|
const handler = yield* handlerScoped
|
||||||
|
|
||||||
|
for (const input of [
|
||||||
|
{ method: "POST", route: "/mcp/missing/auth" },
|
||||||
|
{ method: "POST", route: "/mcp/missing/auth/authenticate" },
|
||||||
|
{ method: "POST", route: "/mcp/missing/auth/callback", body: JSON.stringify({ code: "code" }) },
|
||||||
|
{ method: "DELETE", route: "/mcp/missing/auth" },
|
||||||
|
{ method: "POST", route: "/mcp/missing/connect" },
|
||||||
|
{ method: "POST", route: "/mcp/missing/disconnect" },
|
||||||
|
]) {
|
||||||
|
const response = yield* request(handler, input.route, tmp.directory, {
|
||||||
|
method: input.method,
|
||||||
|
headers: input.body ? { "content-type": "application/json" } : undefined,
|
||||||
|
body: input.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
expect(yield* json(response)).toEqual({
|
||||||
|
_tag: "McpServerNotFoundError",
|
||||||
|
name: "missing",
|
||||||
|
message: "MCP server not found: missing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ config: { mcp: {} } },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -173,4 +173,21 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("documents MCP server not-found errors", () => {
|
||||||
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||||
|
|
||||||
|
for (const route of [
|
||||||
|
["post", "/mcp/{name}/auth"],
|
||||||
|
["post", "/mcp/{name}/auth/authenticate"],
|
||||||
|
["post", "/mcp/{name}/auth/callback"],
|
||||||
|
["delete", "/mcp/{name}/auth"],
|
||||||
|
["post", "/mcp/{name}/connect"],
|
||||||
|
["post", "/mcp/{name}/disconnect"],
|
||||||
|
] as const) {
|
||||||
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||||
|
"McpServerNotFoundError",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue