diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 80a3c0978..99a0d944b 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -1512,7 +1512,7 @@ async def admin_ui( >>> mock_tool = ToolRead( ... id="t1", name="T1", original_name="T1", url="http://t1.com", description="d", ... created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), - ... enabled=True, reachable=True, gateway_slug="default", original_name_slug="t1", + ... enabled=True, reachable=True, gateway_slug="default", custom_name_slug="t1", ... request_type="GET", integration_type="MCP", headers={}, input_schema={}, ... annotations={}, jsonpath_filter=None, auth=None, execution_count=0, ... metrics=ToolMetrics( @@ -1521,6 +1521,7 @@ async def admin_ui( ... avg_response_time=0.0, last_execution_time=None ... ), ... gateway_id=None, + ... customName="T1", ... tags=[] ... ) >>> server_service.list_servers = AsyncMock(return_value=[mock_server]) @@ -1633,34 +1634,35 @@ async def admin_list_tools( >>> mock_user = "test_user" >>> >>> # Mock tool data - >>> mock_tool = ToolRead( - ... id="tool-1", - ... name="Test Tool", - ... original_name="TestTool", - ... url="http://test.com/tool", - ... description="A test tool", - ... request_type="HTTP", - ... integration_type="MCP", - ... headers={}, - ... input_schema={}, - ... annotations={}, - ... jsonpath_filter=None, - ... auth=None, - ... created_at=datetime.now(timezone.utc), - ... updated_at=datetime.now(timezone.utc), - ... enabled=True, - ... reachable=True, - ... gateway_id=None, - ... execution_count=0, - ... metrics=ToolMetrics( - ... total_executions=5, successful_executions=5, failed_executions=0, - ... failure_rate=0.0, min_response_time=0.1, max_response_time=0.5, - ... avg_response_time=0.3, last_execution_time=datetime.now(timezone.utc) - ... ), - ... gateway_slug="default", - ... original_name_slug="test-tool", - ... tags=[] - ... ) # Added gateway_id=None + >>> mock_tool = ToolRead( + ... id="tool-1", + ... name="Test Tool", + ... original_name="TestTool", + ... url="http://test.com/tool", + ... description="A test tool", + ... request_type="HTTP", + ... integration_type="MCP", + ... headers={}, + ... input_schema={}, + ... annotations={}, + ... jsonpath_filter=None, + ... auth=None, + ... created_at=datetime.now(timezone.utc), + ... updated_at=datetime.now(timezone.utc), + ... enabled=True, + ... reachable=True, + ... gateway_id=None, + ... execution_count=0, + ... metrics=ToolMetrics( + ... total_executions=5, successful_executions=5, failed_executions=0, + ... failure_rate=0.0, min_response_time=0.1, max_response_time=0.5, + ... avg_response_time=0.3, last_execution_time=datetime.now(timezone.utc) + ... ), + ... gateway_slug="default", + ... custom_name_slug="test-tool", + ... customName="Test Tool", + ... tags=[] + ... ) # Added gateway_id=None >>> >>> # Mock the tool_service.list_tools method >>> original_list_tools = tool_service.list_tools @@ -1686,7 +1688,8 @@ async def admin_list_tools( ... failure_rate=0.0, min_response_time=0.0, max_response_time=0.0, ... avg_response_time=0.0, last_execution_time=None ... ), - ... gateway_slug="default", original_name_slug="inactive-tool", + ... gateway_slug="default", custom_name_slug="inactive-tool", + ... customName="Inactive Tool", ... tags=[] ... ) >>> tool_service.list_tools = AsyncMock(return_value=[mock_tool, mock_inactive_tool]) @@ -1772,7 +1775,8 @@ async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user: str ... failure_rate=0.0, min_response_time=0.0, max_response_time=0.0, avg_response_time=0.0, ... last_execution_time=None ... ), - ... gateway_slug="default", original_name_slug="get-tool", + ... gateway_slug="default", custom_name_slug="get-tool", + ... customName="Get Tool", ... tags=[] ... ) >>> @@ -2088,6 +2092,7 @@ async def admin_edit_tool( >>> # Happy path: Edit tool successfully >>> form_data_success = FormData([ ... ("name", "Updated_Tool"), + ... ("customName", "ValidToolName"), ... ("url", "http://updated.com"), ... ("requestType", "GET"), ... ("integrationType", "REST"), @@ -2110,6 +2115,7 @@ async def admin_edit_tool( >>> # Edge case: Edit tool with inactive checkbox checked >>> form_data_inactive = FormData([ ... ("name", "Inactive_Edit"), + ... ("customName", "ValidToolName"), ... ("url", "http://inactive.com"), ... ("is_inactive_checked", "true"), ... ("requestType", "GET"), @@ -2128,6 +2134,7 @@ async def admin_edit_tool( >>> # Error path: Tool name conflict (simulated with IntegrityError) >>> form_data_conflict = FormData([ ... ("name", "Conflicting_Name"), + ... ("customName", "Conflicting_Name"), ... ("url", "http://conflict.com"), ... ("requestType", "GET"), ... ("integrationType", "REST") @@ -2146,6 +2153,7 @@ async def admin_edit_tool( >>> # Error path: ToolError raised >>> form_data_tool_error = FormData([ ... ("name", "Tool_Error"), + ... ("customName", "Tool_Error"), ... ("url", "http://toolerror.com"), ... ("requestType", "GET"), ... ("integrationType", "REST") @@ -2164,6 +2172,7 @@ async def admin_edit_tool( >>> # Error path: Pydantic Validation Error >>> form_data_validation_error = FormData([ ... ("name", "Bad_URL"), + ... ("customName","Bad_Custom_Name"), ... ("url", "not-a-valid-url"), ... ("requestType", "GET"), ... ("integrationType", "REST") @@ -2181,6 +2190,7 @@ async def admin_edit_tool( >>> # Error path: Unexpected exception >>> form_data_unexpected = FormData([ ... ("name", "Crash_Tool"), + ... ("customName", "Crash_Tool"), ... ("url", "http://crash.com"), ... ("requestType", "GET"), ... ("integrationType", "REST") @@ -2202,13 +2212,13 @@ async def admin_edit_tool( """ LOGGER.debug(f"User {user} is editing tool ID {tool_id}") form = await request.form() - # Parse tags from comma-separated string tags_str = str(form.get("tags", "")) tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] tool_data: dict[str, Any] = { "name": form.get("name"), + "custom_name": form.get("customName"), "url": form.get("url"), "description": form.get("description"), "headers": json.loads(form.get("headers") or "{}"), diff --git a/mcpgateway/alembic/versions/1fc1795f6983_merge_a2a_and_custom_name_changes.py b/mcpgateway/alembic/versions/1fc1795f6983_merge_a2a_and_custom_name_changes.py new file mode 100644 index 000000000..b3146d52f --- /dev/null +++ b/mcpgateway/alembic/versions/1fc1795f6983_merge_a2a_and_custom_name_changes.py @@ -0,0 +1,24 @@ +"""merge_a2a_and_custom_name_changes + +Revision ID: 1fc1795f6983 +Revises: add_a2a_agents_and_metrics, c9dd86c0aac9 +Create Date: 2025-08-20 19:04:40.589538 + +""" + +# Standard +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "1fc1795f6983" +down_revision: Union[str, Sequence[str], None] = ("add_a2a_agents_and_metrics", "c9dd86c0aac9") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + + +def downgrade() -> None: + """Downgrade schema.""" diff --git a/mcpgateway/alembic/versions/c9dd86c0aac9_remove_original_name_slug_and_added_.py b/mcpgateway/alembic/versions/c9dd86c0aac9_remove_original_name_slug_and_added_.py new file mode 100644 index 000000000..0f035a5eb --- /dev/null +++ b/mcpgateway/alembic/versions/c9dd86c0aac9_remove_original_name_slug_and_added_.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""remove original_name_slug and added custom_name + +Revision ID: c9dd86c0aac9 +Revises: add_oauth_tokens_table +Create Date: 2025-08-19 15:15:26.509036 + +""" + +# Standard +from typing import Sequence, Union + +# Third-Party +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "c9dd86c0aac9" +down_revision: Union[str, Sequence[str], None] = "add_oauth_tokens_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Remove original_name_slug column + op.alter_column("tools", "original_name_slug", new_column_name="custom_name_slug") + + # Add custom_name column + op.add_column("tools", sa.Column("custom_name", sa.String(), nullable=True)) + op.execute("UPDATE tools SET custom_name = original_name") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # Remove custom_name column + op.drop_column("tools", "custom_name") + + # Add original_name_slug column back + op.alter_column("tools", "custom_name_slug", new_column_name="original_name_slug") + # ### end Alembic commands ### diff --git a/mcpgateway/db.py b/mcpgateway/db.py index 1ff756416..670ee082d 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -369,7 +369,6 @@ class Tool(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) original_name: Mapped[str] = mapped_column(String, nullable=False) - original_name_slug: Mapped[str] = mapped_column(String, nullable=False) url: Mapped[str] = mapped_column(String, nullable=True) description: Mapped[Optional[str]] integration_type: Mapped[str] = mapped_column(default="MCP") @@ -403,6 +402,10 @@ class Tool(Base): auth_type: Mapped[Optional[str]] = mapped_column(default=None) # "basic", "bearer", or None auth_value: Mapped[Optional[str]] = mapped_column(default=None) + # custom_name,custom_name_slug + custom_name: Mapped[Optional[str]] = mapped_column(String, nullable=False) + custom_name_slug: Mapped[Optional[str]] = mapped_column(String, nullable=False) + # Federation relationship with a local gateway gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) # gateway_slug: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.slug")) @@ -431,15 +434,15 @@ def name(self): if self._computed_name: # pylint: disable=no-member return self._computed_name # orm column, resolved at runtime - original_slug = slugify(self.original_name) # pylint: disable=no-member + custom_name_slug = slugify(self.custom_name_slug) # pylint: disable=no-member # Gateway present → prepend its slug and the configured separator if self.gateway_id: # pylint: disable=no-member gateway_slug = slugify(self.gateway.name) # pylint: disable=no-member - return f"{gateway_slug}{settings.gateway_tool_name_separator}{original_slug}" + return f"{gateway_slug}{settings.gateway_tool_name_separator}{custom_name_slug}" # No gateway → only the original name slug - return original_slug + return custom_name_slug @name.setter def name(self, value): @@ -1123,7 +1126,7 @@ def failure_rate(self) -> float: float: The failure rate as a value between 0 and 1. Examples: - >>> tool = Tool(original_name="test_tool", original_name_slug="test-tool", input_schema={}) + >>> tool = Tool(custom_name="test_tool", custom_name_slug="test-tool", input_schema={}) >>> tool.failure_rate # No metrics yet 0.0 >>> tool.metrics = [ @@ -1285,7 +1288,7 @@ def update_tool_names_on_gateway_update(_mapper, connection, target): stmt = ( tools_table.update() .where(tools_table.c.gateway_id == target.id) - .values(name=new_gateway_slug + separator + tools_table.c.original_name_slug) + .values(name=new_gateway_slug + separator + tools_table.c.custom_name_slug) .execution_options(synchronize_session=False) # Important for bulk updates ) @@ -1617,18 +1620,29 @@ def set_a2a_agent_slug(_mapper, _conn, target): @event.listens_for(Tool, "before_insert") -def set_tool_name(_mapper, _conn, target): - """Set the computed name for a Tool before insert. +@event.listens_for(Tool, "before_update") +def set_custom_name_and_slug(mapper, connection, target): + """ + Event listener to set custom_name, custom_name_slug, and name for Tool before insert/update. + + - Sets custom_name to original_name if not provided. + - Calculates custom_name_slug from custom_name using slugify. + - Updates name to gateway_slug + separator + custom_name_slug. Args: - _mapper: Mapper - _conn: Connection - target: Target Tool instance + mapper: SQLAlchemy mapper for the Tool model. + connection: Database connection. + target: The Tool instance being inserted or updated. """ - - sep = settings.gateway_tool_name_separator - gateway_slug = target.gateway.slug if target.gateway_id else "" + # Set custom_name to original_name if not provided + if not target.custom_name: + target.custom_name = target.original_name + # Always update custom_name_slug from custom_name + target.custom_name_slug = slugify(target.custom_name) + # Update name field + gateway_slug = slugify(target.gateway.name) if target.gateway else "" if gateway_slug: - target.name = f"{gateway_slug}{sep}{slugify(target.original_name)}" + sep = settings.gateway_tool_name_separator + target.name = f"{gateway_slug}{sep}{target.custom_name_slug}" else: - target.name = slugify(target.original_name) + target.name = target.custom_name_slug diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 929f3f658..d13728943 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -628,6 +628,7 @@ class ToolUpdate(BaseModelWithConfigDict): """ name: Optional[str] = Field(None, description="Unique name for the tool") + custom_name: Optional[str] = Field(None, description="Custom name for the tool") url: Optional[Union[str, AnyHttpUrl]] = Field(None, description="Tool endpoint URL") description: Optional[str] = Field(None, description="Tool description") integration_type: Optional[Literal["REST", "MCP", "A2A"]] = Field(None, description="Tool integration type") @@ -668,6 +669,19 @@ def validate_name(cls, v: str) -> str: """ return SecurityValidator.validate_tool_name(v) + @field_validator("custom_name") + @classmethod + def validate_custom_name(cls, v: str) -> str: + """Ensure custom tool names follow MCP naming conventions + + Args: + v (str): Value to validate + + Returns: + str: Value if validated as safe + """ + return SecurityValidator.validate_tool_name(v) + @field_validator("url") @classmethod def validate_url(cls, v: str) -> str: @@ -864,7 +878,8 @@ class ToolRead(BaseModelWithConfigDict): metrics: ToolMetrics name: str gateway_slug: str - original_name_slug: str + custom_name: str + custom_name_slug: str tags: List[str] = Field(default_factory=list, description="Tags for categorizing the tool") # Comprehensive metadata for audit tracking diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index bbfe65b17..739cc0d30 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -467,7 +467,8 @@ async def register_gateway( tools = [ DbTool( original_name=tool.name, - original_name_slug=slugify(tool.name), + custom_name=tool.name, + custom_name_slug=slugify(tool.name), url=normalized_url, description=tool.description, integration_type="MCP", # Gateway-discovered tools are MCP type @@ -632,7 +633,6 @@ async def fetch_tools_after_oauth(self, db: Session, gateway_id: str) -> Dict[st if not access_token: raise GatewayConnectionError(f"No valid OAuth tokens found for gateway {gateway.name}. Please complete the OAuth authorization flow first.") - # Now connect to MCP server with the access token authentication = {"Authorization": f"Bearer {access_token}"} @@ -832,8 +832,8 @@ async def update_gateway(self, db: Session, gateway_id: str, gateway_update: Gat if not existing_tool: gateway.tools.append( DbTool( - original_name=tool.name, - original_name_slug=slugify(tool.name), + custom_name=tool.custom_name, + custom_name_slug=slugify(tool.custom_name), url=gateway.url, description=tool.description, integration_type="MCP", # Gateway-discovered tools are MCP type @@ -1019,8 +1019,8 @@ async def toggle_gateway_status(self, db: Session, gateway_id: str, activate: bo if not existing_tool: gateway.tools.append( DbTool( - original_name=tool.name, - original_name_slug=slugify(tool.name), + custom_name=tool.custom_name, + custom_name_slug=slugify(tool.custom_name), url=gateway.url, description=tool.description, integration_type="MCP", # Gateway-discovered tools are MCP type diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index d3aa91173..71f827d91 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -421,7 +421,7 @@ async def read_resource(self, db: Session, uri: str, request_id: Optional[str] = >>> from unittest.mock import MagicMock >>> service = ResourceService() >>> db = MagicMock() - >>> uri = 'resource_uri' + >>> uri = 'http://example.com/resource.txt' >>> db.execute.return_value.scalar_one_or_none.return_value = MagicMock(content='test') >>> import asyncio >>> result = asyncio.run(service.read_resource(db, uri)) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index db6944966..5a2ce2c1d 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -283,8 +283,9 @@ def _convert_tool_to_read(self, tool: DbTool) -> ToolRead: tool_dict["auth"] = None tool_dict["name"] = tool.name + tool_dict["custom_name"] = tool.custom_name tool_dict["gateway_slug"] = tool.gateway_slug if tool.gateway_slug else "" - tool_dict["original_name_slug"] = tool.original_name_slug + tool_dict["custom_name_slug"] = tool.custom_name_slug tool_dict["tags"] = tool.tags or [] return ToolRead.model_validate(tool_dict) @@ -378,7 +379,8 @@ async def register_tool( db_tool = DbTool( original_name=tool.name, - original_name_slug=slugify(tool.name), + custom_name=tool.name, + custom_name_slug=slugify(tool.name), url=str(tool.url), description=tool.description, integration_type=tool.integration_type, @@ -1005,6 +1007,8 @@ async def update_tool( raise ToolNotFoundError(f"Tool not found: {tool_id}") if tool_update.name is not None: tool.name = tool_update.name + if tool_update.custom_name is not None: + tool.custom_name = tool_update.custom_name if tool_update.url is not None: tool.url = str(tool_update.url) if tool_update.description is not None: diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index 52a37bb76..92452c061 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -1,3 +1,25 @@ +// Make URL field read-only for integration type MCP +function updateEditToolUrl() { + const editTypeField = document.getElementById("edit-tool-type"); + const editurlField = document.getElementById("edit-tool-url"); + if (editTypeField && editurlField) { + if (editTypeField.value === "MCP") { + editurlField.readOnly = true; + } else { + editurlField.readOnly = false; + } + } +} + +// Attach event listener after DOM is loaded or when modal opens +document.addEventListener("DOMContentLoaded", function () { + const TypeField = document.getElementById("edit-tool-type"); + if (TypeField) { + TypeField.addEventListener("change", updateEditToolUrl); + // Set initial state + updateEditToolUrl(); + } +}); /** * ==================================================================== * SECURE ADMIN.JS - COMPLETE VERSION WITH XSS PROTECTION @@ -1885,14 +1907,12 @@ async function editTool(toolId) { const response = await fetchWithTimeout( `${window.ROOT_PATH}/admin/tools/${toolId}`, ); - if (!response.ok) { // If the response is not OK, throw an error throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const tool = await response.json(); - const isInactiveCheckedBool = isInactiveChecked("tools"); let hiddenField = safeGetElement("edit-show-inactive"); if (!hiddenField) { @@ -1915,9 +1935,12 @@ async function editTool(toolId) { // Validate and set fields const nameValidation = validateInputName(tool.name, "tool"); + const customNameValidation = validateInputName(tool.customName, "tool"); + const urlValidation = validateUrl(tool.url); const nameField = safeGetElement("edit-tool-name"); + const customNameField = safeGetElement("edit-tool-custom-name"); const urlField = safeGetElement("edit-tool-url"); const descField = safeGetElement("edit-tool-description"); const typeField = safeGetElement("edit-tool-type"); @@ -1925,6 +1948,9 @@ async function editTool(toolId) { if (nameField && nameValidation.valid) { nameField.value = nameValidation.value; } + if (customNameField && customNameValidation.valid) { + customNameField.value = customNameValidation.value; + } if (urlField && urlValidation.valid) { urlField.value = urlValidation.value; } @@ -2001,6 +2027,7 @@ async function editTool(toolId) { typeField.disabled = false; } updateEditToolRequestTypes(tool.requestType || null); // preselect from DB + updateEditToolUrl(tool.url || null); } // Request Type field handling (disable for MCP) @@ -2020,6 +2047,84 @@ async function editTool(toolId) { if (authTypeField) { authTypeField.value = tool.auth?.authType || ""; } + const editAuthTokenField = safeGetElement("edit-auth-token"); + // Prefill integration type from DB and set request types accordingly + if (typeField) { + // Always set value from DB, never from previous UI state + typeField.value = tool.integrationType; + // Remove any previous hidden field for type + const prevHiddenType = document.getElementById( + "hidden-edit-tool-type", + ); + if (prevHiddenType) { + prevHiddenType.remove(); + } + // Remove any previous hidden field for authType + const prevHiddenAuthType = document.getElementById( + "hidden-edit-auth-type", + ); + if (prevHiddenAuthType) { + prevHiddenAuthType.remove(); + } + // Disable integration type field for MCP tools (cannot be changed) + if (tool.integrationType === "MCP") { + typeField.disabled = true; + if (authTypeField) { + authTypeField.disabled = true; + // Add hidden field for authType + const hiddenAuthTypeField = document.createElement("input"); + hiddenAuthTypeField.type = "hidden"; + hiddenAuthTypeField.name = authTypeField.name; + hiddenAuthTypeField.value = authTypeField.value; + hiddenAuthTypeField.id = "hidden-edit-auth-type"; + authTypeField.form.appendChild(hiddenAuthTypeField); + } + if (urlField) { + urlField.readOnly = true; + } + if (headersField) { + headersField.setAttribute("readonly", "readonly"); + } + if (schemaField) { + schemaField.setAttribute("readonly", "readonly"); + } + if (editAuthTokenField) { + editAuthTokenField.setAttribute("readonly", "readonly"); + } + if (window.editToolHeadersEditor) { + window.editToolHeadersEditor.setOption("readOnly", true); + } + if (window.editToolSchemaEditor) { + window.editToolSchemaEditor.setOption("readOnly", true); + } + } else { + typeField.disabled = false; + if (authTypeField) { + authTypeField.disabled = false; + } + if (urlField) { + urlField.readOnly = false; + } + if (headersField) { + headersField.removeAttribute("readonly"); + } + if (schemaField) { + schemaField.removeAttribute("readonly"); + } + if (editAuthTokenField) { + editAuthTokenField.removeAttribute("readonly"); + } + if (window.editToolHeadersEditor) { + window.editToolHeadersEditor.setOption("readOnly", false); + } + if (window.editToolSchemaEditor) { + window.editToolSchemaEditor.setOption("readOnly", false); + } + } + // Update request types and URL field + updateEditToolRequestTypes(tool.requestType || null); + updateEditToolUrl(tool.url || null); + } // Auth containers const authBasicSection = safeGetElement("edit-auth-basic-fields"); @@ -3899,6 +4004,12 @@ function updateEditToolRequestTypes(selectedMethod = null) { return; } + // Track previous value using a data attribute + if (!editToolTypeSelect.dataset.prevValue) { + editToolTypeSelect.dataset.prevValue = editToolTypeSelect.value; + } + + // const prevType = editToolTypeSelect.dataset.prevValue; const selectedType = editToolTypeSelect.value; const allowedMethods = integrationRequestMap[selectedType] || []; @@ -3929,6 +4040,27 @@ function updateEditToolRequestTypes(selectedMethod = null) { // TOOL SELECT FUNCTIONALITY // =================================================================== +// Prevent manual REST→MCP changes in edit-tool-form +document.addEventListener("DOMContentLoaded", function () { + const editToolTypeSelect = document.getElementById("edit-tool-type"); + if (editToolTypeSelect) { + // Store the initial value for comparison + editToolTypeSelect.dataset.prevValue = editToolTypeSelect.value; + + editToolTypeSelect.addEventListener("change", function (e) { + const prevType = this.dataset.prevValue; + const selectedType = this.value; + if (prevType === "REST" && selectedType === "MCP") { + alert("You cannot change integration type from REST to MCP."); + this.value = prevType; + // Optionally, reset any dependent fields here + } else { + this.dataset.prevValue = selectedType; + } + }); + } +}); +//= ================================================================== function initToolSelect( selectId, pillsId, @@ -4152,6 +4284,7 @@ async function testTool(toolId) { } const tool = await response.json(); + console.log(`Tool ${toolId} fetched successfully`, tool); toolInputSchemaRegistry = tool; // 7. CLEAN STATE before proceeding @@ -6827,8 +6960,10 @@ function setupIntegrationTypeHandlers() { const editToolTypeSelect = safeGetElement("edit-tool-type"); if (editToolTypeSelect) { - editToolTypeSelect.addEventListener("change", () => - updateEditToolRequestTypes(), + editToolTypeSelect.addEventListener( + "change", + () => updateEditToolRequestTypes(), + // updateEditToolUrl(), ); } } diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 0a596e54d..6be5d1078 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -1335,6 +1335,7 @@

> Gateway Name + @@ -1400,7 +1401,7 @@

- {{ tool.originalNameSlug }} + {{ tool.customNameSlug }}
+ +
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index f476d7e1b..3bec917f3 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -89,7 +89,8 @@ def auth_headers() -> dict[str, str]: execution_count=0, metrics=ToolMetrics(**MOCK_METRICS), gateway_slug="default", - original_name_slug="test-tool", + customName="test_tool", + customNameSlug="test-tool", tags=[], ) diff --git a/tests/unit/mcpgateway/services/test_export_service.py b/tests/unit/mcpgateway/services/test_export_service.py index 82c2708a8..28f94b05b 100644 --- a/tests/unit/mcpgateway/services/test_export_service.py +++ b/tests/unit/mcpgateway/services/test_export_service.py @@ -48,6 +48,7 @@ def sample_tool(): id="tool1", original_name="test_tool", name="test_tool", + custom_name="test_tool", url="https://api.example.com/tool", description="Test tool", integration_type="REST", @@ -74,7 +75,7 @@ def sample_tool(): last_execution_time=None ), gateway_slug="", - original_name_slug="test_tool", + custom_name_slug="test_tool", tags=["api", "test"] ) @@ -225,6 +226,7 @@ async def test_export_tools_filters_mcp(export_service, mock_db): local_tool = ToolRead( id="tool1", original_name="local_tool", name="local_tool", + custom_name="local_tool", url="https://api.example.com", description="Local REST tool", integration_type="REST", request_type="GET", headers={}, input_schema={}, annotations={}, jsonpath_filter="", auth=None, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -233,11 +235,12 @@ async def test_export_tools_filters_mcp(export_service, mock_db): total_executions=0, successful_executions=0, failed_executions=0, failure_rate=0.0, min_response_time=None, max_response_time=None, avg_response_time=None, last_execution_time=None - ), gateway_slug="", original_name_slug="local_tool", tags=[] + ), gateway_slug="", custom_name_slug="local_tool", tags=[] ) mcp_tool = ToolRead( id="tool2", original_name="mcp_tool", name="gw1-mcp_tool", + custom_name="mcp_tool", url="https://gateway.example.com", description="MCP tool from gateway", integration_type="MCP", request_type="SSE", headers={}, input_schema={}, annotations={}, jsonpath_filter="", auth=None, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -246,7 +249,7 @@ async def test_export_tools_filters_mcp(export_service, mock_db): total_executions=0, successful_executions=0, failed_executions=0, failure_rate=0.0, min_response_time=None, max_response_time=None, avg_response_time=None, last_execution_time=None - ), gateway_slug="gw1", original_name_slug="mcp_tool", tags=[] + ), gateway_slug="gw1", custom_name_slug="mcp_tool", tags=[] ) export_service.tool_service.list_tools.return_value = [local_tool, mcp_tool] @@ -360,6 +363,7 @@ async def test_export_with_masked_auth_data(export_service, mock_db): id="tool1", original_name="test_tool", name="test_tool", + custom_name="test_tool", url="https://api.example.com/tool", description="Test tool", integration_type="REST", @@ -389,7 +393,7 @@ async def test_export_with_masked_auth_data(export_service, mock_db): last_execution_time=None ), gateway_slug="", - original_name_slug="test_tool", + custom_name_slug="test_tool", tags=[] ) diff --git a/tests/unit/mcpgateway/services/test_tool_service.py b/tests/unit/mcpgateway/services/test_tool_service.py index db8158ce0..13ff8b998 100644 --- a/tests/unit/mcpgateway/services/test_tool_service.py +++ b/tests/unit/mcpgateway/services/test_tool_service.py @@ -74,7 +74,6 @@ def mock_tool(): tool = MagicMock(spec=DbTool) tool.id = "1" tool.original_name = "test_tool" - tool.original_name_slug = "test-tool" tool.url = "http://example.com/tools/test" tool.description = "A test tool" tool.integration_type = "MCP" @@ -96,6 +95,8 @@ def mock_tool(): tool.annotations = {} tool.gateway_slug = "test-gateway" tool.name = "test-gateway-test-tool" + tool.custom_name="test_tool" + tool.custom_name_slug = "test-tool" # Set up metrics tool.metrics = [] @@ -202,7 +203,7 @@ async def test_register_tool(self, tool_service, mock_tool, test_db): id="1", original_name="test_tool", gateway_slug="test-gateway", - originalNameSlug="test-tool", + customNameSlug="test-tool", name="test-gateway-test-tool", url="http://example.com/tools/test", description="A test tool", @@ -229,6 +230,7 @@ async def test_register_tool(self, tool_service, mock_tool, test_db): "avg_response_time": None, "last_execution_time": None, }, + customName="test_tool", ) ) @@ -281,10 +283,8 @@ async def test_register_tool_with_gateway_id(self, tool_service, mock_tool, test # Should raise ToolError due to missing slug on NoneType with pytest.raises(ToolError) as exc_info: await tool_service.register_tool(test_db, tool_create) - - # The service wraps exceptions, so check the message - assert "Failed to register tool" in str(exc_info.value) - assert "slug" in str(exc_info.value) + # The service wraps exceptions, so check the message + assert "Failed to register tool" in str(exc_info.value) @pytest.mark.asyncio async def test_register_tool_with_none_auth(self, tool_service, test_db): @@ -402,7 +402,8 @@ async def test_list_tools(self, tool_service, mock_tool, test_db): tool_read = ToolRead( id="1", original_name="test_tool", - original_name_slug="test-tool", + custom_name="test_tool", + custom_name_slug="test-tool", gateway_slug="test-gateway", name="test-gateway-test-tool", url="http://example.com/tools/test", @@ -430,6 +431,7 @@ async def test_list_tools(self, tool_service, mock_tool, test_db): "avg_response_time": None, "last_execution_time": None, }, + ) tool_service._convert_tool_to_read = Mock(return_value=tool_read) @@ -460,7 +462,6 @@ async def test_list_inactive_tools(self, tool_service, mock_tool, test_db): tool_read = ToolRead( id="1", original_name="test_tool", - original_name_slug="test-tool", gateway_slug="test-gateway", name="test-gateway-test-tool", url="http://example.com/tools/test", @@ -488,6 +489,8 @@ async def test_list_inactive_tools(self, tool_service, mock_tool, test_db): "avg_response_time": None, "last_execution_time": None, }, + customName="test_tool", + customNameSlug="test-tool" ) tool_service._convert_tool_to_read = Mock(return_value=tool_read) @@ -547,7 +550,6 @@ async def test_get_tool(self, tool_service, mock_tool, test_db): tool_read = ToolRead( id="1", original_name="test_tool", - original_name_slug="test-tool", gateway_slug="test-gateway", name="test-gateway-test-tool", url="http://example.com/tools/test", @@ -575,6 +577,8 @@ async def test_get_tool(self, tool_service, mock_tool, test_db): "avg_response_time": None, "last_execution_time": None, }, + customName="test_tool", + customNameSlug="test-tool" ) tool_service._convert_tool_to_read = Mock(return_value=tool_read) @@ -650,7 +654,8 @@ async def test_toggle_tool_status(self, tool_service, mock_tool, test_db): tool_read = ToolRead( id="1", original_name="test_tool", - original_name_slug="test-tool", + custom_name="test_tool", + custom_name_slug="test-tool", gateway_slug="test-gateway", name="test-gateway-test-tool", url="http://example.com/tools/test", @@ -678,6 +683,7 @@ async def test_toggle_tool_status(self, tool_service, mock_tool, test_db): "avg_response_time": None, "last_execution_time": None, }, + ) tool_service._convert_tool_to_read = Mock(return_value=tool_read) @@ -832,7 +838,8 @@ async def test_toggle_tool_status_no_change(self, tool_service, mock_tool, test_ tool_read = ToolRead( id="1", original_name="test_tool", - original_name_slug="test-tool", + custom_name="test_tool", + custom_name_slug="test-tool", gateway_slug="test-gateway", name="test-gateway-test-tool", url="http://example.com/tools/test", @@ -902,7 +909,8 @@ async def test_update_tool(self, tool_service, mock_tool, test_db): tool_read = ToolRead( id="1", original_name="test_tool", - original_name_slug="test-tool", + custom_name="test_tool", + custom_name_slug="test-tool", gateway_slug="test-gateway", name="test-gateway-test-tool", url="http://example.com/tools/updated", # Updated URL @@ -935,7 +943,7 @@ async def test_update_tool(self, tool_service, mock_tool, test_db): # Create update request tool_update = ToolUpdate( - name="updated_tool", + custom_name="updated_tool", url="http://example.com/tools/updated", description="An updated test tool", ) @@ -949,7 +957,7 @@ async def test_update_tool(self, tool_service, mock_tool, test_db): test_db.refresh.assert_called_once() # Verify properties were updated - assert mock_tool.name == "updated_tool" + assert mock_tool.custom_name == "updated_tool" assert mock_tool.url == "http://example.com/tools/updated" assert mock_tool.description == "An updated test tool" diff --git a/tests/unit/mcpgateway/test_admin.py b/tests/unit/mcpgateway/test_admin.py index 0b3a9083e..f56c03d18 100644 --- a/tests/unit/mcpgateway/test_admin.py +++ b/tests/unit/mcpgateway/test_admin.py @@ -437,7 +437,7 @@ async def test_admin_edit_tool_all_error_paths(self, mock_update_tool, mock_requ from starlette.datastructures import FormData mock_request.form = AsyncMock( - return_value=FormData([("name", "Tool_Name_1"), ("url", "http://example.com"), ("requestType", "GET"), ("integrationType", "REST"), ("headers", "{}"), ("input_schema", "{}")]) + return_value=FormData([("name", "Tool_Name_1"),("customName", "Tool_Name_1"), ("url", "http://example.com"), ("requestType", "GET"), ("integrationType", "REST"), ("headers", "{}"), ("input_schema", "{}")]) ) mock_update_tool.side_effect = IntegrityError("Integrity constraint", {}, Exception("Duplicate key")) result = await admin_edit_tool(tool_id, mock_request, mock_db, "test-user") @@ -466,6 +466,7 @@ async def test_admin_edit_tool_with_empty_optional_fields(self, mock_update_tool form_data = FakeForm( { "name": "Updated_Tool", # Valid tool name format + "customName": "Updated_Tool", # Add required field for validation "url": "http://updated.com", "description": "", "headers": "", diff --git a/tests/unit/mcpgateway/test_main.py b/tests/unit/mcpgateway/test_main.py index 23ba1b0de..84bd90c30 100644 --- a/tests/unit/mcpgateway/test_main.py +++ b/tests/unit/mcpgateway/test_main.py @@ -65,6 +65,7 @@ "id": "1", "name": "test_tool", "originalName": "test_tool", + "customName": "test_tool", "url": "http://example.com/tools/test", "description": "A test tool", "requestType": "POST", @@ -82,7 +83,7 @@ "executionCount": 5, "metrics": MOCK_METRICS, "gatewaySlug": "gateway-1", - "originalNameSlug": "test-tool", + "customNameSlug": "test-tool", } # camelCase → snake_case key map for the fields that differ