🐞 Bug Summary
#6057 / #6129 fixed duplicate custom_name values slipping through the tool rename path (update_tool, edit modal). While reviewing that fix, several other paths that create or modify tools were checked, and duplicate custom_name values within the same visibility scope are still possible through all of them. Per the MCP spec, each tool exposed by a server must be uniquely identified by name — tools/call has no other disambiguator — so a duplicate custom_name within one visibility scope is a protocol-correctness issue, not just cosmetic.
🧩 Affected Component
Gaps found (with evidence)
1. Federation bulk-import performs no conflict check at all
mcpgateway/services/gateway_service.py:1739 builds DbTool(custom_name=tool.name, ...) in a loop over the upstream server's discovered tools and appends directly to db_tools — it never calls any conflict-check helper. Two different upstream gateways both exposing a tool named e.g. search, federated into the same visibility scope (e.g. public), land as two DbTool rows with the same custom_name. Nothing blocks it.
2. register_tool's creation-time check has a slug/case mismatch that lets duplicates through
mcpgateway/services/tool_service.py:2061-2072 (in register_tool) checks:
existing_tool = db.execute(select(DbTool).where(DbTool.name == tool.name, DbTool.visibility == "public")).scalar_one_or_none()
This compares the raw incoming tool.name against the stored, already-slugified DbTool.name column. DbTool.name is only normalized to slugify(custom_name) in the before_insert event listener (mcpgateway/db.py:6779,6805), which runs after this check. So an existing public tool stored as name="my-tool" and a new tool submitted with tool.name="My Tool" (different case/spacing, same slug) do not match in the check ("My Tool" != "my-tool"), and the second tool is created — producing two tools that resolve to an identical exposed slug.
3. No conflict check at all for private visibility at creation time
Same location (mcpgateway/services/tool_service.py:2061-2072) — only if visibility == "public" and elif visibility == "team" branches exist. There is no private branch, so creating two private tools with the same custom_name under the same owner is never checked.
4. No DB-level backstop for the scope the new update_tool check enforces
mcpgateway/db.py:3432-3433 — the only unique constraint on tools is UniqueConstraint("team_id", "owner_email", "name"), keyed on the computed, gateway-prefixed name column and requiring a matching owner_email. It does not back custom_name uniqueness across different owners in the same team or across public tools from different owners — which is exactly the scope _check_tool_name_conflict (added in #6129) checks in application code. get_for_update (mcpgateway/db.py:6017) only locks existing matching rows; if no row matches yet, there's nothing to lock, so two concurrent requests renaming/creating tools to the same not-yet-used custom_name can both pass the check and both commit.
🔁 Steps to Reproduce
Example for gap 1 (federation, no check):
- Register two separate MCP gateways, each exposing an upstream tool named
search.
- Federate both gateways with
visibility=public (or any shared scope).
- Observe two
Tool rows created with custom_name="search", same visibility — no error raised.
Example for gap 2 (slug mismatch):
- Create a public tool via
POST /tools (or admin UI) with name="My Tool".
- Create a second public tool with
name="my-tool".
- Both succeed —
DbTool.name == tool.name never matches because one side is pre-slugify and the other is post-slugify.
🤔 Expected Behavior
register_tool and federation import should enforce the same custom_name uniqueness semantics (per visibility scope, using the normalized/slugified value) that update_tool now enforces after #6129, and that scope should be backed by a DB-level unique constraint (not just an application-level locked-read check) to close the concurrent-request race.
🧠 Environment Info
| Key |
Value |
| Version or commit |
main (as of PR #6129) |
| Runtime |
Python 3.11 |
| Platform / OS |
n/a — code-level finding |
| Container |
n/a |
🧩 Additional Context
Found while reviewing #6129, which fixes the duplicate-custom_name-on-rename bug described in #6057. That PR only touches update_tool; the gaps above are in register_tool, gateway_service.py's federation sync, and the DB schema, all of which are outside that PR's diff.
🐞 Bug Summary
#6057 / #6129 fixed duplicate
custom_namevalues slipping through the tool rename path (update_tool, edit modal). While reviewing that fix, several other paths that create or modify tools were checked, and duplicatecustom_namevalues within the same visibility scope are still possible through all of them. Per the MCP spec, each tool exposed by a server must be uniquely identified by name —tools/callhas no other disambiguator — so a duplicatecustom_namewithin one visibility scope is a protocol-correctness issue, not just cosmetic.🧩 Affected Component
mcpgateway- APImcpgateway- UI (admin panel)Gaps found (with evidence)
1. Federation bulk-import performs no conflict check at all
mcpgateway/services/gateway_service.py:1739buildsDbTool(custom_name=tool.name, ...)in a loop over the upstream server's discovered tools and appends directly todb_tools— it never calls any conflict-check helper. Two different upstream gateways both exposing a tool named e.g.search, federated into the same visibility scope (e.g.public), land as twoDbToolrows with the samecustom_name. Nothing blocks it.2.
register_tool's creation-time check has a slug/case mismatch that lets duplicates throughmcpgateway/services/tool_service.py:2061-2072(inregister_tool) checks:This compares the raw incoming
tool.nameagainst the stored, already-slugifiedDbTool.namecolumn.DbTool.nameis only normalized toslugify(custom_name)in thebefore_insertevent listener (mcpgateway/db.py:6779,6805), which runs after this check. So an existing public tool stored asname="my-tool"and a new tool submitted withtool.name="My Tool"(different case/spacing, same slug) do not match in the check ("My Tool" != "my-tool"), and the second tool is created — producing two tools that resolve to an identical exposed slug.3. No conflict check at all for
privatevisibility at creation timeSame location (
mcpgateway/services/tool_service.py:2061-2072) — onlyif visibility == "public"andelif visibility == "team"branches exist. There is noprivatebranch, so creating two private tools with the samecustom_nameunder the same owner is never checked.4. No DB-level backstop for the scope the new
update_toolcheck enforcesmcpgateway/db.py:3432-3433— the only unique constraint ontoolsisUniqueConstraint("team_id", "owner_email", "name"), keyed on the computed, gateway-prefixednamecolumn and requiring a matchingowner_email. It does not backcustom_nameuniqueness across different owners in the same team or across public tools from different owners — which is exactly the scope_check_tool_name_conflict(added in #6129) checks in application code.get_for_update(mcpgateway/db.py:6017) only locks existing matching rows; if no row matches yet, there's nothing to lock, so two concurrent requests renaming/creating tools to the same not-yet-usedcustom_namecan both pass the check and both commit.🔁 Steps to Reproduce
Example for gap 1 (federation, no check):
search.visibility=public(or any shared scope).Toolrows created withcustom_name="search", same visibility — no error raised.Example for gap 2 (slug mismatch):
POST /tools(or admin UI) withname="My Tool".name="my-tool".DbTool.name == tool.namenever matches because one side is pre-slugify and the other is post-slugify.🤔 Expected Behavior
register_tooland federation import should enforce the samecustom_nameuniqueness semantics (per visibility scope, using the normalized/slugified value) thatupdate_toolnow enforces after #6129, and that scope should be backed by a DB-level unique constraint (not just an application-level locked-read check) to close the concurrent-request race.🧠 Environment Info
🧩 Additional Context
Found while reviewing #6129, which fixes the duplicate-
custom_name-on-rename bug described in #6057. That PR only touchesupdate_tool; the gaps above are inregister_tool,gateway_service.py's federation sync, and the DB schema, all of which are outside that PR's diff.