Skip to content

Commit 3c26fc8

Browse files
authored
Merge pull request #287 from GitHubSecurityLab/anticomputer/mcp-tool-name-leading-digit
Prefix compressed MCP namespaces so tool names start with a letter
2 parents 6038bb4 + b8f6b78 commit 3c26fc8

2 files changed

Lines changed: 43 additions & 2 deletions

File tree

‎src/seclab_taskflow_agent/mcp_utils.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@
4444
# We hash long names down to this many hex characters.
4545
COMPRESSED_NAME_LENGTH: int = 12
4646

47+
# Gemini requires a function name to begin with a letter or an underscore, and
48+
# a hex digest begins with a digit ten times in sixteen. Left bare, a namespace
49+
# yields tool names such as ``8fb2adfa03efcontainer_shell_exec``, which Gemini
50+
# refuses with 400 ``invalid_request_body`` for the whole request, so one
51+
# unlucky digest takes down every tool in the run and not just that server's.
52+
# OpenAI and Anthropic document the laxer ``^[a-zA-Z0-9_-]{1,64}$`` and do
53+
# accept a leading digit, which is why this presents as a model-specific fault.
54+
# https://ai.google.dev/api/caching#FunctionDeclaration
55+
COMPRESSED_NAME_PREFIX: str = "ns"
56+
4757

4858
def compress_name(name: str) -> str:
4959
"""Return a short hash of *name* to fit the OpenAI 64-char tool-name limit.
@@ -52,11 +62,12 @@ def compress_name(name: str) -> str:
5262
name: The original tool / toolbox name.
5363
5464
Returns:
55-
A 12-character lowercase hex digest.
65+
A lowercase hex digest of ``COMPRESSED_NAME_LENGTH`` characters, behind
66+
a fixed prefix so the result always starts with a letter.
5667
"""
5768
m = hashlib.sha256()
5869
m.update(name.encode("utf-8"))
59-
return m.hexdigest()[:COMPRESSED_NAME_LENGTH]
70+
return COMPRESSED_NAME_PREFIX + m.hexdigest()[:COMPRESSED_NAME_LENGTH]
6071

6172

6273
class MCPNamespaceWrap:

‎tests/test_mcp_utils.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,36 @@ def test_list_tools_unfiltered_idempotent_on_prefixed_input():
134134
assert not result[0].name.startswith(f"{ns}{ns}")
135135

136136

137+
# -- compress_name() --
138+
139+
140+
@pytest.mark.parametrize(
141+
"server_name",
142+
# "ContainerShell" is one whose sha256 starts with a digit, which is what
143+
# made this show up as an intermittent failure of unrelated toolboxes.
144+
["ContainerShell", "RepoContext", "FindingLedger", "RepoSurvey", "", "x" * 200],
145+
)
146+
def test_compress_name_starts_with_a_letter(server_name):
147+
"""Gemini rejects a function name that starts with a digit.
148+
149+
A namespace is prefixed to every tool a server exposes, so a digest
150+
beginning with a digit produces names like `8fb2adfa03efcontainer_shell_exec`.
151+
Gemini answers 400 `invalid_request_body` to the whole request, taking down
152+
every tool in the run and not just the offending server's.
153+
"""
154+
assert compress_name(server_name)[0].isalpha()
155+
156+
157+
def test_compress_name_is_stable_and_distinct():
158+
assert compress_name("RepoContext") == compress_name("RepoContext")
159+
assert compress_name("RepoContext") != compress_name("RepoSurvey")
160+
161+
162+
def test_compress_name_leaves_room_under_the_64_character_limit():
163+
"""The prefix must not eat the headroom the compression exists to create."""
164+
assert len(compress_name("x" * 200)) + len("a_very_long_tool_name_indeed") <= 64
165+
166+
137167
# -- list_tools() (regression) --
138168

139169

0 commit comments

Comments
 (0)