From 0f716f94517dcb6b63e3d3615cc2879eea0514a6 Mon Sep 17 00:00:00 2001 From: bsatapat Date: Wed, 1 Oct 2025 23:47:48 +0530 Subject: [PATCH] Added api to get all available tools from configured MCP --- src/app/endpoints/tools.py | 186 ++++++++++++++++++++++++++++++++++++ src/app/routers.py | 2 + src/models/config.py | 1 + src/models/responses.py | 47 +++++++++ src/utils/tool_formatter.py | 138 ++++++++++++++++++++++++++ 5 files changed, 374 insertions(+) create mode 100644 src/app/endpoints/tools.py create mode 100644 src/utils/tool_formatter.py diff --git a/src/app/endpoints/tools.py b/src/app/endpoints/tools.py new file mode 100644 index 00000000..da91796e --- /dev/null +++ b/src/app/endpoints/tools.py @@ -0,0 +1,186 @@ +"""Handler for REST API call to list available tools from MCP servers.""" + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.params import Depends +from llama_stack_client import APIConnectionError + +from authentication import get_auth_dependency +from authentication.interface import AuthTuple +from authorization.middleware import authorize +from client import AsyncLlamaStackClientHolder +from configuration import configuration +from models.config import Action +from models.responses import ToolsResponse +from utils.endpoints import check_configuration_loaded +from utils.tool_formatter import format_tools_list + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["tools"]) + + +tools_responses: dict[int | str, dict[str, Any]] = { + 200: { + "description": "Successful Response", + "content": { + "application/json": { + "example": { + "tools": [ + { + "identifier": "filesystem_read", + "description": "Read contents of a file from the filesystem", + "parameters": [ + { + "name": "path", + "description": "Path to the file to read", + "parameter_type": "string", + "required": True + } + ], + "provider_id": "model-context-protocol", + "toolgroup_id": "filesystem-tools", + "server_source": "http://localhost:3000", + "type": "tool", + "metadata": {} + } + ] + } + } + } + }, + 500: {"description": "Connection to Llama Stack is broken or MCP server error"}, +} + + +@router.get("/tools", responses=tools_responses) +@authorize(Action.GET_TOOLS) +async def tools_endpoint_handler( + request: Request, + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], +) -> ToolsResponse: + """ + Handle requests to the /tools endpoint. + + Process GET requests to the /tools endpoint, returning a consolidated list of + available tools from all configured MCP servers. + + Raises: + HTTPException: If unable to connect to the Llama Stack server or if + tool retrieval fails for any reason. + + Returns: + ToolsResponse: An object containing the consolidated list of available tools + with metadata including tool name, description, parameters, and server source. + """ + # Used only by the middleware + _ = auth + + # Nothing interesting in the request + _ = request + + check_configuration_loaded(configuration) + + try: + # Get Llama Stack client + client = AsyncLlamaStackClientHolder().get_client() + + consolidated_tools = [] + + # First, get built-in tools from Llama Stack (like RAG tools) + try: + logger.debug("Retrieving built-in tools from Llama Stack") + # Get all available toolgroups + toolgroups_response = await client.toolgroups.list() + + for toolgroup in toolgroups_response: + try: + # Get tools for each toolgroup + tools_response = await client.tools.list(toolgroup_id=toolgroup.identifier) + + # Convert tools to dict format + for tool in tools_response: + tool_dict = dict(tool) + # Add source information for built-in tools + tool_dict["server_source"] = "builtin" + consolidated_tools.append(tool_dict) + + logger.debug( + "Retrieved %d tools from built-in toolgroup %s", + len(tools_response), + toolgroup.identifier + ) + + except Exception as e: + logger.warning( + "Failed to retrieve tools from toolgroup %s: %s", + toolgroup.identifier, + e + ) + continue + + except Exception as e: + logger.warning("Failed to retrieve built-in tools: %s", e) + + # Then, iterate through each configured MCP server (if any) + if configuration.mcp_servers: + for mcp_server in configuration.mcp_servers: + try: + logger.debug("Retrieving tools from MCP server: %s", mcp_server.name) + + # Get tools for this specific toolgroup (MCP server) + tools_response = await client.tools.list(toolgroup_id=mcp_server.name) + + # Convert tools to dict format and add server source information + for tool in tools_response: + tool_dict = dict(tool) + # Add server source information + tool_dict["server_source"] = mcp_server.url + consolidated_tools.append(tool_dict) + + logger.debug( + "Retrieved %d tools from MCP server %s", + len(tools_response), + mcp_server.name + ) + + except Exception as e: + logger.warning( + "Failed to retrieve tools from MCP server %s: %s", + mcp_server.name, + e + ) + # Continue with other servers even if one fails + continue + + logger.info("Retrieved total of %d tools (%d from built-in toolgroups, %d from MCP servers)", + len(consolidated_tools), + len([t for t in consolidated_tools if t.get("server_source") == "builtin"]), + len([t for t in consolidated_tools if t.get("server_source") != "builtin"])) + + # Format tools with structured description parsing + formatted_tools = format_tools_list(consolidated_tools) + + return ToolsResponse(tools=formatted_tools) + + # Connection to Llama Stack server + except APIConnectionError as e: + logger.error("Unable to connect to Llama Stack: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "response": "Unable to connect to Llama Stack", + "cause": str(e), + }, + ) from e + # Any other exception that can occur during tool listing + except Exception as e: + logger.error("Unable to retrieve list of tools: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "response": "Unable to retrieve list of tools", + "cause": str(e), + }, + ) from e diff --git a/src/app/routers.py b/src/app/routers.py index bd4de2e5..9a30afad 100644 --- a/src/app/routers.py +++ b/src/app/routers.py @@ -15,6 +15,7 @@ conversations, conversations_v2, metrics, + tools, ) @@ -27,6 +28,7 @@ def include_routers(app: FastAPI) -> None: app.include_router(root.router) app.include_router(info.router, prefix="/v1") app.include_router(models.router, prefix="/v1") + app.include_router(tools.router, prefix="/v1") app.include_router(query.router, prefix="/v1") app.include_router(streaming_query.router, prefix="/v1") app.include_router(config.router, prefix="/v1") diff --git a/src/models/config.py b/src/models/config.py index 1598d16b..0a690587 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -358,6 +358,7 @@ class Action(str, Enum): DELETE_CONVERSATION = "delete_conversation" FEEDBACK = "feedback" GET_MODELS = "get_models" + GET_TOOLS = "get_tools" GET_METRICS = "get_metrics" GET_CONFIG = "get_config" diff --git a/src/models/responses.py b/src/models/responses.py index 7674b884..a08a3008 100644 --- a/src/models/responses.py +++ b/src/models/responses.py @@ -36,6 +36,53 @@ class ModelsResponse(BaseModel): ) +class ToolsResponse(BaseModel): + """Model representing a response to tools request.""" + + tools: list[dict[str, Any]] = Field( + ..., + description="List of tools available from all configured MCP servers", + examples=[ + { + "identifier": "filesystem_read", + "description": "Read contents of a file from the filesystem", + "parameters": [ + { + "name": "path", + "description": "Path to the file to read", + "parameter_type": "string", + "required": True, + "default": None + } + ], + "provider_id": "model-context-protocol", + "toolgroup_id": "filesystem-tools", + "server_source": "http://localhost:3000", + "type": "tool", + "metadata": {} + }, + { + "identifier": "git_status", + "description": "Get the status of a git repository", + "parameters": [ + { + "name": "repository_path", + "description": "Path to the git repository", + "parameter_type": "string", + "required": True, + "default": None + } + ], + "provider_id": "model-context-protocol", + "toolgroup_id": "git-tools", + "server_source": "http://localhost:3001", + "type": "tool", + "metadata": {} + } + ], + ) + + class RAGChunk(BaseModel): """Model representing a RAG chunk used in the response.""" diff --git a/src/utils/tool_formatter.py b/src/utils/tool_formatter.py new file mode 100644 index 00000000..92e57042 --- /dev/null +++ b/src/utils/tool_formatter.py @@ -0,0 +1,138 @@ +"""Utility functions for formatting and parsing MCP tool descriptions.""" + +import re +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +def parse_mcp_tool_description(description: str) -> Dict[str, Any]: + """ + Parse MCP tool description to extract structured information. + + Args: + description: Raw description string from MCP tool + + Returns: + Dictionary with parsed structured information + """ + parsed = { + "tool_name": None, + "display_name": None, + "use_case": None, + "instructions": None, + "input_description": None, + "output_description": None, + "examples": [], + "prerequisites": [], + "agent_decision_criteria": None, + "clean_description": None + } + + try: + # Split description into lines for parsing + lines = description.split('\n') + + # Extract structured fields using regex + for line in lines: + line = line.strip() + + if line.startswith('TOOL_NAME='): + parsed["tool_name"] = line.replace('TOOL_NAME=', '').strip() + elif line.startswith('DISPLAY_NAME='): + parsed["display_name"] = line.replace('DISPLAY_NAME=', '').strip() + elif line.startswith('USECASE='): + parsed["use_case"] = line.replace('USECASE=', '').strip() + elif line.startswith('INSTRUCTIONS='): + parsed["instructions"] = line.replace('INSTRUCTIONS=', '').strip() + elif line.startswith('INPUT_DESCRIPTION='): + parsed["input_description"] = line.replace('INPUT_DESCRIPTION=', '').strip() + elif line.startswith('OUTPUT_DESCRIPTION='): + parsed["output_description"] = line.replace('OUTPUT_DESCRIPTION=', '').strip() + elif line.startswith('EXAMPLES='): + examples_text = line.replace('EXAMPLES=', '').strip() + # Split examples by common separators + parsed["examples"] = [ex.strip() for ex in re.split(r'[;,]', examples_text) if ex.strip()] + elif line.startswith('PREREQUISITES='): + prereq_text = line.replace('PREREQUISITES=', '').strip() + # Split prerequisites by common separators + parsed["prerequisites"] = [pr.strip() for pr in re.split(r'[;,]', prereq_text) if pr.strip()] + elif line.startswith('AGENT_DECISION_CRITERIA='): + parsed["agent_decision_criteria"] = line.replace('AGENT_DECISION_CRITERIA=', '').strip() + + # Extract clean description (everything after the structured metadata) + # Look for the main description after all the metadata + description_parts = description.split('\n\n') + for part in description_parts: + if not any(part.strip().startswith(prefix) for prefix in [ + 'TOOL_NAME=', 'DISPLAY_NAME=', 'USECASE=', 'INSTRUCTIONS=', + 'INPUT_DESCRIPTION=', 'OUTPUT_DESCRIPTION=', 'EXAMPLES=', + 'PREREQUISITES=', 'AGENT_DECISION_CRITERIA=' + ]): + if part.strip() and len(part.strip()) > 20: # Reasonable description length + parsed["clean_description"] = part.strip() + break + + # If no clean description found, use the use_case or display_name + if not parsed["clean_description"]: + parsed["clean_description"] = parsed["use_case"] or parsed["display_name"] or "No description available" + + except Exception as e: + logger.warning("Failed to parse MCP tool description: %s", e) + parsed["clean_description"] = description[:200] + "..." if len(description) > 200 else description + + return parsed + + +def format_tool_response(tool_dict: Dict[str, Any]) -> Dict[str, Any]: + """ + Format a tool dictionary with structured description parsing. + + Args: + tool_dict: Raw tool dictionary from Llama Stack + + Returns: + Formatted tool dictionary with structured information + """ + formatted_tool = tool_dict.copy() + + # Parse description if it exists and looks like MCP format + description = tool_dict.get("description", "") + if description and ("TOOL_NAME=" in description or "DISPLAY_NAME=" in description): + parsed_desc = parse_mcp_tool_description(description) + + # Add structured fields + formatted_tool.update({ + "display_name": parsed_desc["display_name"] or tool_dict.get("identifier", "Unknown Tool"), + "use_case": parsed_desc["use_case"], + "instructions": parsed_desc["instructions"], + "examples": parsed_desc["examples"], + "prerequisites": parsed_desc["prerequisites"], + "agent_decision_criteria": parsed_desc["agent_decision_criteria"], + "description": parsed_desc["clean_description"] # Replace with clean description + }) + + # Add metadata section for additional info + formatted_tool["metadata"] = formatted_tool.get("metadata", {}) + formatted_tool["metadata"].update({ + "input_description": parsed_desc["input_description"], + "output_description": parsed_desc["output_description"], + "original_tool_name": parsed_desc["tool_name"] + }) + + return formatted_tool + + +def format_tools_list(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Format a list of tools with structured description parsing. + + Args: + tools: List of raw tool dictionaries + + Returns: + List of formatted tool dictionaries + """ + return [format_tool_response(tool) for tool in tools] +