From 0237fb3419a70214ec9ee51130d0685cc2f15b00 Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 26 Jun 2025 13:16:00 +0400 Subject: [PATCH] Set read-only annotation hints on each tool for security --- config.py | 4 ++-- server.py | 6 ++++++ tools/listmodels.py | 13 ++++++++----- tools/shared/base_tool.py | 13 +++++++++++++ tools/simple/base.py | 15 +++++++++++++++ tools/version.py | 13 ++++++++----- tools/workflow/base.py | 15 +++++++++++++++ 7 files changed, 67 insertions(+), 12 deletions(-) diff --git a/config.py b/config.py index 5e8667a..8a859be 100644 --- a/config.py +++ b/config.py @@ -14,9 +14,9 @@ import os # These values are used in server responses and for tracking releases # IMPORTANT: This is the single source of truth for version and author info # Semantic versioning: MAJOR.MINOR.PATCH -__version__ = "5.7.0" +__version__ = "5.7.1" # Last update date in ISO format -__updated__ = "2025-06-23" +__updated__ = "2025-06-26" # Primary maintainer __author__ = "Fahad Gilani" diff --git a/server.py b/server.py index ebb5ce2..32ec5b9 100644 --- a/server.py +++ b/server.py @@ -47,6 +47,7 @@ from mcp.types import ( # noqa: E402 ServerCapabilities, TextContent, Tool, + ToolAnnotations, ToolsCapability, ) @@ -559,11 +560,16 @@ async def handle_list_tools() -> list[Tool]: # Add all registered AI-powered tools from the TOOLS registry for tool in TOOLS.values(): + # Get optional annotations from the tool + annotations = tool.get_annotations() + tool_annotations = ToolAnnotations(**annotations) if annotations else None + tools.append( Tool( name=tool.name, description=tool.description, inputSchema=tool.get_input_schema(), + annotations=tool_annotations, ) ) diff --git a/tools/listmodels.py b/tools/listmodels.py index 621d4cd..2d47b0a 100644 --- a/tools/listmodels.py +++ b/tools/listmodels.py @@ -43,11 +43,11 @@ class ListModelsTool(BaseTool): def get_input_schema(self) -> dict[str, Any]: """Return the JSON schema for the tool's input""" - return { - "type": "object", - "properties": {"model": {"type": "string", "description": "Model to use (ignored by listmodels tool)"}}, - "required": [], - } + return {"type": "object", "properties": {}, "required": []} + + def get_annotations(self) -> Optional[dict[str, Any]]: + """Return tool annotations indicating this is a read-only tool""" + return {"readOnlyHint": True} def get_system_prompt(self) -> str: """No AI model needed for this tool""" @@ -57,6 +57,9 @@ class ListModelsTool(BaseTool): """Return the Pydantic model for request validation.""" return ToolRequest + def requires_model(self) -> bool: + return False + async def prepare_prompt(self, request: ToolRequest) -> str: """Not used for this utility tool""" return "" diff --git a/tools/shared/base_tool.py b/tools/shared/base_tool.py index 10b223f..f6cc658 100644 --- a/tools/shared/base_tool.py +++ b/tools/shared/base_tool.py @@ -153,6 +153,19 @@ class BaseTool(ABC): """ pass + def get_annotations(self) -> Optional[dict[str, Any]]: + """ + Return optional annotations for this tool. + + Annotations provide hints about tool behavior without being security-critical. + They help MCP clients make better decisions about tool usage. + + Returns: + Optional[dict]: Dictionary with annotation fields like readOnlyHint, destructiveHint, etc. + Returns None if no annotations are needed. + """ + return None + def requires_model(self) -> bool: """ Return whether this tool requires AI model access. diff --git a/tools/simple/base.py b/tools/simple/base.py index 31cd8b4..e001435 100644 --- a/tools/simple/base.py +++ b/tools/simple/base.py @@ -100,6 +100,21 @@ class SimpleTool(BaseTool): """ return [] + def get_annotations(self) -> Optional[dict[str, Any]]: + """ + Return tool annotations. Simple tools are read-only by default. + + All simple tools perform operations without modifying the environment. + They may call external AI models for analysis or conversation, but they + don't write files or make system changes. + + Override this method if your simple tool needs different annotations. + + Returns: + Dictionary with readOnlyHint set to True + """ + return {"readOnlyHint": True} + def format_response(self, response: str, request, model_info: Optional[dict] = None) -> str: """ Format the AI response before returning to the client. diff --git a/tools/version.py b/tools/version.py index 10886a9..1ce9311 100644 --- a/tools/version.py +++ b/tools/version.py @@ -147,11 +147,11 @@ class VersionTool(BaseTool): def get_input_schema(self) -> dict[str, Any]: """Return the JSON schema for the tool's input""" - return { - "type": "object", - "properties": {"model": {"type": "string", "description": "Model to use (ignored by version tool)"}}, - "required": [], - } + return {"type": "object", "properties": {}, "required": []} + + def get_annotations(self) -> Optional[dict[str, Any]]: + """Return tool annotations indicating this is a read-only tool""" + return {"readOnlyHint": True} def get_system_prompt(self) -> str: """No AI model needed for this tool""" @@ -161,6 +161,9 @@ class VersionTool(BaseTool): """Return the Pydantic model for request validation.""" return ToolRequest + def requires_model(self) -> bool: + return False + async def prepare_prompt(self, request: ToolRequest) -> str: """Not used for this utility tool""" return "" diff --git a/tools/workflow/base.py b/tools/workflow/base.py index 66a05d3..09d4172 100644 --- a/tools/workflow/base.py +++ b/tools/workflow/base.py @@ -110,6 +110,21 @@ class WorkflowTool(BaseTool, BaseWorkflowMixin): """ return [] + def get_annotations(self) -> Optional[dict[str, Any]]: + """ + Return tool annotations. Workflow tools are read-only by default. + + All workflow tools perform analysis and investigation without modifying + the environment. They may call external AI models for expert analysis, + but they don't write files or make system changes. + + Override this method if your workflow tool needs different annotations. + + Returns: + Dictionary with readOnlyHint set to True + """ + return {"readOnlyHint": True} + def get_input_schema(self) -> dict[str, Any]: """ Generate the complete input schema using SchemaBuilder.