Skip to content

Commit 78294c1

Browse files
committed
Clean PR #694: drop .gitignore additions, map tool names via dicts
Revert the beads/dolt entries the PR added to .gitignore so the merge touches code only. Replace the per-call scan in responses.original_tool_name() with module-level dicts that cache the original/sanitized MCP tool name pairs. register_tool_names() fills them from coder.mcp_tools inside get_tool_list(), so dispatching a tool call is now a dict lookup instead of iterating every advertised tool. sanitize_tool_name() also reuses its cache.
1 parent ea92c10 commit 78294c1

4 files changed

Lines changed: 59 additions & 21 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,4 @@ __pycache__/
4747
cecli/website/_site/*
4848
cecli/website/.sass-cache/*
4949
cecli/website/.docmd-*/*
50-
cecli/website/node_modules/*
51-
52-
# Beads / Dolt files (added by bd init)
53-
.dolt/
54-
*.db
55-
.beads-credential-key
56-
.beads/proxieddb/
50+
cecli/website/node_modules/*

‎cecli/coders/base_coder.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3412,7 +3412,7 @@ async def call_mcp_tool_from_session(self, session, tool_call):
34123412

34133413
# Providers require a provider-safe tool name, so the name coming back
34143414
# from the model may not be the name the MCP server advertises.
3415-
name = responses.original_tool_name(name, self.mcp_tools)
3415+
name = responses.original_tool_name(name)
34163416

34173417
return await session.call_tool(name=name, arguments=arguments)
34183418

@@ -3503,6 +3503,7 @@ def mcp_tools(self, value):
35033503

35043504
def get_tool_list(self):
35053505
"""Get a flattened list of all MCP tools with server prefixes, filtered by registered_servers."""
3506+
responses.register_tool_names(self.mcp_tools)
35063507
tool_list = []
35073508
if self.mcp_tools:
35083509
for server_name, server_tools in self.mcp_tools:

‎cecli/helpers/responses.py‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ def extract_tools_from_pseudo_json(content: str) -> Optional[List[ChatCompletion
247247
return None
248248

249249

250+
_tool_name_to_sanitized: dict[str, str] = {}
251+
_sanitized_to_tool_name: dict[str, str] = {}
252+
253+
250254
def sanitize_tool_name(name: str) -> str:
251255
"""Make a name acceptable in OpenAI-style ``tools[].function.name``.
252256
@@ -255,22 +259,42 @@ def sanitize_tool_name(name: str) -> str:
255259
Idempotent, so it is also safe on an incoming tool call before the name is
256260
compared against, or mapped back to, the server's own name.
257261
"""
258-
return re.sub(r"[^A-Za-z0-9_-]", "_", name)[:64]
262+
cached = _tool_name_to_sanitized.get(name)
259263

264+
if cached is not None:
265+
return cached
260266

261-
def original_tool_name(name: str, server_tools) -> str:
262-
"""Map a sanitized tool name back to the name the MCP server advertises.
267+
sanitized = re.sub(r"[^A-Za-z0-9_-]", "_", name)[:64]
268+
_tool_name_to_sanitized[name] = sanitized
269+
270+
return sanitized
263271

264-
``server_tools`` is an iterable of ``(server_name, tools)`` pairs. Returns
265-
``name`` unchanged when nothing matches, so an unsanitized name still
266-
reaches the server as-is.
272+
273+
def register_tool_names(server_tools) -> None:
274+
"""Cache the original/sanitized MCP tool name pairs.
275+
276+
``server_tools`` is an iterable of ``(server_name, tools)`` pairs, i.e. the
277+
shape of ``coder.mcp_tools``. Registering when the tool list is built means
278+
mapping a call back to the server's advertised name is a dict lookup rather
279+
than a scan over every tool on every call.
267280
"""
268281
for _server_name, tools in server_tools or []:
269282
for tool in tools:
270-
candidate = nested.getter(tool, "function.name", "")
271-
if candidate and sanitize_tool_name(candidate) == name:
272-
return candidate
273-
return name
283+
original = nested.getter(tool, "function.name", "")
284+
285+
if not original:
286+
continue
287+
288+
_sanitized_to_tool_name[sanitize_tool_name(original)] = original
289+
290+
291+
def original_tool_name(name: str) -> str:
292+
"""Map a sanitized tool name back to the name the MCP server advertises.
293+
294+
Returns ``name`` unchanged when nothing matches, so an unsanitized name
295+
still reaches the server as-is.
296+
"""
297+
return _sanitized_to_tool_name.get(name, name)
274298

275299

276300
def prefix_tool_name(server_name: str, tool_name: str) -> str:

‎tests/mcp/test_tool_name_sanitization.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ def test_honors_provider_length_limit(self):
4343
assert len(responses.sanitize_tool_name("a" * 200)) == 64
4444

4545

46+
class TestRegisteredToolNames:
47+
def test_registered_name_maps_back(self):
48+
responses.register_tool_names([("browser", [_tool("browser.persisted_session.list")])])
49+
50+
assert (
51+
responses.original_tool_name("browser_persisted_session_list")
52+
== "browser.persisted_session.list"
53+
)
54+
55+
def test_unknown_name_is_unchanged(self):
56+
assert responses.original_tool_name("never_registered") == "never_registered"
57+
58+
4659
class TestGetToolListSanitizesNames:
4760
def _coder(self, tools):
4861
return SimpleNamespace(
@@ -88,9 +101,15 @@ def test_sanitized_call_resolves_to_its_server(self):
88101
class TestCallToolMapsBackToAdvertisedName:
89102
"""The name the model calls must reach the server unsanitized."""
90103

104+
def _coder(self):
105+
coder = SimpleNamespace(mcp_tools=[("browser", [_tool("browser.fetch")])])
106+
responses.register_tool_names(coder.mcp_tools)
107+
108+
return coder
109+
91110
@pytest.mark.asyncio
92111
async def test_sanitized_name_is_mapped_back(self):
93-
coder = SimpleNamespace(mcp_tools=[("browser", [_tool("browser.fetch")])])
112+
coder = self._coder()
94113
session = MagicMock()
95114
session.call_tool = AsyncMock(return_value="ok")
96115

@@ -100,7 +119,7 @@ async def test_sanitized_name_is_mapped_back(self):
100119

101120
@pytest.mark.asyncio
102121
async def test_unsanitized_name_passes_through(self):
103-
coder = SimpleNamespace(mcp_tools=[("browser", [_tool("browser.fetch")])])
122+
coder = self._coder()
104123
session = MagicMock()
105124
session.call_tool = AsyncMock(return_value="ok")
106125

@@ -110,7 +129,7 @@ async def test_unsanitized_name_passes_through(self):
110129

111130
@pytest.mark.asyncio
112131
async def test_unknown_name_is_not_rewritten(self):
113-
coder = SimpleNamespace(mcp_tools=[("browser", [_tool("browser.fetch")])])
132+
coder = self._coder()
114133
session = MagicMock()
115134
session.call_tool = AsyncMock(return_value="ok")
116135

0 commit comments

Comments
 (0)