Skip to content

Commit 1f3ab2b

Browse files
authored
fix: prevent duplicate MCP tools error by deduplicating servers at source (#10096)
1 parent 1d4fc52 commit 1f3ab2b

File tree

4 files changed

+307
-6
lines changed

4 files changed

+307
-6
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type OpenAI from "openai"
2+
import { getMcpServerTools } from "../mcp_server"
3+
import type { McpHub } from "../../../../../services/mcp/McpHub"
4+
import type { McpServer, McpTool } from "../../../../../shared/mcp"
5+
6+
// Helper type to access function tools
7+
type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" }
8+
9+
// Helper to get the function property from a tool
10+
const getFunction = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function
11+
12+
describe("getMcpServerTools", () => {
13+
const createMockTool = (name: string, description = "Test tool"): McpTool => ({
14+
name,
15+
description,
16+
inputSchema: {
17+
type: "object",
18+
properties: {},
19+
},
20+
})
21+
22+
const createMockServer = (name: string, tools: McpTool[], source: "global" | "project" = "global"): McpServer => ({
23+
name,
24+
config: JSON.stringify({ type: "stdio", command: "test" }),
25+
status: "connected",
26+
source,
27+
tools,
28+
})
29+
30+
const createMockMcpHub = (servers: McpServer[]): Partial<McpHub> => ({
31+
getServers: vi.fn().mockReturnValue(servers),
32+
})
33+
34+
it("should return empty array when mcpHub is undefined", () => {
35+
const result = getMcpServerTools(undefined)
36+
expect(result).toEqual([])
37+
})
38+
39+
it("should return empty array when no servers are available", () => {
40+
const mockHub = createMockMcpHub([])
41+
const result = getMcpServerTools(mockHub as McpHub)
42+
expect(result).toEqual([])
43+
})
44+
45+
it("should generate tool definitions for server tools", () => {
46+
const server = createMockServer("testServer", [createMockTool("testTool")])
47+
const mockHub = createMockMcpHub([server])
48+
49+
const result = getMcpServerTools(mockHub as McpHub)
50+
51+
expect(result).toHaveLength(1)
52+
expect(result[0].type).toBe("function")
53+
expect(getFunction(result[0]).name).toBe("mcp--testServer--testTool")
54+
expect(getFunction(result[0]).description).toBe("Test tool")
55+
})
56+
57+
it("should filter out tools with enabledForPrompt set to false", () => {
58+
const enabledTool = createMockTool("enabledTool")
59+
const disabledTool = { ...createMockTool("disabledTool"), enabledForPrompt: false }
60+
const server = createMockServer("testServer", [enabledTool, disabledTool])
61+
const mockHub = createMockMcpHub([server])
62+
63+
const result = getMcpServerTools(mockHub as McpHub)
64+
65+
expect(result).toHaveLength(1)
66+
expect(getFunction(result[0]).name).toBe("mcp--testServer--enabledTool")
67+
})
68+
69+
it("should deduplicate tools when same server exists in both global and project configs", () => {
70+
const globalServer = createMockServer(
71+
"context7",
72+
[createMockTool("resolve-library-id", "Global description")],
73+
"global",
74+
)
75+
const projectServer = createMockServer(
76+
"context7",
77+
[createMockTool("resolve-library-id", "Project description")],
78+
"project",
79+
)
80+
81+
// McpHub.getServers() deduplicates with project servers taking priority
82+
// This test simulates the deduplicated result (only project server returned)
83+
const mockHub = createMockMcpHub([projectServer])
84+
85+
const result = getMcpServerTools(mockHub as McpHub)
86+
87+
// Should only have one tool (from project server)
88+
expect(result).toHaveLength(1)
89+
expect(getFunction(result[0]).name).toBe("mcp--context7--resolve-library-id")
90+
// Project server takes priority
91+
expect(getFunction(result[0]).description).toBe("Project description")
92+
})
93+
94+
it("should allow tools with different names from the same server", () => {
95+
const server = createMockServer("testServer", [
96+
createMockTool("tool1"),
97+
createMockTool("tool2"),
98+
createMockTool("tool3"),
99+
])
100+
const mockHub = createMockMcpHub([server])
101+
102+
const result = getMcpServerTools(mockHub as McpHub)
103+
104+
expect(result).toHaveLength(3)
105+
const toolNames = result.map((t) => getFunction(t).name)
106+
expect(toolNames).toContain("mcp--testServer--tool1")
107+
expect(toolNames).toContain("mcp--testServer--tool2")
108+
expect(toolNames).toContain("mcp--testServer--tool3")
109+
})
110+
111+
it("should allow tools with same name from different servers", () => {
112+
const server1 = createMockServer("server1", [createMockTool("commonTool")])
113+
const server2 = createMockServer("server2", [createMockTool("commonTool")])
114+
const mockHub = createMockMcpHub([server1, server2])
115+
116+
const result = getMcpServerTools(mockHub as McpHub)
117+
118+
expect(result).toHaveLength(2)
119+
const toolNames = result.map((t) => getFunction(t).name)
120+
expect(toolNames).toContain("mcp--server1--commonTool")
121+
expect(toolNames).toContain("mcp--server2--commonTool")
122+
})
123+
124+
it("should skip servers without tools", () => {
125+
const serverWithTools = createMockServer("withTools", [createMockTool("tool1")])
126+
const serverWithoutTools = createMockServer("withoutTools", [])
127+
const serverWithUndefinedTools: McpServer = {
128+
...createMockServer("undefinedTools", []),
129+
tools: undefined,
130+
}
131+
const mockHub = createMockMcpHub([serverWithTools, serverWithoutTools, serverWithUndefinedTools])
132+
133+
const result = getMcpServerTools(mockHub as McpHub)
134+
135+
expect(result).toHaveLength(1)
136+
expect(getFunction(result[0]).name).toBe("mcp--withTools--tool1")
137+
})
138+
139+
it("should include required fields from tool schema", () => {
140+
const toolWithRequired: McpTool = {
141+
name: "toolWithRequired",
142+
description: "Tool with required fields",
143+
inputSchema: {
144+
type: "object",
145+
properties: {
146+
requiredField: { type: "string" },
147+
optionalField: { type: "number" },
148+
},
149+
required: ["requiredField"],
150+
},
151+
}
152+
const server = createMockServer("testServer", [toolWithRequired])
153+
const mockHub = createMockMcpHub([server])
154+
155+
const result = getMcpServerTools(mockHub as McpHub)
156+
157+
expect(result).toHaveLength(1)
158+
expect(getFunction(result[0]).parameters).toEqual({
159+
type: "object",
160+
properties: {
161+
requiredField: { type: "string" },
162+
optionalField: { type: "number" },
163+
},
164+
additionalProperties: false,
165+
required: ["requiredField"],
166+
})
167+
})
168+
169+
it("should not include required field when schema has no required fields", () => {
170+
const toolWithoutRequired: McpTool = {
171+
name: "toolWithoutRequired",
172+
description: "Tool without required fields",
173+
inputSchema: {
174+
type: "object",
175+
properties: {
176+
optionalField: { type: "string" },
177+
},
178+
},
179+
}
180+
const server = createMockServer("testServer", [toolWithoutRequired])
181+
const mockHub = createMockMcpHub([server])
182+
183+
const result = getMcpServerTools(mockHub as McpHub)
184+
185+
expect(result).toHaveLength(1)
186+
expect(getFunction(result[0]).parameters).toEqual({
187+
type: "object",
188+
properties: {
189+
optionalField: { type: "string" },
190+
},
191+
additionalProperties: false,
192+
})
193+
expect(getFunction(result[0]).parameters).not.toHaveProperty("required")
194+
})
195+
})

src/core/prompts/tools/native-tools/mcp_server.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { buildMcpToolName } from "../../../../utils/mcp-name"
44

55
/**
66
* Dynamically generates native tool definitions for all enabled tools across connected MCP servers.
7+
* Tools are deduplicated by name to prevent API errors. When the same server exists in both
8+
* global and project configs, project servers take priority (handled by McpHub.getServers()).
79
*
810
* @param mcpHub The McpHub instance containing connected servers.
911
* @returns An array of OpenAI.Chat.ChatCompletionTool definitions.
@@ -15,6 +17,8 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
1517

1618
const servers = mcpHub.getServers()
1719
const tools: OpenAI.Chat.ChatCompletionTool[] = []
20+
// Track seen tool names to prevent duplicates (e.g., when same server exists in both global and project configs)
21+
const seenToolNames = new Set<string>()
1822

1923
for (const server of servers) {
2024
if (!server.tools) {
@@ -26,6 +30,16 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
2630
continue
2731
}
2832

33+
// Build sanitized tool name for API compliance
34+
// The name is sanitized to conform to API requirements (e.g., Gemini's function name restrictions)
35+
const toolName = buildMcpToolName(server.name, tool.name)
36+
37+
// Skip duplicate tool names - first occurrence wins (project servers come before global servers)
38+
if (seenToolNames.has(toolName)) {
39+
continue
40+
}
41+
seenToolNames.add(toolName)
42+
2943
const originalSchema = tool.inputSchema as Record<string, any> | undefined
3044
const toolInputProps = originalSchema?.properties ?? {}
3145
const toolInputRequired = (originalSchema?.required ?? []) as string[]
@@ -44,10 +58,6 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
4458
parameters.required = toolInputRequired
4559
}
4660

47-
// Build sanitized tool name for API compliance
48-
// The name is sanitized to conform to API requirements (e.g., Gemini's function name restrictions)
49-
const toolName = buildMcpToolName(server.name, tool.name)
50-
5161
const toolDefinition: OpenAI.Chat.ChatCompletionTool = {
5262
type: "function",
5363
function: {

src/services/mcp/McpHub.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,23 @@ export class McpHub {
435435
}
436436

437437
getServers(): McpServer[] {
438-
// Only return enabled servers
439-
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
438+
// Only return enabled servers, deduplicating by name with project servers taking priority
439+
const enabledConnections = this.connections.filter((conn) => !conn.server.disabled)
440+
441+
// Deduplicate by server name: project servers take priority over global servers
442+
const serversByName = new Map<string, McpServer>()
443+
for (const conn of enabledConnections) {
444+
const existing = serversByName.get(conn.server.name)
445+
if (!existing) {
446+
serversByName.set(conn.server.name, conn.server)
447+
} else if (conn.server.source === "project" && existing.source !== "project") {
448+
// Project server overrides global server with the same name
449+
serversByName.set(conn.server.name, conn.server)
450+
}
451+
// If existing is project and current is global, keep existing (project wins)
452+
}
453+
454+
return Array.from(serversByName.values())
440455
}
441456

442457
getAllServers(): McpServer[] {

src/services/mcp/__tests__/McpHub.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,87 @@ describe("McpHub", () => {
11361136
expect(servers[0].name).toBe("enabled-server")
11371137
})
11381138

1139+
it("should deduplicate servers by name with project servers taking priority", () => {
1140+
const mockConnections: McpConnection[] = [
1141+
{
1142+
type: "connected",
1143+
server: {
1144+
name: "shared-server",
1145+
config: '{"source":"global"}',
1146+
status: "connected",
1147+
disabled: false,
1148+
source: "global",
1149+
},
1150+
client: {} as any,
1151+
transport: {} as any,
1152+
} as ConnectedMcpConnection,
1153+
{
1154+
type: "connected",
1155+
server: {
1156+
name: "shared-server",
1157+
config: '{"source":"project"}',
1158+
status: "connected",
1159+
disabled: false,
1160+
source: "project",
1161+
},
1162+
client: {} as any,
1163+
transport: {} as any,
1164+
} as ConnectedMcpConnection,
1165+
{
1166+
type: "connected",
1167+
server: {
1168+
name: "unique-global-server",
1169+
config: "{}",
1170+
status: "connected",
1171+
disabled: false,
1172+
source: "global",
1173+
},
1174+
client: {} as any,
1175+
transport: {} as any,
1176+
} as ConnectedMcpConnection,
1177+
]
1178+
1179+
mcpHub.connections = mockConnections
1180+
const servers = mcpHub.getServers()
1181+
1182+
// Should have 2 servers: deduplicated "shared-server" + "unique-global-server"
1183+
expect(servers.length).toBe(2)
1184+
1185+
// Find the shared-server - it should be the project version
1186+
const sharedServer = servers.find((s) => s.name === "shared-server")
1187+
expect(sharedServer).toBeDefined()
1188+
expect(sharedServer!.source).toBe("project")
1189+
expect(sharedServer!.config).toBe('{"source":"project"}')
1190+
1191+
// The unique global server should also be present
1192+
const uniqueServer = servers.find((s) => s.name === "unique-global-server")
1193+
expect(uniqueServer).toBeDefined()
1194+
})
1195+
1196+
it("should keep global server when no project server with same name exists", () => {
1197+
const mockConnections: McpConnection[] = [
1198+
{
1199+
type: "connected",
1200+
server: {
1201+
name: "global-only-server",
1202+
config: "{}",
1203+
status: "connected",
1204+
disabled: false,
1205+
source: "global",
1206+
},
1207+
client: {} as any,
1208+
transport: {} as any,
1209+
} as ConnectedMcpConnection,
1210+
]
1211+
1212+
mcpHub.connections = mockConnections
1213+
const servers = mcpHub.getServers()
1214+
1215+
expect(servers.length).toBe(1)
1216+
expect(servers[0].name).toBe("global-only-server")
1217+
expect(servers[0].source).toBe("global")
1218+
})
1219+
11391220
it("should prevent calling tools on disabled servers", async () => {
11401221
// Mock fs.readFile to return a disabled server config
11411222
vi.mocked(fs.readFile).mockResolvedValue(

0 commit comments

Comments
 (0)