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..87291d618 --- /dev/null +++ b/mcpgateway/alembic/versions/9e028ecf59c4_tag_records_changes_list_str_to_list_.py @@ -0,0 +1,148 @@ +"""tag records changes list[str] to list[Dict[str,str]] + +Revision ID: 9e028ecf59c4 +Revises: 191a2def08d7 +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 + +# revision identifiers, used by Alembic. +revision: str = "9e028ecf59c4" +down_revision: Union[str, Sequence[str], None] = "z1a2b3c4d5e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """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 = [ + "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 + + 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] + 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 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: + """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. + """ + + 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 + + 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] + 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) + + stmt = sa.update(tbl).where(tbl.c.id == rec_id).values(tags=json.dumps(old_tags)) + conn.execute(stmt) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 22b79a5ed..85257ca38 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 @@ -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") @@ -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") @@ -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/export_service.py b/mcpgateway/services/export_service.py index 6d0ab3342..0334160b5 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/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/services/tool_service.py b/mcpgateway/services/tool_service.py index 89198514f..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() @@ -1964,6 +1963,23 @@ async def create_tool_from_a2a_agent( return self._convert_tool_to_read(existing_tool) # Create tool entry for the A2A agent + 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. + 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 +2002,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 acd6b3b07..97762e0d1 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -2612,7 +2612,14 @@ 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 +3269,14 @@ 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 +3912,14 @@ 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 +4021,11 @@ 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 +4335,14 @@ 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 +4651,14 @@ 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( @@ -4977,7 +5016,11 @@ 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 { @@ -5457,7 +5500,14 @@ 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 9a69189ef..16b07bd7b 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 %} @@ -3990,7 +3995,7 @@

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

{% if agent.tags %} {% for tag in agent.tags %} - {{ tag }} + {{ tag.id }} {% endfor %} {% 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/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 diff --git a/mcpgateway/validation/tags.py b/mcpgateway/validation/tags.py index d6f5e6075..22ccecc74 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 Dict, List, Optional class TagValidator: @@ -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). @@ -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 @@ -228,15 +230,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: 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) 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, ) 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: