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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ dist/
.venv/
.env

# Chainlit (generated at runtime by mcp-agent-web)
.chainlit/
chainlit.md

# Tooling caches
.mypy_cache/
.pytest_cache/
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ server.
route for k8s probes.
- **`packages/mcp-cli`** — Typer/rich client (`mcp-cli`) to list and call
tools on a running service.
- **`packages/mcp-agent`** — example chat agent (`mcp-agent`) that discovers
every server behind an index URL and drives their tools with a Mistral
model.
- **`toolsets/*`** — one directory per toolset; each becomes an MCP service.
- **`charts/mcp-toolset`** — generic Helm chart all toolsets deploy through.

Expand Down Expand Up @@ -245,6 +248,21 @@ connections = httpx.get("https://<host>/").json()["connections"]
tools = await MultiServerMCPClient(connections).get_tools()
```

`packages/mcp-agent` does exactly that as an interactive chat (Mistral as the
LLM; `MISTRAL_API_KEY` is read from the environment or a `.env` file in the
working directory):

```sh
uv run mcp-agent https://<host>/ # all deployed toolsets
uv run mcp-agent http://localhost:8000/mcp # or one local mcp-serve
uv run mcp-agent https://<host>/ --model mistral-large-latest
```

The same agent is available as a Chainlit chat UI: `uv run mcp-agent-web`
serves it at `http://localhost:8080`, configured entirely from the
environment/.env — `MCP_URL` (which index or server to chat with),
`MISTRAL_MODEL` and `CHAINLIT_PORT`.

Each Helm release owns its own Ingress for the same host and the controller
merges them, so the domain's routing table tracks deploys with no central
config to edit; the index's `/` path only catches what no toolset claims.
Expand Down
23 changes: 23 additions & 0 deletions packages/mcp-agent/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[project]
name = "mcp-agent"
version = "0.1.0"
description = "Example chat agent that discovers MCP servers behind an index URL and drives their tools with a Mistral model."
requires-python = ">=3.12,<3.14"
dependencies = [
"chainlit<3.0.0,>=2.11.1",
"httpx<1.0.0,>=0.28.1",
"langchain<2.0.0,>=1.3.7",
"langchain-mcp-adapters<0.4.0,>=0.3.0",
"langchain-mistralai<2.0.0,>=1.1.5",
"pydantic-settings<3.0.0,>=2.12.0",
"rich<16.0.0,>=15.0.0",
"typer<0.27.0,>=0.26.7",
]

[project.scripts]
mcp-agent = "mcp_agent.main:app"
mcp-agent-web = "mcp_agent.web:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Empty file.
145 changes: 145 additions & 0 deletions packages/mcp-agent/src/mcp_agent/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Chat with every toolset behind an mcp-toolsets index URL.

Point ``mcp-agent`` at an index root (anything serving a ``connections`` map
shaped for ``MultiServerMCPClient``) or directly at a single MCP endpoint;
it loads every server's tools and lets a Mistral model drive them in an
interactive chat. Requires ``MISTRAL_API_KEY``.
"""

import asyncio
from typing import Annotated, Any, cast

import httpx
import typer
from langchain.agents import create_agent
from langchain_core.messages import BaseMessage, HumanMessage
from langchain_core.tools import BaseTool
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mistralai import ChatMistralAI
from pydantic import Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
from rich.console import Console
from rich.markdown import Markdown

DEFAULT_MODEL = "mistral-small-latest"
SYSTEM_PROMPT = (
"You are a helpful assistant with tools from one or more MCP toolsets. "
"Use them whenever they can ground your answer; otherwise answer directly."
)

app = typer.Typer(no_args_is_help=True, help=__doc__)
console = Console()


class AgentSettings(BaseSettings):
"""Agent configuration, validated from the environment or a .env file.

