Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to the HelpingAI Python SDK will be documented in this file.

## [1.2.0] - 2025-07-19

### Added
- **🔌 MCP Integration**: Full [Model context Protocol (MCP)](docs/mcp_integration.md) support for external tool connections
- **🖥️ Multiple Transport Types**: Support for stdio, SSE, and streamable-http MCP servers
- **🔄 Automatic Tool Discovery**: MCP tools automatically converted to OpenAI-compatible format
- **📁 Resource Support**: Built-in `list_resources` and `read_resource` tools for MCP resources
- **🔀 Mixed Tools Support**: Seamlessly combine MCP servers with regular OpenAI-format tools
- **⚡ Process Management**: Automatic cleanup of MCP server processes on exit
- **🔁 Reconnection Logic**: Handles server disconnections automatically
- **🛡️ Graceful Error Handling**: Works without MCP package installed with helpful error messages
- **📦 Optional MCP Dependency**: Install with `pip install HelpingAI[mcp]` for MCP features
- New MCP integration documentation and examples

### Enhanced
- **🛠️ Extended Tools Compatibility**: Enhanced tools framework to support MCP server configurations
- **🌐 Popular MCP Servers**: Ready support for mcp-server-time, mcp-server-fetch, mcp-server-filesystem, and more
- **🏗️ Backward Compatibility**: Fully backward compatible with no breaking changes to existing functionality

## [1.1.3] - 2025-07-18

### Added
Expand Down
1 change: 1 addition & 0 deletions HelpingAI/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Fn class: Represent callable functions with metadata
- get_tools(): Get registered tools (preferred over get_tools_format)
- get_registry(): Access the tool registry for advanced management
- MCP integration: Support for Multi-Channel Protocol servers
"""

from .core import Fn, tools, get_tools, get_tools_format, clear_registry, get_registry
Expand Down
44 changes: 43 additions & 1 deletion HelpingAI/tools/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,35 @@ def _convert_fns_to_tools(fns: Optional[List[Fn]]) -> List[Dict[str, Any]]:
return tools


def _handle_mcp_servers_config(mcp_config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Handle MCP servers configuration and return tools in OpenAI format.

Args:
mcp_config: MCP servers configuration dictionary

Returns:
List of tools in OpenAI format from MCP servers

Raises:
ImportError: If MCP dependencies are not available
ValueError: If MCP configuration is invalid
"""
try:
from .mcp_manager import MCPManager
except ImportError as e:
raise ImportError(
'MCP functionality requires the `mcp` package. '
'Install it with `pip install -U mcp`.'
) from e

# Initialize MCP manager and get tools
manager = MCPManager()
mcp_tools = manager.init_config(mcp_config)

# Convert to OpenAI format
return _convert_fns_to_tools(mcp_tools)


def ensure_openai_format(tools: Optional[Union[List[Dict[str, Any]], List[Fn], str]]) -> Optional[List[Dict[str, Any]]]:
"""Ensure tools are in OpenAI format regardless of input type.

Expand All @@ -368,7 +397,7 @@ def ensure_openai_format(tools: Optional[Union[List[Dict[str, Any]], List[Fn], s
tools: Tools in various formats:
- None: No tools
- str: Category name to get from registry
- List[Dict]: Already in OpenAI format
- List[Dict]: Already in OpenAI format, or MCP servers config
- List[Fn]: Fn objects to convert

Returns:
Expand All @@ -392,6 +421,19 @@ def ensure_openai_format(tools: Optional[Union[List[Dict[str, Any]], List[Fn], s

first_item = tools[0]

# Check for MCP servers configuration
if isinstance(first_item, dict) and "mcpServers" in first_item:
# Handle MCP servers configuration
all_tools = []
for config_item in tools:
if "mcpServers" in config_item:
mcp_tools = _handle_mcp_servers_config(config_item)
all_tools.extend(mcp_tools)
elif "type" in config_item:
# Regular OpenAI tool format
all_tools.append(config_item)
return all_tools

# Already in OpenAI format
if isinstance(first_item, dict) and "type" in first_item:
return tools
Expand Down
215 changes: 215 additions & 0 deletions HelpingAI/tools/mcp_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""MCP client implementation for HelpingAI SDK.

This module provides the MCPClient class for connecting to and interacting with
MCP servers using various transport methods (stdio, SSE, streamable-http).
"""

import asyncio
import datetime
import json
import threading
import uuid
from contextlib import AsyncExitStack
from typing import Dict, List, Optional, Union, Any

from ..error import HAIError
from .errors import ToolExecutionError


class MCPClient:
"""Client for connecting to and interacting with MCP servers."""

def __init__(self):
try:
from mcp import ClientSession
except ImportError as e:
raise ImportError(
'Could not import mcp. Please install mcp with `pip install -U mcp`.'
) from e

self.session: Optional[ClientSession] = None
self.tools: List = []
self.exit_stack = AsyncExitStack()
self.resources: bool = False
self._last_mcp_server_name = None
self._last_mcp_server = None
self.client_id = None

async def connect_server(self, mcp_server_name: str, mcp_server: Dict[str, Any]):
"""Connect to an MCP server and retrieve the available tools.

Args:
mcp_server_name: Name identifier for the MCP server
mcp_server: Server configuration dictionary

Raises:
HAIError: If connection fails
"""
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client

# Save parameters for reconnection
self._last_mcp_server_name = mcp_server_name
self._last_mcp_server = mcp_server

try:
if 'url' in mcp_server:
# HTTP-based connection (SSE or streamable-http)
url = mcp_server.get('url')
sse_read_timeout = mcp_server.get('sse_read_timeout', 300)

if mcp_server.get('type', 'sse') == 'streamable-http':
# Streamable HTTP mode
self._streams_context = streamablehttp_client(
url=url,
sse_read_timeout=datetime.timedelta(seconds=sse_read_timeout)
)
read_stream, write_stream, get_session_id = await self.exit_stack.enter_async_context(
self._streams_context
)
self._session_context = ClientSession(read_stream, write_stream)
self.session = await self.exit_stack.enter_async_context(self._session_context)
else:
# SSE mode
headers = mcp_server.get('headers', {'Accept': 'text/event-stream'})
self._streams_context = sse_client(url, headers, sse_read_timeout=sse_read_timeout)
streams = await self.exit_stack.enter_async_context(self._streams_context)
self._session_context = ClientSession(*streams)
self.session = await self.exit_stack.enter_async_context(self._session_context)
else:
# Stdio-based connection
server_params = StdioServerParameters(
command=mcp_server['command'],
args=mcp_server['args'],
env=mcp_server.get('env', None)
)
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
ClientSession(self.stdio, self.write)
)

# Initialize session and get tools
await self.session.initialize()
list_tools = await self.session.list_tools()
self.tools = list_tools.tools

# Check for resources
try:
list_resources = await self.session.list_resources()
if list_resources.resources:
self.resources = True
except Exception:
pass # No resources available

except Exception as e:
raise HAIError(f'Failed to connect to MCP server {mcp_server_name}: {e}') from e

async def reconnect(self):
"""Reconnect to the MCP server.

Returns:
New MCPClient instance with the same configuration
"""
if self.client_id is None:
raise RuntimeError(
'Cannot reconnect: client_id is None. '
'This usually means the client was not properly registered.'
)

new_client = MCPClient()
new_client.client_id = self.client_id
await new_client.connect_server(self._last_mcp_server_name, self._last_mcp_server)
return new_client

async def execute_function(self, tool_name: str, tool_args: Dict[str, Any]) -> str:
"""Execute a tool function on the MCP server.

Args:
tool_name: Name of the tool to execute
tool_args: Arguments for the tool

Returns:
Tool execution result as string

Raises:
ToolExecutionError: If tool execution fails
"""
from mcp.types import TextResourceContents

# Check if session is alive
try:
await self.session.send_ping()
except Exception as e:
# Attempt to reconnect
try:
from .mcp_manager import MCPManager
manager = MCPManager()
if self.client_id is not None:
manager.clients[self.client_id] = await self.reconnect()
return await manager.clients[self.client_id].execute_function(tool_name, tool_args)
else:
raise ToolExecutionError(
f"Session reconnect failed: client_id is None",
tool_name=tool_name
)
except Exception as e3:
raise ToolExecutionError(
f"Session reconnect failed: {e3}",
tool_name=tool_name,
original_error=e3
)

try:
if tool_name == 'list_resources':
list_resources = await self.session.list_resources()
if list_resources.resources:
resources_str = '\n\n'.join(str(resource) for resource in list_resources.resources)
else:
resources_str = 'No resources found'
return resources_str

elif tool_name == 'read_resource':
uri = tool_args.get('uri')
if not uri:
raise ValueError('URI is required for read_resource')

read_resource = await self.session.read_resource(uri)
texts = []
for resource in read_resource.contents:
if isinstance(resource, TextResourceContents):
texts.append(resource.text)

if texts:
return '\n\n'.join(texts)
else:
return 'Failed to read resource'

else:
# Execute regular tool
response = await self.session.call_tool(tool_name, tool_args)
texts = []
for content in response.content:
if content.type == 'text':
texts.append(content.text)

if texts:
return '\n\n'.join(texts)
else:
return 'Tool execution completed with no text output'

except Exception as e:
raise ToolExecutionError(
f"Failed to execute tool '{tool_name}': {e}",
tool_name=tool_name,
original_error=e
)

async def cleanup(self):
"""Clean up client resources."""
try:
await self.exit_stack.aclose()
except Exception:
pass # Ignore cleanup errors
Loading