Skip to content

Add MCP Client Support #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

mconflitti-pbc
Copy link
Contributor

Issue #21

Adds ability to register an SSE or stdio MCP server with any Chat provider.

Tests provide contained examples of these MCP servers that the tests interact with to register tools remotely.

Slightly modifies how tools are created: Tool.from_func (maintains existing behavior) and Tool.from_mcp static methods.

The use of MCP does mean that Python 3.9 support would be dropped. If this is a major problem, happy to brainstorm ways to make MCP an extra and conditionally import it when needing it.

TODOs:

  • More docs
  • Updating Readme(s)
  • Verify tests

@wch
Copy link

wch commented Feb 5, 2025

It's great to see MCP support for Chatlas. A few thoughts about dependencies and requirements: The mcp dependency could be conditional on Python version, like "mcp>=1.2.1;python_version>='3.10'", and then the imports could be done conditional on Python version. But it might also make sense to make mcp an optional dependency, in a group named mcp.

@mconflitti-pbc
Copy link
Contributor Author

It's great to see MCP support for Chatlas. A few thoughts about dependencies and requirements: The mcp dependency could be conditional on Python version, like "mcp>=1.2.1;python_version>='3.10'", and then the imports could be done conditional on Python version. But it might also make sense to make mcp an optional dependency, in a group named mcp.

Sure thing! Out of curiosity, is there something that would require us to keep py3.9 support? I suppose that would be considered a breaking change.

@schloerke
Copy link
Contributor

schloerke commented Feb 5, 2025

Out of curiosity, is there something that would require us to keep py3.9 support?

Posit Open Source tries to support all python versions that are receiving security updates. Py3.9 will end its support in Oct '25.

Similarly, given the mcp is a non-critical code path, the mcp requirements shouldn't hinder the core functionality from running on an earlier python versions.

@cpsievert
Copy link
Collaborator

Overall, this is looking great, thank you! I think we'll have some additional suggestions for code changes/improvements, but also excited to see what you come up with for docs.

