Skip to content

Commit bfaa7f5

Browse files
committed
Include tool annotations
1 parent 688a2da commit bfaa7f5

File tree

13 files changed

+325
-30
lines changed

13 files changed

+325
-30
lines changed

mcpgateway/db.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ class Tool(Base):
302302
request_type: Mapped[str] = mapped_column(default="SSE")
303303
headers: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON)
304304
input_schema: Mapped[Dict[str, Any]] = mapped_column(JSON)
305+
annotations: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, default=lambda: {})
305306
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
306307
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
307308
is_active: Mapped[bool] = mapped_column(default=True)

mcpgateway/schemas.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,10 @@ class ToolCreate(BaseModelWithConfig):
286286
default_factory=lambda: {"type": "object", "properties": {}},
287287
description="JSON Schema for validating tool parameters",
288288
)
289+
annotations: Optional[Dict[str, Any]] = Field(
290+
default_factory=dict,
291+
description="Tool annotations for behavior hints (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint)",
292+
)
289293
jsonpath_filter: Optional[str] = Field(default="", description="JSON modification filter")
290294
auth: Optional[AuthenticationValues] = Field(None, description="Authentication credentials (Basic or Bearer Token or custom headers) if required")
291295
gateway_id: Optional[int] = Field(None, description="id of gateway for the tool")
@@ -344,6 +348,7 @@ class ToolUpdate(BaseModelWithConfig):
344348
integration_type: Optional[Literal["MCP", "REST"]] = Field(None, description="Tool integration type")
345349
headers: Optional[Dict[str, str]] = Field(None, description="Additional headers to send when invoking the tool")
346350
input_schema: Optional[Dict[str, Any]] = Field(None, description="JSON Schema for validating tool parameters")
351+
annotations: Optional[Dict[str, Any]] = Field(None, description="Tool annotations for behavior hints")
347352
jsonpath_filter: Optional[str] = Field(None, description="JSON path filter for rpc tool calls")
348353
auth: Optional[AuthenticationValues] = Field(None, description="Authentication credentials (Basic or Bearer Token or custom headers) if required")
349354
gateway_id: Optional[int] = Field(None, description="id of gateway for the tool")
@@ -411,6 +416,7 @@ class ToolRead(BaseModelWithConfig):
411416
integration_type: str
412417
headers: Optional[Dict[str, str]]
413418
input_schema: Dict[str, Any]
419+
annotations: Optional[Dict[str, Any]]
414420
jsonpath_filter: Optional[str]
415421
auth: Optional[AuthenticationValues]
416422
created_at: datetime

mcpgateway/services/tool_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def _convert_tool_to_read(self, tool: DbTool) -> ToolRead:
124124
tool_dict["execution_count"] = tool.execution_count
125125
tool_dict["metrics"] = tool.metrics_summary
126126
tool_dict["request_type"] = tool.request_type
127+
tool_dict["annotations"] = tool.annotations or {}
127128

128129
decoded_auth_value = decode_auth(tool.auth_value)
129130
if tool.auth_type == "basic":
@@ -213,6 +214,7 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead:
213214
request_type=tool.request_type,
214215
headers=tool.headers,
215216
input_schema=tool.input_schema,
217+
annotations=tool.annotations,
216218
jsonpath_filter=tool.jsonpath_filter,
217219
auth_type=auth_type,
218220
auth_value=auth_value,
@@ -644,6 +646,8 @@ async def update_tool(self, db: Session, tool_id: int, tool_update: ToolUpdate)
644646
tool.headers = tool_update.headers
645647
if tool_update.input_schema is not None:
646648
tool.input_schema = tool_update.input_schema
649+
if tool_update.annotations is not None:
650+
tool.annotations = tool_update.annotations
647651
if tool_update.jsonpath_filter is not None:
648652
tool.jsonpath_filter = tool_update.jsonpath_filter
649653

mcpgateway/static/admin.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,54 @@ async function viewTool(toolId) {
569569
authHTML = `<p><strong>Authentication Type:</strong> None</p>`;
570570
}
571571

