vibe/vibe/core/tools/mcp/tools.py
Clément Drouin 6bedf271ce
v2.17.0 (#822)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Hdandria <henri.dandria@mistral.ai>
Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai>
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-06-19 11:01:24 +02:00

540 lines
19 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable
import contextlib
from dataclasses import dataclass
from datetime import timedelta
import hashlib
import os
from pathlib import Path
import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
import httpx
from pydantic import BaseModel, ConfigDict, Field, field_validator
from mcp import ClientSession
from mcp.client.auth import OAuthFlowError
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from vibe.core.logger import logger
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
)
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
from vibe.core.utils.http import build_ssl_context
from vibe.core.utils.io import decode_safe
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
# Mirrors MCP's default Streamable HTTP timeout values while avoiding an import from
# mcp.shared._httpx_utils, which is an internal module.
_MCP_DEFAULT_TIMEOUT = 30.0
_MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0
def _stderr_logger_thread(read_fd: int) -> None:
with open(read_fd, "rb") as f:
for line in iter(f.readline, b""):
decoded = decode_safe(line, from_subprocess=True).text.rstrip()
if decoded:
logger.debug(f"[MCP stderr] {decoded}")
@contextlib.asynccontextmanager
async def _mcp_stderr_capture() -> AsyncGenerator[TextIO, None]:
r, w = os.pipe()
errlog = None
thread_started = False
try:
thread = threading.Thread(target=_stderr_logger_thread, args=(r,), daemon=True)
thread.start()
thread_started = True
errlog = os.fdopen(w, "w")
yield errlog
finally:
if errlog is not None:
errlog.close()
elif thread_started:
os.close(w)
else:
os.close(r)
os.close(w)
class _OpenArgs(BaseModel):
model_config = ConfigDict(extra="allow")
class MCPToolResult(BaseModel):
ok: bool = True
server: str
tool: str
text: str | None = None
structured: dict[str, Any] | None = None
class MCPTool(
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
ToolUIData[_OpenArgs, MCPToolResult],
):
_server_name: ClassVar[str] = ""
_remote_name: ClassVar[str] = ""
_is_connector: ClassVar[bool] = False
@classmethod
def get_server_name(cls) -> str | None:
return cls._server_name or None
@classmethod
def get_remote_name(cls) -> str:
return cls._remote_name or cls.get_name()
@classmethod
def is_connector(cls) -> bool:
return cls._is_connector
class RemoteTool(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
description: str | None = None
input_schema: dict[str, Any] = Field(
default_factory=lambda: {"type": "object", "properties": {}},
validation_alias="inputSchema",
)
@field_validator("name")
@classmethod
def _non_empty_name(cls, v: str) -> str:
if not isinstance(v, str) or not v.strip():
raise ValueError("MCP tool missing valid 'name'")
return v
@field_validator("input_schema", mode="before")
@classmethod
def _normalize_schema(cls, v: Any) -> dict[str, Any]:
if v is None:
return {"type": "object", "properties": {}}
if isinstance(v, dict):
return v
dump = getattr(v, "model_dump", None)
if callable(dump):
try:
v = dump()
except Exception:
raise ValueError(
"inputSchema must be a dict or have a valid model_dump method"
)
if not isinstance(v, dict):
raise ValueError("inputSchema must be a dict")
return v
@dataclass(frozen=True)
class MCPHttpOAuthRuntime:
lock: asyncio.Lock
failure_callback: Callable[[str], Awaitable[None]]
class _MCPContentBlock(BaseModel):
model_config = ConfigDict(from_attributes=True)
text: str | None = None
class _MCPResultIn(BaseModel):
model_config = ConfigDict(from_attributes=True)
structuredContent: dict[str, Any] | None = None
content: list[_MCPContentBlock] | None = None
@field_validator("structuredContent", mode="before")
@classmethod
def _normalize_structured(cls, v: Any) -> dict[str, Any] | None:
if v is None:
return None
if isinstance(v, dict):
return v
dump = getattr(v, "model_dump", None)
if callable(dump):
try:
v = dump()
except Exception:
return None
return v if isinstance(v, dict) else None
def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult:
parsed = _MCPResultIn.model_validate(result_obj)
if (structured := parsed.structuredContent) is not None:
return MCPToolResult(server=server, tool=tool, text=None, structured=structured)
blocks = parsed.content or []
parts = [b.text for b in blocks if isinstance(b.text, str)]
text = "\n".join(parts) if parts else None
return MCPToolResult(server=server, tool=tool, text=text, structured=None)
def create_vibe_mcp_http_client(
headers: dict[str, str] | None, *, auth: httpx.Auth | None = None
) -> httpx.AsyncClient:
return httpx.AsyncClient(
follow_redirects=True,
headers=headers,
auth=auth,
timeout=httpx.Timeout(_MCP_DEFAULT_TIMEOUT, read=_MCP_DEFAULT_SSE_READ_TIMEOUT),
verify=build_ssl_context(),
)
async def list_tools_http(
url: str,
*,
headers: dict[str, str] | None = None,
auth: httpx.Auth | None = None,
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with create_vibe_mcp_http_client(headers, auth=auth) as http_client:
async with streamable_http_client(url, http_client=http_client) as (
read,
write,
_,
):
async with ClientSession(
read, write, read_timeout_seconds=timeout
) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_http(
url: str,
tool_name: str,
arguments: dict[str, Any],
*,
headers: dict[str, str] | None = None,
auth: httpx.Auth | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with create_vibe_mcp_http_client(headers, auth=auth) as http_client:
async with streamable_http_client(url, http_client=http_client) as (
read,
write,
_,
):
async with ClientSession(
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
) as session:
await session.initialize()
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result(url, tool_name, result)
def create_mcp_http_proxy_tool_class(
*,
url: str,
remote: RemoteTool,
alias: str | None = None,
server_hint: str | None = None,
headers: dict[str, str] | None = None,
auth: httpx.Auth | None = None,
oauth_runtime: MCPHttpOAuthRuntime | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_enabled: bool = True,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
from urllib.parse import urlparse
def _alias_from_url(url: str) -> str:
p = urlparse(url)
host = (p.hostname or "mcp").replace(".", "_")
port = f"_{p.port}" if p.port else ""
return f"{host}{port}"
computed_alias = alias or _alias_from_url(url)
published_name = f"{computed_alias}_{remote.name}"
class MCPHttpProxyTool(MCPTool):
description: ClassVar[str] = (
(f"[{computed_alias}] " if computed_alias else "")
+ (remote.description or f"MCP tool '{remote.name}' from {url}")
+ (f"\nHint: {server_hint}" if server_hint else "")
)
_server_name: ClassVar[str] = computed_alias
_mcp_url: ClassVar[str] = url
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_headers: ClassVar[dict[str, str]] = dict(headers or {})
_auth: ClassVar[httpx.Auth | None] = auth
_oauth_runtime: ClassVar[MCPHttpOAuthRuntime | None] = oauth_runtime
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
_sampling_enabled: ClassVar[bool] = sampling_enabled
@classmethod
def get_name(cls) -> str:
return published_name
@classmethod
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
async def run(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
sampling_callback = (
ctx.sampling_callback if ctx and self._sampling_enabled else None
)
payload = args.model_dump(exclude_none=True)
if self._oauth_runtime is None:
yield await self._call_remote(payload, sampling_callback)
return
async with self._oauth_runtime.lock:
result = await self._call_remote(payload, sampling_callback)
yield result
except OAuthFlowError as exc:
if self._oauth_runtime is not None:
await self._oauth_runtime.failure_callback(self._server_name)
raise ToolError(
f"MCP server '{self._server_name}' lost authentication. "
"Stop the current turn and ask the user to run "
f"`/mcp login {self._server_name}` to re-authenticate."
) from exc
except Exception as exc:
raise ToolError(f"MCP call failed: {exc}") from exc
@classmethod
async def _call_remote(
cls, payload: dict[str, Any], sampling_callback: MCPSamplingHandler | None
) -> MCPToolResult:
return await call_tool_http(
cls._mcp_url,
cls._remote_name,
payload,
headers=cls._headers,
auth=cls._auth,
startup_timeout_sec=cls._startup_timeout_sec,
tool_timeout_sec=cls._tool_timeout_sec,
sampling_callback=sampling_callback,
)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
return ToolResultDisplay(
success=False,
message=event.error or event.skip_reason or "No result",
)
message = f"MCP tool {event.result.tool} completed"
return ToolResultDisplay(success=event.result.ok, message=message)
@classmethod
def get_status_text(cls) -> str:
return f"Calling MCP tool {remote.name}"
MCPHttpProxyTool.__name__ = f"MCP_{computed_alias}__{remote.name}"
return MCPHttpProxyTool
def build_stdio_params(
command: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None
) -> StdioServerParameters:
return StdioServerParameters(command=command[0], args=command[1:], env=env, cwd=cwd)
async def enter_stdio_session(
stack: contextlib.AsyncExitStack,
params: StdioServerParameters,
*,
init_timeout: timedelta | None,
sampling_callback: MCPSamplingHandler | None = None,
) -> ClientSession:
"""Enter the stderr-capture, stdio_client, and ClientSession contexts on *stack*.
The caller owns ``stack`` and decides when to close it. Returns an initialized
session. The one-shot helpers close the stack immediately; the connection pool
keeps it open for the session lifetime.
"""
errlog = await stack.enter_async_context(_mcp_stderr_capture())
read, write = await stack.enter_async_context(stdio_client(params, errlog=errlog))
session = await stack.enter_async_context(
ClientSession(
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
)
)
await session.initialize()
return session
async def list_tools_stdio(
command: list[str],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
params = build_stdio_params(command, env=env, cwd=cwd)
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with contextlib.AsyncExitStack() as stack:
session = await enter_stdio_session(stack, params, init_timeout=timeout)
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_stdio(
command: list[str],
tool_name: str,
arguments: dict[str, Any],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
params = build_stdio_params(command, env=env, cwd=cwd)
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with contextlib.AsyncExitStack() as stack:
session = await enter_stdio_session(
stack,
params,
init_timeout=init_timeout,
sampling_callback=sampling_callback,
)
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
def create_mcp_stdio_proxy_tool_class(
*,
command: list[str],
remote: RemoteTool,
alias: str | None = None,
server_hint: str | None = None,
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_enabled: bool = True,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
def _alias_from_command(cmd: list[str]) -> str:
prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
digest = hashlib.blake2s(
"\0".join(cmd).encode("utf-8"), digest_size=4
).hexdigest()
return f"{prog}_{digest}"
computed_alias = alias or _alias_from_command(command)
published_name = f"{computed_alias}_{remote.name}"
class MCPStdioProxyTool(MCPTool):
description: ClassVar[str] = (
(f"[{computed_alias}] " if computed_alias else "")
+ (
remote.description
or f"MCP tool '{remote.name}' from stdio command: {' '.join(command)}"
)
+ (f"\nHint: {server_hint}" if server_hint else "")
)
_server_name: ClassVar[str] = computed_alias
_stdio_command: ClassVar[list[str]] = command
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_env: ClassVar[dict[str, str] | None] = env
_cwd: ClassVar[str | None] = cwd
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
_sampling_enabled: ClassVar[bool] = sampling_enabled
@classmethod
def get_name(cls) -> str:
return published_name
@classmethod
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
async def run(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
sampling_callback = (
ctx.sampling_callback if ctx and self._sampling_enabled else None
)
payload = args.model_dump(exclude_none=True)
pool = ctx.mcp_pool if ctx else None
if pool is not None:
yield await pool.call_tool(
command=self._stdio_command,
tool_name=self._remote_name,
arguments=payload,
env=self._env,
cwd=self._cwd,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
return
yield await call_tool_stdio(
self._stdio_command,
self._remote_name,
payload,
env=self._env,
cwd=self._cwd,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
except Exception as exc:
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
return ToolResultDisplay(
success=False,
message=event.error or event.skip_reason or "No result",
)
message = f"MCP tool {event.result.tool} completed"
return ToolResultDisplay(success=event.result.ok, message=message)
@classmethod
def get_status_text(cls) -> str:
return f"Calling MCP tool {remote.name}"
MCPStdioProxyTool.__name__ = f"MCP_STDIO_{computed_alias}__{remote.name}"
return MCPStdioProxyTool