Coverage for src / mcp_server_langgraph / tools / tool_discovery.py: 98%
79 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
1"""
2Tool discovery for progressive disclosure
4Implements search_tools endpoint following Anthropic's MCP best practices
5for token-efficient tool discovery.
6"""
8import json
9import logging
10from typing import Literal
12from langchain_core.tools import BaseTool, tool
13from pydantic import BaseModel, Field
15from mcp_server_langgraph.tools import ALL_TOOLS
17logger = logging.getLogger(__name__)
19DetailLevel = Literal["minimal", "standard", "full"]
22class SearchToolsInput(BaseModel):
23 """Input schema for search_tools"""
25 query: str | None = Field(default=None, description="Search query (keyword or category)")
26 category: str | None = Field(
27 default=None,
28 description="Tool category (calculator, search, filesystem, execution)",
29 )
30 detail_level: DetailLevel = Field(
31 default="minimal",
32 description="Level of detail: minimal (name+desc), standard (+params), full (+examples)",
33 )
36def _filter_tools_by_category(category: str) -> list[BaseTool]:
37 """Filter tools by category."""
38 category_lower = category.lower()
39 if category_lower == "calculator":
40 from mcp_server_langgraph.tools import CALCULATOR_TOOLS
42 return CALCULATOR_TOOLS
43 elif category_lower == "search":
44 from mcp_server_langgraph.tools import SEARCH_TOOLS
46 return SEARCH_TOOLS
47 elif category_lower == "filesystem":
48 from mcp_server_langgraph.tools import FILESYSTEM_TOOLS
50 return FILESYSTEM_TOOLS
51 elif category_lower == "execution":
52 from mcp_server_langgraph.tools import CODE_EXECUTION_TOOLS
54 return CODE_EXECUTION_TOOLS
55 return ALL_TOOLS
58def _filter_tools_by_query(tools: list[BaseTool], query: str) -> list[BaseTool]:
59 """Filter tools by search query."""
60 query_lower = query.lower()
61 return [t for t in tools if query_lower in t.name.lower() or query_lower in (t.description or "").lower()]
64def _format_tool_minimal(t: BaseTool) -> str:
65 """Format tool in minimal mode."""
66 return f"- **{t.name}**: {t.description}\n"
69def _format_tool_standard(t: BaseTool) -> str:
70 """Format tool in standard mode."""
71 result = f"### {t.name}\n{t.description}\n\n"
73 if hasattr(t, "args_schema") and t.args_schema:
74 schema = t.args_schema.model_json_schema() # type: ignore[union-attr]
75 if "properties" in schema: 75 ↛ 81line 75 didn't jump to line 81 because the condition on line 75 was always true
76 result += "**Parameters:**\n"
77 for param_name, param_info in schema["properties"].items():
78 param_desc = param_info.get("description", "No description")
79 param_type = param_info.get("type", "any")
80 result += f"- `{param_name}` ({param_type}): {param_desc}\n"
81 result += "\n"
83 return result
86def _format_tool_full(t: BaseTool) -> str:
87 """Format tool in full mode."""
88 result = f"### {t.name}\n{t.description}\n\n"
90 if hasattr(t, "args_schema") and t.args_schema:
91 schema = t.args_schema.model_json_schema() # type: ignore[union-attr]
92 if "properties" in schema: 92 ↛ 105line 92 didn't jump to line 105 because the condition on line 92 was always true
93 result += "**Parameters:**\n"
94 for param_name, param_info in schema["properties"].items():
95 param_desc = param_info.get("description", "No description")
96 param_type = param_info.get("type", "any")
97 required = param_name in schema.get("required", [])
98 req_str = "required" if required else "optional"
99 result += f"- `{param_name}` ({param_type}, {req_str}): {param_desc}\n"
101 result += "\n**Full Schema:**\n```json\n"
102 result += json.dumps(schema, indent=2)
103 result += "\n```\n\n"
105 return result
108def _format_tool_results(tools: list[BaseTool], detail_level: str) -> str:
109 """Format tool results based on detail level."""
110 result = f"Found {len(tools)} tool(s):\n\n"
112 if detail_level == "minimal":
113 for t in tools:
114 result += _format_tool_minimal(t)
115 elif detail_level == "standard":
116 for t in tools:
117 result += _format_tool_standard(t)
118 else: # full
119 for t in tools:
120 result += _format_tool_full(t)
122 return result
125@tool
126def search_tools(
127 query: str | None = None,
128 category: str | None = None,
129 detail_level: str = "minimal",
130) -> str:
131 """
132 Search and discover available tools (progressive disclosure for token efficiency).
134 Implements Anthropic's best practice for progressive tool discovery, allowing
135 agents to query tools by keyword or category rather than loading all tool
136 definitions upfront. This can save 98%+ tokens compared to listing all tools.
138 Args:
139 query: Optional keyword search (searches name and description)
140 category: Optional category filter (calculator, search, filesystem, execution)
141 detail_level: Level of detail - minimal, standard, or full
143 Returns:
144 Formatted list of matching tools with requested detail level
146 Example:
147 >>> search_tools.invoke({"category": "calculator", "detail_level": "minimal"})
148 "Found 5 tools:\\n- calculator: Evaluate mathematical expressions\\n..."
149 """
150 # Get all tools or filter by category
151 tools = _filter_tools_by_category(category) if category else ALL_TOOLS
153 # Filter by query if provided
154 if query:
155 tools = _filter_tools_by_query(tools, query)
157 if not tools:
158 return f"No tools found matching criteria (query={query}, category={category})"
160 # Format and return results
161 return _format_tool_results(tools, detail_level)