diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index f0b8bbe5cb..1b30c19ee6 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -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: diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 8ed4944f43..c252938f7c 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -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 # ---------------------------------------------------------------------------