From 1bcd339b69959bc64cd4d2a0e9e7a5b8ba917c96 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Wed, 26 Nov 2025 14:51:26 +0530 Subject: [PATCH 01/15] tag str to dict Signed-off-by: rakdutta --- mcpgateway/admin.py | 3 +- mcpgateway/schemas.py | 2 +- mcpgateway/services/server_service.py | 5 ++- mcpgateway/static/admin.js | 3 +- mcpgateway/templates/admin.html | 23 +++++++----- .../templates/mcp_registry_partial.html | 17 ++++----- mcpgateway/validation/tags.py | 36 ++++++++++--------- 7 files changed, 51 insertions(+), 38 deletions(-) diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index bed8b9c3b..5f030faea 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -906,9 +906,10 @@ async def admin_list_servers( >>> asyncio.run(test_admin_list_servers_exception()) True """ - LOGGER.debug(f"User {get_user_email(user)} requested server list") + LOGGER.info(f"User {get_user_email(user)} requested server list") user_email = get_user_email(user) servers = await server_service.list_servers_for_user(db, user_email, include_inactive=include_inactive) + LOGGER.info(f"servers:{servers}") return [server.model_dump(by_alias=True) for server in servers] diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 22b79a5ed..d2903eb42 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -3716,7 +3716,7 @@ class ServerRead(BaseModelWithConfigDict): associated_prompts: List[int] = [] associated_a2a_agents: List[str] = [] metrics: ServerMetrics - tags: List[str] = Field(default_factory=list, description="Tags for categorizing the server") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the server") # Comprehensive metadata for audit tracking created_by: Optional[str] = Field(None, description="Username who created this entity") diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index f93c21df3..4df32d53a 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -37,6 +37,7 @@ from mcpgateway.services.team_management_service import TeamManagementService from mcpgateway.utils.metrics_common import build_top_performers from mcpgateway.utils.sqlalchemy_modifier import json_contains_expr +from mcpgateway.validation.tags import TagValidator # Initialize logging service first logging_service = LoggingService() @@ -246,7 +247,9 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["associated_resources"] = [res.id for res in server.resources] if server.resources else [] server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else [] server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents] if server.a2a_agents else [] - server_dict["tags"] = server.tags or [] + # Convert DB-stored tag strings into validated tag dicts expected by ServerRead + # (`TagValidator.validate_list` returns a list of {"id":..., "label":...} dicts) + server_dict["tags"] = TagValidator.validate_list(server.tags or []) # Include metadata fields for proper API response server_dict["created_by"] = getattr(server, "created_by", None) diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index acd6b3b07..b81c8c817 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -4977,7 +4977,8 @@ async function viewServer(serverId) { const tagSpan = document.createElement("span"); tagSpan.className = "inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mr-1 mb-1 dark:bg-blue-900 dark:text-blue-200"; - tagSpan.textContent = tag; + const raw = (typeof tag === 'object' && tag !== null) ? (tag.id || tag.label) : tag; + tagSpan.textContent = raw; tagsP.appendChild(tagSpan); }); } else { diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 9a69189ef..b8bb0af58 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -1875,15 +1875,20 @@

{% endif %} - {% if server.tags %} {% for tag in server.tags %} - {{ tag }} - {% endfor %} {% else %} - None + {% if server.tags %} + {% for tag in server.tags %} + + {% if tag is mapping %} + {{ tag.id }} + {% else %} + {{ tag }} + {% endif %} + + {% endfor %} + {% else %} + None {% endif %} diff --git a/mcpgateway/templates/mcp_registry_partial.html b/mcpgateway/templates/mcp_registry_partial.html index 765c1b461..ba27a0a59 100644 --- a/mcpgateway/templates/mcp_registry_partial.html +++ b/mcpgateway/templates/mcp_registry_partial.html @@ -473,15 +473,16 @@

{% if server.tags %}
{% for tag in server.tags[:3] %} - - {{ tag }} - - {% endfor %} {% if server.tags|length > 3 %} - - +{{ server.tags|length - 3 }} more + + {% if tag is mapping %} + {{ tag.id }} + {% else %} + {{ tag }} + {% endif %} + {% endfor %} + {% if server.tags|length > 3 %} + +{{ server.tags|length - 3 }} more {% endif %}
{% endif %} diff --git a/mcpgateway/validation/tags.py b/mcpgateway/validation/tags.py index d6f5e6075..6a2ff2ea6 100644 --- a/mcpgateway/validation/tags.py +++ b/mcpgateway/validation/tags.py @@ -11,7 +11,7 @@ # Standard import re -from typing import List, Optional +from typing import List, Optional, Dict class TagValidator: @@ -130,7 +130,7 @@ def validate(tag: str) -> bool: return True @staticmethod - def validate_list(tags: Optional[List[str]]) -> List[str]: + def validate_list(tags: Optional[List[str]]) -> List[Dict[str, str]]: """Validate and normalize a list of tags. Filters out invalid tags, removes duplicates, and handles edge cases. @@ -139,21 +139,21 @@ def validate_list(tags: Optional[List[str]]) -> List[str]: tags: List of tags to validate and normalize. Returns: - List of valid, normalized, unique tags. + List of valid tag dicts with `id` (normalized tag) and `label` (original string). Examples: >>> TagValidator.validate_list(["Analytics", "ANALYTICS", "ml"]) - ['analytics', 'ml'] + [{'id': 'analytics', 'label': 'Analytics'}, {'id': 'ml', 'label': 'ml'}] >>> TagValidator.validate_list(["", "a", "valid-tag"]) - ['valid-tag'] + [{'id': 'valid-tag', 'label': 'valid-tag'}] >>> TagValidator.validate_list(None) [] >>> TagValidator.validate_list([" Finance ", "FINANCE", " finance "]) - ['finance'] + [{'id': 'finance', 'label': 'Finance'}] >>> TagValidator.validate_list(["API", None, "", " ", "api"]) - ['api'] + [{'id': 'api', 'label': 'API'}] >>> TagValidator.validate_list(["Machine Learning", "machine-learning"]) - ['machine-learning'] + [{'id': 'machine-learning', 'label': 'Machine Learning'}] """ if not tags: return [] @@ -161,21 +161,23 @@ def validate_list(tags: Optional[List[str]]) -> List[str]: # Filter out None values and convert everything to strings string_tags = [str(tag) for tag in tags if tag is not None] - # Normalize all tags - normalized_tags = [] + # Normalize all tags while preserving the original input for the label + normalized_pairs: List[tuple[str, str]] = [] for tag in string_tags: # Skip empty strings or strings with only whitespace if tag and tag.strip(): - normalized_tags.append(TagValidator.normalize(tag)) + original = tag.strip() + normalized = TagValidator.normalize(original) + normalized_pairs.append((normalized, original)) # Filter valid tags and remove duplicates while preserving order seen = set() - valid_tags = [] - for tag in normalized_tags: - # Validate and check for duplicates - if tag and TagValidator.validate(tag) and tag not in seen: - seen.add(tag) - valid_tags.append(tag) + valid_tags: List[Dict[str, str]] = [] + for normalized, original in normalized_pairs: + # Validate and check for duplicates (use normalized value for uniqueness) + if normalized and TagValidator.validate(normalized) and normalized not in seen: + seen.add(normalized) + valid_tags.append({"id": normalized, "label": original}) return valid_tags From 327a9b8477bf8c848e4ddeb4b00bdf590b982c48 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Wed, 26 Nov 2025 18:34:27 +0530 Subject: [PATCH 02/15] alembic, schema change Signed-off-by: rakdutta --- ...4_tag_records_changes_list_str_to_list_.py | 129 ++++++++++++++++++ mcpgateway/schemas.py | 16 +-- mcpgateway/services/server_service.py | 7 +- mcpgateway/validation/tags.py | 10 +- 4 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py diff --git a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py new file mode 100644 index 000000000..db1a2e320 --- /dev/null +++ b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py @@ -0,0 +1,129 @@ +"""tag records changes list[str] to list[Dict[str,str]] + +Revision ID: 9e028ecf59c4 +Revises: 191a2def08d7 +Create Date: 2025-11-26 18:15:07.113528 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import json + +# revision identifiers, used by Alembic. +revision: str = '9e028ecf59c4' +down_revision: Union[str, Sequence[str], None] = '191a2def08d7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Data migration: convert servers.tags which are currently lists of strings + # into lists of dicts with keys `id` (normalized) and `label` (original). + conn = op.get_bind() + # Apply same transformation to multiple tables that use a `tags` JSON column. + tables = [ + "servers", + "tools", + "prompts", + "resources", + "a2a_agents", + "gateways", + "grpc_services", + ] + + inspector = sa.inspect(conn) + available = set(inspector.get_table_names()) + + for table in tables: + if table not in available: + # Skip non-existent tables in older DBs + continue + + rows = conn.execute(sa.text(f"SELECT id, tags FROM {table}")).fetchall() + + for row in rows: + rec_id = row[0] + tags_raw = row[1] + + # Parse JSON (SQLite returns string) + if isinstance(tags_raw, str): + tags = json.loads(tags_raw) + else: + tags = tags_raw + + # Skip if not a list + if not isinstance(tags, list): + continue + + contains_string = any(isinstance(t, str) for t in tags) + if not contains_string: + continue + + # Convert strings → dict format + new_tags = [] + for t in tags: + if isinstance(t, str): + new_tags.append({"id": t, "label": t}) + else: + new_tags.append(t) + + # Convert back to JSON for storage + conn.execute( + sa.text(f"UPDATE {table} SET tags = :new_tags WHERE id = :id"), + {"new_tags": json.dumps(new_tags), "id": rec_id} + ) + + +def downgrade(): + conn = op.get_bind() + # Reverse the transformation across the same set of tables. + tables = [ + "servers", + "tools", + "prompts", + "resources", + "a2a_agents", + "gateways", + "grpc_services", + ] + + inspector = sa.inspect(conn) + available = set(inspector.get_table_names()) + + for table in tables: + if table not in available: + continue + + rows = conn.execute(sa.text(f"SELECT id, tags FROM {table}")).fetchall() + + for row in rows: + rec_id = row[0] + tags_raw = row[1] + + if isinstance(tags_raw, str): + tags = json.loads(tags_raw) + else: + tags = tags_raw + + if not isinstance(tags, list): + continue + + contains_dict = any(isinstance(t, dict) and "id" in t for t in tags) + if not contains_dict: + continue + + old_tags = [] + for t in tags: + if isinstance(t, dict) and "id" in t: + old_tags.append(t["id"]) + else: + old_tags.append(t) + + conn.execute( + sa.text(f"UPDATE {table} SET tags = :tags WHERE id = :id"), + {"tags": json.dumps(old_tags), "id": rec_id} + ) + \ No newline at end of file diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index d2903eb42..a75a5155c 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -1284,7 +1284,7 @@ class ToolRead(BaseModelWithConfigDict): gateway_slug: str custom_name: str custom_name_slug: str - tags: List[str] = Field(default_factory=list, description="Tags for categorizing the tool") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the tool") # Comprehensive metadata for audit tracking created_by: Optional[str] = Field(None, description="Username who created this entity") @@ -1782,7 +1782,7 @@ class ResourceRead(BaseModelWithConfigDict): updated_at: datetime is_active: bool metrics: ResourceMetrics - tags: List[str] = Field(default_factory=list, description="Tags for categorizing the resource") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the resource") # Comprehensive metadata for audit tracking created_by: Optional[str] = Field(None, description="Username who created this entity") @@ -2288,7 +2288,7 @@ class PromptRead(BaseModelWithConfigDict): created_at: datetime updated_at: datetime is_active: bool - tags: List[str] = Field(default_factory=list, description="Tags for categorizing the prompt") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the prompt") metrics: PromptMetrics # Comprehensive metadata for audit tracking @@ -2418,7 +2418,7 @@ class GatewayCreate(BaseModel): # One time auth - do not store the auth in gateway flag one_time_auth: Optional[bool] = Field(default=False, description="The authentication should be used only once and not stored in the gateway") - tags: Optional[List[str]] = Field(default_factory=list, description="Tags for categorizing the gateway") + tags: Optional[List[Dict[str, str]]] = Field(default_factory=list, description="Tags for categorizing the gateway") # Team scoping fields for resource organization team_id: Optional[str] = Field(None, description="Team ID this gateway belongs to") @@ -2432,7 +2432,7 @@ class GatewayCreate(BaseModel): @field_validator("tags") @classmethod - def validate_tags(cls, v: Optional[List[str]]) -> List[str]: + def validate_tags(cls, v: Optional[List[Dict[str, str]]]) -> List[Dict[str, str]]: """Validate and normalize tags. Args: @@ -2947,7 +2947,7 @@ class GatewayRead(BaseModelWithConfigDict): auth_token: Optional[str] = Field(None, description="token for bearer authentication") auth_header_key: Optional[str] = Field(None, description="key for custom headers authentication") auth_header_value: Optional[str] = Field(None, description="vallue for custom headers authentication") - tags: List[str] = Field(default_factory=list, description="Tags for categorizing the gateway") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the gateway") auth_password_unmasked: Optional[str] = Field(default=None, description="Unmasked password for basic authentication") auth_token_unmasked: Optional[str] = Field(default=None, description="Unmasked bearer token for authentication") @@ -4489,7 +4489,7 @@ class A2AAgentRead(BaseModelWithConfigDict): created_at: datetime updated_at: datetime last_interaction: Optional[datetime] - tags: List[str] = Field(default_factory=list, description="Tags for categorizing the agent") + tags: List[Dict[str,str]] = Field(default_factory=list, description="Tags for categorizing the agent") metrics: A2AAgentMetrics passthrough_headers: Optional[List[str]] = Field(default=None, description="List of headers allowed to be passed through from client to target") # Authorizations @@ -6250,7 +6250,7 @@ class GrpcServiceRead(BaseModel): last_reflection: Optional[datetime] = Field(None, description="Last reflection timestamp") # Tags - tags: List[str] = Field(default_factory=list, description="Service tags") + tags: List[Dict[str,str]] = Field(default_factory=list, description="Service tags") # Timestamps created_at: datetime = Field(..., description="Creation timestamp") diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 4df32d53a..98d6933b8 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -248,9 +248,9 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else [] server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents] if server.a2a_agents else [] # Convert DB-stored tag strings into validated tag dicts expected by ServerRead - # (`TagValidator.validate_list` returns a list of {"id":..., "label":...} dicts) - server_dict["tags"] = TagValidator.validate_list(server.tags or []) - + # (`TagValidator.to_tag_objects` returns a list of {"id":..., "label":...} dicts) + #server_dict["tags"] = TagValidator.validate_list(server.tags or []) + server_dict["tags"] = TagValidator.to_tag_objects(server.tags or []) # Include metadata fields for proper API response server_dict["created_by"] = getattr(server, "created_by", None) server_dict["modified_by"] = getattr(server, "modified_by", None) @@ -258,6 +258,7 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["updated_at"] = getattr(server, "updated_at", None) server_dict["version"] = getattr(server, "version", None) server_dict["team"] = getattr(server, "team", None) + logger.info(f"Server dict for conversion: {server_dict}") return ServerRead.model_validate(server_dict) diff --git a/mcpgateway/validation/tags.py b/mcpgateway/validation/tags.py index 6a2ff2ea6..28e7368c0 100644 --- a/mcpgateway/validation/tags.py +++ b/mcpgateway/validation/tags.py @@ -213,7 +213,15 @@ def get_validation_errors(tags: List[str]) -> List[str]: errors.append(f'Tag "{normalized}" contains invalid characters or format') return errors - + @staticmethod + def to_tag_objects(tags: Optional[List[str]]) -> List[Dict[str,str]]: + if not tags: + return [] + # Use existing normalization & validation but return dicts + normalized = TagValidator.validate_list(tags) # if validate_list returns strings + # If validate_list returns dicts in current code, adapt accordingly + return [{"id": TagValidator.normalize(t) if isinstance(t, str) else t["id"], + "label": t if isinstance(t, str) else t["label"]} for t in normalized] def validate_tags_field(tags: Optional[List[str]]) -> List[str]: """Pydantic field validator for tags. From 6b67cdce3b4d2fd16a1653b692f022d06aa5e09c Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 10:03:02 +0530 Subject: [PATCH 03/15] revert tags Signed-off-by: rakdutta --- mcpgateway/services/server_service.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 98d6933b8..84a9ec566 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -247,10 +247,7 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["associated_resources"] = [res.id for res in server.resources] if server.resources else [] server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else [] server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents] if server.a2a_agents else [] - # Convert DB-stored tag strings into validated tag dicts expected by ServerRead - # (`TagValidator.to_tag_objects` returns a list of {"id":..., "label":...} dicts) - #server_dict["tags"] = TagValidator.validate_list(server.tags or []) - server_dict["tags"] = TagValidator.to_tag_objects(server.tags or []) + server_dict["tags"] = server.tags or [] # Include metadata fields for proper API response server_dict["created_by"] = getattr(server, "created_by", None) server_dict["modified_by"] = getattr(server, "modified_by", None) From ba92a6a375f91b8f05bf992d564361290efe20ff Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 11:48:32 +0530 Subject: [PATCH 04/15] tag sturcture Signed-off-by: rakdutta --- mcpgateway/schemas.py | 4 ++-- mcpgateway/services/server_service.py | 2 +- mcpgateway/services/tool_service.py | 19 ++++++++++++++++- mcpgateway/static/admin.js | 21 ++++++++++++------- mcpgateway/templates/admin.html | 4 ++-- mcpgateway/templates/prompts_partial.html | 2 +- mcpgateway/templates/resources_partial.html | 2 +- mcpgateway/templates/tools_partial.html | 2 +- .../templates/tools_with_pagination.html | 2 +- 9 files changed, 41 insertions(+), 17 deletions(-) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index a75a5155c..1c51e68a7 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -2418,7 +2418,7 @@ class GatewayCreate(BaseModel): # One time auth - do not store the auth in gateway flag one_time_auth: Optional[bool] = Field(default=False, description="The authentication should be used only once and not stored in the gateway") - tags: Optional[List[Dict[str, str]]] = Field(default_factory=list, description="Tags for categorizing the gateway") + tags: Optional[List[str]] = Field(default_factory=list, description="Tags for categorizing the gateway") # Team scoping fields for resource organization team_id: Optional[str] = Field(None, description="Team ID this gateway belongs to") @@ -2432,7 +2432,7 @@ class GatewayCreate(BaseModel): @field_validator("tags") @classmethod - def validate_tags(cls, v: Optional[List[Dict[str, str]]]) -> List[Dict[str, str]]: + def validate_tags(cls, v: Optional[List[str]]) -> List[str]: """Validate and normalize tags. Args: diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 84a9ec566..40496a159 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -248,6 +248,7 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else [] server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents] if server.a2a_agents else [] server_dict["tags"] = server.tags or [] + # Include metadata fields for proper API response server_dict["created_by"] = getattr(server, "created_by", None) server_dict["modified_by"] = getattr(server, "modified_by", None) @@ -255,7 +256,6 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["updated_at"] = getattr(server, "updated_at", None) server_dict["version"] = getattr(server, "version", None) server_dict["team"] = getattr(server, "team", None) - logger.info(f"Server dict for conversion: {server_dict}") return ServerRead.model_validate(server_dict) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 89198514f..ec279fd45 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1964,6 +1964,23 @@ async def create_tool_from_a2a_agent( return self._convert_tool_to_read(existing_tool) # Create tool entry for the A2A agent + logger.info(f"agent.tags: {agent.tags} for agent: {agent.name} (ID: {agent.id})") + + # Normalize tags: if agent.tags contains dicts like {'id':..,'label':..}, + # extract the human-friendly label. If tags are already strings, keep them. + normalized_tags: list[str] = [] + for t in (agent.tags or []): + if isinstance(t, dict): + # Prefer 'label', fall back to 'id' or stringified dict + normalized_tags.append(t.get("label") or t.get("id") or str(t)) + elif hasattr(t, "label"): + normalized_tags.append(getattr(t, "label")) + else: + normalized_tags.append(str(t)) + + # Ensure we include identifying A2A tags + normalized_tags = normalized_tags + ["a2a", "agent"] + tool_data = ToolCreate( name=tool_name, displayName=generate_display_name(agent.name), @@ -1986,7 +2003,7 @@ async def create_tool_from_a2a_agent( }, auth_type=agent.auth_type, auth_value=agent.auth_value, - tags=agent.tags + ["a2a", "agent"], + tags=normalized_tags, ) return await self.register_tool( diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index b81c8c817..e01fbd313 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -2612,7 +2612,8 @@ async function editTool(toolId) { // Set tags field const tagsField = safeGetElement("edit-tool-tags"); if (tagsField) { - tagsField.value = tool.tags ? tool.tags.join(", ") : ""; + const rawTags = tool.tags ? tool.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + tagsField.value = rawTags.join(", "); } const teamId = new URL(window.location.href).searchParams.get( @@ -3262,7 +3263,8 @@ async function editA2AAgent(agentId) { // Set tags field const tagsField = safeGetElement("a2a-agent-tags-edit"); if (tagsField) { - tagsField.value = agent.tags ? agent.tags.join(", ") : ""; + const rawTags = agent.tags ? agent.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + tagsField.value = rawTags.join(", "); } const teamId = new URL(window.location.href).searchParams.get( @@ -3898,7 +3900,8 @@ async function editResource(resourceUri) { // Set tags field const tagsField = safeGetElement("edit-resource-tags"); if (tagsField) { - tagsField.value = resource.tags ? resource.tags.join(", ") : ""; + const rawTags = resource.tags ? resource.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + tagsField.value = rawTags.join(", "); } if (contentField) { @@ -4000,7 +4003,8 @@ async function viewPrompt(promptName) { const tagSpan = document.createElement("span"); tagSpan.className = "inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mr-1 mb-1 dark:bg-blue-900 dark:text-blue-200"; - tagSpan.textContent = tag; + const raw = (typeof tag === 'object' && tag !== null) ? (tag.id || tag.label) : tag; + tagSpan.textContent = raw; tagsP.appendChild(tagSpan); }); } else { @@ -4310,7 +4314,8 @@ async function editPrompt(promptId) { // Set tags field const tagsField = safeGetElement("edit-prompt-tags"); if (tagsField) { - tagsField.value = prompt.tags ? prompt.tags.join(", ") : ""; + const rawTags = prompt.tags ? prompt.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + tagsField.value = rawTags.join(", "); } if (templateField) { @@ -4619,7 +4624,8 @@ async function editGateway(gatewayId) { // Set tags field const tagsField = safeGetElement("edit-gateway-tags"); if (tagsField) { - tagsField.value = gateway.tags ? gateway.tags.join(", ") : ""; + const rawTags = gateway.tags ? gateway.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + tagsField.value = rawTags.join(", "); } const teamId = new URL(window.location.href).searchParams.get( @@ -5458,7 +5464,8 @@ async function editServer(serverId) { // Set tags field const tagsField = safeGetElement("edit-server-tags"); if (tagsField) { - tagsField.value = server.tags ? server.tags.join(", ") : ""; + const rawTags = server.tags ? server.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + tagsField.value = rawTags.join(", "); } // Set icon field diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index b8bb0af58..16b07bd7b 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -3995,7 +3995,7 @@

{% if gateway.tags %} {% for tag in gateway.tags %} {{ tag }}{{ tag.id }} {% endfor %} {% else %} No tags @@ -5373,7 +5373,7 @@

{% if agent.tags %} {% for tag in agent.tags %} - {{ tag }} + {{ tag.id }} {% endfor %} {% endif %} diff --git a/mcpgateway/templates/prompts_partial.html b/mcpgateway/templates/prompts_partial.html index ff3b2914e..16d1bbec6 100644 --- a/mcpgateway/templates/prompts_partial.html +++ b/mcpgateway/templates/prompts_partial.html @@ -19,7 +19,7 @@ {{ (pagination.page - 1) * pagination.per_page + loop.index }} {{ prompt.name }} {% set clean_desc = (prompt.description or "") | replace('\n', ' ') | replace('\r', ' ') %} {% set refactor_desc = clean_desc | striptags | trim | escape %} {% if refactor_desc | length is greaterthan 220 %} {{ refactor_desc[:400] + "..." }} {% else %} {{ refactor_desc }} {% endif %} - {% if prompt.tags %} {% for tag in prompt.tags %}{{ tag }}{% endfor %} {% else %}None{% endif %} + {% if prompt.tags %} {% for tag in prompt.tags %}{{ tag.id }}{% endfor %} {% else %}None{% endif %} {{ prompt.owner_email }} {% if prompt.team %}{{ prompt.team }}{% else %}None{% endif %} {% if prompt.visibility == 'public' %}🌍 Public{% elif prompt.visibility == 'team' %}👥 Team{% else %}🔒 Private{% endif %} diff --git a/mcpgateway/templates/resources_partial.html b/mcpgateway/templates/resources_partial.html index 5d013f0c1..0431c6b72 100644 --- a/mcpgateway/templates/resources_partial.html +++ b/mcpgateway/templates/resources_partial.html @@ -23,7 +23,7 @@ {{ resource.name }} {{ resource.description or 'N/A' }} {{ resource.mimeType or 'N/A' }} - {% if resource.tags %}{% for tag in resource.tags %}{{ tag }}{% endfor %}{% else %}None{% endif %} + {% if resource.tags %}{% for tag in resource.tags %}{{ tag.id }}{% endfor %}{% else %}None{% endif %} {% if resource.ownerEmail %}{{ resource.ownerEmail }}{% else %}N/A{% endif %} {% if resource.team %}{{ resource.team.replace(' ', '
')|safe }}
{% else %}N/A{% endif %} {% if resource.visibility == 'private' %}Private{% elif resource.visibility == 'team' %}Team{% elif resource.visibility == 'public' %}Public{% else %}N/A{% endif %} diff --git a/mcpgateway/templates/tools_partial.html b/mcpgateway/templates/tools_partial.html index 3436696a3..9e8c825b8 100644 --- a/mcpgateway/templates/tools_partial.html +++ b/mcpgateway/templates/tools_partial.html @@ -91,7 +91,7 @@ {% if tool.tags %} {% for tag in tool.tags %} {{ tag }}{{ tag.id }} {% endfor %} {% else %} None diff --git a/mcpgateway/templates/tools_with_pagination.html b/mcpgateway/templates/tools_with_pagination.html index f7a628374..d5d8b7213 100644 --- a/mcpgateway/templates/tools_with_pagination.html +++ b/mcpgateway/templates/tools_with_pagination.html @@ -216,7 +216,7 @@ {% if tool.tags %} {% for tag in tool.tags %} - {{ tag }} + {{ tag.id }} {% endfor %} {% else %} None From aeb00de4c219b9c1766fbb4dc30ba1a9d004ccb4 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 13:33:59 +0530 Subject: [PATCH 05/15] remove etra function Signed-off-by: rakdutta --- mcpgateway/validation/tags.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/mcpgateway/validation/tags.py b/mcpgateway/validation/tags.py index 28e7368c0..fa7539018 100644 --- a/mcpgateway/validation/tags.py +++ b/mcpgateway/validation/tags.py @@ -213,16 +213,7 @@ def get_validation_errors(tags: List[str]) -> List[str]: errors.append(f'Tag "{normalized}" contains invalid characters or format') return errors - @staticmethod - def to_tag_objects(tags: Optional[List[str]]) -> List[Dict[str,str]]: - if not tags: - return [] - # Use existing normalization & validation but return dicts - normalized = TagValidator.validate_list(tags) # if validate_list returns strings - # If validate_list returns dicts in current code, adapt accordingly - return [{"id": TagValidator.normalize(t) if isinstance(t, str) else t["id"], - "label": t if isinstance(t, str) else t["label"]} for t in normalized] - + def validate_tags_field(tags: Optional[List[str]]) -> List[str]: """Pydantic field validator for tags. From 58732d22ab9ea041c3237bcbb2150c8173ab016c Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 13:51:41 +0530 Subject: [PATCH 06/15] doctest Signed-off-by: rakdutta --- mcpgateway/services/resource_service.py | 2 +- mcpgateway/validation/tags.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index 371e0d79b..591b00d38 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -217,7 +217,7 @@ def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead: >>> m2 = SimpleNamespace(is_success=False, response_time=0.3, timestamp=now) >>> r = SimpleNamespace( ... id=1, uri='res://x', name='R', description=None, mime_type='text/plain', size=123, - ... created_at=now, updated_at=now, is_active=True, tags=['t'], metrics=[m1, m2] + ... created_at=now, updated_at=now, is_active=True, tags=[{"id": "t", "label": "T"}], metrics=[m1, m2] ... ) >>> out = svc._convert_resource_to_read(r) >>> out.metrics.total_executions diff --git a/mcpgateway/validation/tags.py b/mcpgateway/validation/tags.py index fa7539018..2a835c0dc 100644 --- a/mcpgateway/validation/tags.py +++ b/mcpgateway/validation/tags.py @@ -34,7 +34,7 @@ class TagValidator: >>> TagValidator.validate("a") False >>> TagValidator.validate_list(["Finance", "FINANCE", " finance "]) - ['finance'] + [{'id': 'finance', 'label': 'Finance'}] Attributes: MIN_LENGTH (int): Minimum allowed tag length (2). @@ -229,15 +229,15 @@ def validate_tags_field(tags: Optional[List[str]]) -> List[str]: Examples: >>> validate_tags_field(["Analytics", "ml"]) - ['analytics', 'ml'] + [{'id': 'analytics', 'label': 'Analytics'}, {'id': 'ml', 'label': 'ml'}] >>> validate_tags_field(["valid", "", "a", "invalid-"]) - ['valid'] + [{'id': 'valid', 'label': 'valid'}] >>> validate_tags_field(None) [] >>> validate_tags_field(["API", "api", " API "]) - ['api'] + [{'id': 'api', 'label': 'API'}] >>> validate_tags_field(["machine learning", "Machine-Learning", "ML"]) - ['machine-learning', 'ml'] + [{'id': 'machine-learning', 'label': 'machine learning'}, {'id': 'ml', 'label': 'ML'}] """ # Handle None, empty lists, and any other falsy values if not tags: From acfea6d81ee6498589d76f3cde10b60f16b35aff Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 14:32:48 +0530 Subject: [PATCH 07/15] lint test Signed-off-by: rakdutta --- mcpgateway/services/export_service.py | 4 +- mcpgateway/static/admin.js | 58 ++++++++++++++++--- .../services/test_export_service.py | 8 +-- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/mcpgateway/services/export_service.py b/mcpgateway/services/export_service.py index 6d0ab3342..55aecc871 100644 --- a/mcpgateway/services/export_service.py +++ b/mcpgateway/services/export_service.py @@ -340,8 +340,8 @@ async def _export_gateways(self, db: Session, tags: Optional[List[str]], include exported_gateways = [] for gateway in gateways: - # Filter by tags if specified - if tags and not any(tag in (gateway.tags or []) for tag in tags): + # Filter by tags if specified — match by tag 'id' when tag objects present + if tags and not any(str(tag) in {(str(t.get('id')) if isinstance(t, dict) and t.get('id') is not None else str(t)) for t in (gateway.tags or [])} for tag in tags): continue gateway_data = { diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index e01fbd313..97762e0d1 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -2612,7 +2612,13 @@ async function editTool(toolId) { // Set tags field const tagsField = safeGetElement("edit-tool-tags"); if (tagsField) { - const rawTags = tool.tags ? tool.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + const rawTags = tool.tags + ? tool.tags.map((tag) => + typeof tag === "object" && tag !== null + ? tag.label || tag.id + : tag, + ) + : []; tagsField.value = rawTags.join(", "); } @@ -3263,7 +3269,13 @@ async function editA2AAgent(agentId) { // Set tags field const tagsField = safeGetElement("a2a-agent-tags-edit"); if (tagsField) { - const rawTags = agent.tags ? agent.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + const rawTags = agent.tags + ? agent.tags.map((tag) => + typeof tag === "object" && tag !== null + ? tag.label || tag.id + : tag, + ) + : []; tagsField.value = rawTags.join(", "); } @@ -3900,7 +3912,13 @@ async function editResource(resourceUri) { // Set tags field const tagsField = safeGetElement("edit-resource-tags"); if (tagsField) { - const rawTags = resource.tags ? resource.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + const rawTags = resource.tags + ? resource.tags.map((tag) => + typeof tag === "object" && tag !== null + ? tag.label || tag.id + : tag, + ) + : []; tagsField.value = rawTags.join(", "); } @@ -4003,7 +4021,10 @@ async function viewPrompt(promptName) { const tagSpan = document.createElement("span"); tagSpan.className = "inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mr-1 mb-1 dark:bg-blue-900 dark:text-blue-200"; - const raw = (typeof tag === 'object' && tag !== null) ? (tag.id || tag.label) : tag; + const raw = + typeof tag === "object" && tag !== null + ? tag.id || tag.label + : tag; tagSpan.textContent = raw; tagsP.appendChild(tagSpan); }); @@ -4314,7 +4335,13 @@ async function editPrompt(promptId) { // Set tags field const tagsField = safeGetElement("edit-prompt-tags"); if (tagsField) { - const rawTags = prompt.tags ? prompt.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + const rawTags = prompt.tags + ? prompt.tags.map((tag) => + typeof tag === "object" && tag !== null + ? tag.label || tag.id + : tag, + ) + : []; tagsField.value = rawTags.join(", "); } @@ -4624,7 +4651,13 @@ async function editGateway(gatewayId) { // Set tags field const tagsField = safeGetElement("edit-gateway-tags"); if (tagsField) { - const rawTags = gateway.tags ? gateway.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + const rawTags = gateway.tags + ? gateway.tags.map((tag) => + typeof tag === "object" && tag !== null + ? tag.label || tag.id + : tag, + ) + : []; tagsField.value = rawTags.join(", "); } @@ -4983,7 +5016,10 @@ async function viewServer(serverId) { const tagSpan = document.createElement("span"); tagSpan.className = "inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mr-1 mb-1 dark:bg-blue-900 dark:text-blue-200"; - const raw = (typeof tag === 'object' && tag !== null) ? (tag.id || tag.label) : tag; + const raw = + typeof tag === "object" && tag !== null + ? tag.id || tag.label + : tag; tagSpan.textContent = raw; tagsP.appendChild(tagSpan); }); @@ -5464,7 +5500,13 @@ async function editServer(serverId) { // Set tags field const tagsField = safeGetElement("edit-server-tags"); if (tagsField) { - const rawTags = server.tags ? server.tags.map(tag => (typeof tag === 'object' && tag !== null) ? (tag.label || tag.id) : tag) : []; + const rawTags = server.tags + ? server.tags.map((tag) => + typeof tag === "object" && tag !== null + ? tag.label || tag.id + : tag, + ) + : []; tagsField.value = rawTags.join(", "); } diff --git a/tests/unit/mcpgateway/services/test_export_service.py b/tests/unit/mcpgateway/services/test_export_service.py index 48d0d3652..af5d2d58f 100644 --- a/tests/unit/mcpgateway/services/test_export_service.py +++ b/tests/unit/mcpgateway/services/test_export_service.py @@ -90,7 +90,7 @@ def sample_tool(): ), gateway_slug="", custom_name_slug="test_tool", - tags=["api", "test"], + tags=[{"id": "api", "label": "API"}, {"id": "test", "label": "Test"}], ) @@ -116,7 +116,7 @@ def sample_gateway(): auth_token=None, auth_header_key=None, auth_header_value=None, - tags=["gateway", "test"], + tags=[{"id": "gateway", "label": "Gateway"}, {"id": "test", "label": "Test"}], slug="test_gateway", passthrough_headers=None, ) @@ -590,7 +590,7 @@ async def test_export_gateways_with_tag_filtering(export_service, mock_db): auth_token=None, auth_header_key=None, auth_header_value=None, - tags=["production", "api"], + tags=[{"id": "production", "label": "Production"}, {"id": "api", "label": "API"}], slug="gateway_with_tags", passthrough_headers=None, ) @@ -614,7 +614,7 @@ async def test_export_gateways_with_tag_filtering(export_service, mock_db): auth_token=None, auth_header_key=None, auth_header_value=None, - tags=["test", "dev"], + tags=[{"id": "test", "label": "Test"}, {"id": "dev", "label": "Dev"}], slug="gateway_no_tags", passthrough_headers=None, ) From 2efc4900f2cb552d28d2a11fbbe795bf5938a68b Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 14:50:04 +0530 Subject: [PATCH 08/15] test Signed-off-by: rakdutta --- .../mcpgateway/services/test_import_service.py | 8 ++++---- tests/unit/mcpgateway/validation/test_tags.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/unit/mcpgateway/services/test_import_service.py b/tests/unit/mcpgateway/services/test_import_service.py index 6611591f4..fe2e8523c 100644 --- a/tests/unit/mcpgateway/services/test_import_service.py +++ b/tests/unit/mcpgateway/services/test_import_service.py @@ -1494,7 +1494,7 @@ async def test_server_update_conversion(import_service, mock_db): assert server_update.name == "update_server" assert server_update.description == "Updated server description" assert server_update.associated_tools is None # None because no tools found to resolve - assert server_update.tags == ["server", "update"] + assert server_update.tags == [{'id':'server','label':'server'}, {'id':'update','label':'update'}] @pytest.mark.asyncio @@ -1522,7 +1522,7 @@ async def test_prompt_update_conversion_with_schema(import_service): assert prompt_update.arguments[0].required == True assert prompt_update.arguments[1].name == "value" assert prompt_update.arguments[1].required == False - assert prompt_update.tags == ["prompt", "update"] + assert prompt_update.tags == [{'id':'prompt','label':'prompt'}, {'id':'update','label':'update'}] @pytest.mark.asyncio @@ -1535,7 +1535,7 @@ async def test_prompt_update_conversion_no_schema(import_service): assert prompt_update.template == "Simple template" assert prompt_update.description == "Simple prompt" assert prompt_update.arguments is None # No arguments when no schema - assert prompt_update.tags == ["simple"] + assert prompt_update.tags == [{'id':'simple','label':'simple'}] @pytest.mark.asyncio @@ -1548,7 +1548,7 @@ async def test_resource_update_conversion(import_service): assert resource_update.description == "Updated resource description" assert resource_update.mime_type == "application/xml" assert resource_update.content == "updated content" - assert resource_update.tags == ["resource", "xml"] + assert resource_update.tags == [{'id':'resource','label':'resource'}, {'id':'xml','label':'xml'}] @pytest.mark.asyncio diff --git a/tests/unit/mcpgateway/validation/test_tags.py b/tests/unit/mcpgateway/validation/test_tags.py index b662c72eb..0fb733aaa 100644 --- a/tests/unit/mcpgateway/validation/test_tags.py +++ b/tests/unit/mcpgateway/validation/test_tags.py @@ -48,11 +48,11 @@ def test_validate_list(self): """Test validation of tag lists.""" # Basic test with duplicates result = TagValidator.validate_list(["Analytics", "ANALYTICS", "ml"]) - assert result == ["analytics", "ml"] + assert result == [{'id':'analytics','label':'Analytics'}, {'id':'ml','label':'ml'}] # Test with invalid tags result = TagValidator.validate_list(["", "a", "valid-tag", "-invalid"]) - assert result == ["valid-tag"] + assert result == [{'id':'valid-tag','label':'valid-tag'}] # Test with None assert TagValidator.validate_list(None) == [] @@ -62,7 +62,7 @@ def test_validate_list(self): # Test preserving order result = TagValidator.validate_list(["zebra", "apple", "banana"]) - assert result == ["zebra", "apple", "banana"] + assert result == [{'id':'zebra','label':'zebra'}, {'id':'apple','label':'apple'}, {'id':'banana','label':'banana'}] def test_get_validation_errors(self): """Test getting validation errors.""" @@ -88,7 +88,7 @@ class TestValidateTagsField: def test_validate_tags_field_valid(self): """Test field validation with valid tags.""" result = validate_tags_field(["Analytics", "ml", "production"]) - assert result == ["analytics", "ml", "production"] + assert result == [{'id':'analytics','label':'Analytics'}, {'id':'ml','label':'ml'}, {'id':'production','label':'production'}] def test_validate_tags_field_none(self): """Test field validation with None.""" @@ -102,7 +102,7 @@ def test_validate_tags_field_with_invalid(self): """Test field validation with some invalid tags.""" # Should filter out invalid tags silently result = validate_tags_field(["valid", "", "a"]) - assert result == ["valid"] + assert result == [{'id':'valid','label':'valid'}] def test_validate_tags_field_all_invalid(self): """Test field validation with all invalid tags.""" @@ -113,12 +113,12 @@ def test_validate_tags_field_all_invalid(self): def test_validate_tags_field_duplicates(self): """Test field validation removes duplicates.""" result = validate_tags_field(["finance", "Finance", "FINANCE"]) - assert result == ["finance"] + assert result == [{'id':'finance','label':'finance'}] def test_validate_tags_field_special_chars(self): """Test field validation with special characters.""" result = validate_tags_field(["high-priority", "team:backend", "v2.0"]) - assert result == ["high-priority", "team:backend", "v2.0"] + assert result == [{'id':'high-priority','label':'high-priority'}, {'id':'team:backend','label':'team:backend'}, {'id':'v2.0','label':'v2.0'}] class TestTagPatterns: From 5f2c83067dbcdb2bf0f3f1c31a3bbf2e0cba52c0 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 15:18:14 +0530 Subject: [PATCH 09/15] pytest Signed-off-by: rakdutta --- tests/unit/mcpgateway/services/test_a2a_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/mcpgateway/services/test_a2a_service.py b/tests/unit/mcpgateway/services/test_a2a_service.py index 1db5cc251..34a2e34b2 100644 --- a/tests/unit/mcpgateway/services/test_a2a_service.py +++ b/tests/unit/mcpgateway/services/test_a2a_service.py @@ -70,7 +70,7 @@ def sample_db_agent(self): auth_value="encoded-auth-value", enabled=True, reachable=True, - tags=["test", "ai"], + tags=[{'id': "test", "label": "test"}, {'id': "ai", "label": "ai"}], created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), version=1, @@ -445,7 +445,7 @@ def test_db_to_schema_conversion(self, service, sample_db_agent): sample_db_agent.auth_type = "none" sample_db_agent.auth_header_key = "Authorization" sample_db_agent.auth_header_value = "Basic dGVzdDp2YWx1ZQ==" # base64 for "test:value" - + print(f"sample_db_agent: {sample_db_agent}") # Patch decode_auth to return a dummy decoded dict with patch("mcpgateway.schemas.decode_auth", return_value={"user": "decoded"}): result = service._db_to_schema(mock_db, sample_db_agent) From 977445b88314c6cb8edb40682ee5570a780a036a Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 15:31:05 +0530 Subject: [PATCH 10/15] ruff Signed-off-by: rakdutta --- ...4_tag_records_changes_list_str_to_list_.py | 47 ++++++++++++------- mcpgateway/schemas.py | 4 +- mcpgateway/services/export_service.py | 2 +- mcpgateway/services/server_service.py | 3 +- mcpgateway/services/tool_service.py | 2 +- mcpgateway/validation/tags.py | 5 +- 6 files changed, 39 insertions(+), 24 deletions(-) diff --git a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py index db1a2e320..bdb7ed6e6 100644 --- a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py +++ b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py @@ -5,23 +5,37 @@ Create Date: 2025-11-26 18:15:07.113528 """ + +# Standard +import json from typing import Sequence, Union +# Third-Party from alembic import op import sqlalchemy as sa -import json # revision identifiers, used by Alembic. -revision: str = '9e028ecf59c4' -down_revision: Union[str, Sequence[str], None] = '191a2def08d7' +revision: str = "9e028ecf59c4" +down_revision: Union[str, Sequence[str], None] = "191a2def08d7" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" - # Data migration: convert servers.tags which are currently lists of strings - # into lists of dicts with keys `id` (normalized) and `label` (original). + """Convert string-only tag lists into dict-form tag lists. + + Many tables store a JSON `tags` column. Older versions stored tags as a + list of plain strings. The application now expects each tag to be a + mapping with an `id` and a `label` (for example: + `{"id": "network", "label": "network"}`). + + This migration iterates over a set of known tables and, for any row + where `tags` is a list that contains plain strings, replaces those + strings with dicts of the form `{"id": , "label": }`. + Non-list `tags` values and tags already in dict form are left + unchanged. Tables that are not present in the database are skipped. + """ + conn = op.get_bind() # Apply same transformation to multiple tables that use a `tags` JSON column. tables = [ @@ -71,13 +85,18 @@ def upgrade() -> None: new_tags.append(t) # Convert back to JSON for storage - conn.execute( - sa.text(f"UPDATE {table} SET tags = :new_tags WHERE id = :id"), - {"new_tags": json.dumps(new_tags), "id": rec_id} - ) + conn.execute(sa.text(f"UPDATE {table} SET tags = :new_tags WHERE id = :id"), {"new_tags": json.dumps(new_tags), "id": rec_id}) + + +def downgrade() -> None: + """Revert dict-form tag lists back to string-only lists. + Reverse the transformation applied in `upgrade()`: for any tag that is a + dict and contains an `id` key, replace the dict with its `id` string. + Other values are left unchanged. The operation is applied across the + same set of tables and skips missing tables or non-list `tags` values. + """ -def downgrade(): conn = op.get_bind() # Reverse the transformation across the same set of tables. tables = [ @@ -122,8 +141,4 @@ def downgrade(): else: old_tags.append(t) - conn.execute( - sa.text(f"UPDATE {table} SET tags = :tags WHERE id = :id"), - {"tags": json.dumps(old_tags), "id": rec_id} - ) - \ No newline at end of file + conn.execute(sa.text(f"UPDATE {table} SET tags = :tags WHERE id = :id"), {"tags": json.dumps(old_tags), "id": rec_id}) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 1c51e68a7..85257ca38 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -4489,7 +4489,7 @@ class A2AAgentRead(BaseModelWithConfigDict): created_at: datetime updated_at: datetime last_interaction: Optional[datetime] - tags: List[Dict[str,str]] = Field(default_factory=list, description="Tags for categorizing the agent") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the agent") metrics: A2AAgentMetrics passthrough_headers: Optional[List[str]] = Field(default=None, description="List of headers allowed to be passed through from client to target") # Authorizations @@ -6250,7 +6250,7 @@ class GrpcServiceRead(BaseModel): last_reflection: Optional[datetime] = Field(None, description="Last reflection timestamp") # Tags - tags: List[Dict[str,str]] = Field(default_factory=list, description="Service tags") + tags: List[Dict[str, str]] = Field(default_factory=list, description="Service tags") # Timestamps created_at: datetime = Field(..., description="Creation timestamp") diff --git a/mcpgateway/services/export_service.py b/mcpgateway/services/export_service.py index 55aecc871..0334160b5 100644 --- a/mcpgateway/services/export_service.py +++ b/mcpgateway/services/export_service.py @@ -341,7 +341,7 @@ async def _export_gateways(self, db: Session, tags: Optional[List[str]], include for gateway in gateways: # Filter by tags if specified — match by tag 'id' when tag objects present - if tags and not any(str(tag) in {(str(t.get('id')) if isinstance(t, dict) and t.get('id') is not None else str(t)) for t in (gateway.tags or [])} for tag in tags): + if tags and not any(str(tag) in {(str(t.get("id")) if isinstance(t, dict) and t.get("id") is not None else str(t)) for t in (gateway.tags or [])} for tag in tags): continue gateway_data = { diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 40496a159..f93c21df3 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -37,7 +37,6 @@ from mcpgateway.services.team_management_service import TeamManagementService from mcpgateway.utils.metrics_common import build_top_performers from mcpgateway.utils.sqlalchemy_modifier import json_contains_expr -from mcpgateway.validation.tags import TagValidator # Initialize logging service first logging_service = LoggingService() @@ -248,7 +247,7 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else [] server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents] if server.a2a_agents else [] server_dict["tags"] = server.tags or [] - + # Include metadata fields for proper API response server_dict["created_by"] = getattr(server, "created_by", None) server_dict["modified_by"] = getattr(server, "modified_by", None) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index ec279fd45..cd6cf48e0 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1969,7 +1969,7 @@ async def create_tool_from_a2a_agent( # Normalize tags: if agent.tags contains dicts like {'id':..,'label':..}, # extract the human-friendly label. If tags are already strings, keep them. normalized_tags: list[str] = [] - for t in (agent.tags or []): + for t in agent.tags or []: if isinstance(t, dict): # Prefer 'label', fall back to 'id' or stringified dict normalized_tags.append(t.get("label") or t.get("id") or str(t)) diff --git a/mcpgateway/validation/tags.py b/mcpgateway/validation/tags.py index 2a835c0dc..22ccecc74 100644 --- a/mcpgateway/validation/tags.py +++ b/mcpgateway/validation/tags.py @@ -11,7 +11,7 @@ # Standard import re -from typing import List, Optional, Dict +from typing import Dict, List, Optional class TagValidator: @@ -213,7 +213,8 @@ def get_validation_errors(tags: List[str]) -> List[str]: errors.append(f'Tag "{normalized}" contains invalid characters or format') return errors - + + def validate_tags_field(tags: Optional[List[str]]) -> List[str]: """Pydantic field validator for tags. From dd14b15653d1fa41596f45e480643d0dd25145e3 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 17:32:57 +0530 Subject: [PATCH 11/15] down_revision change Signed-off-by: rakdutta --- .../9e028ecf59c4_tag_records_changes_list_str_to_list_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py index bdb7ed6e6..846c10296 100644 --- a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py +++ b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "9e028ecf59c4" -down_revision: Union[str, Sequence[str], None] = "191a2def08d7" +down_revision: Union[str, Sequence[str], None] = "z1a2b3c4d5e6" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 63450d0532d1b6f91fe44fd1837b1f9424cf4ef8 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 17:38:28 +0530 Subject: [PATCH 12/15] logg Signed-off-by: rakdutta --- mcpgateway/admin.py | 2 +- mcpgateway/services/tool_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 5f030faea..7dc1b506d 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -906,7 +906,7 @@ async def admin_list_servers( >>> asyncio.run(test_admin_list_servers_exception()) True """ - LOGGER.info(f"User {get_user_email(user)} requested server list") + LOGGER.debug(f"User {get_user_email(user)} requested server list") user_email = get_user_email(user) servers = await server_service.list_servers_for_user(db, user_email, include_inactive=include_inactive) LOGGER.info(f"servers:{servers}") diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index cd6cf48e0..0869fe7c5 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1964,7 +1964,7 @@ async def create_tool_from_a2a_agent( return self._convert_tool_to_read(existing_tool) # Create tool entry for the A2A agent - logger.info(f"agent.tags: {agent.tags} for agent: {agent.name} (ID: {agent.id})") + logger.debug(f"agent.tags: {agent.tags} for agent: {agent.name} (ID: {agent.id})") # Normalize tags: if agent.tags contains dicts like {'id':..,'label':..}, # extract the human-friendly label. If tags are already strings, keep them. From 7243f97b010ca3531bfd46584d779fbcb255c7ab Mon Sep 17 00:00:00 2001 From: rakdutta Date: Thu, 27 Nov 2025 17:39:37 +0530 Subject: [PATCH 13/15] logg Signed-off-by: rakdutta --- mcpgateway/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 7dc1b506d..bed8b9c3b 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -909,7 +909,6 @@ async def admin_list_servers( LOGGER.debug(f"User {get_user_email(user)} requested server list") user_email = get_user_email(user) servers = await server_service.list_servers_for_user(db, user_email, include_inactive=include_inactive) - LOGGER.info(f"servers:{servers}") return [server.model_dump(by_alias=True) for server in servers] From d4f808949450435541b39aabf4a4192e46b09974 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Fri, 28 Nov 2025 11:26:37 +0530 Subject: [PATCH 14/15] bandit alembic Signed-off-by: rakdutta --- ...cf59c4_tag_records_changes_list_str_to_list_.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py index 846c10296..87291d618 100644 --- a/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py +++ b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py @@ -56,7 +56,8 @@ def upgrade() -> None: # Skip non-existent tables in older DBs continue - rows = conn.execute(sa.text(f"SELECT id, tags FROM {table}")).fetchall() + tbl = sa.table(table, sa.column("id"), sa.column("tags")) + rows = conn.execute(sa.select(tbl.c.id, tbl.c.tags)).fetchall() for row in rows: rec_id = row[0] @@ -84,8 +85,9 @@ def upgrade() -> None: else: new_tags.append(t) - # Convert back to JSON for storage - conn.execute(sa.text(f"UPDATE {table} SET tags = :new_tags WHERE id = :id"), {"new_tags": json.dumps(new_tags), "id": rec_id}) + # Convert back to JSON for storage using SQLAlchemy constructs + stmt = sa.update(tbl).where(tbl.c.id == rec_id).values(tags=json.dumps(new_tags)) + conn.execute(stmt) def downgrade() -> None: @@ -116,7 +118,8 @@ def downgrade() -> None: if table not in available: continue - rows = conn.execute(sa.text(f"SELECT id, tags FROM {table}")).fetchall() + tbl = sa.table(table, sa.column("id"), sa.column("tags")) + rows = conn.execute(sa.select(tbl.c.id, tbl.c.tags)).fetchall() for row in rows: rec_id = row[0] @@ -141,4 +144,5 @@ def downgrade() -> None: else: old_tags.append(t) - conn.execute(sa.text(f"UPDATE {table} SET tags = :tags WHERE id = :id"), {"tags": json.dumps(old_tags), "id": rec_id}) + stmt = sa.update(tbl).where(tbl.c.id == rec_id).values(tags=json.dumps(old_tags)) + conn.execute(stmt) From caa3a72a65cea35a98da51317a1fac82c1c8c0aa Mon Sep 17 00:00:00 2001 From: rakdutta Date: Fri, 28 Nov 2025 13:36:16 +0530 Subject: [PATCH 15/15] remove extra logg Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 0869fe7c5..c147c51a1 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1954,7 +1954,6 @@ async def create_tool_from_a2a_agent( ToolNameConflictError: If a tool with the same name already exists. """ # Check if tool already exists for this agent - logger.info(f"testing Creating tool for A2A agent: {vars(agent)}") tool_name = f"a2a_{agent.slug}" existing_query = select(DbTool).where(DbTool.original_name == tool_name) existing_tool = db.execute(existing_query).scalar_one_or_none()