From 8983c7db8fc0dbca1f7e7d0c0dc3926c9aacb438 Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 12:59:33 +0530 Subject: [PATCH 01/19] Made prompt and resource IDs UUIDs Signed-off-by: Madhav Kandukuri --- mcpgateway/admin.py | 8 ++++---- mcpgateway/db.py | 18 +++++++++--------- mcpgateway/main.py | 6 +++--- mcpgateway/schemas.py | 8 ++++---- mcpgateway/services/prompt_service.py | 2 +- mcpgateway/services/resource_service.py | 2 +- mcpgateway/services/server_service.py | 8 ++++---- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index dd4cdb292..dc48c7638 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -3442,7 +3442,7 @@ async def admin_delete_resource(uri: str, request: Request, db: Session = Depend @admin_router.post("/resources/{resource_id}/toggle") async def admin_toggle_resource( - resource_id: int, + resource_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -3456,7 +3456,7 @@ async def admin_toggle_resource( logs any errors that might occur during the status toggle operation. Args: - resource_id (int): The ID of the resource whose status to toggle. + resource_id (str): The ID of the resource whose status to toggle. request (Request): FastAPI request containing form data with the 'activate' field. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -3927,7 +3927,7 @@ async def admin_delete_prompt(name: str, request: Request, db: Session = Depends @admin_router.post("/prompts/{prompt_id}/toggle") async def admin_toggle_prompt( - prompt_id: int, + prompt_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -3941,7 +3941,7 @@ async def admin_toggle_prompt( logs any errors that might occur during the status toggle operation. Args: - prompt_id (int): The ID of the prompt whose status to toggle. + prompt_id (str): The ID of the prompt whose status to toggle. request (Request): FastAPI request containing form data with the 'activate' field. db (Session): Database session dependency. user (str): Authenticated user dependency. diff --git a/mcpgateway/db.py b/mcpgateway/db.py index 9aa0fca4c..60c7132bd 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -173,7 +173,7 @@ class Base(DeclarativeBase): "server_resource_association", Base.metadata, Column("server_id", String, ForeignKey("servers.id"), primary_key=True), - Column("resource_id", Integer, ForeignKey("resources.id"), primary_key=True), + Column("resource_id", String, ForeignKey("resources.id"), primary_key=True), ) # Association table for servers and prompts @@ -181,7 +181,7 @@ class Base(DeclarativeBase): "server_prompt_association", Base.metadata, Column("server_id", String, ForeignKey("servers.id"), primary_key=True), - Column("prompt_id", Integer, ForeignKey("prompts.id"), primary_key=True), + Column("prompt_id", String, ForeignKey("prompts.id"), primary_key=True), ) # Association table for servers and A2A agents @@ -241,7 +241,7 @@ class ResourceMetric(Base): Attributes: id (int): Primary key. - resource_id (int): Foreign key linking to the resource. + resource_id (str): Foreign key linking to the resource. timestamp (datetime): The time when the invocation occurred. response_time (float): The response time in seconds. is_success (bool): True if the invocation succeeded, False otherwise. @@ -251,7 +251,7 @@ class ResourceMetric(Base): __tablename__ = "resource_metrics" id: Mapped[int] = mapped_column(primary_key=True) - resource_id: Mapped[int] = mapped_column(Integer, ForeignKey("resources.id"), nullable=False) + resource_id: Mapped[str] = mapped_column(Integer, ForeignKey("resources.id"), nullable=False) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) response_time: Mapped[float] = mapped_column(Float, nullable=False) is_success: Mapped[bool] = mapped_column(Boolean, nullable=False) @@ -293,7 +293,7 @@ class PromptMetric(Base): Attributes: id (int): Primary key. - prompt_id (int): Foreign key linking to the prompt. + prompt_id (str): Foreign key linking to the prompt. timestamp (datetime): The time when the invocation occurred. response_time (float): The response time in seconds. is_success (bool): True if the invocation succeeded, False otherwise. @@ -303,7 +303,7 @@ class PromptMetric(Base): __tablename__ = "prompt_metrics" id: Mapped[int] = mapped_column(primary_key=True) - prompt_id: Mapped[int] = mapped_column(Integer, ForeignKey("prompts.id"), nullable=False) + prompt_id: Mapped[str] = mapped_column(Integer, ForeignKey("prompts.id"), nullable=False) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) response_time: Mapped[float] = mapped_column(Float, nullable=False) is_success: Mapped[bool] = mapped_column(Boolean, nullable=False) @@ -645,7 +645,7 @@ class Resource(Base): __tablename__ = "resources" - id: Mapped[int] = mapped_column(primary_key=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) uri: Mapped[str] = mapped_column(unique=True) name: Mapped[str] description: Mapped[Optional[str]] @@ -846,7 +846,7 @@ class ResourceSubscription(Base): __tablename__ = "resource_subscriptions" id: Mapped[int] = mapped_column(primary_key=True) - resource_id: Mapped[int] = mapped_column(ForeignKey("resources.id")) + resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id")) subscriber_id: Mapped[str] # Client identifier created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) last_notification: Mapped[Optional[datetime]] = mapped_column(DateTime) @@ -874,7 +874,7 @@ class Prompt(Base): __tablename__ = "prompts" - id: Mapped[int] = mapped_column(primary_key=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) name: Mapped[str] = mapped_column(unique=True) description: Mapped[Optional[str]] template: Mapped[str] = mapped_column(Text) diff --git a/mcpgateway/main.py b/mcpgateway/main.py index 768ac28e3..c5f277d53 100644 --- a/mcpgateway/main.py +++ b/mcpgateway/main.py @@ -1742,7 +1742,7 @@ async def list_resource_templates( @resource_router.post("/{resource_id}/toggle") async def toggle_resource_status( - resource_id: int, + resource_id: str, activate: bool = True, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -1751,7 +1751,7 @@ async def toggle_resource_status( Activate or deactivate a resource by its ID. Args: - resource_id (int): The ID of the resource. + resource_id (str): The ID of the resource. activate (bool): True to activate, False to deactivate. db (Session): Database session. user (str): Authenticated user. @@ -1983,7 +1983,7 @@ async def subscribe_resource(uri: str, user: str = Depends(require_auth)) -> Str ############### @prompt_router.post("/{prompt_id}/toggle") async def toggle_prompt_status( - prompt_id: int, + prompt_id: str, activate: bool = True, db: Session = Depends(get_db), user: str = Depends(require_auth), diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 743424f3a..98535f663 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1374,7 +1374,7 @@ class ResourceRead(BaseModelWithConfigDict): - Metrics: Aggregated metrics for the resource invocations. """ - id: int + id: str uri: str name: str description: Optional[str] @@ -1847,7 +1847,7 @@ class PromptRead(BaseModelWithConfigDict): - Metrics: Aggregated metrics for the prompt invocations. """ - id: int + id: str name: str description: Optional[str] template: str @@ -3107,8 +3107,8 @@ class ServerRead(BaseModelWithConfigDict): updated_at: datetime is_active: bool associated_tools: List[str] = [] - associated_resources: List[int] = [] - associated_prompts: List[int] = [] + associated_resources: List[str] = [] + associated_prompts: List[str] = [] associated_a2a_agents: List[str] = [] metrics: ServerMetrics tags: List[str] = Field(default_factory=list, description="Tags for categorizing the server") diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 9e2bb6380..87f33766e 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -675,7 +675,7 @@ async def update_prompt(self, db: Session, name: str, prompt_update: PromptUpdat db.rollback() raise PromptError(f"Failed to update prompt: {str(e)}") - async def toggle_prompt_status(self, db: Session, prompt_id: int, activate: bool) -> PromptRead: + async def toggle_prompt_status(self, db: Session, prompt_id: str, activate: bool) -> PromptRead: """ Toggle the activation status of a prompt. diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index fe5bd0bd0..eee159c8f 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -545,7 +545,7 @@ async def read_resource(self, db: Session, uri: str, request_id: Optional[str] = # Return content return content - async def toggle_resource_status(self, db: Session, resource_id: int, activate: bool) -> ResourceRead: + async def toggle_resource_status(self, db: Session, resource_id: str, activate: bool) -> ResourceRead: """ Toggle the activation status of a resource. diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 492635352..150a2b4d0 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -338,7 +338,7 @@ async def register_server(self, db: Session, server_in: ServerCreate) -> ServerR for resource_id in server_in.associated_resources: if resource_id.strip() == "": continue - resource_obj = db.get(DbResource, int(resource_id)) + resource_obj = db.get(DbResource, resource_id) if not resource_obj: raise ServerError(f"Resource with id {resource_id} does not exist.") db_server.resources.append(resource_obj) @@ -348,7 +348,7 @@ async def register_server(self, db: Session, server_in: ServerCreate) -> ServerR for prompt_id in server_in.associated_prompts: if prompt_id.strip() == "": continue - prompt_obj = db.get(DbPrompt, int(prompt_id)) + prompt_obj = db.get(DbPrompt, prompt_id) if not prompt_obj: raise ServerError(f"Prompt with id {prompt_id} does not exist.") db_server.prompts.append(prompt_obj) @@ -559,7 +559,7 @@ async def update_server(self, db: Session, server_id: str, server_update: Server if server_update.associated_resources is not None: server.resources = [] for resource_id in server_update.associated_resources: - resource_obj = db.get(DbResource, int(resource_id)) + resource_obj = db.get(DbResource, resource_id) if resource_obj: server.resources.append(resource_obj) @@ -567,7 +567,7 @@ async def update_server(self, db: Session, server_id: str, server_update: Server if server_update.associated_prompts is not None: server.prompts = [] for prompt_id in server_update.associated_prompts: - prompt_obj = db.get(DbPrompt, int(prompt_id)) + prompt_obj = db.get(DbPrompt, prompt_id) if prompt_obj: server.prompts.append(prompt_obj) From 74df929dcfd6351a6756b5ad00caf7f1400bf74e Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 13:40:57 +0530 Subject: [PATCH 02/19] Fix test cases Signed-off-by: Madhav Kandukuri --- tests/integration/test_integration.py | 2 +- .../services/test_export_service.py | 8 ++--- .../services/test_prompt_service_extended.py | 4 +-- .../services/test_resource_service.py | 28 ++++++++-------- .../services/test_server_service.py | 32 +++++++++---------- tests/unit/mcpgateway/test_admin.py | 2 +- tests/unit/mcpgateway/test_main.py | 8 ++--- tests/unit/mcpgateway/test_schemas.py | 16 +++++----- 8 files changed, 50 insertions(+), 50 deletions(-) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index dfb2924e2..39e703478 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -146,7 +146,7 @@ def auth_headers() -> dict[str, str]: ) MOCK_RESOURCE = ResourceRead( - id=1, + id="1", uri="file:///tmp/hello.txt", name="Hello", description="demo text", diff --git a/tests/unit/mcpgateway/services/test_export_service.py b/tests/unit/mcpgateway/services/test_export_service.py index 061b2a8e4..44a6317c2 100644 --- a/tests/unit/mcpgateway/services/test_export_service.py +++ b/tests/unit/mcpgateway/services/test_export_service.py @@ -911,14 +911,14 @@ async def test_export_selective_all_entity_types(export_service, mock_db): ) sample_prompt = PromptRead( - id=1, name="test_prompt", template="Test template", + id="1", name="test_prompt", template="Test template", description="Test prompt", arguments=[], is_active=True, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), metrics=create_default_prompt_metrics(), tags=[] ) sample_resource = ResourceRead( - id=1, name="test_resource", uri="file:///test.txt", + id="1", name="test_resource", uri="file:///test.txt", description="Test resource", mime_type="text/plain", size=None, is_active=True, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), metrics=create_default_resource_metrics(), tags=[] @@ -1056,7 +1056,7 @@ async def test_export_selected_prompts(export_service, mock_db): from mcpgateway.schemas import PromptRead sample_prompt = PromptRead( - id=1, name="test_prompt", template="Test template", + id="1", name="test_prompt", template="Test template", description="Test prompt", arguments=[], is_active=True, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), metrics=create_default_prompt_metrics(), tags=[] @@ -1088,7 +1088,7 @@ async def test_export_selected_resources(export_service, mock_db): from mcpgateway.schemas import ResourceRead sample_resource = ResourceRead( - id=1, name="test_resource", uri="file:///test.txt", + id="1", name="test_resource", uri="file:///test.txt", description="Test resource", mime_type="text/plain", size=None, is_active=True, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), metrics=create_default_resource_metrics(), tags=[] diff --git a/tests/unit/mcpgateway/services/test_prompt_service_extended.py b/tests/unit/mcpgateway/services/test_prompt_service_extended.py index ccbc29136..22b583b86 100644 --- a/tests/unit/mcpgateway/services/test_prompt_service_extended.py +++ b/tests/unit/mcpgateway/services/test_prompt_service_extended.py @@ -54,10 +54,10 @@ async def test_prompt_name_conflict_error_init(self): assert "test_prompt" in str(error) # Test inactive prompt conflict - error_inactive = PromptNameConflictError("inactive_prompt", False, 123) + error_inactive = PromptNameConflictError("inactive_prompt", False, "123") assert error_inactive.name == "inactive_prompt" assert error_inactive.is_active is False - assert error_inactive.prompt_id == 123 + assert error_inactive.prompt_id == "123" assert "inactive_prompt" in str(error_inactive) assert "currently inactive, ID: 123" in str(error_inactive) diff --git a/tests/unit/mcpgateway/services/test_resource_service.py b/tests/unit/mcpgateway/services/test_resource_service.py index d0cdb6fc8..c9a3987e8 100644 --- a/tests/unit/mcpgateway/services/test_resource_service.py +++ b/tests/unit/mcpgateway/services/test_resource_service.py @@ -62,7 +62,7 @@ def mock_resource(): resource = MagicMock() # core attributes - resource.id = 1 + resource.id = "1" resource.uri = "http://example.com/resource" resource.name = "Test Resource" resource.description = "A test resource" @@ -94,7 +94,7 @@ def mock_inactive_resource(): resource = MagicMock() # core attributes - resource.id = 2 + resource.id = "2" resource.uri = "http://example.com/inactive" resource.name = "Inactive Resource" resource.description = "An inactive resource" @@ -177,7 +177,7 @@ async def test_register_resource_success(self, resource_service, mock_db, sample patch.object(resource_service, "_convert_resource_to_read") as mock_convert, ): mock_convert.return_value = ResourceRead( - id=1, + id="1", uri=sample_resource_create.uri, name=sample_resource_create.name, description=sample_resource_create.description or "", @@ -290,7 +290,7 @@ async def test_register_resource_binary_content(self, resource_service, mock_db) patch.object(resource_service, "_convert_resource_to_read") as mock_convert, ): mock_convert.return_value = ResourceRead( - id=1, + id="1", uri=binary_resource.uri, name=binary_resource.name, description=binary_resource.description or "", @@ -442,7 +442,7 @@ async def test_toggle_resource_status_activate(self, resource_service, mock_db, with patch.object(resource_service, "_notify_resource_activated", new_callable=AsyncMock), patch.object(resource_service, "_convert_resource_to_read") as mock_convert: mock_convert.return_value = ResourceRead( - id=2, + id="2", uri=mock_inactive_resource.uri, name=mock_inactive_resource.name, description=mock_inactive_resource.description or "", @@ -464,7 +464,7 @@ async def test_toggle_resource_status_activate(self, resource_service, mock_db, }, ) - result = await resource_service.toggle_resource_status(mock_db, 2, activate=True) + result = await resource_service.toggle_resource_status(mock_db, "2", activate=True) assert mock_inactive_resource.is_active is True mock_db.commit.assert_called_once() @@ -476,7 +476,7 @@ async def test_toggle_resource_status_deactivate(self, resource_service, mock_db with patch.object(resource_service, "_notify_resource_deactivated", new_callable=AsyncMock), patch.object(resource_service, "_convert_resource_to_read") as mock_convert: mock_convert.return_value = ResourceRead( - id=1, + id="1", uri=mock_resource.uri, name=mock_resource.name, description=mock_resource.description, @@ -498,7 +498,7 @@ async def test_toggle_resource_status_deactivate(self, resource_service, mock_db }, ) - result = await resource_service.toggle_resource_status(mock_db, 1, activate=False) + result = await resource_service.toggle_resource_status(mock_db, "1", activate=False) assert mock_resource.is_active is False mock_db.commit.assert_called_once() @@ -522,7 +522,7 @@ async def test_toggle_resource_status_no_change(self, resource_service, mock_db, with patch.object(resource_service, "_convert_resource_to_read") as mock_convert: mock_convert.return_value = ResourceRead( - id=1, + id="1", uri=mock_resource.uri, name=mock_resource.name, description=mock_resource.description, @@ -545,7 +545,7 @@ async def test_toggle_resource_status_no_change(self, resource_service, mock_db, ) # Try to activate already active resource - result = await resource_service.toggle_resource_status(mock_db, 1, activate=True) + result = await resource_service.toggle_resource_status(mock_db, "1", activate=True) # Should not commit or notify mock_db.commit.assert_not_called() @@ -561,7 +561,7 @@ async def test_update_resource_success(self, resource_service, mock_db, mock_res with patch.object(resource_service, "_notify_resource_updated", new_callable=AsyncMock), patch.object(resource_service, "_convert_resource_to_read") as mock_convert: mock_convert.return_value = ResourceRead( - id=1, + id="1", uri=mock_resource.uri, name="Updated Name", description="Updated description", @@ -630,7 +630,7 @@ async def test_update_resource_binary_content(self, resource_service, mock_db, m with patch.object(resource_service, "_notify_resource_updated", new_callable=AsyncMock), patch.object(resource_service, "_convert_resource_to_read") as mock_convert: mock_convert.return_value = ResourceRead( - id=1, + id="1", uri=mock_resource.uri, name=mock_resource.name, description=mock_resource.description, @@ -1384,7 +1384,7 @@ async def test_get_top_resources(self, resource_service, mock_db): """Test getting top performing resources.""" # Mock query results mock_result1 = MagicMock() - mock_result1.id = 1 + mock_result1.id = "1" mock_result1.name = "resource1" mock_result1.execution_count = 10 mock_result1.avg_response_time = 1.5 @@ -1392,7 +1392,7 @@ async def test_get_top_resources(self, resource_service, mock_db): mock_result1.last_execution = "2025-01-10T12:00:00" mock_result2 = MagicMock() - mock_result2.id = 2 + mock_result2.id = "2" mock_result2.name = "resource2" mock_result2.execution_count = 7 mock_result2.avg_response_time = 2.3 diff --git a/tests/unit/mcpgateway/services/test_server_service.py b/tests/unit/mcpgateway/services/test_server_service.py index e853fd9e9..b0f4a3957 100644 --- a/tests/unit/mcpgateway/services/test_server_service.py +++ b/tests/unit/mcpgateway/services/test_server_service.py @@ -47,7 +47,7 @@ def mock_tool(): @pytest.fixture def mock_resource(): res = MagicMock(spec=DbResource) - res.id = 201 + res.id = "201" res.name = "test_resource" res._sa_instance_state = MagicMock() # Mock the SQLAlchemy instance state return res @@ -56,7 +56,7 @@ def mock_resource(): @pytest.fixture def mock_prompt(): pr = MagicMock(spec=DbPrompt) - pr.id = 301 + pr.id = "301" pr.name = "test_prompt" pr._sa_instance_state = MagicMock() # Mock the SQLAlchemy instance state return pr @@ -157,8 +157,8 @@ def capture_add(server): test_db.get = Mock( side_effect=lambda cls, _id: { (DbTool, "101"): mock_tool, - (DbResource, 201): mock_resource, - (DbPrompt, 301): mock_prompt, + (DbResource, "201"): mock_resource, + (DbPrompt, "301"): mock_prompt, }.get((cls, _id)) ) @@ -174,8 +174,8 @@ def capture_add(server): updated_at="2023-01-01T00:00:00", is_active=True, associated_tools=["101"], - associated_resources=[201], - associated_prompts=[301], + associated_resources=["201"], + associated_prompts=["301"], metrics={ "total_executions": 0, "successful_executions": 0, @@ -209,8 +209,8 @@ def capture_add(server): assert result.name == "test_server" assert "101" in result.associated_tools - assert 201 in result.associated_resources - assert 301 in result.associated_prompts + assert "201" in result.associated_resources + assert "301" in result.associated_prompts @pytest.mark.asyncio async def test_register_server_name_conflict(self, server_service, mock_server, test_db): @@ -279,8 +279,8 @@ async def test_list_servers(self, server_service, mock_server, test_db): updated_at="2023-01-01T00:00:00", is_active=True, associated_tools=["101"], - associated_resources=[201], - associated_prompts=[301], + associated_resources=["201"], + associated_prompts=["301"], metrics={ "total_executions": 0, "successful_executions": 0, @@ -313,8 +313,8 @@ async def test_get_server(self, server_service, mock_server, test_db): updated_at="2023-01-01T00:00:00", is_active=True, associated_tools=["101"], - associated_resources=[201], - associated_prompts=[301], + associated_resources=["201"], + associated_prompts=["301"], metrics={ "total_executions": 0, "successful_executions": 0, @@ -410,8 +410,8 @@ async def test_update_server(self, server_service, mock_server, test_db, mock_to updated_at="2023-01-01T00:00:00", is_active=True, associated_tools=["102"], - associated_resources=[202], - associated_prompts=[302], + associated_resources=["202"], + associated_prompts=["302"], metrics={ "total_executions": 0, "successful_executions": 0, @@ -490,8 +490,8 @@ async def test_toggle_server_status(self, server_service, mock_server, test_db): updated_at="2023-01-01T00:00:00", is_active=False, associated_tools=["101"], - associated_resources=[201], - associated_prompts=[301], + associated_resources=["201"], + associated_prompts=["301"], metrics={ "total_executions": 0, "successful_executions": 0, diff --git a/tests/unit/mcpgateway/test_admin.py b/tests/unit/mcpgateway/test_admin.py index 097e16160..0cb4ca18d 100644 --- a/tests/unit/mcpgateway/test_admin.py +++ b/tests/unit/mcpgateway/test_admin.py @@ -910,7 +910,7 @@ async def test_admin_list_prompts_with_complex_arguments(self, mock_list_prompts async def test_admin_get_prompt_with_detailed_metrics(self, mock_get_prompt_details, mock_db): """Test getting prompt with detailed metrics.""" mock_get_prompt_details.return_value = { - "id": 1, + "id": "1", "name": "test-prompt", "template": "Test {{var}}", "description": "Test prompt", diff --git a/tests/unit/mcpgateway/test_main.py b/tests/unit/mcpgateway/test_main.py index 6f1af1a14..aad41f96d 100644 --- a/tests/unit/mcpgateway/test_main.py +++ b/tests/unit/mcpgateway/test_main.py @@ -56,8 +56,8 @@ "updated_at": "2023-01-01T00:00:00+00:00", "is_active": True, "associated_tools": ["101"], - "associated_resources": [201], - "associated_prompts": [301], + "associated_resources": ["201"], + "associated_prompts": ["301"], "metrics": MOCK_METRICS, } @@ -116,7 +116,7 @@ def camel_to_snake_tool(d: dict) -> dict: MOCK_RESOURCE_READ = { - "id": 1, + "id": "1", "uri": "test/resource", "name": "Test Resource", "description": "A test resource", @@ -129,7 +129,7 @@ def camel_to_snake_tool(d: dict) -> dict: } MOCK_PROMPT_READ = { - "id": 1, + "id": "1", "name": "test_prompt", "description": "A test prompt", "template": "Hello {name}", diff --git a/tests/unit/mcpgateway/test_schemas.py b/tests/unit/mcpgateway/test_schemas.py index 0514ac680..460c4c2f4 100644 --- a/tests/unit/mcpgateway/test_schemas.py +++ b/tests/unit/mcpgateway/test_schemas.py @@ -757,8 +757,8 @@ def test_server_read(self): updated_at=now, is_active=True, associated_tools=["1", "2", "3"], - associated_resources=[4, 5], - associated_prompts=[6], + associated_resources=["4", "5"], + associated_prompts=["6"], metrics=ServerMetrics( total_executions=100, successful_executions=95, @@ -779,8 +779,8 @@ def test_server_read(self): assert server.updated_at == now assert server.is_active is True assert server.associated_tools == ["1", "2", "3"] - assert server.associated_resources == [4, 5] - assert server.associated_prompts == [6] + assert server.associated_resources == ["4", "5"] + assert server.associated_prompts == ["6"] assert server.metrics.total_executions == 100 assert server.metrics.successful_executions == 95 @@ -794,8 +794,8 @@ def test_server_read(self): updated_at=now, is_active=True, associated_tools=[Mock(id="10"), Mock(id="11")], - associated_resources=[Mock(id=12)], - associated_prompts=[Mock(id=13)], + associated_resources=[Mock(id="12")], + associated_prompts=[Mock(id="13")], metrics=ServerMetrics( total_executions=10, successful_executions=10, @@ -805,8 +805,8 @@ def test_server_read(self): ) assert server_with_objects.associated_tools == ["10", "11"] - assert server_with_objects.associated_resources == [12] - assert server_with_objects.associated_prompts == [13] + assert server_with_objects.associated_resources == ["12"] + assert server_with_objects.associated_prompts == ["13"] class TestToggleAndListSchemas: From 74d482f271fd3d467f6a4c36a4739175d9e410fd Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 16:38:24 +0530 Subject: [PATCH 03/19] Add custom names Signed-off-by: Madhav Kandukuri --- mcpgateway/db.py | 251 +++++++++++++++++++++++- mcpgateway/schemas.py | 4 + mcpgateway/services/prompt_service.py | 3 + mcpgateway/services/resource_service.py | 3 + 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/mcpgateway/db.py b/mcpgateway/db.py index 60c7132bd..e58eec705 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -647,7 +647,7 @@ class Resource(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) uri: Mapped[str] = mapped_column(unique=True) - name: Mapped[str] + original_name: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[Optional[str]] mime_type: Mapped[Optional[str]] size: Mapped[Optional[int]] @@ -681,6 +681,11 @@ class Resource(Base): # Subscription tracking subscriptions: Mapped[List["ResourceSubscription"]] = relationship("ResourceSubscription", back_populates="resource", cascade="all, delete-orphan") + # custom_name,custom_name_slug, display_name + custom_name: Mapped[Optional[str]] = mapped_column(String, nullable=False) + custom_name_slug: Mapped[Optional[str]] = mapped_column(String, nullable=False) + display_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="resources") # federated_with = relationship("Gateway", secondary=resource_gateway_table, back_populates="federated_resources") @@ -688,6 +693,8 @@ class Resource(Base): # Many-to-many relationship with Servers servers: Mapped[List["Server"]] = relationship("Server", secondary=server_resource_association, back_populates="resources") + _computed_name = Column("name", String, unique=True) # Stored column + @property def content(self) -> ResourceContent: """ @@ -742,6 +749,69 @@ def content(self) -> ResourceContent: ) raise ValueError("Resource has no content") + @hybrid_property + def name(self): + """Return the display/lookup name. + + Returns: + str: Name to display + """ + if self._computed_name: # pylint: disable=no-member + return self._computed_name # orm column, resolved at runtime + + 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}{custom_name_slug}" + + # No gateway → only the original name slug + return custom_name_slug + + @name.setter + def name(self, value): + """Store an explicit value that overrides the calculated one. + + Args: + value (str): Value to set to _computed_name + """ + self._computed_name = value + + @name.expression + @classmethod + def name(cls): + """ + SQL expression used when the hybrid appears in a filter/order_by. + Simply forwards to the ``_computed_name`` column; the Python-side + reconstruction above is not needed on the SQL side. + + Returns: + str: computed name for SQL use + """ + return cls._computed_name + + __table_args__ = (UniqueConstraint("gateway_id", "original_name", name="uq_gateway_id__original_name"),) + + @hybrid_property + def gateway_slug(self): + """Always returns the current slug from the related Gateway + + Returns: + str: slug for Python use + """ + return self.gateway.slug if self.gateway else None + + @gateway_slug.expression + @classmethod + def gateway_slug(cls): + """For database queries - auto-joins to get current slug + + Returns: + str: slug for SQL use + """ + return select(Gateway.slug).where(Gateway.id == cls.gateway_id).scalar_subquery() + @property def execution_count(self) -> int: """ @@ -875,7 +945,7 @@ class Prompt(Base): __tablename__ = "prompts" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) - name: Mapped[str] = mapped_column(unique=True) + original_name: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[Optional[str]] template: Mapped[str] = mapped_column(Text) argument_schema: Mapped[Dict[str, Any]] = mapped_column(JSON) @@ -901,6 +971,11 @@ class Prompt(Base): metrics: Mapped[List["PromptMetric"]] = relationship("PromptMetric", back_populates="prompt", cascade="all, delete-orphan") + # custom_name,custom_name_slug, display_name + custom_name: Mapped[Optional[str]] = mapped_column(String, nullable=False) + custom_name_slug: Mapped[Optional[str]] = mapped_column(String, nullable=False) + display_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="prompts") # federated_with = relationship("Gateway", secondary=prompt_gateway_table, back_populates="federated_prompts") @@ -908,6 +983,71 @@ class Prompt(Base): # Many-to-many relationship with Servers servers: Mapped[List["Server"]] = relationship("Server", secondary=server_prompt_association, back_populates="prompts") + _computed_name = Column("name", String, unique=True) # Stored column + + @hybrid_property + def name(self): + """Return the display/lookup name. + + Returns: + str: Name to display + """ + if self._computed_name: # pylint: disable=no-member + return self._computed_name # orm column, resolved at runtime + + 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}{custom_name_slug}" + + # No gateway → only the original name slug + return custom_name_slug + + @name.setter + def name(self, value): + """Store an explicit value that overrides the calculated one. + + Args: + value (str): Value to set to _computed_name + """ + self._computed_name = value + + @name.expression + @classmethod + def name(cls): + """ + SQL expression used when the hybrid appears in a filter/order_by. + Simply forwards to the ``_computed_name`` column; the Python-side + reconstruction above is not needed on the SQL side. + + Returns: + str: computed name for SQL use + """ + return cls._computed_name + + __table_args__ = (UniqueConstraint("gateway_id", "original_name", name="uq_gateway_id__original_name"),) + + @hybrid_property + def gateway_slug(self): + """Always returns the current slug from the related Gateway + + Returns: + str: slug for Python use + """ + return self.gateway.slug if self.gateway else None + + @gateway_slug.expression + @classmethod + def gateway_slug(cls): + """For database queries - auto-joins to get current slug + + Returns: + str: slug for SQL use + """ + return select(Gateway.slug).where(Gateway.id == cls.gateway_id).scalar_subquery() + def validate_arguments(self, args: Dict[str, str]) -> None: """ Validate prompt arguments against the argument schema. @@ -1304,6 +1444,48 @@ def update_tool_names_on_gateway_update(_mapper, connection, target): # 5. Execute the statement using the connection from the ongoing transaction. connection.execute(stmt) + print(f"Gateway name changed for ID {target.id}. Issuing bulk update for prompts.") + + # 2. Get a reference to the underlying database table for Prompts + prompts_table = Prompt.__table__ + + # 3. Prepare the new values + new_gateway_slug = slugify(target.name) + separator = settings.gateway_tool_name_separator + + # 4. Construct a single, powerful UPDATE statement using SQLAlchemy Core. + # This is highly efficient as it all happens in the database. + stmt = ( + prompts_table.update() + .where(prompts_table.c.gateway_id == target.id) + .values(name=new_gateway_slug + separator + prompts_table.c.custom_name_slug) + .execution_options(synchronize_session=False) # Important for bulk updates + ) + + # 5. Execute the statement using the connection from the ongoing transaction. + connection.execute(stmt) + + print(f"Gateway name changed for ID {target.id}. Issuing bulk update for resources.") + + # 2. Get a reference to the underlying database table for Prompts + resources_table = Resource.__table__ + + # 3. Prepare the new values + new_gateway_slug = slugify(target.name) + separator = settings.gateway_tool_name_separator + + # 4. Construct a single, powerful UPDATE statement using SQLAlchemy Core. + # This is highly efficient as it all happens in the database. + stmt = ( + resources_table.update() + .where(resources_table.c.gateway_id == target.id) + .values(name=new_gateway_slug + separator + resources_table.c.custom_name_slug) + .execution_options(synchronize_session=False) # Important for bulk updates + ) + + # 5. Execute the statement using the connection from the ongoing transaction. + connection.execute(stmt) + class A2AAgent(Base): """ @@ -1659,3 +1841,68 @@ def set_custom_name_and_slug(mapper, connection, target): # pylint: disable=unu target.name = f"{gateway_slug}{sep}{target.custom_name_slug}" else: target.name = target.custom_name_slug + + +@event.listens_for(Prompt, "before_insert") +@event.listens_for(Prompt, "before_update") +def set_custom_name_and_slug(mapper, connection, target): # pylint: disable=unused-argument + """ + Event listener to set custom_name, custom_name_slug, and name for Prompt 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. + - Sets display_name to custom_name if not provided. + + Args: + mapper: SQLAlchemy mapper for the Prompt model. + connection: Database connection. + target: The Prompt instance being inserted or updated. + """ + # Set custom_name to original_name if not provided + if not target.custom_name: + target.custom_name = target.original_name + # Set display_name to custom_name if not provided + if not target.display_name: + target.display_name = target.custom_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: + sep = settings.gateway_tool_name_separator + target.name = f"{gateway_slug}{sep}{target.custom_name_slug}" + else: + target.name = target.custom_name_slug + +@event.listens_for(Resource, "before_insert") +@event.listens_for(Resource, "before_update") +def set_custom_name_and_slug(mapper, connection, target): # pylint: disable=unused-argument + """ + Event listener to set custom_name, custom_name_slug, and name for Resource 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. + - Sets display_name to custom_name if not provided. + + Args: + mapper: SQLAlchemy mapper for the Resource model. + connection: Database connection. + target: The Resource instance being inserted or updated. + """ + # Set custom_name to original_name if not provided + if not target.custom_name: + target.custom_name = target.original_name + # Set display_name to custom_name if not provided + if not target.display_name: + target.display_name = target.custom_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: + sep = settings.gateway_tool_name_separator + target.name = f"{gateway_slug}{sep}{target.custom_name_slug}" + else: + target.name = target.custom_name_slug diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 98535f663..09b61c643 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1384,6 +1384,8 @@ class ResourceRead(BaseModelWithConfigDict): updated_at: datetime is_active: bool metrics: ResourceMetrics + custom_name: str + custom_name_slug: str tags: List[str] = Field(default_factory=list, description="Tags for categorizing the resource") # Comprehensive metadata for audit tracking @@ -1855,6 +1857,8 @@ class PromptRead(BaseModelWithConfigDict): created_at: datetime updated_at: datetime is_active: bool + custom_name: str + custom_name_slug: str tags: List[str] = Field(default_factory=list, description="Tags for categorizing the prompt") metrics: PromptMetrics diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 87f33766e..2eba2d4c4 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -38,6 +38,7 @@ from mcpgateway.schemas import PromptCreate, PromptRead, PromptUpdate, TopPerformer from mcpgateway.services.logging_service import LoggingService from mcpgateway.utils.metrics_common import build_top_performers +from mcpgateway.utils.create_slug import slugify # Initialize logging service first logging_service = LoggingService() @@ -310,6 +311,8 @@ async def register_prompt( # Create DB model db_prompt = DbPrompt( name=prompt.name, + custom_name=prompt.name, + custom_name_slug=slugify(prompt.name), description=prompt.description, template=prompt.template, argument_schema=argument_schema, diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index eee159c8f..eeeb244d0 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -50,6 +50,7 @@ from mcpgateway.schemas import ResourceCreate, ResourceMetrics, ResourceRead, ResourceSubscription, ResourceUpdate, TopPerformer from mcpgateway.services.logging_service import LoggingService from mcpgateway.utils.metrics_common import build_top_performers +from mcpgateway.utils.create_slug import slugify # Plugin support imports (conditional) try: @@ -280,6 +281,8 @@ async def register_resource( db_resource = DbResource( uri=resource.uri, name=resource.name, + custom_name=resource.name, + custom_name_slug=slugify(resource.name), description=resource.description, mime_type=mime_type, template=resource.template, From fdb01253bd19bd496fba851e7531ecafeb9cd171 Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 17:45:06 +0530 Subject: [PATCH 04/19] Testing updates Signed-off-by: Madhav Kandukuri --- mcpgateway/schemas.py | 4 + mcpgateway/services/gateway_service.py | 18 +- mcpgateway/services/prompt_service.py | 2 +- mcpgateway/services/resource_service.py | 2 +- mcpgateway/templates/admin.html | 264 +++++++++++++++++++++--- 5 files changed, 252 insertions(+), 38 deletions(-) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 09b61c643..4e93f7ccd 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1261,6 +1261,8 @@ class ResourceUpdate(BaseModelWithConfigDict): """ name: Optional[str] = Field(None, description="Human-readable resource name") + displayName: Optional[str] = Field(None, description="Display name for the tool (shown in UI)") # noqa: N815 + custom_name: Optional[str] = Field(None, description="Custom name for the resource") description: Optional[str] = Field(None, description="Resource description") mime_type: Optional[str] = Field(None, description="Resource MIME type") template: Optional[str] = Field(None, description="URI template for parameterized resources") @@ -1757,6 +1759,8 @@ class PromptUpdate(BaseModelWithConfigDict): """ name: Optional[str] = Field(None, description="Unique name for the prompt") + displayName: Optional[str] = Field(None, description="Display name for the prompt (shown in UI)") # noqa: N815 + custom_name: Optional[str] = Field(None, description="Custom name for the prompt") description: Optional[str] = Field(None, description="Prompt description") template: Optional[str] = Field(None, description="Prompt template text") arguments: Optional[List[PromptArgument]] = Field(None, description="List of arguments for the template") diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index af8f9607e..b6886718f 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -496,7 +496,9 @@ async def register_gateway( db_resources = [ DbResource( uri=resource.uri, - name=resource.name, + original_name=resource.name, + custom_name=resource.name, + custom_name_slug=slugify(resource.name), description=resource.description, mime_type=resource.mime_type, template=resource.template, @@ -514,7 +516,9 @@ async def register_gateway( # Create prompt DB models db_prompts = [ DbPrompt( - name=prompt.name, + original_name=prompt.name, + custom_name=prompt.name, + custom_name_slug=slugify(prompt.name), description=prompt.description, template=prompt.template if hasattr(prompt, "template") else "", argument_schema={}, # Use argument_schema instead of arguments @@ -863,7 +867,10 @@ async def update_gateway(self, db: Session, gateway_id: str, gateway_update: Gat gateway.resources.append( DbResource( uri=resource.uri, - name=resource.name, + original_name=resource.name, + custom_name=resource.custom_name, + custom_name_slug=slugify(resource.custom_name), + display_name=generate_display_name(resource.custom_name), description=resource.description, mime_type=resource.mime_type, template=resource.template, @@ -876,7 +883,10 @@ async def update_gateway(self, db: Session, gateway_id: str, gateway_update: Gat if not existing_prompt: gateway.prompts.append( DbPrompt( - name=prompt.name, + original_name=prompt.name, + custom_name=prompt.custom_name, + custom_name_slug=slugify(prompt.custom_name), + display_name=generate_display_name(prompt.custom_name), description=prompt.description, template=prompt.template if hasattr(prompt, "template") else "", argument_schema={}, # Use argument_schema instead of arguments diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 2eba2d4c4..057244b74 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -310,7 +310,7 @@ async def register_prompt( # Create DB model db_prompt = DbPrompt( - name=prompt.name, + original_name=prompt.name, custom_name=prompt.name, custom_name_slug=slugify(prompt.name), description=prompt.description, diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index eeeb244d0..f747584ae 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -280,7 +280,7 @@ async def register_resource( # Create DB model db_resource = DbResource( uri=resource.uri, - name=resource.name, + original_name=resource.name, custom_name=resource.name, custom_name_slug=slugify(resource.name), description=resource.description, diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 335fe01fb..5ae32b0f8 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -1190,29 +1190,125 @@

aria-live="polite" > -
+
+ Associated Resources + +
+ {% for resource in resources %} + + {% endfor %} + +
+
+ + + +
-
+
+ Associated Prompts + +
+ {% for prompt in prompts %} + + {% endfor %} + +
+
+ + + +
@@ -2312,7 +2408,12 @@

- {{ resource.id }} + {{ loop.index }} + + + {{ resource.gatewaySlug }} - {{ prompt.id }} + {{ loop.index }} + + + {{ prompt.gatewaySlug }} >

-
+
- +
+ class="max-h-60 overflow-y-auto rounded-md border border-gray-600 shadow-sm p-3 bg-gray-50 dark:bg-gray-900" + > + {% for resource in resources %} + + {% endfor %} +
+
+ + + +
+ + +
+ + +
-
+
- +
+ class="max-h-60 overflow-y-auto rounded-md border border-gray-600 shadow-sm p-3 bg-gray-50 dark:bg-gray-900" + > + {% for prompt in prompts %} + + {% endfor %} +
+
+ + + +
+ + +
+ + +
Date: Thu, 28 Aug 2025 18:04:25 +0530 Subject: [PATCH 05/19] Some more changes to schemas and services Signed-off-by: Madhav Kandukuri --- mcpgateway/schemas.py | 116 ++++++++++++++++++++++++ mcpgateway/services/gateway_service.py | 14 ++- mcpgateway/services/prompt_service.py | 12 ++- mcpgateway/services/resource_service.py | 11 +++ 4 files changed, 148 insertions(+), 5 deletions(-) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 4e93f7ccd..1f7267dad 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1206,6 +1206,35 @@ def validate_description(cls, v: Optional[str]) -> Optional[str]: raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}") return SecurityValidator.sanitize_display_text(v, "Description") + @field_validator("displayName") + @classmethod + def validate_display_name(cls, v: Optional[str]) -> Optional[str]: + """Ensure display names display safely + + Args: + v (str): Value to validate + + Returns: + str: Value if validated as safe + + Raises: + ValueError: When displayName contains unsafe content or exceeds length limits + + Examples: + >>> from mcpgateway.schemas import ResourceCreate + >>> ResourceCreate.validate_display_name('My Custom Resource') + 'My Custom Resource' + >>> ResourceCreate.validate_display_name('') + Traceback (most recent call last): + ... + ValueError: ... + """ + if v is None: + return v + if len(v) > SecurityValidator.MAX_NAME_LENGTH: + raise ValueError(f"Display name exceeds maximum length of {SecurityValidator.MAX_NAME_LENGTH}") + return SecurityValidator.sanitize_display_text(v, "Display name") + @field_validator("mime_type") @classmethod def validate_mime_type(cls, v: Optional[str]) -> Optional[str]: @@ -1364,6 +1393,35 @@ def validate_content(cls, v: Optional[Union[str, bytes]]) -> Optional[Union[str, return v + @field_validator("displayName") + @classmethod + def validate_display_name(cls, v: Optional[str]) -> Optional[str]: + """Ensure display names display safely + + Args: + v (str): Value to validate + + Returns: + str: Value if validated as safe + + Raises: + ValueError: When displayName contains unsafe content or exceeds length limits + + Examples: + >>> from mcpgateway.schemas import ResourceUpdate + >>> ResourceUpdate.validate_display_name('My Custom Resource') + 'My Custom Resource' + >>> ResourceUpdate.validate_display_name('') + Traceback (most recent call last): + ... + ValueError: ... + """ + if v is None: + return v + if len(v) > SecurityValidator.MAX_NAME_LENGTH: + raise ValueError(f"Display name exceeds maximum length of {SecurityValidator.MAX_NAME_LENGTH}") + return SecurityValidator.sanitize_display_text(v, "Display name") + class ResourceRead(BaseModelWithConfigDict): """Schema for reading resource information. @@ -1695,6 +1753,35 @@ def validate_description(cls, v: Optional[str]) -> Optional[str]: if len(v) > SecurityValidator.MAX_DESCRIPTION_LENGTH: raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}") return SecurityValidator.sanitize_display_text(v, "Description") + + @field_validator("displayName") + @classmethod + def validate_display_name(cls, v: Optional[str]) -> Optional[str]: + """Ensure display names display safely + + Args: + v (str): Value to validate + + Returns: + str: Value if validated as safe + + Raises: + ValueError: When displayName contains unsafe content or exceeds length limits + + Examples: + >>> from mcpgateway.schemas import PromptCreate + >>> PromptCreate.validate_display_name('My Custom Prompt') + 'My Custom Prompt' + >>> PromptCreate.validate_display_name('') + Traceback (most recent call last): + ... + ValueError: ... + """ + if v is None: + return v + if len(v) > SecurityValidator.MAX_NAME_LENGTH: + raise ValueError(f"Display name exceeds maximum length of {SecurityValidator.MAX_NAME_LENGTH}") + return SecurityValidator.sanitize_display_text(v, "Display name") @field_validator("template") @classmethod @@ -1842,6 +1929,35 @@ def validate_arguments(cls, v: Dict[str, Any]) -> Dict[str, Any]: SecurityValidator.validate_json_depth(v) return v + @field_validator("displayName") + @classmethod + def validate_display_name(cls, v: Optional[str]) -> Optional[str]: + """Ensure display names display safely + + Args: + v (str): Value to validate + + Returns: + str: Value if validated as safe + + Raises: + ValueError: When displayName contains unsafe content or exceeds length limits + + Examples: + >>> from mcpgateway.schemas import PromptUpdate + >>> PromptUpdate.validate_display_name('My Custom Prompt') + 'My Custom Prompt' + >>> PromptUpdate.validate_display_name('') + Traceback (most recent call last): + ... + ValueError: ... + """ + if v is None: + return v + if len(v) > SecurityValidator.MAX_NAME_LENGTH: + raise ValueError(f"Display name exceeds maximum length of {SecurityValidator.MAX_NAME_LENGTH}") + return SecurityValidator.sanitize_display_text(v, "Display name") + class PromptRead(BaseModelWithConfigDict): """Schema for reading prompt information. diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index b6886718f..ca95c3bb4 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -896,7 +896,7 @@ async def update_gateway(self, db: Session, gateway_id: str, gateway_update: Gat gateway.capabilities = capabilities gateway.tools = [tool for tool in gateway.tools if tool.original_name in new_tool_names] # keep only still-valid rows gateway.resources = [resource for resource in gateway.resources if resource.uri in new_resource_uris] # keep only still-valid rows - gateway.prompts = [prompt for prompt in gateway.prompts if prompt.name in new_prompt_names] # keep only still-valid rows + gateway.prompts = [prompt for prompt in gateway.prompts if prompt.original_name in new_prompt_names] # keep only still-valid rows gateway.last_seen = datetime.now(timezone.utc) # Update tracking with new URL @@ -1062,7 +1062,10 @@ async def toggle_gateway_status(self, db: Session, gateway_id: str, activate: bo gateway.resources.append( DbResource( uri=resource.uri, - name=resource.name, + original_name=resource.name, + custom_name=resource.custom_name, + custom_name_slug=slugify(resource.custom_name), + display_name=generate_display_name(resource.custom_name), description=resource.description, mime_type=resource.mime_type, template=resource.template, @@ -1075,7 +1078,10 @@ async def toggle_gateway_status(self, db: Session, gateway_id: str, activate: bo if not existing_prompt: gateway.prompts.append( DbPrompt( - name=prompt.name, + original_name=prompt.name, + custom_name=prompt.custom_name, + custom_name_slug=slugify(prompt.custom_name), + display_name=generate_display_name(prompt.custom_name), description=prompt.description, template=prompt.template if hasattr(prompt, "template") else "", argument_schema={}, # Use argument_schema instead of arguments @@ -1085,7 +1091,7 @@ async def toggle_gateway_status(self, db: Session, gateway_id: str, activate: bo gateway.capabilities = capabilities gateway.tools = [tool for tool in gateway.tools if tool.original_name in new_tool_names] # keep only still-valid rows gateway.resources = [resource for resource in gateway.resources if resource.uri in new_resource_uris] # keep only still-valid rows - gateway.prompts = [prompt for prompt in gateway.prompts if prompt.name in new_prompt_names] # keep only still-valid rows + gateway.prompts = [prompt for prompt in gateway.prompts if prompt.original_name in new_prompt_names] # keep only still-valid rows gateway.last_seen = datetime.now(timezone.utc) except Exception as e: logger.warning(f"Failed to initialize reactivated gateway: {e}") diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 057244b74..61dbf0677 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -219,7 +219,7 @@ def _convert_db_prompt(self, db_prompt: DbPrompt) -> Dict[str, Any]: avg_rt = (sum(m.response_time for m in db_prompt.metrics) / total) if total > 0 else None last_time = max((m.timestamp for m in db_prompt.metrics), default=None) if total > 0 else None - return { + prompt_dict = { "id": db_prompt.id, "name": db_prompt.name, "description": db_prompt.description, @@ -241,6 +241,16 @@ def _convert_db_prompt(self, db_prompt: DbPrompt) -> Dict[str, Any]: "tags": db_prompt.tags or [], } + display_name = getattr(db_prompt, "display_name", None) + custom_name = getattr(db_prompt, "custom_name", db_prompt.original_name) + prompt_dict["displayName"] = display_name or custom_name + prompt_dict["custom_name"] = custom_name + prompt_dict["gateway_slug"] = getattr(db_prompt, "gateway_slug", "") or "" + prompt_dict["custom_name_slug"] = getattr(db_prompt, "custom_name_slug", "") or "" + prompt_dict["tags"] = getattr(db_prompt, "tags", []) or [] + + return prompt_dict + async def register_prompt( self, db: Session, diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index f747584ae..81249b021 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -218,6 +218,17 @@ def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead: "last_execution_time": last_time, } resource_dict["tags"] = resource.tags or [] + + resource_dict["name"] = resource.name + # Handle displayName with fallback and None checks + display_name = getattr(resource, "display_name", None) + custom_name = getattr(resource, "custom_name", resource.original_name) + resource_dict["displayName"] = display_name or custom_name + resource_dict["custom_name"] = custom_name + resource_dict["gateway_slug"] = getattr(resource, "gateway_slug", "") or "" + resource_dict["custom_name_slug"] = getattr(resource, "custom_name_slug", "") or "" + resource_dict["tags"] = getattr(resource, "tags", []) or [] + return ResourceRead.model_validate(resource_dict) async def register_resource( From 9b5d57978f678d9bbd0a098bb5417bae6aa1f4ba Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 18:12:13 +0530 Subject: [PATCH 06/19] Fix bugs Signed-off-by: Madhav Kandukuri --- mcpgateway/schemas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 1f7267dad..5ce1fb66c 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1141,6 +1141,7 @@ class ResourceCreate(BaseModel): uri: str = Field(..., description="Unique URI for the resource") name: str = Field(..., description="Human-readable resource name") + displayName: Optional[str] = Field(None, description="Display name for the resource (shown in UI)") # noqa: N815 description: Optional[str] = Field(None, description="Resource description") mime_type: Optional[str] = Field(None, description="Resource MIME type") template: Optional[str] = Field(None, description="URI template for parameterized resources") @@ -1703,6 +1704,7 @@ class PromptCreate(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) name: str = Field(..., description="Unique name for the prompt") + displayName: Optional[str] = Field(None, description="Display name for the prompt (shown in UI)") # noqa: N815 description: Optional[str] = Field(None, description="Prompt description") template: str = Field(..., description="Prompt template text") arguments: List[PromptArgument] = Field(default_factory=list, description="List of arguments for the template") From 5650318c233b2ce61ab4daa455e114a9f846f798 Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 18:16:27 +0530 Subject: [PATCH 07/19] UI fix Signed-off-by: Madhav Kandukuri --- mcpgateway/templates/admin.html | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 5ae32b0f8..eeed73156 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -2361,7 +2361,12 @@

- ID + S. No. + + + Gateway Name - ID + S. No. + + + Gateway Name Date: Thu, 28 Aug 2025 18:22:38 +0530 Subject: [PATCH 08/19] Some more db changes Signed-off-by: Madhav Kandukuri --- mcpgateway/db.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/mcpgateway/db.py b/mcpgateway/db.py index e58eec705..81e8108c1 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -126,7 +126,7 @@ def utc_now() -> datetime: def refresh_slugs_on_startup(): - """Refresh slugs for all gateways and names of tools on startup.""" + """Refresh slugs for all gateways and names of tools, resources and prompts on startup.""" with SessionLocal() as session: gateways = session.query(Gateway).all() @@ -143,6 +143,14 @@ def refresh_slugs_on_startup(): for tool in tools: session.expire(tool, ["gateway"]) + resources = session.query(Resource).all() + for resource in resources: + session.expire(resource, ["gateway"]) + + prompts = session.query(Prompt).all() + for prompt in prompts: + session.expire(prompt, ["gateway"]) + updated = False for tool in tools: if tool.gateway: @@ -152,6 +160,24 @@ def refresh_slugs_on_startup(): if tool.name != new_name: tool.name = new_name updated = True + + for resource in resources: + if resource.gateway: + new_name = f"{resource.gateway.slug}{settings.gateway_tool_name_separator}{slugify(resource.original_name)}" + else: + new_name = slugify(resource.original_name) + if resource.name != new_name: + resource.name = new_name + updated = True + + for prompt in prompts: + if prompt.gateway: + new_name = f"{prompt.gateway.slug}{settings.gateway_tool_name_separator}{slugify(prompt.original_name)}" + else: + new_name = slugify(prompt.original_name) + if prompt.name != new_name: + prompt.name = new_name + updated = True if updated: session.commit() From 7d06c8a59707179dc5c6aee32c3e49a788ca38e2 Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 18:41:37 +0530 Subject: [PATCH 09/19] Add gateway name to resources and prompts Signed-off-by: Madhav Kandukuri --- mcpgateway/db.py | 4 ++-- mcpgateway/schemas.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mcpgateway/db.py b/mcpgateway/db.py index 81e8108c1..b353c3c02 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -713,7 +713,7 @@ class Resource(Base): display_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) - gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="resources") + gateway: Mapped["Gateway"] = relationship("Gateway", primaryjoin="Resource.gateway_id == Gateway.id", foreign_keys=[gateway_id], back_populates="resources") # federated_with = relationship("Gateway", secondary=resource_gateway_table, back_populates="federated_resources") # Many-to-many relationship with Servers @@ -1003,7 +1003,7 @@ class Prompt(Base): display_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) - gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="prompts") + gateway: Mapped["Gateway"] = relationship("Gateway", primaryjoin="Prompt.gateway_id == Gateway.id", foreign_keys=[gateway_id], back_populates="prompts") # federated_with = relationship("Gateway", secondary=prompt_gateway_table, back_populates="federated_prompts") # Many-to-many relationship with Servers diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 5ce1fb66c..b6d67c2fa 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1445,6 +1445,8 @@ class ResourceRead(BaseModelWithConfigDict): updated_at: datetime is_active: bool metrics: ResourceMetrics + displayName: Optional[str] = Field(None, description="Display name for the resource (shown in UI)") # noqa: N815 + gateway_slug: str custom_name: str custom_name_slug: str tags: List[str] = Field(default_factory=list, description="Tags for categorizing the resource") @@ -1979,6 +1981,8 @@ class PromptRead(BaseModelWithConfigDict): created_at: datetime updated_at: datetime is_active: bool + displayName: Optional[str] = Field(None, description="Display name for the prompt (shown in UI)") # noqa: N815 + gateway_slug: str custom_name: str custom_name_slug: str tags: List[str] = Field(default_factory=list, description="Tags for categorizing the prompt") From 0e14c26fbf7bc4ca41f3296ecfa94ab04fa4ee8b Mon Sep 17 00:00:00 2001 From: Madhav Kandukuri Date: Thu, 28 Aug 2025 19:05:47 +0530 Subject: [PATCH 10/19] Add search Signed-off-by: Madhav Kandukuri --- mcpgateway/admin.py | 8 ++-- mcpgateway/templates/admin.html | 77 ++++++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index dc48c7638..be5986b50 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -627,8 +627,8 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user description=form.get("description"), icon=form.get("icon"), associated_tools=",".join(form.getlist("associatedTools")), - associated_resources=form.get("associatedResources"), - associated_prompts=form.get("associatedPrompts"), + associated_resources=",".join(form.getlist("associatedResources")), + associated_prompts=",".join(form.getlist("associatedPrompts")), tags=tags, ) except KeyError as e: @@ -785,8 +785,8 @@ async def admin_edit_server( description=form.get("description"), icon=form.get("icon"), associated_tools=",".join(form.getlist("associatedTools")), - associated_resources=form.get("associatedResources"), - associated_prompts=form.get("associatedPrompts"), + associated_resources=",".join(form.getlist("associatedResources")), + associated_prompts=",".join(form.getlist("associatedPrompts")), tags=tags, ) await server_service.update_server(db, server_id, server) diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index eeed73156..601c528ae 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -959,16 +959,16 @@

- {% if server.associatedResources %} {{ - server.associatedResources | join(', ') }} {% else %} + {% if server.associatedResources %} {{ server.associatedResources | + map('string') | join(', ') }} {% else %} N/A {% endif %} - {% if server.associatedPrompts %} {{ - server.associatedPrompts | join(', ') }} {% else %} + {% if server.associatedPrompts %} {{ server.associatedPrompts | + map('string') | join(', ') }} {% else %} N/A {% endif %} @@ -1154,7 +1154,7 @@

class="text-gray-700 dark:text-gray-300" style="display: none" > - No tool found containing "" + No tool found containing ""

@@ -1227,7 +1227,7 @@

class="text-gray-700 dark:text-gray-300" style="display: none" > - No resource found containing "" + No resource found containing ""

@@ -1287,7 +1287,7 @@

class="text-gray-700 dark:text-gray-300" style="display: none" > - No prompt found containing "" + No prompt found containing ""

@@ -6003,12 +6003,23 @@

// Add search functionality for filtering tools document.addEventListener('DOMContentLoaded', function() { - const searchBox = document.getElementById('searchTools'); + const toolSearchBox = document.getElementById('searchTools'); + const resourceSearchBox = document.getElementById('searchResources'); + const promptSearchBox = document.getElementById('searchPrompts'); + const toolItems = document.querySelectorAll('#associatedTools .tool-item'); + const resourceItems = document.querySelectorAll('#associatedResources .resource-item'); + const promptItems = document.querySelectorAll('#associatedPrompts .prompt-item'); + const noToolsMessage = document.getElementById('noToolsMessage'); - const searchQuerySpan = document.getElementById('searchQuery'); + const noResourcesMessage = document.getElementById('noResourcesMessage'); + const noPromptsMessage = document.getElementById('noPromptsMessage'); + + const toolSearchQuerySpan = document.getElementById('toolSearchQuery'); + const resourceSearchQuerySpan = document.getElementById('resourceSearchQuery'); + const promptSearchQuerySpan = document.getElementById('promptSearchQuery'); - searchBox.addEventListener('input', function() { + toolSearchBox.addEventListener('input', function() { const filter = this.value.toLowerCase(); let hasVisibleItems = false; @@ -6025,10 +6036,54 @@

if (hasVisibleItems) { noToolsMessage.style.display = 'none'; } else { - searchQuerySpan.textContent = filter; + toolSearchQuerySpan.textContent = filter; noToolsMessage.style.display = 'block'; } }); + + resourceSearchBox.addEventListener('input', function() { + const filter = this.value.toLowerCase(); + let hasVisibleItems = false; + + resourceItems.forEach(function(resourceItem) { + const resourceName = resourceItem.querySelector('span').textContent.toLowerCase(); + if (resourceName.includes(filter)) { + resourceItem.style.display = ''; + hasVisibleItems = true; + } else { + resourceItem.style.display = 'none'; + } + }); + + if (hasVisibleItems) { + noResourcesMessage.style.display = 'none'; + } else { + resourceSearchQuerySpan.textContent = filter; + noResourcesMessage.style.display = 'block'; + } + }); + + promptSearchBox.addEventListener('input', function() { + const filter = this.value.toLowerCase(); + let hasVisibleItems = false; + + promptItems.forEach(function(promptItem) { + const promptName = promptItem.querySelector('span').textContent.toLowerCase(); + if (promptName.includes(filter)) { + promptItem.style.display = ''; + hasVisibleItems = true; + } else { + promptItem.style.display = 'none'; + } + }); + + if (hasVisibleItems) { + noPromptsMessage.style.display = 'none'; + } else { + promptSearchQuerySpan.textContent = filter; + noPromptsMessage.style.display = 'block'; + } + }); });