The CLI takes the URL and model as arguments; the web UI (``web.py``)
reads ``MCP_URL`` and ``MISTRAL_MODEL`` from here instead.
"""

model_config = SettingsConfigDict(env_file=".env", extra="ignore")

mistral_api_key: SecretStr
mistral_model: str = DEFAULT_MODEL
mcp_url: str = "http://localhost:8000/mcp"
chainlit_port: int = Field(default=8080, ge=1, le=65535)


def connections_from(url: str, payload: Any) -> dict[str, Any]:
"""Extract an index payload's connections map, else treat url as one server."""
if isinstance(payload, dict) and isinstance(payload.get("connections"), dict):
return payload["connections"]
return {"server": {"transport": "streamable_http", "url": url}}


def fetch_connections(url: str) -> dict[str, Any]:
"""Resolve an index or single-server URL to a MultiServerMCPClient config."""
try:
payload = httpx.get(url, follow_redirects=True, timeout=10.0).json()
except (httpx.HTTPError, ValueError):
payload = None
return connections_from(url, payload)


async def build_agent(
url: str, model: str, api_key: SecretStr
) -> tuple[Any, dict[str, Any], list[BaseTool]]:
"""Discover the servers behind ``url`` and build a tool-calling agent."""
connections = fetch_connections(url)
tools = await MultiServerMCPClient(connections).get_tools()
agent = create_agent(
ChatMistralAI(model_name=model, api_key=api_key),
tools,
system_prompt=SYSTEM_PROMPT,
)
return agent, connections, tools


async def run_turn(
agent: Any, messages: list[BaseMessage], text: str
) -> tuple[list[BaseMessage], list[BaseMessage]]:
"""Run one chat turn; return the full history and this turn's new messages."""
state = {"messages": [*messages, HumanMessage(text)]}
result = await agent.ainvoke(cast(Any, state))
history: list[BaseMessage] = result["messages"]
return history, history[len(messages) + 1 :]


async def chat_loop(url: str, model: str, api_key: SecretStr) -> None:
try:
agent, connections, tools = await build_agent(url, model, api_key)
except* (httpx.HTTPError, OSError) as group:
console.print(
f"[red]Could not reach the MCP server(s) behind {url}: "
f"{group.exceptions[0]}[/red]"
)
raise typer.Exit(1) from None

console.print(
f"Connected to [bold]{len(connections)}[/bold] server(s): "
f"{', '.join(connections)}"
)
console.print(f"[dim]{len(tools)} tools: {', '.join(t.name for t in tools)}[/dim]")
console.print("[dim]Type a message, or quit to exit.[/dim]")

messages: list[BaseMessage] = []
while True:
try:
line = console.input("[bold cyan]you>[/bold cyan] ").strip()
except (EOFError, KeyboardInterrupt):
break
if not line:
continue
if line in ("quit", "exit"):
break
try:
messages, new_messages = await run_turn(agent, messages, line)
except Exception as error: # noqa: BLE001 - keep the chat alive
console.print(f"[red]{error}[/red]")
continue
for message in new_messages:
for call in getattr(message, "tool_calls", None) or []:
console.print(f"[dim]→ {call['name']} {call['args']}[/dim]")
console.print(Markdown(str(messages[-1].content)))


@app.command()
def chat(
url: Annotated[
str,
typer.Argument(
help="Index URL serving a connections map, or a single MCP endpoint."
),
],
model: Annotated[str, typer.Option("--model", help="Mistral model.")] = (
DEFAULT_MODEL
),
) -> None:
"""Discover the MCP servers behind URL and chat with their tools."""
try:
settings = AgentSettings()
except ValidationError:
console.print("[red]MISTRAL_API_KEY is not set (environment or .env)[/red]")
raise typer.Exit(1) from None
asyncio.run(chat_loop(url, model, settings.mistral_api_key))
71 changes: 71 additions & 0 deletions packages/mcp-agent/src/mcp_agent/web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Chainlit chat UI over the same agent as the ``mcp-agent`` CLI.

