Skip to content
Open
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
28 changes: 21 additions & 7 deletions nanobot/agent/tools/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,23 +173,37 @@ def _filter_malformed_mcp_progress_notifications(read_stream: Any, server_name:
return _MalformedProgressNotificationFilter(read_stream, server_name)


def _sanitize_name(name: str) -> str:
"""Sanitize an MCP-derived name for model API compatibility."""
return _SANITIZE_RE.sub("_", re.sub(r"[^a-zA-Z0-9_-]", "_", name))


_MAX_TOOL_NAME_LENGTH = 64
_HASH_LENGTH = 8


def _short_digest(value: str, length: int = _HASH_LENGTH) -> str:
"""Return a truncated hex digest of ``value`` for naming purposes.

Not for security; just produces a stable, collision-resistant short suffix
when a name must be shortened or replaced.
"""
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:length]


def _sanitize_name(name: str) -> str:
"""Sanitize an MCP-derived name for model API compatibility."""
sanitized = _SANITIZE_RE.sub("_", re.sub(r"[^a-zA-Z0-9_-]", "_", name))
if re.search(r"[^\x00-\x7f]", name):
core = sanitized.rstrip("_")
if core == "":
return f"tool_{_short_digest(name)}"
return f"{core}_{_short_digest(name)}"
return sanitized


def _limit_tool_name(name: str, max_length: int = _MAX_TOOL_NAME_LENGTH) -> str:
"""Limit a tool name while keeping short names unchanged."""
if len(name) <= max_length:
return name

digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:_HASH_LENGTH]
prefix_length = max_length - _HASH_LENGTH - 1
return f"{name[:prefix_length]}_{digest}"
return f"{name[:prefix_length]}_{_short_digest(name)}"


def _sanitize_mcp_tool_name(name: str) -> str:
Expand Down
42 changes: 42 additions & 0 deletions tests/tools/test_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1812,6 +1812,48 @@ def test_sanitize_name_noop_for_already_clean_names() -> None:
assert _sanitize_name("mcp_server_tool") == "mcp_server_tool"


def test_sanitize_name_falls_back_to_hash_for_all_underscore_names() -> None:
# Fully non-ASCII names collapse to underscores; they must fall back to a
# deterministic hash-based name instead of silently colliding on "_".
import hashlib

name = "获取天气"
expected = f"tool_{hashlib.sha1(name.encode('utf-8')).hexdigest()[:8]}"
assert _sanitize_name(name) == expected


def test_sanitize_name_keeps_distinct_non_ascii_names_unique() -> None:
# Two different non-ASCII names must sanitize to different results so they
# don't collide when registered.
assert _sanitize_name("获取天气") != _sanitize_name("日本語ツール")


def test_sanitize_name_hashes_non_ascii_with_ascii_prefix() -> None:
# The real call path always carries an ASCII ``mcp_{server}_`` prefix, so
# the old ``tool_{digest}`` fallback never fired and Chinese tool names
# collapsed to the same underscore run. The digest must be appended to the
# stripped core instead, without stacking underscores.
name = "mcp_server_城市天气实况"
expected = f"mcp_server_{mcp_mod._short_digest(name)}"
assert _sanitize_name(name) == expected


def test_sanitize_name_keeps_ascii_part_for_mixed_names() -> None:
# Mixed Chinese+ASCII names keep their readable ASCII part (15, POI, ...)
# in front of the digest.
name = "mcp_天气预报_城市15日预报"
assert _sanitize_name(name) == f"mcp_15_{mcp_mod._short_digest(name)}"
assert "__" not in _sanitize_name(name)


def test_sanitize_name_leaves_pure_ascii_names_untouched() -> None:
# Only non-ASCII names get a digest appended; ASCII names with spaces or
# special characters sanitize readably without a hash.
assert _sanitize_name("mcp_srv_My Tool") == "mcp_srv_My_Tool"
assert _sanitize_name("mcp_my server_prompt_design-schema") == "mcp_my_server_prompt_design-schema"
assert _sanitize_name("mcp_server_tool") == "mcp_server_tool"


# ---------------------------------------------------------------------------
# Wrapper sanitization tests
# ---------------------------------------------------------------------------
Expand Down