Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions packages/opencode/src/mcp/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ export async function paginate<T, R extends { nextCursor?: string }>(
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 {
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new failed to get tools log in create() records the server under field key, but the identical log in McpCatalog.defs (used by watch() and authenticate()) records it under clientName. A Loki query filtering by one server field misses failures logged by the other path, which weakens the exact observability this PR is adding. Log the server name as clientName here to match defs()/fetch().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/mcp/index.ts, line 412:

<comment>The new `failed to get tools` log in create() records the server under field `key`, but the identical log in McpCatalog.defs (used by watch() and authenticate()) records it under `clientName`. A Loki query filtering by one server field misses failures logged by the other path, which weakens the exact observability this PR is adding. Log the server name as `clientName` here to match defs()/fetch().</comment>

<file context>
@@ -405,10 +405,14 @@ const layer = Layer.effect(
+          // 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}`)),
+              )
</file context>
Suggested change
Effect.tapError((error) => Effect.logWarning("failed to get tools", { key, error: error.message })),
Effect.tapError((error) => Effect.logWarning("failed to get tools", { clientName: key, error: error.message })),

Effect.mapError((error) => new Error(`Failed to get tools: ${error.message}`)),
)
: []
return {
mcpClient,
status,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/test/mcp/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading