Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""tag records changes list[str] to list[Dict[str,str]]

Revision ID: 9e028ecf59c4
Revises: add_toolops_test_cases_table
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] = "add_toolops_test_cases_table"
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": <string>, "label": <string>}`.
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)
14 changes: 7 additions & 7 deletions mcpgateway/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions mcpgateway/services/export_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion mcpgateway/services/resource_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions mcpgateway/services/tag_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ async def get_all_tags(self, db: Session, entity_types: Optional[List[str]] = No

for entity in result.scalars():
tags = entity.tags if entity.tags else []
for tag in tags:
for raw_tag in tags:
tag = self._get_tag_id(raw_tag)
if tag not in tag_data:
tag_data[tag] = {"stats": TagStats(tools=0, resources=0, prompts=0, servers=0, gateways=0, total=0), "entities": []}

Expand Down Expand Up @@ -192,7 +193,8 @@ async def get_all_tags(self, db: Session, entity_types: Optional[List[str]] = No

for row in result:
tags = row[0] if row[0] else []
for tag in tags:
for raw_tag in tags:
tag = self._get_tag_id(raw_tag)
if tag not in tag_data:
tag_data[tag] = {"stats": TagStats(tools=0, resources=0, prompts=0, servers=0, gateways=0, total=0), "entities": []}

Expand Down Expand Up @@ -256,6 +258,25 @@ def _update_stats(self, stats: TagStats, entity_type: str) -> None:
stats.total += 1
# Invalid entity types are ignored (no increment)

def _get_tag_id(self, tag) -> str:
"""Return the tag id for a tag entry which may be a string or a dict.

Supports legacy string tags and new dict tags with an 'id' field.
Falls back to 'label' or the string representation when 'id' is missing.

Args:
tag: Tag value which may be a string (legacy) or a dict with an
'id' or 'label' key.

Returns:
The normalized tag id as a string.
"""
if isinstance(tag, str):
return tag
if isinstance(tag, dict):
return tag.get("id") or tag.get("label") or str(tag)
return str(tag)

async def get_entities_by_tag(self, db: Session, tag_name: str, entity_types: Optional[List[str]] = None) -> List[TaggedEntity]:
"""Get all entities that have a specific tag.

Expand Down Expand Up @@ -343,7 +364,9 @@ async def get_entities_by_tag(self, db: Session, tag_name: str, entity_types: Op
result = db.execute(stmt)

for entity in result.scalars():
if tag_name in (entity.tags or []):
entity_tags = entity.tags or []
entity_tag_ids = [self._get_tag_id(t) for t in entity_tags]
if tag_name in entity_tag_ids:
# Determine the ID
if hasattr(entity, "id") and entity.id is not None:
entity_id = str(entity.id)
Expand Down
20 changes: 18 additions & 2 deletions mcpgateway/services/tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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),
Expand All @@ -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(
Expand Down
Loading
Loading