Skip to content
Open
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
23 changes: 22 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Config } from "@/config/config"
import { ConfigMCP } from "../config/mcp"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Hash } from "@opencode-ai/core/util/hash"
import z from "zod/v4"
import { Installation } from "../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
Expand Down Expand Up @@ -114,6 +115,26 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {

const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")

// OpenAI's tool-use API rejects tools[].function.name strings longer than
// 64 chars. When `<server>_<tool>` exceeds the limit, opencode silently
// dies because the provider returns 400 and the error never reaches the
// TUI. Truncate to 64 with a content-derived suffix so colliding prefixes
// stay distinct (issue #3523).
const MAX_TOOL_NAME = 64

const buildToolName = (clientName: string, toolName: string): string => {
const combined = sanitize(clientName) + "_" + sanitize(toolName)
if (combined.length <= MAX_TOOL_NAME) return combined
const hash = Hash.fast(combined).slice(0, 8)
const truncated = combined.slice(0, MAX_TOOL_NAME - hash.length - 1) + "_" + hash
log.warn(`MCP tool name exceeds ${MAX_TOOL_NAME} chars, truncating with hash suffix`, {
server: clientName,
tool: toolName,
truncated,
})
return truncated
}

// 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 @@ -642,7 +663,7 @@ export const layer = Layer.effect(

const timeout = entry?.timeout ?? defaultTimeout
for (const mcpTool of listed) {
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
result[buildToolName(clientName, mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
}
}),
{ concurrency: "unbounded" },
Expand Down
76 changes: 76 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,3 +784,79 @@ test(
}),
),
)

// ========================================================================
// Test: tool name length cap (issue #3523)
// ========================================================================
// When the combined `<server>_<tool>` exceeds 64 chars OpenAI rejects the
// request and opencode silently dies. Truncate to 64 with a content-derived
// suffix so collisions on the prefix still produce distinct keys.

test(
"tools() truncates names exceeding 64 chars and preserves uniqueness",
withInstance({}, (mcp) =>
Effect.gen(function* () {
// Server name from the issue's reported reproducer (35 chars).
const longServer = "chrome-devtools-aaaaaaaaaaaaaaaaaaa"
lastCreatedClientName = longServer
const serverState = getOrCreateClientState(longServer)
// Two tool names that share the first 55 chars after the
// "<server>_" prefix — these would collide under blind truncation.
serverState.tools = [
{
name: "perform_extremely_specific_workflow_step_alpha",
description: "first long-named tool",
inputSchema: { type: "object", properties: {} },
},
{
name: "perform_extremely_specific_workflow_step_beta",
description: "second long-named tool",
inputSchema: { type: "object", properties: {} },
},
]

yield* mcp.add(longServer, {
type: "local",
command: ["echo", "test"],
})

const tools = yield* mcp.tools()
const keys = Object.keys(tools)

// Both tools registered — neither got dropped.
expect(keys.length).toBe(2)

// Every key fits the 64-char provider limit.
for (const key of keys) {
expect(key.length).toBeLessThanOrEqual(64)
}

// Both keys are present and distinct (collision avoidance).
expect(new Set(keys).size).toBe(2)

// Truncated names end with an 8-char hex hash suffix.
for (const key of keys) {
expect(key).toMatch(/_[0-9a-f]{8}$/)
}
}),
),
)

test(
"tools() leaves short names untouched",
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "short"
const serverState = getOrCreateClientState("short")
serverState.tools = [{ name: "ping", description: "ping", inputSchema: { type: "object", properties: {} } }]

yield* mcp.add("short", {
type: "local",
command: ["echo", "test"],
})

const tools = yield* mcp.tools()
expect(Object.keys(tools)).toContain("short_ping")
}),
),
)
Loading