Skip to content

Commit f66e4a3

Browse files
authored
Merge pull request #1516 from IBM/feature_update-tool-tag-structure
Refactor: Replace String Tags with Structured Tag Objects Across Backend, Database, and UI
2 parents 6b57983 + 3f9e887 commit f66e4a3

18 files changed

+330
-85
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""tag records changes list[str] to list[Dict[str,str]]
2+
3+
Revision ID: 9e028ecf59c4
4+
Revises: add_toolops_test_cases_table
5+
Create Date: 2025-11-26 18:15:07.113528
6+
7+
"""
8+
9+
# Standard
10+
import json
11+
from typing import Sequence, Union
12+
13+
# Third-Party
14+
from alembic import op
15+
import sqlalchemy as sa
16+
17+
# revision identifiers, used by Alembic.
18+
revision: str = "9e028ecf59c4"
19+
down_revision: Union[str, Sequence[str], None] = "add_toolops_test_cases_table"
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
def upgrade() -> None:
25+
"""Convert string-only tag lists into dict-form tag lists.
26+
27+
Many tables store a JSON `tags` column. Older versions stored tags as a
28+
list of plain strings. The application now expects each tag to be a
29+
mapping with an `id` and a `label` (for example:
30+
`{"id": "network", "label": "network"}`).
31+
32+
This migration iterates over a set of known tables and, for any row
33+
where `tags` is a list that contains plain strings, replaces those
34+
strings with dicts of the form `{"id": <string>, "label": <string>}`.
35+
Non-list `tags` values and tags already in dict form are left
36+
unchanged. Tables that are not present in the database are skipped.
37+
"""
38+
39+
conn = op.get_bind()
40+
# Apply same transformation to multiple tables that use a `tags` JSON column.
41+
tables = [
42+
"servers",
43+
"tools",
44+
"prompts",
45+
"resources",
46+
"a2a_agents",
47+
"gateways",
48+
"grpc_services",
49+
]
50+
51+
inspector = sa.inspect(conn)
52+
available = set(inspector.get_table_names())
53+
54+
for table in tables:
55+
if table not in available:
56+
# Skip non-existent tables in older DBs
57+
continue
58+
59+
tbl = sa.table(table, sa.column("id"), sa.column("tags"))
60+
rows = conn.execute(sa.select(tbl.c.id, tbl.c.tags)).fetchall()
61+
62+
for row in rows:
63+
rec_id = row[0]
64+
tags_raw = row[1]
65+
66+
# Parse JSON (SQLite returns string)
67+
if isinstance(tags_raw, str):
68+
tags = json.loads(tags_raw)
69+
else:
70+
tags = tags_raw
71+
72+
# Skip if not a list
73+
if not isinstance(tags, list):
74+
continue
75+
76+
contains_string = any(isinstance(t, str) for t in tags)
77+
if not contains_string:
78+
continue
79+
80+
# Convert strings → dict format
81+
new_tags = []
82+
for t in tags:
83+
if isinstance(t, str):
84+
new_tags.append({"id": t, "label": t})
85+
else:
86+
new_tags.append(t)
87+
88+
# Convert back to JSON for storage using SQLAlchemy constructs
89+
stmt = sa.update(tbl).where(tbl.c.id == rec_id).values(tags=json.dumps(new_tags))
90+
conn.execute(stmt)
91+
92+
93+
def downgrade() -> None:
94+
"""Revert dict-form tag lists back to string-only lists.
95+
96+
Reverse the transformation applied in `upgrade()`: for any tag that is a
97+
dict and contains an `id` key, replace the dict with its `id` string.
98+
Other values are left unchanged. The operation is applied across the
99+
same set of tables and skips missing tables or non-list `tags` values.
100+
"""
101+
102+
conn = op.get_bind()
103+
# Reverse the transformation across the same set of tables.
104+
tables = [
105+
"servers",
106+
"tools",
107+
"prompts",
108+
"resources",
109+
"a2a_agents",
110+
"gateways",
111+
"grpc_services",
112+
]
113+
114+
inspector = sa.inspect(conn)
115+
available = set(inspector.get_table_names())
116+
117+
for table in tables:
118+
if table not in available:
119+
continue
120+
121+
tbl = sa.table(table, sa.column("id"), sa.column("tags"))
122+
rows = conn.execute(sa.select(tbl.c.id, tbl.c.tags)).fetchall()
123+
124+
for row in rows:
125+
rec_id = row[0]
126+
tags_raw = row[1]
127+
128+
if isinstance(tags_raw, str):
129+
tags = json.loads(tags_raw)
130+
else:
131+
tags = tags_raw
132+
133+
if not isinstance(tags, list):
134+
continue
135+
136+
contains_dict = any(isinstance(t, dict) and "id" in t for t in tags)
137+
if not contains_dict:
138+
continue
139+
140+
old_tags = []
141+
for t in tags:
142+
if isinstance(t, dict) and "id" in t:
143+
old_tags.append(t["id"])
144+
else:
145+
old_tags.append(t)
146+
147+
stmt = sa.update(tbl).where(tbl.c.id == rec_id).values(tags=json.dumps(old_tags))
148+
conn.execute(stmt)

mcpgateway/schemas.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,7 +1284,7 @@ class ToolRead(BaseModelWithConfigDict):
12841284
gateway_slug: str
12851285
custom_name: str
12861286
custom_name_slug: str
1287-
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the tool")
1287+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the tool")
12881288

12891289
# Comprehensive metadata for audit tracking
12901290
created_by: Optional[str] = Field(None, description="Username who created this entity")
@@ -1782,7 +1782,7 @@ class ResourceRead(BaseModelWithConfigDict):
17821782
updated_at: datetime
17831783
is_active: bool
17841784
metrics: ResourceMetrics
1785-
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the resource")
1785+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the resource")
17861786

17871787
# Comprehensive metadata for audit tracking
17881788
created_by: Optional[str] = Field(None, description="Username who created this entity")
@@ -2288,7 +2288,7 @@ class PromptRead(BaseModelWithConfigDict):
22882288
created_at: datetime
22892289
updated_at: datetime
22902290
is_active: bool
2291-
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the prompt")
2291+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the prompt")
22922292
metrics: PromptMetrics
22932293

22942294
# Comprehensive metadata for audit tracking
@@ -2947,7 +2947,7 @@ class GatewayRead(BaseModelWithConfigDict):
29472947
auth_token: Optional[str] = Field(None, description="token for bearer authentication")
29482948
auth_header_key: Optional[str] = Field(None, description="key for custom headers authentication")
29492949
auth_header_value: Optional[str] = Field(None, description="vallue for custom headers authentication")
2950-
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the gateway")
2950+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the gateway")
29512951

29522952
auth_password_unmasked: Optional[str] = Field(default=None, description="Unmasked password for basic authentication")
29532953
auth_token_unmasked: Optional[str] = Field(default=None, description="Unmasked bearer token for authentication")
@@ -3716,7 +3716,7 @@ class ServerRead(BaseModelWithConfigDict):
37163716
associated_prompts: List[int] = []
37173717
associated_a2a_agents: List[str] = []
37183718
metrics: ServerMetrics
3719-
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the server")
3719+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the server")
37203720

37213721
# Comprehensive metadata for audit tracking
37223722
created_by: Optional[str] = Field(None, description="Username who created this entity")
@@ -4489,7 +4489,7 @@ class A2AAgentRead(BaseModelWithConfigDict):
44894489
created_at: datetime
44904490
updated_at: datetime
44914491
last_interaction: Optional[datetime]
4492-
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the agent")
4492+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Tags for categorizing the agent")
44934493
metrics: A2AAgentMetrics
44944494
passthrough_headers: Optional[List[str]] = Field(default=None, description="List of headers allowed to be passed through from client to target")
44954495
# Authorizations
@@ -6250,7 +6250,7 @@ class GrpcServiceRead(BaseModel):
62506250
last_reflection: Optional[datetime] = Field(None, description="Last reflection timestamp")
62516251

62526252
# Tags
6253-
tags: List[str] = Field(default_factory=list, description="Service tags")
6253+
tags: List[Dict[str, str]] = Field(default_factory=list, description="Service tags")
62546254

62556255
# Timestamps
62566256
created_at: datetime = Field(..., description="Creation timestamp")

mcpgateway/services/export_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,8 @@ async def _export_gateways(self, db: Session, tags: Optional[List[str]], include
340340
exported_gateways = []
341341

342342
for gateway in gateways:
343-
# Filter by tags if specified
344-
if tags and not any(tag in (gateway.tags or []) for tag in tags):
343+
# Filter by tags if specified — match by tag 'id' when tag objects present
344+
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):
345345
continue
346346

347347
gateway_data = {

mcpgateway/services/resource_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead:
217217
>>> m2 = SimpleNamespace(is_success=False, response_time=0.3, timestamp=now)
218218
>>> r = SimpleNamespace(
219219
... id=1, uri='res://x', name='R', description=None, mime_type='text/plain', size=123,
220-
... created_at=now, updated_at=now, is_active=True, tags=['t'], metrics=[m1, m2]
220+
... created_at=now, updated_at=now, is_active=True, tags=[{"id": "t", "label": "T"}], metrics=[m1, m2]
221221
... )
222222
>>> out = svc._convert_resource_to_read(r)
223223
>>> out.metrics.total_executions

mcpgateway/services/tag_service.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ async def get_all_tags(self, db: Session, entity_types: Optional[List[str]] = No
152152

153153
for entity in result.scalars():
154154
tags = entity.tags if entity.tags else []
155-
for tag in tags:
155+
for raw_tag in tags:
156+
tag = self._get_tag_id(raw_tag)
156157
if tag not in tag_data:
157158
tag_data[tag] = {"stats": TagStats(tools=0, resources=0, prompts=0, servers=0, gateways=0, total=0), "entities": []}
158159

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

193194
for row in result:
194195
tags = row[0] if row[0] else []
195-
for tag in tags:
196+
for raw_tag in tags:
197+
tag = self._get_tag_id(raw_tag)
196198
if tag not in tag_data:
197199
tag_data[tag] = {"stats": TagStats(tools=0, resources=0, prompts=0, servers=0, gateways=0, total=0), "entities": []}
198200

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

261+
def _get_tag_id(self, tag) -> str:
262+
"""Return the tag id for a tag entry which may be a string or a dict.
263+
264+
Supports legacy string tags and new dict tags with an 'id' field.
265+
Falls back to 'label' or the string representation when 'id' is missing.
266+
267+
Args:
268+
tag: Tag value which may be a string (legacy) or a dict with an
269+
'id' or 'label' key.
270+
271+
Returns:
272+
The normalized tag id as a string.
273+
"""
274+
if isinstance(tag, str):
275+
return tag
276+
if isinstance(tag, dict):
277+
return tag.get("id") or tag.get("label") or str(tag)
278+
return str(tag)
279+
259280
async def get_entities_by_tag(self, db: Session, tag_name: str, entity_types: Optional[List[str]] = None) -> List[TaggedEntity]:
260281
"""Get all entities that have a specific tag.
261282
@@ -343,7 +364,9 @@ async def get_entities_by_tag(self, db: Session, tag_name: str, entity_types: Op
343364
result = db.execute(stmt)
344365

345366
for entity in result.scalars():
346-
if tag_name in (entity.tags or []):
367+
entity_tags = entity.tags or []
368+
entity_tag_ids = [self._get_tag_id(t) for t in entity_tags]
369+
if tag_name in entity_tag_ids:
347370
# Determine the ID
348371
if hasattr(entity, "id") and entity.id is not None:
349372
entity_id = str(entity.id)

mcpgateway/services/tool_service.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1954,7 +1954,6 @@ async def create_tool_from_a2a_agent(
19541954
ToolNameConflictError: If a tool with the same name already exists.
19551955
"""
19561956
# Check if tool already exists for this agent
1957-
logger.info(f"testing Creating tool for A2A agent: {vars(agent)}")
19581957
tool_name = f"a2a_{agent.slug}"
19591958
existing_query = select(DbTool).where(DbTool.original_name == tool_name)
19601959
existing_tool = db.execute(existing_query).scalar_one_or_none()
@@ -1964,6 +1963,23 @@ async def create_tool_from_a2a_agent(
19641963
return self._convert_tool_to_read(existing_tool)
19651964

19661965
# Create tool entry for the A2A agent
1966+
logger.debug(f"agent.tags: {agent.tags} for agent: {agent.name} (ID: {agent.id})")
1967+
1968+
# Normalize tags: if agent.tags contains dicts like {'id':..,'label':..},
1969+
# extract the human-friendly label. If tags are already strings, keep them.
1970+
normalized_tags: list[str] = []
1971+
for t in agent.tags or []:
1972+
if isinstance(t, dict):
1973+
# Prefer 'label', fall back to 'id' or stringified dict
1974+
normalized_tags.append(t.get("label") or t.get("id") or str(t))
1975+
elif hasattr(t, "label"):
1976+
normalized_tags.append(getattr(t, "label"))
1977+
else:
1978+
normalized_tags.append(str(t))
1979+
1980+
# Ensure we include identifying A2A tags
1981+
normalized_tags = normalized_tags + ["a2a", "agent"]
1982+
19671983
tool_data = ToolCreate(
19681984
name=tool_name,
19691985
displayName=generate_display_name(agent.name),
@@ -1986,7 +2002,7 @@ async def create_tool_from_a2a_agent(
19862002
},
19872003
auth_type=agent.auth_type,
19882004
auth_value=agent.auth_value,
1989-
tags=agent.tags + ["a2a", "agent"],
2005+
tags=normalized_tags,
19902006
)
19912007

19922008
return await self.register_tool(

0 commit comments

Comments
 (0)