Skip to content

Commit 94c0d9f

Browse files
authored
Merge pull request #176 from onmete/tool-annotations
Include tool annotations
2 parents 86804bb + 18b754a commit 94c0d9f

File tree

13 files changed

+205
-14
lines changed

13 files changed

+205
-14
lines changed

mcpgateway/db.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ class Tool(Base):
308308
request_type: Mapped[str] = mapped_column(default="SSE")
309309
headers: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON)
310310
input_schema: Mapped[Dict[str, Any]] = mapped_column(JSON)
311+
annotations: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, default=lambda: {})
311312
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
312313
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
313314
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[str] = 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[str] = 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/gateway_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway
200200
request_type=tool.request_type,
201201
headers=tool.headers,
202202
input_schema=tool.input_schema,
203+
annotations=tool.annotations,
203204
jsonpath_filter=tool.jsonpath_filter,
204205
auth_type=auth_type,
205206
auth_value=auth_value,

mcpgateway/services/tool_service.py

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

130131
decoded_auth_value = decode_auth(tool.auth_value)
131132
if tool.auth_type == "basic":
@@ -224,6 +225,7 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead:
224225
request_type=tool.request_type,
225226
headers=tool.headers,
226227
input_schema=tool.input_schema,
228+
annotations=tool.annotations,
227229
jsonpath_filter=tool.jsonpath_filter,
228230
auth_type=auth_type,
229231
auth_value=auth_value,
@@ -554,6 +556,8 @@ async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate)
554556
tool.headers = tool_update.headers
555557
if tool_update.input_schema is not None:
556558
tool.input_schema = tool_update.input_schema
559+
if tool_update.annotations is not None:
560+
tool.annotations = tool_update.annotations
557561
if tool_update.jsonpath_filter is not None:
558562
tool.jsonpath_filter = tool_update.jsonpath_filter
559563

mcpgateway/static/admin.js

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

571+
// Helper function to create annotation badges
572+
const renderAnnotations = (annotations) => {
573+
if (!annotations || Object.keys(annotations).length === 0) {
574+
return '<p><strong>Annotations:</strong> <span class="text-gray-500">None</span></p>';
575+
}
576+
577+
const badges = [];
578+
579+
// Show title if present
580+
if (annotations.title) {
581+
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>`);
582+
}
583+
584+
// Show behavior hints with appropriate colors
585+
if (annotations.readOnlyHint === true) {
586+
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>`);
587+
}
588+
589+
if (annotations.destructiveHint === true) {
590+
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>`);
591+
}
592+
593+
if (annotations.idempotentHint === true) {
594+
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>`);
595+
}
596+
597+
if (annotations.openWorldHint === true) {
598+
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>`);
599+
}
600+
601+
// Show any other custom annotations
602+
Object.keys(annotations).forEach(key => {
603+
if (!['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'].includes(key)) {
604+
const value = annotations[key];
605+
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>`);
606+
}
607+
});
608+
609+
return `
610+
<div>
611+
<strong>Annotations:</strong>
612+
<div class="mt-1 flex flex-wrap">
613+
${badges.join('')}
614+
</div>
615+
</div>
616+
`;
617+
};
618+
571619
document.getElementById("tool-details").innerHTML = `
572620
<div class="space-y-2 dark:bg-gray-900 dark:text-gray-100">
573621
<p><strong>Name:</strong> ${tool.name}</p>
@@ -576,6 +624,7 @@ async function viewTool(toolId) {
576624
<p><strong>Description:</strong> ${tool.description || "N/A"}</p>
577625
<p><strong>Request Type:</strong> ${tool.requestType || "N/A"}</p>
578626
${authHTML}
627+
${renderAnnotations(tool.annotations)}
579628
<div>
580629
<strong>Headers:</strong>
581630
<pre class="mt-1 bg-gray-100 p-2 rounded dark:bg-gray-800 dark:text-gray-100">${JSON.stringify(tool.headers || {}, null, 2)}</pre>
@@ -717,10 +766,12 @@ async function editTool(toolId) {
717766

718767
const headersJson = JSON.stringify(tool.headers || {}, null, 2);
719768
const schemaJson = JSON.stringify(tool.inputSchema || {}, null, 2);
769+
const annotationsJson = JSON.stringify(tool.annotations || {}, null, 2);
720770

721771
// Update the code editor textareas.
722772
document.getElementById("edit-tool-headers").value = headersJson;
723773
document.getElementById("edit-tool-schema").value = schemaJson;
774+
document.getElementById("edit-tool-annotations").value = annotationsJson;
724775
if (window.editToolHeadersEditor) {
725776
window.editToolHeadersEditor.setValue(headersJson);
726777
window.editToolHeadersEditor.refresh();

mcpgateway/templates/admin.html

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,17 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">Add New Server</h3>
419419
<!-- Tools Panel -->
420420
<div id="tools-panel" class="tab-panel hidden">
421421
<div class="flex justify-between items-center mb-4">
422-
<h2 class="text-2xl font-bold dark:text-gray-200">Registered Tools</h2>
423-
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">This is the global catalog of Tools available. Create a Virtual Server using one of these tools.</p>
422+
<div>
423+
<h2 class="text-2xl font-bold dark:text-gray-200">Registered Tools</h2>
424+
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">This is the global catalog of Tools available. Create a Virtual Server using one of these tools.</p>
425+
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
426+
<strong>Annotation badges:</strong>
427+
<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>
428+
<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>
429+
<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>
430+
<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>
431+
</div>
432+
</div>
424433
<div class="flex items-center">
425434
<input
426435
type="checkbox"
@@ -470,6 +479,11 @@ <h2 class="text-2xl font-bold dark:text-gray-200">Registered Tools</h2>
470479
>
471480
Description
472481
</th>
482+
<th
483+
class="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-24"
484+
>
485+
Annotations
486+
</th>
473487
<th
474488
class="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-12"
475489
>
@@ -515,6 +529,27 @@ <h2 class="text-2xl font-bold dark:text-gray-200">Registered Tools</h2>
515529
>
516530
{{ tool.description }}
517531
</td>
532+
<td class="px-2 py-4 whitespace-nowrap">
533+
{% if tool.annotations %}
534+
{% if tool.annotations.title %}
535+
<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>
536+
{% endif %}
537+
{% if tool.annotations.readOnlyHint %}
538+
<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>
539+
{% endif %}
540+
{% if tool.annotations.destructiveHint %}
541+
<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>
542+
{% endif %}
543+
{% if tool.annotations.idempotentHint %}
544+
<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>
545+
{% endif %}
546+
{% if tool.annotations.openWorldHint %}
547+
<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>
548+
{% endif %}
549+
{% else %}
550+
<span class="text-gray-400 text-xs">None</span>
551+
{% endif %}
552+
</td>
518553
<td class="px-3 py-4 whitespace-nowrap">
519554
<span
520555
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 %}"
@@ -1882,6 +1917,19 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Edit Tool</h3>
18821917
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
18831918
></textarea>
18841919
</div>
1920+
<div>
1921+
<label class="block text-sm font-medium text-gray-700"
1922+
>Annotations (JSON) - Read Only</label
1923+
>
1924+
<textarea
1925+
name="annotations"
1926+
id="edit-tool-annotations"
1927+
readonly
1928+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm bg-gray-50 text-gray-600"
1929+
placeholder="Annotations are automatically provided by MCP servers"
1930+
></textarea>
1931+
<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>
1932+
</div>
18851933
<!-- Authentication Section -->
18861934
<div>
18871935
<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()

0 commit comments

Comments
 (0)