Skip to content

symfonic.tools.mcp.routing

routing

How a discovered MCP tool is named and where a call to it goes.

Split out of :mod:symfonic.tools.mcp.provider because it is the whole of T4.2.2's HIGH finding and it is worth reading on its own: the provider used to keep dict[str, tuple[connection, definition]] keyed on the bare tool name, so two servers advertising search shared one slot and the second one registered inherited the first one's traffic — arguments included — with nothing logged.

Two properties close that, and they are separate:

  • Routing is pinned at discovery. A :class:ToolRoute holds the connection the tool was found on. Dispatch is a lookup of a route, never a lookup of a server by name, so there is no later payload that can redirect a call.
  • Names are disambiguated on collision. A name advertised by more than one server becomes "{server}.{tool}" for every server advertising it, so neither party keeps the ambiguous name and no server is quietly favoured.

The naming rule is deliberately "on collision" rather than always. Namespacing unconditionally is the cleaner design — it is what :class:~symfonic.capabilities.extensions.McpExtensionAdapter does — but this module is the shipped, publicly-exported path, and renaming every tool would break prompts and allowlists that adopters already have, in a release that is not a MAJOR. Disambiguating only what is genuinely ambiguous fixes the finding and leaves every non-colliding deployment byte-identical.

ToolRoute dataclass

ToolRoute(server: str, connection: MCPServerConnection, definition: MCPToolDefinition, wire_name: str)

One discovered tool: what it is called, and where its calls go.

definition instance-attribute

definition: MCPToolDefinition

The definition as exposed, i.e. carrying :attr:exposed_name.

wire_name instance-attribute

wire_name: str

The name the owning server knows. The prefix is ours, not theirs, so a call is placed under this name even when the tool is exposed namespaced.

namespaced

namespaced(server: str, tool_name: str) -> str

The disambiguated form of tool_name under server.

Source code in src/symfonic/tools/mcp/routing.py
def namespaced(server: str, tool_name: str) -> str:
    """The disambiguated form of *tool_name* under *server*."""
    return f"{server}{SEPARATOR}{tool_name}"

resolve_routes

resolve_routes(catalogues: Mapping[str, tuple[MCPServerConnection, Sequence[MCPToolDefinition]]]) -> dict[str, ToolRoute]

Build the routing table for every server's current catalogue.

Parameters:

Name Type Description Default
catalogues Mapping[str, tuple[MCPServerConnection, Sequence[MCPToolDefinition]]]

Server label -> (connection, the definitions it advertises). Insertion order is preserved in the result, so discovery keeps returning tools in server-registration order.

required

Returns:

Type Description
dict[str, ToolRoute]

Exposed tool name -> the route that serves it.

Source code in src/symfonic/tools/mcp/routing.py
def resolve_routes(
    catalogues: Mapping[str, tuple[MCPServerConnection, Sequence[MCPToolDefinition]]],
) -> dict[str, ToolRoute]:
    """Build the routing table for every server's current catalogue.

    Args:
        catalogues: Server label -> (connection, the definitions it advertises).
            Insertion order is preserved in the result, so discovery keeps
            returning tools in server-registration order.

    Returns:
        Exposed tool name -> the route that serves it.
    """
    colliding = _colliding_names(catalogues)
    routes: dict[str, ToolRoute] = {}
    for server, (connection, definitions) in catalogues.items():
        for definition in definitions:
            wire_name = definition.name
            exposed = (
                namespaced(server, wire_name) if wire_name in colliding else wire_name
            )
            if exposed in routes:
                # Two servers whose *namespaced* forms still collide, or one
                # server advertising a name twice. Rare, and a silent overwrite
                # here would be the original defect in miniature.
                logger.warning(
                    "MCP tool name %r is claimed by more than one route; "
                    "keeping the one discovered on server %r",
                    exposed,
                    routes[exposed].server,
                )
                continue
            routes[exposed] = ToolRoute(
                server=server,
                connection=connection,
                definition=replace(definition, name=exposed),
                wire_name=wire_name,
            )
    return routes

tool_name_of

tool_name_of(row: Any) -> str | None

The tool name on a discovery row, or None when it has no usable one.

Total by design. The shipped code indexed tool_data["name"] and let a KeyError escape, and because discovery catches Exception per server, one malformed row discarded every other tool that server advertised — a whole integration lost to one bad entry. Returning None lets the caller drop the row and keep its siblings.

Source code in src/symfonic/tools/mcp/routing.py
def tool_name_of(row: Any) -> str | None:
    """The tool name on a discovery row, or ``None`` when it has no usable one.

    Total by design. The shipped code indexed ``tool_data["name"]`` and let a
    ``KeyError`` escape, and because discovery catches ``Exception`` *per
    server*, one malformed row discarded every other tool that server
    advertised — a whole integration lost to one bad entry. Returning ``None``
    lets the caller drop the row and keep its siblings.
    """
    name = getattr(row, "name", None)
    if isinstance(name, str) and name:
        return name
    return None