deer-flow/backend/tests/test_mcp_routing_index_normalization.py
yetuge 35656d6a9f
fix(agents): ignore malformed entries in a serialized MCP routing index (#7751)
* fix(agents): ignore malformed entries in a serialized MCP routing index

`McpRoutingMiddleware._normalize_index` documents that it accepts "arbitrary
serialized routing data, not only the output of the builder", but two shapes
escaped the coercion and raised instead:

- an entry that is not a mapping (`str`, `list`, `None`, a number) reached
  `.get` and raised `AttributeError`;
- a non-finite `priority` raised `OverflowError` from `int()` — JSON accepts
  `Infinity`/`-Infinity` literals, so a hand-edited or foreign index can carry
  one.

The middleware is constructed per run, so either shape fails every model call
in that process instead of the entry being ignored the way an entry with no
keywords already is.

Both now degrade to "not indexable": a non-mapping entry is skipped, and a
priority that cannot be a finite int falls back to 0 (the existing value for
`None`/non-numeric priorities). Valid entries are unchanged.

Tests cover each rejected shape, the control shapes that already degraded, and
the builder-shaped output that must stay identical. Dropping only the mapping
guard fails the non-mapping cases; dropping only `OverflowError` fails the
non-finite cases.

* fix(mcp): handle infinite routing priorities in the builder

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-01 08:08:08 +08:00

97 lines
3.9 KiB
Python

"""A serialized MCP routing index must not break middleware construction.
``McpRoutingMiddleware._normalize_index`` documents that it accepts "arbitrary
serialized routing data, not only the output of the builder". Two shapes the
loader can produce nevertheless escaped the coercion:
- an entry that is not a mapping (``str`` / ``list`` / ``None``) raised
``AttributeError`` on ``.get``;
- a non-finite ``priority`` raised ``OverflowError`` from ``int()`` — JSON
accepts ``Infinity``, so a hand-edited or foreign index can carry one.
Construction happens once per run, so either shape turns every model call in
that process into an exception instead of the entry being ignored.
"""
from __future__ import annotations
import pytest
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, build_mcp_routing_middleware
from deerflow.tools.mcp_metadata import tag_mcp_routing, tag_mcp_tool
normalize = McpRoutingMiddleware._normalize_index
@pytest.mark.parametrize("bad_entry", ["just-a-string", ["keyword"], None, 7, 1.5])
def test_non_mapping_entries_are_skipped(bad_entry):
"""The malformed entry is dropped; its valid sibling still normalizes."""
index = {"tool.bad": bad_entry, "tool.good": {"priority": 2, "keywords": ["search"]}}
assert normalize(index) == {"tool.good": (2, ("search",))}
@pytest.mark.parametrize("priority", [float("inf"), float("-inf")])
def test_non_finite_priority_falls_back_to_zero(priority):
"""int() refuses infinity, and JSON accepts it as a literal."""
assert normalize({"tool.a": {"priority": priority, "keywords": ["k"]}}) == {"tool.a": (0, ("k",))}
def test_nan_priority_falls_back_to_zero():
assert normalize({"tool.a": {"priority": float("nan"), "keywords": ["k"]}}) == {"tool.a": (0, ("k",))}
def test_valid_entries_are_unchanged():
"""Control: the builder's own output still normalizes exactly as before."""
index = {
"tool.a": {"priority": 3, "keywords": ["search", "find"]},
"tool.b": {"priority": 0, "keywords": ["read"]},
}
assert normalize(index) == {"tool.a": (3, ("search", "find")), "tool.b": (0, ("read",))}
def test_entry_without_keywords_is_dropped():
"""Existing rule: an entry that cannot match text is not indexed."""
assert normalize({"tool.a": {"priority": 1, "keywords": []}}) == {}
assert normalize({"tool.a": {"priority": 1, "keywords": "search"}}) == {}
def test_empty_name_is_dropped():
assert normalize({"": {"priority": 1, "keywords": ["k"]}}) == {}
@pytest.mark.parametrize(
("priority", "expected"),
[
pytest.param(float("inf"), 0, id="positive-infinity"),
pytest.param(float("-inf"), 0, id="negative-infinity"),
pytest.param(float("nan"), 0, id="nan"),
pytest.param(None, 0, id="none"),
pytest.param("invalid", 0, id="invalid-string"),
pytest.param([], 0, id="invalid-list"),
pytest.param("7", 7, id="numeric-string"),
pytest.param(7.5, 7, id="finite-float"),
pytest.param(-2, -2, id="negative-int"),
pytest.param(True, 1, id="bool"),
],
)
def test_builder_and_serialized_index_share_priority_coercion(priority, expected):
@tool
def routed_search(query: str) -> str:
"""Search test data."""
return query
routing = {"mode": "prefer", "priority": priority, "keywords": ["search"]}
tag_mcp_routing(tag_mcp_tool(routed_search), routing)
tools, setup = assemble_deferred_tools([routed_search], enabled=True)
middleware = build_mcp_routing_middleware(tools, setup, top_k=1)
assert isinstance(middleware, McpRoutingMiddleware)
assert middleware._routing_index == normalize({"routed_search": routing}) == {"routed_search": (expected, ("search",))}
assert middleware._matched_names({"messages": [HumanMessage(content="search")]}) == ["routed_search"]