Run locally with ``uv run mcp-agent-web``. Configuration comes from
``AgentSettings`` (environment or .env): ``MISTRAL_API_KEY`` (required),
``MCP_URL`` (index root or single MCP endpoint, default
``http://localhost:8000/mcp``), ``MISTRAL_MODEL`` and ``CHAINLIT_PORT``
(default 8080).
"""

import os
import sys
from pathlib import Path
from typing import Any

import chainlit as cl
from langchain_core.messages import BaseMessage, ToolMessage
from pydantic import ValidationError

from mcp_agent.main import AgentSettings, build_agent, run_turn


@cl.on_chat_start
async def start() -> None:
settings = AgentSettings()
agent, connections, tools = await build_agent(
settings.mcp_url, settings.mistral_model, settings.mistral_api_key
)
cl.user_session.set("agent", agent)
cl.user_session.set("messages", [])
await cl.Message(
f"Connected to **{len(connections)}** server(s) "
f"({', '.join(connections)}) with **{len(tools)}** tools: "
f"{', '.join(tool.name for tool in tools)}."
).send()


@cl.on_message
async def on_message(message: cl.Message) -> None:
agent = cl.user_session.get("agent")
messages: list[BaseMessage] = cl.user_session.get("messages") or []
try:
history, new_messages = await run_turn(agent, messages, message.content)
except Exception as error: # noqa: BLE001 - surface in the UI, keep chatting
await cl.Message(f"Error: {error}").send()
return
cl.user_session.set("messages", history)

tool_outputs: dict[str, Any] = {
msg.tool_call_id: msg.content
for msg in new_messages
if isinstance(msg, ToolMessage)
}
for msg in new_messages:
for call in getattr(msg, "tool_calls", None) or []:
async with cl.Step(name=call["name"]) as step:
step.input = call["args"]
step.output = str(tool_outputs.get(call["id"], ""))
await cl.Message(str(history[-1].content)).send()


def main() -> None:
"""Console entry point (``mcp-agent-web``)."""
from chainlit.cli import run_chainlit

try:
settings = AgentSettings()
except ValidationError:
print("MISTRAL_API_KEY is not set (environment or .env)", file=sys.stderr)
raise SystemExit(1) from None
os.environ["CHAINLIT_PORT"] = str(settings.chainlit_port)
run_chainlit(str(Path(__file__).resolve()))
48 changes: 48 additions & 0 deletions packages/mcp-agent/tests/test_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import pytest
from pydantic import ValidationError

from mcp_agent.main import AgentSettings, connections_from


def test_settings_from_env(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "sk-test")
settings = AgentSettings(_env_file=None)
assert settings.mistral_api_key.get_secret_value() == "sk-test"


def test_settings_from_dotenv(monkeypatch, tmp_path):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
env_file = tmp_path / ".env"
env_file.write_text("MISTRAL_API_KEY=sk-dotenv\nUNRELATED=ignored\n")
settings = AgentSettings(_env_file=env_file)
assert settings.mistral_api_key.get_secret_value() == "sk-dotenv"


def test_settings_require_key(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
with pytest.raises(ValidationError):
AgentSettings(_env_file=None)


def test_connections_from_index_payload():
payload = {
"connections": {
"dataset-search": {
"transport": "streamable_http",
"url": "https://mcp.example.com/dataset-search/mcp",
}
},
"toolsets": [],
}
assert (
connections_from("https://mcp.example.com/", payload)
== (payload["connections"])
)


def test_connections_from_non_index_payload_wraps_url():
expected = {
"server": {"transport": "streamable_http", "url": "http://localhost:8000/mcp"}
}
assert connections_from("http://localhost:8000/mcp", None) == expected
assert connections_from("http://localhost:8000/mcp", {"status": "ok"}) == expected
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requires-python = ">=3.12,<3.14"
dependencies = [
"mcp-runtime",
"mcp-cli",
"mcp-agent",
"dataset-search",
"aoi-generator",
]
Expand All @@ -28,6 +29,7 @@ members = ["packages/*", "toolsets/*"]
[tool.uv.sources]
mcp-runtime = { workspace = true }
mcp-cli = { workspace = true }
mcp-agent = { workspace = true }
dataset-search = { workspace = true }
aoi-generator = { workspace = true }

Expand Down
Loading
Loading