Skip to content

Commit 21e85e1

Browse files
feat: add option to prefix tool name w/ server name (#387)
Adds a `tool_name_prefix` argument to `MultiServerMCPClient`. If `True`, tool names are prefixed with the server name using an underscore separator (e.g., `"weather_search"` instead of `"search"`). This helps avoid conflicts when multiple servers have tools with the same name. Defaults to `False` (backwards compat). In the future, I could see us extending this to use a custom callable, but I don't think that's important in the short term (haven't gotten any requests for this). Fixes #273
1 parent 1681291 commit 21e85e1

File tree

3 files changed

+206
-1
lines changed

3 files changed

+206
-1
lines changed

langchain_mcp_adapters/client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __init__(
5454
*,
5555
callbacks: Callbacks | None = None,
5656
tool_interceptors: list[ToolCallInterceptor] | None = None,
57+
tool_name_prefix: bool = False,
5758
) -> None:
5859
"""Initialize a `MultiServerMCPClient` with MCP servers connections.
5960
@@ -63,6 +64,10 @@ def __init__(
6364
callbacks: Optional callbacks for handling notifications and events.
6465
tool_interceptors: Optional list of tool call interceptors for modifying
6566
requests and responses.
67+
tool_name_prefix: If `True`, tool names are prefixed with the server name
68+
using an underscore separator (e.g., `"weather_search"` instead of
69+
`"search"`). This helps avoid conflicts when multiple servers have tools
70+
with the same name. Defaults to `False`.
6671
6772
!!! example "Basic usage (starting a new session on each tool call)"
6873
@@ -104,6 +109,7 @@ def __init__(
104109
)
105110
self.callbacks = callbacks or Callbacks()
106111
self.tool_interceptors = tool_interceptors or []
112+
self.tool_name_prefix = tool_name_prefix
107113

108114
@asynccontextmanager
109115
async def session(
@@ -171,6 +177,7 @@ async def get_tools(self, *, server_name: str | None = None) -> list[BaseTool]:
171177
callbacks=self.callbacks,
172178
server_name=server_name,
173179
tool_interceptors=self.tool_interceptors,
180+
tool_name_prefix=self.tool_name_prefix,
174181
)
175182

176183
all_tools: list[BaseTool] = []
@@ -183,6 +190,7 @@ async def get_tools(self, *, server_name: str | None = None) -> list[BaseTool]:
183190
callbacks=self.callbacks,
184191
server_name=name,
185192
tool_interceptors=self.tool_interceptors,
193+
tool_name_prefix=self.tool_name_prefix,
186194
)
187195
)
188196
load_mcp_tool_tasks.append(load_mcp_tool_task)

langchain_mcp_adapters/tools.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ def convert_mcp_tool_to_langchain_tool(
277277
callbacks: Callbacks | None = None,
278278
tool_interceptors: list[ToolCallInterceptor] | None = None,
279279
server_name: str | None = None,
280+
tool_name_prefix: bool = False,
280281
) -> BaseTool:
281282
"""Convert an MCP tool to a LangChain tool.
282283
@@ -290,6 +291,8 @@ def convert_mcp_tool_to_langchain_tool(
290291
callbacks: Optional callbacks for handling notifications and events
291292
tool_interceptors: Optional list of interceptors for tool call processing
292293
server_name: Name of the server this tool belongs to
294+
tool_name_prefix: If `True` and `server_name` is provided, the tool name will be
295+
prefixed w/ server name (e.g., `"weather_search"` instead of `"search"`)
293296
294297
Returns:
295298
a LangChain tool
@@ -415,8 +418,13 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
415418
meta = {"_meta": meta} if meta is not None else {}
416419
metadata = {**base, **meta} or None
417420

421+
# Apply server name prefix if requested
422+
lc_tool_name = tool.name
423+
if tool_name_prefix and server_name:
424+
lc_tool_name = f"{server_name}_{tool.name}"
425+
418426
return StructuredTool(
419-
name=tool.name,
427+
name=lc_tool_name,
420428
description=tool.description or "",
421429
args_schema=tool.inputSchema,
422430
coroutine=call_tool,
@@ -432,6 +440,7 @@ async def load_mcp_tools(
432440
callbacks: Callbacks | None = None,
433441
tool_interceptors: list[ToolCallInterceptor] | None = None,
434442
server_name: str | None = None,
443+
tool_name_prefix: bool = False,
435444
) -> list[BaseTool]:
436445
"""Load all available MCP tools and convert them to LangChain [tools](https://docs.langchain.com/oss/python/langchain/tools).
437446
@@ -441,6 +450,8 @@ async def load_mcp_tools(
441450
callbacks: Optional `Callbacks` for handling notifications and events.
442451
tool_interceptors: Optional list of interceptors for tool call processing.
443452
server_name: Name of the server these tools belong to.
453+
tool_name_prefix: If `True` and `server_name` is provided, tool names will be
454+
prefixed w/ server name (e.g., `"weather_search"` instead of `"search"`).
444455
445456
Returns:
446457
List of LangChain [tools](https://docs.langchain.com/oss/python/langchain/tools).
@@ -480,6 +491,7 @@ async def load_mcp_tools(
480491
callbacks=callbacks,
481492
tool_interceptors=tool_interceptors,
482493
server_name=server_name,
494+
tool_name_prefix=tool_name_prefix,
483495
)
484496
for tool in tools
485497
]

tests/test_tools.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,3 +992,188 @@ class CustomAgentState(AgentState):
992992
isinstance(msg, ToolMessage) and msg.content == "Counter updated!"
993993
for msg in result["messages"]
994994
)
995+
996+
997+
# Tests for tool_name_prefix functionality
998+
999+
1000+
def _create_weather_search_server():
1001+
"""Create a weather server with a search tool."""
1002+
server = FastMCP(port=8185)
1003+
1004+
@server.tool()
1005+
def search(query: str) -> str:
1006+
"""Search for weather information"""
1007+
return f"Weather results for: {query}"
1008+
1009+
return server
1010+
1011+
1012+
def _create_flights_search_server():
1013+
"""Create a flights server with a search tool."""
1014+
server = FastMCP(port=8186)
1015+
1016+
@server.tool()
1017+
def search(destination: str) -> str:
1018+
"""Search for flights"""
1019+
return f"Flight results to: {destination}"
1020+
1021+
return server
1022+
1023+
1024+
@pytest.mark.skipif(not LANGCHAIN_INSTALLED, reason="langchain not installed")
1025+
async def test_parallel_tool_invocation_across_multiple_servers(socket_enabled) -> None:
1026+
"""Test that two servers with identically named tools can be invoked in parallel.
1027+
1028+
This test verifies that:
1029+
1. Two MCP servers can each expose a tool with the same name (search)
1030+
2. With tool_name_prefix=True, they get unique LangChain names
1031+
(weather_search, flights_search)
1032+
3. When an LLM calls both tools in parallel,
1033+
each tool is routed to the correct server
1034+
4. The correct results come back from each server
1035+
"""
1036+
from langchain.agents import AgentState, create_agent # noqa: PLC0415
1037+
from langgraph.checkpoint.memory import MemorySaver # noqa: PLC0415
1038+
1039+
with (
1040+
run_streamable_http(_create_weather_search_server, 8185),
1041+
run_streamable_http(_create_flights_search_server, 8186),
1042+
):
1043+
client = MultiServerMCPClient(
1044+
{
1045+
"weather": {
1046+
"url": "http://localhost:8185/mcp",
1047+
"transport": "streamable_http",
1048+
},
1049+
"flights": {
1050+
"url": "http://localhost:8186/mcp",
1051+
"transport": "streamable_http",
1052+
},
1053+
},
1054+
tool_name_prefix=True,
1055+
)
1056+
tools = await client.get_tools()
1057+
1058+
# Verify we have both prefixed tools
1059+
assert len(tools) == 2
1060+
tool_names = {t.name for t in tools}
1061+
assert tool_names == {"weather_search", "flights_search"}
1062+
1063+
# Simulate an LLM calling both tools in parallel (common pattern for agents)
1064+
model = FixedGenericFakeChatModel(
1065+
messages=iter(
1066+
[
1067+
AIMessage(
1068+
content="",
1069+
tool_calls=[
1070+
{
1071+
"name": "weather_search",
1072+
"args": {"query": "sunny in Paris"},
1073+
"id": "call_weather",
1074+
"type": "tool_call",
1075+
},
1076+
{
1077+
"name": "flights_search",
1078+
"args": {"destination": "Tokyo"},
1079+
"id": "call_flights",
1080+
"type": "tool_call",
1081+
},
1082+
],
1083+
),
1084+
AIMessage(content="Here are your results."),
1085+
]
1086+
)
1087+
)
1088+
1089+
agent = create_agent(
1090+
model,
1091+
tools,
1092+
state_schema=AgentState,
1093+
checkpointer=MemorySaver(),
1094+
)
1095+
1096+
# Run the agent - both tools should be called in parallel
1097+
result = await agent.ainvoke(
1098+
{"messages": [HumanMessage(content="Search weather and flights")]},
1099+
{"configurable": {"thread_id": "test_parallel"}},
1100+
)
1101+
1102+
# Verify both tools were called and returned correct results
1103+
tool_messages = [
1104+
msg for msg in result["messages"] if isinstance(msg, ToolMessage)
1105+
]
1106+
assert len(tool_messages) == 2
1107+
1108+
# Create a mapping of tool_call_id to content for easier assertion
1109+
results_by_id = {msg.tool_call_id: msg.content for msg in tool_messages}
1110+
1111+
# Verify the weather search was routed to the weather server
1112+
assert results_by_id["call_weather"] == [
1113+
{
1114+
"type": "text",
1115+
"text": "Weather results for: sunny in Paris",
1116+
"id": IsLangChainID,
1117+
}
1118+
]
1119+
1120+
# Verify the flights search was routed to the flights server
1121+
assert results_by_id["call_flights"] == [
1122+
{
1123+
"type": "text",
1124+
"text": "Flight results to: Tokyo",
1125+
"id": IsLangChainID,
1126+
}
1127+
]
1128+
1129+
1130+
async def test_get_tools_with_name_conflict(socket_enabled) -> None:
1131+
"""Test fetching tools with name conflict using tool_name_prefix.
1132+
1133+
This test verifies that:
1134+
1. Without tool_name_prefix, both servers would have conflicting "search" tool names
1135+
2. With tool_name_prefix=True, tools get unique names
1136+
(weather_search, flights_search)
1137+
"""
1138+
with (
1139+
run_streamable_http(_create_weather_search_server, 8185),
1140+
run_streamable_http(_create_flights_search_server, 8186),
1141+
):
1142+
# First, verify that without prefix both tools would have the same name
1143+
client_no_prefix = MultiServerMCPClient(
1144+
{
1145+
"weather": {
1146+
"url": "http://localhost:8185/mcp",
1147+
"transport": "streamable_http",
1148+
},
1149+
"flights": {
1150+
"url": "http://localhost:8186/mcp",
1151+
"transport": "streamable_http",
1152+
},
1153+
},
1154+
tool_name_prefix=False,
1155+
)
1156+
tools_no_prefix = await client_no_prefix.get_tools()
1157+
# Both tools are named "search" without prefix
1158+
assert all(t.name == "search" for t in tools_no_prefix)
1159+
1160+
# Now test with prefix - tools should be disambiguated
1161+
client = MultiServerMCPClient(
1162+
{
1163+
"weather": {
1164+
"url": "http://localhost:8185/mcp",
1165+
"transport": "streamable_http",
1166+
},
1167+
"flights": {
1168+
"url": "http://localhost:8186/mcp",
1169+
"transport": "streamable_http",
1170+
},
1171+
},
1172+
tool_name_prefix=True,
1173+
)
1174+
tools = await client.get_tools()
1175+
1176+
# Verify we have both prefixed tools with unique names
1177+
assert len(tools) == 2
1178+
tool_names = {t.name for t in tools}
1179+
assert tool_names == {"weather_search", "flights_search"}

0 commit comments

Comments
 (0)