572+
// Helper function to create annotation badges
573+
const renderAnnotations = (annotations) => {
574+
if (!annotations || Object.keys(annotations).length === 0) {
575+
return '<p><strong>Annotations:</strong> <span class="text-gray-500">None</span></p>';
576+
}
577+
578+
const badges = [];
579+
580+
// Show title if present
581+
if (annotations.title) {
582+
badges.push(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 mr-1 mb-1">${annotations.title}</span>`);
583+
}
584+
585+
// Show behavior hints with appropriate colors
586+
if (annotations.readOnlyHint === true) {
587+
badges.push(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 mr-1 mb-1">📖 Read-Only</span>`);
588+
}
589+
590+
if (annotations.destructiveHint === true) {
591+
badges.push(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 mr-1 mb-1">⚠️ Destructive</span>`);
592+
}
593+
594+
if (annotations.idempotentHint === true) {
595+
badges.push(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 mr-1 mb-1">🔄 Idempotent</span>`);
596+
}
597+
598+
if (annotations.openWorldHint === true) {
599+
badges.push(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 mr-1 mb-1">🌐 External Access</span>`);
600+
}
601+
602+
// Show any other custom annotations
603+
Object.keys(annotations).forEach(key => {
604+
if (!['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'].includes(key)) {
605+
const value = annotations[key];
606+
badges.push(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 mr-1 mb-1">${key}: ${value}</span>`);
607+
}
608+
});
609+
610+
return `
611+
<div>
612+
<strong>Annotations:</strong>
613+
<div class="mt-1 flex flex-wrap">
614+
${badges.join('')}
615+
</div>
616+
</div>
617+
`;
618+
};
619+
572620
document.getElementById("tool-details").innerHTML = `
573621
<div class="space-y-2">
574622
<p><strong>Name:</strong> ${tool.name}</p>
@@ -577,6 +625,7 @@ async function viewTool(toolId) {
577625
<p><strong>Description:</strong> ${tool.description || "N/A"}</p>
578626
<p><strong>Request Type:</strong> ${tool.requestType || "N/A"}</p>
579627
${authHTML}
628+
${renderAnnotations(tool.annotations)}
580629
<div>
581630
<strong>Headers:</strong>
582631
<pre class="mt-1 bg-gray-100 p-2 rounded overflow-auto">${JSON.stringify(tool.headers || {}, null, 2)}</pre>
@@ -665,10 +714,12 @@ async function editTool(toolId) {
665714

666715
const headersJson = JSON.stringify(tool.headers || {}, null, 2);
667716
const schemaJson = JSON.stringify(tool.inputSchema || {}, null, 2);
717+
const annotationsJson = JSON.stringify(tool.annotations || {}, null, 2);
668718

669719
// Update the code editor textareas.
670720
document.getElementById("edit-tool-headers").value = headersJson;
671721
document.getElementById("edit-tool-schema").value = schemaJson;
722+
document.getElementById("edit-tool-annotations").value = annotationsJson;
672723
if (window.editToolHeadersEditor) {
673724
window.editToolHeadersEditor.setValue(headersJson);
674725
window.editToolHeadersEditor.refresh();

mcpgateway/templates/admin.html

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,17 @@ <h3 class="text-lg font-bold mb-4">Add New Server</h3>
393393
<!-- Tools Panel -->
394394
<div id="tools-panel" class="tab-panel hidden">
395395
<div class="flex justify-between items-center mb-4">
396-
<h2 class="text-2xl font-bold">Registered Tools</h2>
397-
<p class="text-sm text-gray-600 mt-1">This is the global catalog of Tools available. Create a Virtual Server using one of these tools.</p>
396+
<div>
397+
<h2 class="text-2xl font-bold">Registered Tools</h2>
398+
<p class="text-sm text-gray-600 mt-1">This is the global catalog of Tools available. Create a Virtual Server using one of these tools.</p>
399+
<div class="mt-2 text-xs text-gray-500">
400+
<strong>Annotation badges:</strong>
401+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 ml-1">📖 Read-Only</span>
402+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 ml-1">⚠️ Destructive</span>
403+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 ml-1">🔄 Idempotent</span>
404+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 ml-1">🌐 External Access</span>
405+
</div>
406+
</div>
398407
<div class="flex items-center">
399408
<input
400409
type="checkbox"
@@ -444,6 +453,11 @@ <h2 class="text-2xl font-bold">Registered Tools</h2>
444453
>
445454
Description
446455
</th>
456+
<th
457+
class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-24"
458+
>
459+
Annotations
460+
</th>
447461
<th
448462
class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-12"
449463
>
@@ -489,6 +503,27 @@ <h2 class="text-2xl font-bold">Registered Tools</h2>
489503
>
490504
{{ tool.description }}
491505
</td>
506+
<td class="px-2 py-4 whitespace-nowrap">
507+
{% if tool.annotations %}
508+
{% if tool.annotations.title %}
509+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 mr-1 mb-1">{{ tool.annotations.title }}</span>
510+
{% endif %}
511+
{% if tool.annotations.readOnlyHint %}
512+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 mr-1 mb-1">📖</span>
513+
{% endif %}
514+
{% if tool.annotations.destructiveHint %}
515+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 mr-1 mb-1">⚠️</span>
516+
{% endif %}
517+
{% if tool.annotations.idempotentHint %}
518+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 mr-1 mb-1">🔄</span>
519+
{% endif %}
520+
{% if tool.annotations.openWorldHint %}
521+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 mr-1 mb-1">🌐</span>
522+
{% endif %}
523+
{% else %}
524+
<span class="text-gray-400 text-xs">None</span>
525+
{% endif %}
526+
</td>
492527
<td class="px-3 py-4 whitespace-nowrap">
493528
<span
494529
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if tool.isActive %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}"
@@ -1856,6 +1891,19 @@ <h3 class="text-lg font-medium text-gray-900">Edit Tool</h3>
18561891
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
18571892
></textarea>
18581893
</div>
1894+
<div>
1895+
<label class="block text-sm font-medium text-gray-700"
1896+
>Annotations (JSON) - Read Only</label
1897+
>
1898+
<textarea
1899+
name="annotations"
1900+
id="edit-tool-annotations"
1901+
readonly
1902+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm bg-gray-50 text-gray-600"
1903+
placeholder="Annotations are automatically provided by MCP servers"
1904+
></textarea>
1905+
<p class="mt-1 text-xs text-gray-500">Annotations like readOnlyHint, destructiveHint are provided by the MCP server and cannot be manually edited.</p>
1906+
</div>
18591907
<!-- Authentication Section -->
18601908
<div>
18611909
<label class="block text-sm font-medium text-gray-700"

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,15 @@ async def list_tools() -> List[types.Tool]:
216216
try:
217217
async with get_db() as db:
218218
tools = await tool_service.list_server_tools(db, server_id)
219-
return [types.Tool(name=tool.name, description=tool.description, inputSchema=tool.input_schema) for tool in tools]
219+
return [types.Tool(name=tool.name, description=tool.description, inputSchema=tool.input_schema, annotations=tool.annotations) for tool in tools]
220220
except Exception as e:
221221
logger.exception(f"Error listing tools:{e}")
222222
return []
223223
else:
224224
try:
225225
async with get_db() as db:
226226
tools = await tool_service.list_tools(db)
227-
return [types.Tool(name=tool.name, description=tool.description, inputSchema=tool.input_schema) for tool in tools]
227+
return [types.Tool(name=tool.name, description=tool.description, inputSchema=tool.input_schema, annotations=tool.annotations) for tool in tools]
228228
except Exception as e:
229229
logger.exception(f"Error listing tools:{e}")
230230
return []

mcpgateway/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ class Tool(BaseModel):
389389
requestType (str): The HTTP method used to invoke the tool (GET, POST, PUT, DELETE, SSE, STDIO).
390390
headers (Dict[str, Any]): A JSON object representing HTTP headers.
391391
input_schema (Dict[str, Any]): A JSON Schema for validating the tool's input.
392+
annotations (Optional[Dict[str, Any]]): Tool annotations for behavior hints.
392393
auth_type (Optional[str]): The type of authentication used ("basic", "bearer", or None).
393394
auth_username (Optional[str]): The username for basic authentication.
394395
auth_password (Optional[str]): The password for basic authentication.
@@ -402,6 +403,7 @@ class Tool(BaseModel):
402403
requestType: str = "SSE"
403404
headers: Dict[str, Any] = Field(default_factory=dict)
404405
input_schema: Dict[str, Any] = Field(default_factory=lambda: {"type": "object", "properties": {}})
406+
annotations: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Tool annotations for behavior hints")
405407
auth_type: Optional[str] = None
406408
auth_username: Optional[str] = None
407409
auth_password: Optional[str] = None

mcpgateway/wrapper.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ async def handle_list_tools() -> List[types.Tool]:
330330
name=str(tool_name),
331331
description=tool.get("description", ""),
332332
inputSchema=tool.get("inputSchema", {}),
333+
annotations=tool.get("annotations", {}),
333334
)
334335
)
335336
return tools

migration_add_annotations.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Migration script to add the annotations column to the tools table.
4+
5+
This migration adds support for MCP tool annotations like readOnlyHint, destructiveHint, etc.
6+
"""
7+
8+
import os
9+
import sys
10+
11+
from sqlalchemy import text
12+
13+
# Add the project root to the path so we can import mcpgateway modules
14+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15+
16+
from mcpgateway.db import engine, get_db
17+
18+
19+
def migrate_up():
20+
"""Add annotations column to tools table."""
21+
print("Adding annotations column to tools table...")
22+
23+
# Check if column already exists
24+
with engine.connect() as conn:
25+
# Try to describe the table first
26+
try:
27+
result = conn.execute(text("PRAGMA table_info(tools)"))
28+
columns = [row[1] for row in result]
29+
30+
if 'annotations' in columns:
31+
print("Annotations column already exists, skipping migration.")
32+
return
33+
except Exception:
34+
# For non-SQLite databases, use a different approach
35+
try:
36+
conn.execute(text("SELECT annotations FROM tools LIMIT 1"))
37+
print("Annotations column already exists, skipping migration.")
38+
return
39+
except Exception:
40+
pass # Column doesn't exist, continue with migration
41+
42+
# Add the annotations column
43+
try:
44+
conn.execute(text("ALTER TABLE tools ADD COLUMN annotations JSON DEFAULT '{}'"))
45+
conn.commit()
46+
print("Successfully added annotations column to tools table.")
47+
except Exception as e:
48+
print(f"Error adding annotations column: {e}")
49+
conn.rollback()
50+
raise
51+
52+
def migrate_down():
53+
"""Remove annotations column from tools table."""
54+
print("Removing annotations column from tools table...")
55+
56+
with engine.connect() as conn:
57+
try:
58+
# Note: SQLite doesn't support DROP COLUMN, so this would require table recreation
59+
# For now, we'll just print a warning
60+
print("Warning: SQLite doesn't support DROP COLUMN. Manual intervention required to remove annotations column.")
61+
except Exception as e:
62+
print(f"Error removing annotations column: {e}")
63+
raise
64+
65+
if __name__ == "__main__":
66+
if len(sys.argv) > 1 and sys.argv[1] == "down":
67+
migrate_down()
68+
else:
69+
migrate_up()

tests/unit/mcpgateway/services/test_tool_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ async def test_register_tool(self, tool_service, mock_tool, test_db):
111111
gateway_id=None,
112112
execution_count=0,
113113
auth=None, # Add auth field
114+
annotations={}, # Add annotations field
114115
metrics={
115116
"total_executions": 0,
116117
"successful_executions": 0,
@@ -231,6 +232,7 @@ async def test_list_tools(self, tool_service, mock_tool, test_db):
231232
gateway_id=None,
232233
execution_count=0,
233234
auth=None, # Add auth field
235+
annotations={}, # Add annotations field
234236
metrics={
235237
"total_executions": 0,
236238
"successful_executions": 0,
@@ -278,6 +280,7 @@ async def test_get_tool(self, tool_service, mock_tool, test_db):
278280
gateway_id=None,
279281
execution_count=0,
280282
auth=None, # Add auth field
283+
annotations={}, # Add annotations field
281284
metrics={
282285
"total_executions": 0,
283286
"successful_executions": 0,
@@ -376,6 +379,7 @@ async def test_toggle_tool_status(self, tool_service, mock_tool, test_db):
376379
gateway_id=None,
377380
execution_count=0,
378381
auth=None, # Add auth field
382+
annotations={}, # Add annotations field
379383
metrics={
380384
"total_executions": 0,
381385
"successful_executions": 0,
@@ -441,6 +445,7 @@ async def test_update_tool(self, tool_service, mock_tool, test_db):
441445
gateway_id=None,
442446
execution_count=0,
443447
auth=None, # Add auth field
448+
annotations={}, # Add annotations field
444449
metrics={
445450
"total_executions": 0,
446451
"successful_executions": 0,

0 commit comments

Comments
 (0)