Server-prefixed tool names are not unique across servers: server 'a'
exposing 'b_x' and server 'a_b' exposing 'x' both bind as 'a_b_x'. The
flatten step forwarded both declarations to the model, where the provider
silently keeps only one and the other server's tool becomes unreachable
with no diagnostic.
Deduplicate by bound name in enabled-server declaration order and drop
later duplicates with a warning.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(tools): escape MCP tool names rendered into deferred prompts
get_deferred_tools_prompt_section and get_mcp_routing_hints_prompt_section
list MCP tool names into the <available-deferred-tools> and
<mcp_routing_hints> system-prompt blocks without escaping, while the mirror
get_skill_index_prompt_section (per its docstring) does escape. An MCP name
is taken verbatim from an external server, so a crafted name could close the
block and forge a framework tag. Escape names (and routing keywords) at
render, mirroring the skill-index section.
* fix(mcp): validate tool names at the load boundary
Escaping at render only neutralizes < > &, so a tool name with newlines or
markdown still injects free-form text into the deferred-tools prompt block.
Deferred (tool_search) tools are never bound, so the provider's function-name
check never runs on them. Drop any MCP tool whose name is not a valid
identifier (^[A-Za-z0-9_-]+$) in get_mcp_tools() — the same charset the
provider enforces at bind time — before it can enter the catalog or the
prompt. Render-time html.escape stays as defense-in-depth. Mirrors the
load-time skill-name validation in skills/storage/skill_storage.py.