Skip to content
Open
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: 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": <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
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
66 changes: 58 additions & 8 deletions mcpgateway/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading