-
Notifications
You must be signed in to change notification settings - Fork 594
Description
Bug Summary
During review of #3503 (fix for #3468), three pre-existing gaps were identified in the tool update conflict checking logic. These are not regressions from that PR — they existed before and remain unchanged. Documenting them here for follow-up.
Gap 1: custom_name-only updates bypass uniqueness checks
Affected code: mcpgateway/services/tool_service.py — update_tool()
update_tool() only triggers conflict validation when tool_update.name changes. However, the before_update event listener set_custom_name_and_slug (mcpgateway/db.py:6387-6441) recomputes the stored name column from custom_name on every commit. A custom_name-only update can therefore produce a slug collision without any application-level conflict check running.
The composite unique constraint (team_id, owner_email, name) on the tools table (db.py:3097) provides partial DB-level protection, but NULL values in team_id/owner_email weaken it for public and some private tools.
Reference: PromptService.update_prompt() (prompt_service.py:1736-1744) correctly handles this by building a candidate name from either name or custom_name before checking conflicts.
Gap 2: Conflict detection compares raw custom_name instead of computed slugified name
Affected code: _check_tool_name_conflict() queries DbTool.custom_name == custom_name
Tool uniqueness is enforced on the stored name column, which is derived from slugify(custom_name) plus the gateway prefix. Slug-equivalent names like foo_bar, Foo Bar, and foo-bar all normalize to the same stored name, but the conflict check compares raw custom_name values and would miss these collisions.
Reference: PromptService correctly checks against computed_name (the slugified form) via _compute_prompt_name() (prompt_service.py:1743-1747).
Gap 3: Visibility changes to team/private proceed without required scope fields
Affected code: _check_tool_name_conflict() and update_tool()
When changing visibility to team or private, if the DB record lacks the required scope field (team_id or owner_email), the conflict check logs a warning and skips, but update_tool() still writes the new visibility. This can create an inconsistent record (e.g., visibility="team" with team_id=NULL).
Since ToolUpdate does not expose team_id/owner_email (by design — ownership is derived from the DB), there is no way for the caller to provide the missing scope. Consider returning HTTP 400 instead of warning-and-proceeding.
Note: PR #3503 improved observability here by adding the warning log. The old code would crash with AttributeError on tool_update.team_id (which doesn't exist on the schema), so the current behavior is strictly better than before.
Suggested Fix
Rework update_tool() conflict checking to mirror PromptService:
- Build a candidate persisted name from either
nameorcustom_nameusing the same slugification logic as thebefore_updatehook - Check conflicts against the stored
namecolumn, not rawcustom_name - Replace warn-and-proceed with an explicit 400 when a team/private visibility change lacks required scope fields
Related
- PR fix(3468):Tool update resets visibility to public when field not provided #3503 (fix for [BUG][API]: Tool update resets visibility to public when field not provided #3468) — the review that identified these gaps
PromptService.update_prompt()— reference implementation
Gap 4: name-only rename silently fails for most tool names
Affected code: mcpgateway/services/tool_service.py — update_tool() lines 4242 and 4247
The auto-tracking condition that syncs custom_name when only name is provided compares tool.name == tool.custom_name. But tool.name is the slugified stored value (e.g., my-cool-tool) while tool.custom_name is the raw value (e.g., my_cool_tool). These are never equal for names containing underscores, mixed case, or special characters — which is the majority of tools.
Consequence: When a user sends {"name": "new_name"} without custom_name, the auto-tracking condition evaluates to False, custom_name is not updated, and the before_update hook recomputes name from the unchanged custom_name, effectively making the rename a silent no-op.
Verified behavior:
custom_name="my-slug-safe"→name="my-slug-safe"→ equal → auto-tracking workscustom_name="my_cool_tool"→name="my-cool-tool"→ not equal → auto-tracking broken
Fix: Replace the comparison with slug-aware logic:
# Before (broken):
if tool_update.custom_name is None and tool.name == tool.custom_name:
# After (fixed):
if tool_update.custom_name is None and tool.custom_name_slug == tool.name:This applies to both occurrences (lines 4242 and 4247). The same fix should be applied to the custom_name_ref resolution logic at line 4242.