Skip to content

Commit 00af363

Browse files
authored
Merge pull request #626 from bsatapat-jpg/tools_mcp
LCORE-202: Add GET /v1/tools endpoint to list tools from MCP servers
2 parents 511d289 + 0393a96 commit 00af363

File tree

7 files changed

+881
-2
lines changed

7 files changed

+881
-2
lines changed

src/app/endpoints/tools.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Handler for REST API call to list available tools from MCP servers."""
2+
3+
import logging
4+
from typing import Annotated, Any
5+
6+
from fastapi import APIRouter, Depends, HTTPException, Request, status
7+
from llama_stack_client import APIConnectionError
8+
9+
from authentication import get_auth_dependency
10+
from authentication.interface import AuthTuple
11+
from authorization.middleware import authorize
12+
from client import AsyncLlamaStackClientHolder
13+
from configuration import configuration
14+
from models.config import Action
15+
from models.responses import ToolsResponse
16+
from utils.endpoints import check_configuration_loaded
17+
from utils.tool_formatter import format_tools_list
18+
19+
logger = logging.getLogger(__name__)
20+
router = APIRouter(tags=["tools"])
21+
22+
23+
tools_responses: dict[int | str, dict[str, Any]] = {
24+
200: {
25+
"description": "Successful Response",
26+
"content": {
27+
"application/json": {
28+
"example": {
29+
"tools": [
30+
{
31+
"identifier": "",
32+
"description": "",
33+
"parameters": [
34+
{
35+
"name": "",
36+
"description": "",
37+
"parameter_type": "",
38+
"required": "True/False",
39+
"default": "null",
40+
}
41+
],
42+
"provider_id": "",
43+
"toolgroup_id": "",
44+
"server_source": "",
45+
"type": "tool",
46+
}
47+
]
48+
}
49+
}
50+
},
51+
},
52+
500: {"description": "Connection to Llama Stack is broken or MCP server error"},
53+
}
54+
55+
56+
@router.get("/tools", responses=tools_responses)
57+
@authorize(Action.GET_TOOLS)
58+
async def tools_endpoint_handler(
59+
request: Request,
60+
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
61+
) -> ToolsResponse:
62+
"""
63+
Handle requests to the /tools endpoint.
64+
65+
Process GET requests to the /tools endpoint, returning a consolidated list of
66+
available tools from all configured MCP servers.
67+
68+
Raises:
69+
HTTPException: If unable to connect to the Llama Stack server or if
70+
tool retrieval fails for any reason.
71+
72+
Returns:
73+
ToolsResponse: An object containing the consolidated list of available tools
74+
with metadata including tool name, description, parameters, and server source.
75+
"""
76+
# Used only by the middleware
77+
_ = auth
78+
79+
# Nothing interesting in the request
80+
_ = request
81+
82+
check_configuration_loaded(configuration)
83+
84+
try:
85+
# Get Llama Stack client
86+
client = AsyncLlamaStackClientHolder().get_client()
87+
88+
consolidated_tools = []
89+
mcp_server_names = (
90+
{mcp_server.name for mcp_server in configuration.mcp_servers}
91+
if configuration.mcp_servers
92+
else set()
93+
)
94+
95+
# Get all available toolgroups
96+
try:
97+
logger.debug("Retrieving tools from all toolgroups")
98+
toolgroups_response = await client.toolgroups.list()
99+
100+
for toolgroup in toolgroups_response:
101+
try:
102+
# Get tools for each toolgroup
103+
tools_response = await client.tools.list(
104+
toolgroup_id=toolgroup.identifier
105+
)
106+
107+
# Convert tools to dict format
108+
tools_count = 0
109+
server_source = "unknown"
110+
111+
for tool in tools_response:
112+
tool_dict = dict(tool)
113+
114+
# Determine server source based on toolgroup type
115+
if toolgroup.identifier in mcp_server_names:
116+
# This is an MCP server toolgroup
117+
mcp_server = next(
118+
(
119+
s
120+
for s in configuration.mcp_servers
121+
if s.name == toolgroup.identifier
122+
),
123+
None,
124+
)
125+
tool_dict["server_source"] = (
126+
mcp_server.url if mcp_server else toolgroup.identifier
127+
)
128+
else:
129+
# This is a built-in toolgroup
130+
tool_dict["server_source"] = "builtin"
131+
132+
consolidated_tools.append(tool_dict)
133+
tools_count += 1
134+
server_source = tool_dict["server_source"]
135+
136+
logger.debug(
137+
"Retrieved %d tools from toolgroup %s (source: %s)",
138+
tools_count,
139+
toolgroup.identifier,
140+
server_source,
141+
)
142+
143+
except Exception as e: # pylint: disable=broad-exception-caught
144+
# Catch any exception from individual toolgroup failures to allow
145+
# processing of other toolgroups to continue (partial failure scenario)
146+
logger.warning(
147+
"Failed to retrieve tools from toolgroup %s: %s",
148+
toolgroup.identifier,
149+
e,
150+
)
151+
continue
152+
153+
except APIConnectionError as e:
154+
logger.warning("Failed to retrieve tools from toolgroups: %s", e)
155+
raise
156+
except (ValueError, AttributeError) as e:
157+
logger.warning("Failed to retrieve tools from toolgroups: %s", e)
158+
159+
logger.info(
160+
"Retrieved total of %d tools (%d from built-in toolgroups, %d from MCP servers)",
161+
len(consolidated_tools),
162+
len([t for t in consolidated_tools if t.get("server_source") == "builtin"]),
163+
len([t for t in consolidated_tools if t.get("server_source") != "builtin"]),
164+
)
165+
166+
# Format tools with structured description parsing
167+
formatted_tools = format_tools_list(consolidated_tools)
168+
169+
return ToolsResponse(tools=formatted_tools)
170+
171+
# Connection to Llama Stack server
172+
except APIConnectionError as e:
173+
logger.error("Unable to connect to Llama Stack: %s", e)
174+
raise HTTPException(
175+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
176+
detail={
177+
"response": "Unable to connect to Llama Stack",
178+
"cause": str(e),
179+
},
180+
) from e
181+
# Any other exception that can occur during tool listing
182+
except Exception as e:
183+
logger.error("Unable to retrieve list of tools: %s", e)
184+
raise HTTPException(
185+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
186+
detail={
187+
"response": "Unable to retrieve list of tools",
188+
"cause": str(e),
189+
},
190+
) from e

src/app/routers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
conversations,
1717
conversations_v2,
1818
metrics,
19+
tools,
1920
)
2021

2122

@@ -28,6 +29,7 @@ def include_routers(app: FastAPI) -> None:
2829
app.include_router(root.router)
2930
app.include_router(info.router, prefix="/v1")
3031
app.include_router(models.router, prefix="/v1")
32+
app.include_router(tools.router, prefix="/v1")
3133
app.include_router(shields.router, prefix="/v1")
3234
app.include_router(query.router, prefix="/v1")
3335
app.include_router(streaming_query.router, prefix="/v1")

src/models/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ class Action(str, Enum):
358358
DELETE_CONVERSATION = "delete_conversation"
359359
FEEDBACK = "feedback"
360360
GET_MODELS = "get_models"
361+
GET_TOOLS = "get_tools"
361362
GET_SHIELDS = "get_shields"
362363
GET_METRICS = "get_metrics"
363364
GET_CONFIG = "get_config"

src/models/responses.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ class ModelsResponse(BaseModel):
3636
)
3737

3838

39+
class ToolsResponse(BaseModel):
40+
"""Model representing a response to tools request."""
41+
42+
tools: list[dict[str, Any]] = Field(
43+
description=(
44+
"List of tools available from all configured MCP servers and built-in toolgroups"
45+
),
46+
examples=[
47+
[
48+
{
49+
"identifier": "filesystem_read",
50+
"description": "Read contents of a file from the filesystem",
51+
"parameters": [
52+
{
53+
"name": "path",
54+
"description": "Path to the file to read",
55+
"parameter_type": "string",
56+
"required": True,
57+
"default": None,
58+
}
59+
],
60+
"provider_id": "model-context-protocol",
61+
"toolgroup_id": "filesystem-tools",
62+
"server_source": "http://localhost:3000",
63+
"type": "tool",
64+
}
65+
]
66+
],
67+
)
68+
69+
3970
class ShieldsResponse(BaseModel):
4071
"""Model representing a response to shields request."""
4172

src/utils/tool_formatter.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Utility functions for formatting and parsing MCP tool descriptions."""
2+
3+
import logging
4+
from typing import Any
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
def format_tool_response(tool_dict: dict[str, Any]) -> dict[str, Any]:
10+
"""
11+
Format a tool dictionary to include only required fields.
12+
13+
Args:
14+
tool_dict: Raw tool dictionary from Llama Stack
15+
16+
Returns:
17+
Formatted tool dictionary with only required fields
18+
"""
19+
# Clean up description if it contains structured metadata
20+
description = tool_dict.get("description", "")
21+
if description and ("TOOL_NAME=" in description or "DISPLAY_NAME=" in description):
22+
# Extract clean description from structured metadata
23+
clean_description = extract_clean_description(description)
24+
description = clean_description
25+
26+
# Extract only the required fields
27+
formatted_tool = {
28+
"identifier": tool_dict.get("identifier", ""),
29+
"description": description,
30+
"parameters": tool_dict.get("parameters", []),
31+
"provider_id": tool_dict.get("provider_id", ""),
32+
"toolgroup_id": tool_dict.get("toolgroup_id", ""),
33+
"server_source": tool_dict.get("server_source", ""),
34+
"type": tool_dict.get("type", ""),
35+
}
36+
37+
return formatted_tool
38+
39+
40+
def extract_clean_description(description: str) -> str:
41+
"""
42+
Extract a clean description from structured metadata format.
43+
44+
Args:
45+
description: Raw description with structured metadata
46+
47+
Returns:
48+
Clean description without metadata
49+
"""
50+
min_description_length = 20
51+
fallback_truncation_length = 200
52+
53+
try:
54+
# Look for the main description after all the metadata
55+
description_parts = description.split("\n\n")
56+
for part in description_parts:
57+
if not any(
58+
part.strip().startswith(prefix)
59+
for prefix in [
60+
"TOOL_NAME=",
61+
"DISPLAY_NAME=",
62+
"USECASE=",
63+
"INSTRUCTIONS=",
64+
"INPUT_DESCRIPTION=",
65+
"OUTPUT_DESCRIPTION=",
66+
"EXAMPLES=",
67+
"PREREQUISITES=",
68+
"AGENT_DECISION_CRITERIA=",
69+
]
70+
):
71+
if (
72+
part.strip() and len(part.strip()) > min_description_length
73+
): # Reasonable description length
74+
return part.strip()
75+
76+
# If no clean description found, try to extract from USECASE
77+
lines = description.split("\n")
78+
for line in lines:
79+
if line.startswith("USECASE="):
80+
return line.replace("USECASE=", "").strip()
81+
82+
# Fallback to first 200 characters
83+
return (
84+
description[:fallback_truncation_length] + "..."
85+
if len(description) > fallback_truncation_length
86+
else description
87+
)
88+
89+
except (ValueError, AttributeError) as e:
90+
logger.warning("Failed to extract clean description: %s", e)
91+
return (
92+
description[:fallback_truncation_length] + "..."
93+
if len(description) > fallback_truncation_length
94+
else description
95+
)
96+
97+
98+
def format_tools_list(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
99+
"""
100+
Format a list of tools with structured description parsing.
101+
102+
Args:
103+
tools: List of raw tool dictionaries
104+
105+
Returns:
106+
List of formatted tool dictionaries
107+
"""
108+
return [format_tool_response(tool) for tool in tools]

0 commit comments

Comments
 (0)