From b8f6b7803ae1cd4bbda02bd57b691454a0b7474a Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Thu, 30 Jul 2026 16:02:25 -0400 Subject: [PATCH] Prefix compressed MCP namespaces so tool names start with a letter compress_name returned a bare sha256 prefix, so the namespace prepended to every tool of a server began with a digit ten times in sixteen, producing names like `8fb2adfa03efcontainer_shell_exec`. Gemini documents that a function name must start with a letter or an underscore and enforces it by rejecting the whole request with 400 invalid_request_body, so one unlucky server name disabled every tool in the run rather than that server's alone. OpenAI and Anthropic document the laxer ^[a-zA-Z0-9_-]{1,64}$ and accept a leading digit, which is why this looked like an intermittent, model-specific failure. Prefix the digest with a fixed "ns". Adds compress_name tests for the leading character, stability, distinctness and the 64-character budget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dadec5f9-3bf8-449c-84d6-db45be19bb6a --- src/seclab_taskflow_agent/mcp_utils.py | 15 +++++++++++-- tests/test_mcp_utils.py | 30 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/seclab_taskflow_agent/mcp_utils.py b/src/seclab_taskflow_agent/mcp_utils.py index 684fe36..84cc313 100644 --- a/src/seclab_taskflow_agent/mcp_utils.py +++ b/src/seclab_taskflow_agent/mcp_utils.py @@ -44,6 +44,16 @@ # We hash long names down to this many hex characters. COMPRESSED_NAME_LENGTH: int = 12 +# Gemini requires a function name to begin with a letter or an underscore, and +# a hex digest begins with a digit ten times in sixteen. Left bare, a namespace +# yields tool names such as ``8fb2adfa03efcontainer_shell_exec``, which Gemini +# refuses with 400 ``invalid_request_body`` for the whole request, so one +# unlucky digest takes down every tool in the run and not just that server's. +# OpenAI and Anthropic document the laxer ``^[a-zA-Z0-9_-]{1,64}$`` and do +# accept a leading digit, which is why this presents as a model-specific fault. +# https://ai.google.dev/api/caching#FunctionDeclaration +COMPRESSED_NAME_PREFIX: str = "ns" + def compress_name(name: str) -> str: """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: name: The original tool / toolbox name. Returns: - A 12-character lowercase hex digest. + A lowercase hex digest of ``COMPRESSED_NAME_LENGTH`` characters, behind + a fixed prefix so the result always starts with a letter. """ m = hashlib.sha256() m.update(name.encode("utf-8")) - return m.hexdigest()[:COMPRESSED_NAME_LENGTH] + return COMPRESSED_NAME_PREFIX + m.hexdigest()[:COMPRESSED_NAME_LENGTH] class MCPNamespaceWrap: diff --git a/tests/test_mcp_utils.py b/tests/test_mcp_utils.py index 01272a4..fd9ee48 100644 --- a/tests/test_mcp_utils.py +++ b/tests/test_mcp_utils.py @@ -134,6 +134,36 @@ def test_list_tools_unfiltered_idempotent_on_prefixed_input(): assert not result[0].name.startswith(f"{ns}{ns}") +# -- compress_name() -- + + +@pytest.mark.parametrize( + "server_name", + # "ContainerShell" is one whose sha256 starts with a digit, which is what + # made this show up as an intermittent failure of unrelated toolboxes. + ["ContainerShell", "RepoContext", "FindingLedger", "RepoSurvey", "", "x" * 200], +) +def test_compress_name_starts_with_a_letter(server_name): + """Gemini rejects a function name that starts with a digit. + + A namespace is prefixed to every tool a server exposes, so a digest + beginning with a digit produces names like `8fb2adfa03efcontainer_shell_exec`. + Gemini answers 400 `invalid_request_body` to the whole request, taking down + every tool in the run and not just the offending server's. + """ + assert compress_name(server_name)[0].isalpha() + + +def test_compress_name_is_stable_and_distinct(): + assert compress_name("RepoContext") == compress_name("RepoContext") + assert compress_name("RepoContext") != compress_name("RepoSurvey") + + +def test_compress_name_leaves_room_under_the_64_character_limit(): + """The prefix must not eat the headroom the compression exists to create.""" + assert len(compress_name("x" * 200)) + len("a_very_long_tool_name_indeed") <= 64 + + # -- list_tools() (regression) --