Comment on lines +64 to +65
@classmethod
def from_func(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to have this as a subclass to the Tool class.

I'd also like to restructure the Tool class to be an ABC class. Then ToolFunction, ToolMcp, ToolMcpSse, and ToolMcpStdio would all spawn from Tool.


  • Tool
    • ToolFunction
    • ToolMcp
      • ToolMcpSse
      • ToolMcpStdio

I'm happy to implement this early next week.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure this is necessary at this point? Unless I am missing something, converging into a single tool API seems to be sufficient.

Also, curious what we gain by this abstraction:

  • ToolMcp
    • ToolMcpSse
    • ToolMcpStdio

url: str,
include_tools: Optional[list[str]] = None,
exclude_tools: Optional[list[str]] = None,
transport_kwargs: dict[str, Any] = {},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way we can support typing on these arguments?

Suggested change
transport_kwargs: dict[str, Any] = {},
kwargs: dict[str, Any] = {},

env: dict[str, str] | None = None,
include_tools: Optional[list[str]] = None,
exclude_tools: Optional[list[str]] = None,
transport_kwargs: dict[str, Any] = {},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way we can support typing on these arguments?

Suggested change
transport_kwargs: dict[str, Any] = {},
kwargs: dict[str, Any] = {},

@mconflitti-pbc
Copy link
Contributor Author

Echoing my comment here that was made in ellmer's repo:

After working on supporting this in chatlas, there is a question raised in my mind of the value of this being owned within chatlas (and by extension ellmer): Does it make sense to be contained within these packages and add the overhead of maintaining compliance with the changes of the protocol?

It could make just as much sense to provide examples of building out what a simple MCP Client looks like using chatlas or ellmer that demonstrate how to setup the MCP context and register the tools from the desired MCP server. That is demonstrated here.

I am not aware of any R-based MCP libraries either that would provide what is needed for the protocol to be used with ellmer, so maybe that is the place to start external to this library. Then, add some MCP examples to this repo that show how to use that MCP library with ellmer.

Curious for your thoughts @cpsievert @schloerke @wch @gadenbuie

Comment on lines +27 to +31
from mcp import (
ClientSession as MCPClientSession,
)
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these would have to be run time imports given mcp is an optional dependency

@cpsievert
Copy link
Collaborator

cpsievert commented Apr 10, 2025

Does it make sense to be contained within these packages and add the overhead of maintaining compliance with the changes of the protocol?

Looking at things from purely a Python perspective, I'm OK with the additional overhead. It doesn't seem like we're exposing ourselves to a large/complex API dependency, and I'd be fine making breaking changes if and when mcp does.

Obviously, it'd be a bigger lift for ellmer, so I don't want to speak on it's behalf, but I'll discuss with Hadley/Joe.

It could make just as much sense to provide examples of building out what a simple MCP Client looks like

I'm somewhat open to this, but it's also currently reaching into private APIs, and even with more public APIs it seems fairly non-trivial to follow what's happening. Maybe with some tweaks to chatlas we could make it simpler, but I also feel like if we're gonna officially document something, it's probably worth going all the way with an official wrapper around mcp.

so maybe that is the place to start external to this library.

I agree, I'd prefer that we make a decision the R side first before making any official moves here.

@mconflitti-pbc
Copy link
Contributor Author

I also feel like if we're gonna officially document something, it's probably worth going all the way with an official wrapper around mcp.

That is fair. Maybe we take a different approach then. Something that consumes the Chat object rather than it being contained within it. Then the MCP code can be siloed into its own submodule and not cause issues if the optional mcp dep is not installed. There is state needing to be maintained for the server connections/comms that are currently living in the Chat object which makes optional imports more problematic. I think instead, we make Chat a lower level concept than the MCP client that the client can consume or even spin up on its own if required. Just a thought.

from chatlas import mcp, Chat

chat = Chat()

mcp_client = mcp.Client("server path/url")
mcp_client.register_tools(chat)

@vnijs
Copy link

vnijs commented May 11, 2025

Very interested in this feature. Any chance you have a timeline?

@vnijs
Copy link

vnijs commented May 18, 2025

@mconflitti-pbc Was able to test out your PR. Seems to be working as intended. Thanks!
I forked the PR to https://github.com/vnijs/chatlas-mcp

import os
import sys
import requests
from shiny import App, ui
from pydantic import BaseModel, Field
from chatlas import ChatOpenAI
from dotenv import load_dotenv

load_dotenv()


class GetCurrentTemperature(BaseModel):
    """
    Get the current weather given a latitude and longitude.
    You MUST provide both latitude and longitude as numbers.
    If the user provides a location name (not latitude/longitude),
    you MUST first call the 'get_lat_long' tool to obtain the coordinates
    then call this tool with those coordinates.
    """

    latitude: float = Field(
        description="The latitude of the location. Must be a float."
    )
    longitude: float = Field(
        description="The longitude of the location. Must be a float."
    )


def get_current_temperature(latitude: float, longitude: float) -> dict:
    lat_lng = f"latitude={latitude}&longitude={longitude}"
    url = f"https://api.open-meteo.com/v1/forecast?{lat_lng}&current=temperature_2m,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m"
    response = requests.get(url)
    json = response.json()
    return json["current"]


class GetLatLong(BaseModel):
    """
    Use this tool to get the latitude and longitude for a location name provided by the user.
    If the user asks for weather and only provides a location name, call this tool first to get coordinates.
    """

    location: str = Field(
        description="The location name to get latitude and longitude for. Must be a string."
    )


def get_lat_long(location: str) -> dict:
    url = f"https://geocode.maps.co/search?q='{location}'&api_key={os.getenv('GEO_API_KEY')}"
    response = requests.get(url)
    json = response.json()
    if len(json) == 0:
        raise ValueError(
            f"Could not find location: {location}. Try to determine the location from the LLM response and call this tool again with the new location."
        )
    else:
        return {"latitude": float(json[0]["lat"]), "longitude": float(json[0]["lon"])}


app_ui = ui.page_fluid(ui.panel_title("Weather Assistant"), ui.chat_ui(id="my_chat"))


def server(input, output, session):
    chat = ui.Chat(id="my_chat")
    chat_client = ChatOpenAI(
        model="gpt-4o",
        api_key=os.getenv("OPENAI_API_KEY"),
    )

    if hasattr(chat_client, "register_mcp_stdio_server_async"):
        # register MCP server at session start
        # this requires that the development version of chatlas with
        # MCP client support is installed using the following command:
        # uv add git+https://github.com/vnijs/chatlas-mcp
        @session.on_flush
        async def setup():
            await chat_client.register_mcp_stdio_server_async(
                name="weather-mcp",
                command=sys.executable,
                args=["2e-mcp-llama-server.py"],
            )
    else:
        # if you don't have development version of chatlas with
        # MCP client support installed you can use the following to
        # register the tools
        chat_client.register_tool(get_lat_long, model=GetLatLong)
        chat_client.register_tool(get_current_temperature, model=GetCurrentTemperature)

    @chat.on_user_submit
    async def handle_user_input(user_input: str):
        response = await chat_client.stream_async(user_input)
        await chat.append_message_stream(response)


app = App(app_ui, server)

MCP server:

import os
import requests
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv

load_dotenv()

mcp = FastMCP("weather-mcp")


@mcp.tool()
def get_current_temperature(latitude: float, longitude: float) -> dict:
    """
    Get the current weather given a latitude and longitude.
    You MUST provide both latitude and longitude as numbers.
    If the user provides a location name (not latitude/longitude),
    you MUST first call the 'get_lat_long' tool to obtain the coordinates
    then call this tool with those coordinates."

    Parameters
    ----------
    latitude : float
        The latitude of the location.
    longitude : float
        The longitude of the location.
    """
    lat_lng = f"latitude={latitude}&longitude={longitude}"
    url = f"https://api.open-meteo.com/v1/forecast?{lat_lng}&current=temperature_2m,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m"
    response = requests.get(url)
    json = response.json()
    return json["current"]


@mcp.tool()
def get_lat_long(location: str) -> dict:
    """
    Use this tool to get the latitude and longitude for a location name provided by the user.
    If the user asks for weather or temperature and only provides a location name, call this tool first to get coordinates.
    If the location is not found, raise a ValueError with a message indicating the location was not found and
    then try to determine the location from the LLM response and call this tool again with the new location.

    Example
    -------
    >>> get_lat_long("La Jolla, California")
    {'latitude': 32.8801, 'longitude': -117.2528}

    Parameters
    ----------
    location : str
        The location to get the lat and long for.
    """
    url = f"https://geocode.maps.co/search?q='{location}'&api_key={os.getenv('GEO_API_KEY')}"
    response = requests.get(url)
    json = response.json()
    if len(json) == 0:
        raise ValueError(
            f"Could not find location: {location}. Try to determine the location from the LLM response and call this tool again with the new location."
        )
    else:
        return {"latitude": float(json[0]["lat"]), "longitude": float(json[0]["lon"])}


if __name__ == "__main__":
    mcp.run(transport="stdio")

# if your venv is setup correctly, you can run this with:
# uv run mcp dev 2e-mcp-llama-server.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants