Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/seclab_taskflow_agent/mcp_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_mcp_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) --


Expand Down