Skip to content
Closed
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
43 changes: 41 additions & 2 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000

const TolerantToolSchema = z.looseObject({
name: z.string(),
description: z.string().optional(),
inputSchema: z
.object({
type: z.literal("object"),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
})
.catchall(z.unknown()),
outputSchema: z.unknown().optional(),
})

const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
})

export const Resource = Schema.Struct({
name: Schema.String,
uri: Schema.String,
Expand Down Expand Up @@ -119,6 +137,27 @@ function remoteURL(key: string, value: string) {
log.warn("invalid remote mcp url", { key })
}

function isSchemaReferenceError(err: Error) {
return /can't resolve reference|schema.*reference|reference.*schema/i.test(err.message)
}

async function listTools(key: string, client: MCPClient, timeout: number) {
try {
return (await client.listTools(undefined, { timeout })).tools
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
if (!isSchemaReferenceError(error)) throw error

log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", {
key,
error,
})

const result = await client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout })
return result.tools as MCPToolDef[]
}
}

// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema
Expand Down Expand Up @@ -152,10 +191,10 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number

function defs(key: string, client: MCPClient, timeout?: number) {
return Effect.tryPromise({
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
try: () => listTools(key, client, timeout ?? DEFAULT_TIMEOUT),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.map((tools) => tools),
Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined)
Expand Down
42 changes: 41 additions & 1 deletion packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { MCP as MCPNS } from "../../src/mcp/index"

// Per-client state for controlling mock behavior
interface MockClientState {
tools: Array<{ name: string; description?: string; inputSchema: object }>
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
listToolsCalls: number
listToolsShouldFail: boolean
listToolsError: string
Expand Down Expand Up @@ -139,6 +139,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
return { tools: this._state?.tools ?? [] }
}

async request(request: { method: string }) {
if (request.method === "tools/list") {
return { tools: this._state?.tools ?? [] }
}
throw new Error(`unsupported request: ${request.method}`)
}

async listPrompts() {
if (this._state?.listPromptsShouldFail) {
throw new Error("listPrompts failed")
Expand Down Expand Up @@ -433,6 +440,39 @@ test(
),
)

test(
"falls back when tool output schema reference validation fails",
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "stitch-like-server"
const serverState = getOrCreateClientState("stitch-like-server")
serverState.listToolsShouldFail = true
serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
serverState.tools = [
{
name: "render_screen",
description: "renders a screen",
inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
},
]

const addResult = yield* mcp.add("stitch-like-server", {
type: "local",
command: ["echo", "test"],
})

expect((addResult.status as any)["stitch-like-server"]?.status ?? (addResult.status as any).status).toBe(
"connected",
)

const tools = yield* mcp.tools()
expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true)
expect(serverState.listToolsCalls).toBe(1)
}),
),
)

// ========================================================================
// Test: disabled server via config
// ========================================================================
Expand Down
Loading