mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-06 22:12:12 +02:00
refactor: enhance deduplication logic for HITL tool calls
Updated the deduplication mechanism in the DedupHITLToolCallsMiddleware to utilize a comprehensive list of native HITL tools. The deduplication keys are now dynamically populated from both hardcoded values and metadata from StructuredTool instances. Additionally, integrated HITL approval into MCP tool creation, ensuring all tools are gated by user approval, with the ability to bypass for trusted tools.
This commit is contained in:
parent
0c4fd30cce
commit
82c7d4a2ab
2 changed files with 137 additions and 99 deletions
|
|
@ -20,19 +20,39 @@ from langgraph.runtime import Runtime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_HITL_TOOL_DEDUP_KEYS: dict[str, str] = {
|
_NATIVE_HITL_TOOL_DEDUP_KEYS: dict[str, str] = {
|
||||||
"delete_calendar_event": "event_title_or_id",
|
# Gmail
|
||||||
"update_calendar_event": "event_title_or_id",
|
"send_gmail_email": "subject",
|
||||||
"trash_gmail_email": "email_subject_or_id",
|
"create_gmail_draft": "subject",
|
||||||
"update_gmail_draft": "draft_subject_or_id",
|
"update_gmail_draft": "draft_subject_or_id",
|
||||||
|
"trash_gmail_email": "email_subject_or_id",
|
||||||
|
# Google Calendar
|
||||||
|
"create_calendar_event": "title",
|
||||||
|
"update_calendar_event": "event_title_or_id",
|
||||||
|
"delete_calendar_event": "event_title_or_id",
|
||||||
|
# Google Drive
|
||||||
|
"create_google_drive_file": "file_name",
|
||||||
"delete_google_drive_file": "file_name",
|
"delete_google_drive_file": "file_name",
|
||||||
|
# OneDrive
|
||||||
|
"create_onedrive_file": "file_name",
|
||||||
"delete_onedrive_file": "file_name",
|
"delete_onedrive_file": "file_name",
|
||||||
"delete_notion_page": "page_title",
|
# Dropbox
|
||||||
|
"create_dropbox_file": "file_name",
|
||||||
|
"delete_dropbox_file": "file_name",
|
||||||
|
# Notion
|
||||||
|
"create_notion_page": "title",
|
||||||
"update_notion_page": "page_title",
|
"update_notion_page": "page_title",
|
||||||
"delete_linear_issue": "issue_ref",
|
"delete_notion_page": "page_title",
|
||||||
|
# Linear
|
||||||
|
"create_linear_issue": "title",
|
||||||
"update_linear_issue": "issue_ref",
|
"update_linear_issue": "issue_ref",
|
||||||
|
"delete_linear_issue": "issue_ref",
|
||||||
|
# Jira
|
||||||
|
"create_jira_issue": "summary",
|
||||||
"update_jira_issue": "issue_title_or_key",
|
"update_jira_issue": "issue_title_or_key",
|
||||||
"delete_jira_issue": "issue_title_or_key",
|
"delete_jira_issue": "issue_title_or_key",
|
||||||
|
# Confluence
|
||||||
|
"create_confluence_page": "title",
|
||||||
"update_confluence_page": "page_title_or_id",
|
"update_confluence_page": "page_title_or_id",
|
||||||
"delete_confluence_page": "page_title_or_id",
|
"delete_confluence_page": "page_title_or_id",
|
||||||
}
|
}
|
||||||
|
|
@ -43,22 +63,38 @@ class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
||||||
|
|
||||||
Only the **first** occurrence of each (tool-name, primary-arg-value)
|
Only the **first** occurrence of each (tool-name, primary-arg-value)
|
||||||
pair is kept; subsequent duplicates are silently dropped.
|
pair is kept; subsequent duplicates are silently dropped.
|
||||||
|
|
||||||
|
The dedup map is built from two sources:
|
||||||
|
|
||||||
|
1. A comprehensive list of native HITL tools (hardcoded above).
|
||||||
|
2. Any ``StructuredTool`` instances passed via *agent_tools* whose
|
||||||
|
``metadata`` contains ``{"hitl": True, "hitl_dedup_key": "..."}``.
|
||||||
|
This is how MCP tools automatically get dedup support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tools = ()
|
tools = ()
|
||||||
|
|
||||||
|
def __init__(self, *, agent_tools: list[Any] | None = None) -> None:
|
||||||
|
self._dedup_keys: dict[str, str] = dict(_NATIVE_HITL_TOOL_DEDUP_KEYS)
|
||||||
|
for t in agent_tools or []:
|
||||||
|
meta = getattr(t, "metadata", None) or {}
|
||||||
|
if meta.get("hitl") and meta.get("hitl_dedup_key"):
|
||||||
|
self._dedup_keys[t.name] = meta["hitl_dedup_key"]
|
||||||
|
|
||||||
def after_model(
|
def after_model(
|
||||||
self, state: AgentState, runtime: Runtime[Any]
|
self, state: AgentState, runtime: Runtime[Any]
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
return self._dedup(state)
|
return self._dedup(state, self._dedup_keys)
|
||||||
|
|
||||||
async def aafter_model(
|
async def aafter_model(
|
||||||
self, state: AgentState, runtime: Runtime[Any]
|
self, state: AgentState, runtime: Runtime[Any]
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
return self._dedup(state)
|
return self._dedup(state, self._dedup_keys)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _dedup(state: AgentState) -> dict[str, Any] | None: # type: ignore[type-arg]
|
def _dedup(
|
||||||
|
state: AgentState, dedup_keys: dict[str, str] # type: ignore[type-arg]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
messages = state.get("messages")
|
messages = state.get("messages")
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
|
|
@ -73,7 +109,7 @@ class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
||||||
|
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
name = tc.get("name", "")
|
name = tc.get("name", "")
|
||||||
dedup_key_arg = _HITL_TOOL_DEDUP_KEYS.get(name)
|
dedup_key_arg = dedup_keys.get(name)
|
||||||
if dedup_key_arg is not None:
|
if dedup_key_arg is not None:
|
||||||
arg_val = str(tc.get("args", {}).get(dedup_key_arg, "")).lower()
|
arg_val = str(tc.get("args", {}).get(dedup_key_arg, "")).lower()
|
||||||
key = (name, arg_val)
|
key = (name, arg_val)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ Supports both transport types:
|
||||||
- stdio: Local process-based MCP servers (command, args, env)
|
- stdio: Local process-based MCP servers (command, args, env)
|
||||||
- streamable-http/http/sse: Remote HTTP-based MCP servers (url, headers)
|
- streamable-http/http/sse: Remote HTTP-based MCP servers (url, headers)
|
||||||
|
|
||||||
This implements real MCP protocol support similar to Cursor's implementation.
|
All MCP tools are unconditionally gated by HITL (Human-in-the-Loop) approval.
|
||||||
|
Per the MCP spec: "Clients MUST consider tool annotations to be untrusted unless
|
||||||
|
they come from trusted servers." Users can bypass HITL for specific tools by
|
||||||
|
clicking "Always Allow", which adds the tool name to the connector's
|
||||||
|
``config.trusted_tools`` allow-list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -21,6 +25,7 @@ from pydantic import BaseModel, create_model
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.new_chat.tools.hitl import request_approval
|
||||||
from app.agents.new_chat.tools.mcp_client import MCPClient
|
from app.agents.new_chat.tools.mcp_client import MCPClient
|
||||||
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
||||||
|
|
||||||
|
|
@ -49,27 +54,15 @@ def _create_dynamic_input_model_from_schema(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
input_schema: dict[str, Any],
|
input_schema: dict[str, Any],
|
||||||
) -> type[BaseModel]:
|
) -> type[BaseModel]:
|
||||||
"""Create a Pydantic model from MCP tool's JSON schema.
|
"""Create a Pydantic model from MCP tool's JSON schema."""
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: Name of the tool (used for model class name)
|
|
||||||
input_schema: JSON schema from MCP server
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Pydantic model class for tool input validation
|
|
||||||
|
|
||||||
"""
|
|
||||||
properties = input_schema.get("properties", {})
|
properties = input_schema.get("properties", {})
|
||||||
required_fields = input_schema.get("required", [])
|
required_fields = input_schema.get("required", [])
|
||||||
|
|
||||||
# Build Pydantic field definitions
|
|
||||||
field_definitions = {}
|
field_definitions = {}
|
||||||
for param_name, param_schema in properties.items():
|
for param_name, param_schema in properties.items():
|
||||||
param_description = param_schema.get("description", "")
|
param_description = param_schema.get("description", "")
|
||||||
is_required = param_name in required_fields
|
is_required = param_name in required_fields
|
||||||
|
|
||||||
# Use Any type for complex schemas to preserve structure
|
|
||||||
# This allows the MCP server to do its own validation
|
|
||||||
from typing import Any as AnyType
|
from typing import Any as AnyType
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
@ -85,7 +78,6 @@ def _create_dynamic_input_model_from_schema(
|
||||||
Field(None, description=param_description),
|
Field(None, description=param_description),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create dynamic model
|
|
||||||
model_name = f"{tool_name.replace(' ', '').replace('-', '_')}Input"
|
model_name = f"{tool_name.replace(' ', '').replace('-', '_')}Input"
|
||||||
return create_model(model_name, **field_definitions)
|
return create_model(model_name, **field_definitions)
|
||||||
|
|
||||||
|
|
@ -93,55 +85,70 @@ def _create_dynamic_input_model_from_schema(
|
||||||
async def _create_mcp_tool_from_definition_stdio(
|
async def _create_mcp_tool_from_definition_stdio(
|
||||||
tool_def: dict[str, Any],
|
tool_def: dict[str, Any],
|
||||||
mcp_client: MCPClient,
|
mcp_client: MCPClient,
|
||||||
|
*,
|
||||||
|
connector_name: str = "",
|
||||||
|
connector_id: int | None = None,
|
||||||
|
trusted_tools: list[str] | None = None,
|
||||||
) -> StructuredTool:
|
) -> StructuredTool:
|
||||||
"""Create a LangChain tool from an MCP tool definition (stdio transport).
|
"""Create a LangChain tool from an MCP tool definition (stdio transport).
|
||||||
|
|
||||||
Args:
|
All MCP tools are unconditionally wrapped with HITL approval.
|
||||||
tool_def: Tool definition from MCP server with name, description, input_schema
|
``request_approval()`` is called OUTSIDE the try/except so that
|
||||||
mcp_client: MCP client instance for calling the tool
|
``GraphInterrupt`` propagates cleanly to LangGraph.
|
||||||
|
|
||||||
Returns:
|
|
||||||
LangChain StructuredTool instance
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tool_name = tool_def.get("name", "unnamed_tool")
|
tool_name = tool_def.get("name", "unnamed_tool")
|
||||||
tool_description = tool_def.get("description", "No description provided")
|
tool_description = tool_def.get("description", "No description provided")
|
||||||
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
|
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
|
||||||
|
|
||||||
# Log the actual schema for debugging
|
|
||||||
logger.info(f"MCP tool '{tool_name}' input schema: {input_schema}")
|
logger.info(f"MCP tool '{tool_name}' input schema: {input_schema}")
|
||||||
|
|
||||||
# Create dynamic input model from schema
|
|
||||||
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
|
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
|
||||||
|
|
||||||
async def mcp_tool_call(**kwargs) -> str:
|
async def mcp_tool_call(**kwargs) -> str:
|
||||||
"""Execute the MCP tool call via the client with retry support."""
|
"""Execute the MCP tool call via the client with retry support."""
|
||||||
logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}")
|
logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}")
|
||||||
|
|
||||||
|
# HITL — OUTSIDE try/except so GraphInterrupt propagates to LangGraph
|
||||||
|
hitl_result = request_approval(
|
||||||
|
action_type="mcp_tool_call",
|
||||||
|
tool_name=tool_name,
|
||||||
|
params=kwargs,
|
||||||
|
context={
|
||||||
|
"mcp_server": connector_name,
|
||||||
|
"tool_description": tool_description,
|
||||||
|
"mcp_transport": "stdio",
|
||||||
|
"mcp_connector_id": connector_id,
|
||||||
|
},
|
||||||
|
trusted_tools=trusted_tools,
|
||||||
|
)
|
||||||
|
if hitl_result.rejected:
|
||||||
|
return "Tool call rejected by user."
|
||||||
|
call_kwargs = hitl_result.params
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Connect to server and call tool (connect has built-in retry logic)
|
|
||||||
async with mcp_client.connect():
|
async with mcp_client.connect():
|
||||||
result = await mcp_client.call_tool(tool_name, kwargs)
|
result = await mcp_client.call_tool(tool_name, call_kwargs)
|
||||||
return str(result)
|
return str(result)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# Connection failures after all retries
|
|
||||||
error_msg = f"MCP tool '{tool_name}' connection failed after retries: {e!s}"
|
error_msg = f"MCP tool '{tool_name}' connection failed after retries: {e!s}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return f"Error: {error_msg}"
|
return f"Error: {error_msg}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Tool execution or other errors
|
|
||||||
error_msg = f"MCP tool '{tool_name}' execution failed: {e!s}"
|
error_msg = f"MCP tool '{tool_name}' execution failed: {e!s}"
|
||||||
logger.exception(error_msg)
|
logger.exception(error_msg)
|
||||||
return f"Error: {error_msg}"
|
return f"Error: {error_msg}"
|
||||||
|
|
||||||
# Create StructuredTool with response_format to preserve exact schema
|
|
||||||
tool = StructuredTool(
|
tool = StructuredTool(
|
||||||
name=tool_name,
|
name=tool_name,
|
||||||
description=tool_description,
|
description=tool_description,
|
||||||
coroutine=mcp_tool_call,
|
coroutine=mcp_tool_call,
|
||||||
args_schema=input_model,
|
args_schema=input_model,
|
||||||
# Store the original MCP schema as metadata so we can access it later
|
metadata={
|
||||||
metadata={"mcp_input_schema": input_schema, "mcp_transport": "stdio"},
|
"mcp_input_schema": input_schema,
|
||||||
|
"mcp_transport": "stdio",
|
||||||
|
"hitl": True,
|
||||||
|
"hitl_dedup_key": next(iter(input_schema.get("required", [])), None),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Created MCP tool (stdio): '{tool_name}'")
|
logger.info(f"Created MCP tool (stdio): '{tool_name}'")
|
||||||
|
|
@ -152,43 +159,54 @@ async def _create_mcp_tool_from_definition_http(
|
||||||
tool_def: dict[str, Any],
|
tool_def: dict[str, Any],
|
||||||
url: str,
|
url: str,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
|
*,
|
||||||
|
connector_name: str = "",
|
||||||
|
connector_id: int | None = None,
|
||||||
|
trusted_tools: list[str] | None = None,
|
||||||
) -> StructuredTool:
|
) -> StructuredTool:
|
||||||
"""Create a LangChain tool from an MCP tool definition (HTTP transport).
|
"""Create a LangChain tool from an MCP tool definition (HTTP transport).
|
||||||
|
|
||||||
Args:
|
All MCP tools are unconditionally wrapped with HITL approval.
|
||||||
tool_def: Tool definition from MCP server with name, description, input_schema
|
``request_approval()`` is called OUTSIDE the try/except so that
|
||||||
url: URL of the MCP server
|
``GraphInterrupt`` propagates cleanly to LangGraph.
|
||||||
headers: HTTP headers for authentication
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LangChain StructuredTool instance
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tool_name = tool_def.get("name", "unnamed_tool")
|
tool_name = tool_def.get("name", "unnamed_tool")
|
||||||
tool_description = tool_def.get("description", "No description provided")
|
tool_description = tool_def.get("description", "No description provided")
|
||||||
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
|
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
|
||||||
|
|
||||||
# Log the actual schema for debugging
|
|
||||||
logger.info(f"MCP HTTP tool '{tool_name}' input schema: {input_schema}")
|
logger.info(f"MCP HTTP tool '{tool_name}' input schema: {input_schema}")
|
||||||
|
|
||||||
# Create dynamic input model from schema
|
|
||||||
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
|
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
|
||||||
|
|
||||||
async def mcp_http_tool_call(**kwargs) -> str:
|
async def mcp_http_tool_call(**kwargs) -> str:
|
||||||
"""Execute the MCP tool call via HTTP transport."""
|
"""Execute the MCP tool call via HTTP transport."""
|
||||||
logger.info(f"MCP HTTP tool '{tool_name}' called with params: {kwargs}")
|
logger.info(f"MCP HTTP tool '{tool_name}' called with params: {kwargs}")
|
||||||
|
|
||||||
|
# HITL — OUTSIDE try/except so GraphInterrupt propagates to LangGraph
|
||||||
|
hitl_result = request_approval(
|
||||||
|
action_type="mcp_tool_call",
|
||||||
|
tool_name=tool_name,
|
||||||
|
params=kwargs,
|
||||||
|
context={
|
||||||
|
"mcp_server": connector_name,
|
||||||
|
"tool_description": tool_description,
|
||||||
|
"mcp_transport": "http",
|
||||||
|
"mcp_connector_id": connector_id,
|
||||||
|
},
|
||||||
|
trusted_tools=trusted_tools,
|
||||||
|
)
|
||||||
|
if hitl_result.rejected:
|
||||||
|
return "Tool call rejected by user."
|
||||||
|
call_kwargs = hitl_result.params
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with (
|
async with (
|
||||||
streamablehttp_client(url, headers=headers) as (read, write, _),
|
streamablehttp_client(url, headers=headers) as (read, write, _),
|
||||||
ClientSession(read, write) as session,
|
ClientSession(read, write) as session,
|
||||||
):
|
):
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
|
response = await session.call_tool(tool_name, arguments=call_kwargs)
|
||||||
|
|
||||||
# Call the tool
|
|
||||||
response = await session.call_tool(tool_name, arguments=kwargs)
|
|
||||||
|
|
||||||
# Extract content from response
|
|
||||||
result = []
|
result = []
|
||||||
for content in response.content:
|
for content in response.content:
|
||||||
if hasattr(content, "text"):
|
if hasattr(content, "text"):
|
||||||
|
|
@ -209,7 +227,6 @@ async def _create_mcp_tool_from_definition_http(
|
||||||
logger.exception(error_msg)
|
logger.exception(error_msg)
|
||||||
return f"Error: {error_msg}"
|
return f"Error: {error_msg}"
|
||||||
|
|
||||||
# Create StructuredTool
|
|
||||||
tool = StructuredTool(
|
tool = StructuredTool(
|
||||||
name=tool_name,
|
name=tool_name,
|
||||||
description=tool_description,
|
description=tool_description,
|
||||||
|
|
@ -219,6 +236,8 @@ async def _create_mcp_tool_from_definition_http(
|
||||||
"mcp_input_schema": input_schema,
|
"mcp_input_schema": input_schema,
|
||||||
"mcp_transport": "http",
|
"mcp_transport": "http",
|
||||||
"mcp_url": url,
|
"mcp_url": url,
|
||||||
|
"hitl": True,
|
||||||
|
"hitl_dedup_key": next(iter(input_schema.get("required", [])), None),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -230,20 +249,11 @@ async def _load_stdio_mcp_tools(
|
||||||
connector_id: int,
|
connector_id: int,
|
||||||
connector_name: str,
|
connector_name: str,
|
||||||
server_config: dict[str, Any],
|
server_config: dict[str, Any],
|
||||||
|
trusted_tools: list[str] | None = None,
|
||||||
) -> list[StructuredTool]:
|
) -> list[StructuredTool]:
|
||||||
"""Load tools from a stdio-based MCP server.
|
"""Load tools from a stdio-based MCP server."""
|
||||||
|
|
||||||
Args:
|
|
||||||
connector_id: Connector ID for logging
|
|
||||||
connector_name: Connector name for logging
|
|
||||||
server_config: Server configuration with command, args, env
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of tools from the MCP server
|
|
||||||
"""
|
|
||||||
tools: list[StructuredTool] = []
|
tools: list[StructuredTool] = []
|
||||||
|
|
||||||
# Validate required command field
|
|
||||||
command = server_config.get("command")
|
command = server_config.get("command")
|
||||||
if not command or not isinstance(command, str):
|
if not command or not isinstance(command, str):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -251,7 +261,6 @@ async def _load_stdio_mcp_tools(
|
||||||
)
|
)
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# Validate args field (must be list if present)
|
|
||||||
args = server_config.get("args", [])
|
args = server_config.get("args", [])
|
||||||
if not isinstance(args, list):
|
if not isinstance(args, list):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -259,7 +268,6 @@ async def _load_stdio_mcp_tools(
|
||||||
)
|
)
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# Validate env field (must be dict if present)
|
|
||||||
env = server_config.get("env", {})
|
env = server_config.get("env", {})
|
||||||
if not isinstance(env, dict):
|
if not isinstance(env, dict):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -267,10 +275,8 @@ async def _load_stdio_mcp_tools(
|
||||||
)
|
)
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# Create MCP client
|
|
||||||
mcp_client = MCPClient(command, args, env)
|
mcp_client = MCPClient(command, args, env)
|
||||||
|
|
||||||
# Connect and discover tools
|
|
||||||
async with mcp_client.connect():
|
async with mcp_client.connect():
|
||||||
tool_definitions = await mcp_client.list_tools()
|
tool_definitions = await mcp_client.list_tools()
|
||||||
|
|
||||||
|
|
@ -279,10 +285,15 @@ async def _load_stdio_mcp_tools(
|
||||||
f"'{command}' (connector {connector_id})"
|
f"'{command}' (connector {connector_id})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create LangChain tools from definitions
|
|
||||||
for tool_def in tool_definitions:
|
for tool_def in tool_definitions:
|
||||||
try:
|
try:
|
||||||
tool = await _create_mcp_tool_from_definition_stdio(tool_def, mcp_client)
|
tool = await _create_mcp_tool_from_definition_stdio(
|
||||||
|
tool_def,
|
||||||
|
mcp_client,
|
||||||
|
connector_name=connector_name,
|
||||||
|
connector_id=connector_id,
|
||||||
|
trusted_tools=trusted_tools,
|
||||||
|
)
|
||||||
tools.append(tool)
|
tools.append(tool)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
@ -297,20 +308,11 @@ async def _load_http_mcp_tools(
|
||||||
connector_id: int,
|
connector_id: int,
|
||||||
connector_name: str,
|
connector_name: str,
|
||||||
server_config: dict[str, Any],
|
server_config: dict[str, Any],
|
||||||
|
trusted_tools: list[str] | None = None,
|
||||||
) -> list[StructuredTool]:
|
) -> list[StructuredTool]:
|
||||||
"""Load tools from an HTTP-based MCP server.
|
"""Load tools from an HTTP-based MCP server."""
|
||||||
|
|
||||||
Args:
|
|
||||||
connector_id: Connector ID for logging
|
|
||||||
connector_name: Connector name for logging
|
|
||||||
server_config: Server configuration with url, headers
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of tools from the MCP server
|
|
||||||
"""
|
|
||||||
tools: list[StructuredTool] = []
|
tools: list[StructuredTool] = []
|
||||||
|
|
||||||
# Validate required url field
|
|
||||||
url = server_config.get("url")
|
url = server_config.get("url")
|
||||||
if not url or not isinstance(url, str):
|
if not url or not isinstance(url, str):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -318,7 +320,6 @@ async def _load_http_mcp_tools(
|
||||||
)
|
)
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# Validate headers field (must be dict if present)
|
|
||||||
headers = server_config.get("headers", {})
|
headers = server_config.get("headers", {})
|
||||||
if not isinstance(headers, dict):
|
if not isinstance(headers, dict):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -326,7 +327,6 @@ async def _load_http_mcp_tools(
|
||||||
)
|
)
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# Connect and discover tools via HTTP
|
|
||||||
try:
|
try:
|
||||||
async with (
|
async with (
|
||||||
streamablehttp_client(url, headers=headers) as (read, write, _),
|
streamablehttp_client(url, headers=headers) as (read, write, _),
|
||||||
|
|
@ -334,7 +334,6 @@ async def _load_http_mcp_tools(
|
||||||
):
|
):
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
|
|
||||||
# List available tools
|
|
||||||
response = await session.list_tools()
|
response = await session.list_tools()
|
||||||
tool_definitions = []
|
tool_definitions = []
|
||||||
for tool in response.tools:
|
for tool in response.tools:
|
||||||
|
|
@ -353,11 +352,15 @@ async def _load_http_mcp_tools(
|
||||||
f"'{url}' (connector {connector_id})"
|
f"'{url}' (connector {connector_id})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create LangChain tools from definitions
|
|
||||||
for tool_def in tool_definitions:
|
for tool_def in tool_definitions:
|
||||||
try:
|
try:
|
||||||
tool = await _create_mcp_tool_from_definition_http(
|
tool = await _create_mcp_tool_from_definition_http(
|
||||||
tool_def, url, headers
|
tool_def,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
connector_name=connector_name,
|
||||||
|
connector_id=connector_id,
|
||||||
|
trusted_tools=trusted_tools,
|
||||||
)
|
)
|
||||||
tools.append(tool)
|
tools.append(tool)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -398,14 +401,6 @@ async def load_mcp_tools(
|
||||||
|
|
||||||
Results are cached per search space for up to 5 minutes to avoid
|
Results are cached per search space for up to 5 minutes to avoid
|
||||||
re-spawning MCP server processes on every chat message.
|
re-spawning MCP server processes on every chat message.
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Database session
|
|
||||||
search_space_id: User's search space ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of LangChain StructuredTool instances
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
_evict_expired_mcp_cache()
|
_evict_expired_mcp_cache()
|
||||||
|
|
||||||
|
|
@ -436,6 +431,7 @@ async def load_mcp_tools(
|
||||||
try:
|
try:
|
||||||
config = connector.config or {}
|
config = connector.config or {}
|
||||||
server_config = config.get("server_config", {})
|
server_config = config.get("server_config", {})
|
||||||
|
trusted_tools = config.get("trusted_tools", [])
|
||||||
|
|
||||||
if not server_config or not isinstance(server_config, dict):
|
if not server_config or not isinstance(server_config, dict):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -447,11 +443,17 @@ async def load_mcp_tools(
|
||||||
|
|
||||||
if transport in ("streamable-http", "http", "sse"):
|
if transport in ("streamable-http", "http", "sse"):
|
||||||
connector_tools = await _load_http_mcp_tools(
|
connector_tools = await _load_http_mcp_tools(
|
||||||
connector.id, connector.name, server_config
|
connector.id,
|
||||||
|
connector.name,
|
||||||
|
server_config,
|
||||||
|
trusted_tools=trusted_tools,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
connector_tools = await _load_stdio_mcp_tools(
|
connector_tools = await _load_stdio_mcp_tools(
|
||||||
connector.id, connector.name, server_config
|
connector.id,
|
||||||
|
connector.name,
|
||||||
|
server_config,
|
||||||
|
trusted_tools=trusted_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
tools.extend(connector_tools)
|
tools.extend(connector_tools)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue