diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 3f985eeb94dc..db43f1d0bc41 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -35,8 +35,11 @@ export async function paginate( throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) } -export function defs(client: Client, timeout?: number) { - return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) +export function defs(clientName: string, client: Client, timeout?: number) { + return listTools(client, timeout).pipe( + Effect.tapError((error) => Effect.logWarning("failed to get tools", { clientName, error: error.message })), + Effect.orElseSucceed(() => undefined), + ) } export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { @@ -142,17 +145,20 @@ export function resourceTemplates(client: Client, timeout?: number) { ) } -function listTools(client: Client, timeout: number) { +export function listTools(client: Client, timeout?: number) { + const requestTimeout = timeout ?? DEFAULT_TIMEOUT return Effect.tryPromise({ try: () => paginate( async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { - return await client.listTools(params, { timeout }) + return await client.listTools(params, { timeout: requestTimeout }) } catch (error) { if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error - return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) + return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { + timeout: requestTimeout, + }) } }, (result) => result.tools, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 946c3ff2ca5c..9a750d0b2600 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -405,10 +405,14 @@ const layer = Layer.effect( } return yield* Effect.gen(function* () { - const listed = mcpClient.getServerCapabilities()?.tools ? yield* McpCatalog.defs(mcpClient, mcp.timeout) : [] - if (!listed) { - return yield* Effect.fail(new Error("Failed to get tools")) - } + // Surface the real listTools failure: it becomes this server's failed + // status and a log line, instead of an unexplained "Failed to get tools". + const listed = mcpClient.getServerCapabilities()?.tools + ? yield* McpCatalog.listTools(mcpClient, mcp.timeout).pipe( + Effect.tapError((error) => Effect.logWarning("failed to get tools", { key, error: error.message })), + Effect.mapError((error) => new Error(`Failed to get tools: ${error.message}`)), + ) + : [] return { mcpClient, status, @@ -481,7 +485,7 @@ const layer = Layer.effect( client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await bridge.promise(McpCatalog.defs(client, timeout)) + const listed = await bridge.promise(McpCatalog.defs(name, client, timeout)) if (!listed) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return @@ -928,7 +932,7 @@ const layer = Layer.effect( const listed = client ? client.getServerCapabilities()?.tools - ? yield* McpCatalog.defs(client, mcpConfig.timeout) + ? yield* McpCatalog.defs(mcpName, client, mcpConfig.timeout) : [] : undefined if (!client || !listed) { diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 7b0d6403bb16..d8192729dcc5 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -96,7 +96,7 @@ test("preserves output schema validation across paginated tool discovery", async await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) try { - const tools = await Effect.runPromise(McpCatalog.defs(client)) + const tools = await Effect.runPromise(McpCatalog.defs("pagination", client)) expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( "Structured content does not match the tool's output schema", diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 80c8fd22f886..7a3048716aa9 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -389,6 +389,19 @@ it.instance("does not fall back for protocol tool discovery errors", () => }), ) +it.instance("failed tool discovery surfaces the underlying error in the status", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.listToolsError = "boom-tools-list" + const mcp = yield* MCP.Service + yield* mcp.add("explained-server", remote(server.url)) + + const status = (yield* mcp.status())["explained-server"] + expect(status?.status).toBe("failed") + expect(status && "error" in status ? status.error : undefined).toContain("boom-tools-list") + }), +) + it.instance("disabled server is marked disabled without opening a protocol session", () => Effect.gen(function* () { const server = yield* lifecycleServer()