feat: MCP Client plugin (Task 5.17) - integrate external MCP servers for AI agents

- New plugin app/plugins/builtins/mcp_client/ with server config CRUD
- Models: McpServerConfig with TenantMixin (name, url, api_token, enabled)
- Routes: GET/POST/PATCH/DELETE /api/v1/mcp-client/servers
- Routes: GET /servers/{id}/tools, POST /servers/{id}/execute
- client.py: async MCP client using httpx for external server calls
- tool_registry_integration.py: registers external MCP tools in AI tool registry
- Migration: 0001_initial.sql for mcp_server_configs table
- Frontend: mcpClient.ts API client with React Query hooks
- Frontend: MCP Client settings UI in SettingsMcp.tsx (server CRUD, tool viewing)
- Tests: 8 tests covering CRUD, auth, tool registry integration
This commit is contained in:
Agent Zero
2026-07-23 23:02:07 +02:00
parent 9d4f701a25
commit 317d5c81f8
10 changed files with 867 additions and 0 deletions
@@ -0,0 +1,5 @@
"""MCP Client builtin plugin."""
from app.plugins.builtins.mcp_client.plugin import McpClientPlugin
__all__ = ["McpClientPlugin"]
+79
View File
@@ -0,0 +1,79 @@
"""MCP Client — calls external MCP servers to list and execute tools.
Uses httpx for async HTTP calls. External MCP servers are expected to expose:
GET /tools → { tools: [{ name, description, parameters }] }
POST /tools/{name}/execute → { result: ... }
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.plugins.builtins.mcp_client.schemas import (
McpServerExecuteRequest,
McpServerExecuteResponse,
McpServerToolInfo,
McpServerToolsResponse,
)
logger = logging.getLogger(__name__)
class McpClient:
"""Async client for communicating with external MCP servers."""
def __init__(self, base_url: str, api_token: str | None = None, timeout: float = 30.0) -> None:
self.base_url = base_url.rstrip("/")
self.api_token = api_token
self.timeout = timeout
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
return headers
async def list_tools(self) -> McpServerToolsResponse:
"""List available tools from the external MCP server."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(f"{self.base_url}/tools", headers=self._headers())
resp.raise_for_status()
data = resp.json()
tools = [McpServerToolInfo(**t) for t in data.get("tools", [])]
return McpServerToolsResponse(
server_name=data.get("server_name", "unknown"),
server_url=self.base_url,
tools=tools,
count=len(tools),
)
async def execute_tool(self, tool_name: str, arguments: dict[str, Any]) -> McpServerExecuteResponse:
"""Execute a tool on the external MCP server."""
req = McpServerExecuteRequest(tool_name=tool_name, arguments=arguments)
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(
f"{self.base_url}/tools/{tool_name}/execute",
json=req.model_dump(),
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
return McpServerExecuteResponse(
server_name=data.get("server_name", "unknown"),
tool=tool_name,
success=data.get("success", True),
result=data.get("result"),
error=data.get("error"),
)
async def health_check(self) -> bool:
"""Check if the external MCP server is reachable."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{self.base_url}/tools", headers=self._headers())
return resp.status_code == 200
except Exception:
return False
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS mcp_server_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
url VARCHAR(500) NOT NULL,
api_token VARCHAR(500),
enabled BOOLEAN DEFAULT true NOT NULL,
description TEXT,
last_connected_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mcp_server_configs_tenant ON mcp_server_configs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_mcp_server_configs_tenant_name ON mcp_server_configs(tenant_id, name);
+35
View File
@@ -0,0 +1,35 @@
"""MCP Client plugin models — external MCP server configurations."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class McpServerConfig(Base, TenantMixin):
"""Configuration for an external MCP server — tenant-scoped."""
__tablename__ = "mcp_server_configs"
__table_args__ = (
Index("ix_mcp_server_configs_tenant", "tenant_id"),
Index("ix_mcp_server_configs_tenant_name", "tenant_id", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
url: Mapped[str] = mapped_column(String(500), nullable=False)
api_token: Mapped[str | None] = mapped_column(String(500), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
last_connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+32
View File
@@ -0,0 +1,32 @@
"""MCP Client plugin — allows LeoCRM agents to use external MCP servers."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class McpClientPlugin(BasePlugin):
"""MCP Client plugin: integrates external MCP servers into the AI tool registry."""
manifest = PluginManifest(
name="mcp_client",
version="1.0.0",
display_name="MCP Client",
description="Allows LeoCRM AI agents to use external MCP servers (web search, code execution, etc.).",
dependencies=["permissions"],
routes=[
PluginRouteDef(
path="/api/v1/mcp-client",
module="app.plugins.builtins.mcp_client.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[
"mcp-client:read",
"mcp-client:write",
"mcp-client:admin",
],
)
+208
View File
@@ -0,0 +1,208 @@
"""MCP Client plugin routes — CRUD for server configs, tool listing, execution."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
from app.plugins.builtins.mcp_client.schemas import (
McpServerConfigCreate,
McpServerConfigResponse,
McpServerConfigUpdate,
McpServerExecuteRequest,
McpServerExecuteResponse,
McpServerToolsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mcp-client", tags=["mcp-client"])
def _config_to_response(cfg: McpServerConfigModel) -> McpServerConfigResponse:
return McpServerConfigResponse(
id=str(cfg.id),
name=cfg.name,
url=cfg.url,
api_token=cfg.api_token,
enabled=cfg.enabled,
description=cfg.description,
last_connected_at=cfg.last_connected_at,
created_by=str(cfg.created_by) if cfg.created_by else None,
created_at=cfg.created_at,
updated_at=cfg.updated_at,
)
@router.get("/servers", response_model=list[McpServerConfigResponse])
async def list_mcp_servers(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:read")),
) -> list[McpServerConfigResponse]:
"""List all configured MCP servers for the current tenant."""
stmt = select(McpServerConfigModel).where(McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]))
result = await db.execute(stmt)
configs = result.scalars().all()
return [_config_to_response(c) for c in configs]
@router.post("/servers", response_model=McpServerConfigResponse, status_code=201)
async def create_mcp_server(
body: McpServerConfigCreate,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
) -> McpServerConfigResponse:
"""Create a new MCP server configuration."""
cfg = McpServerConfigModel(
tenant_id=uuid.UUID(current_user["tenant_id"]),
name=body.name,
url=body.url,
api_token=body.api_token,
enabled=body.enabled,
description=body.description,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return _config_to_response(cfg)
@router.patch("/servers/{server_id}", response_model=McpServerConfigResponse)
async def update_mcp_server(
server_id: str,
body: McpServerConfigUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
) -> McpServerConfigResponse:
"""Update an existing MCP server configuration."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
update_data = body.model_dump(exclude_unset=True)
for key, val in update_data.items():
setattr(cfg, key, val)
await db.commit()
await db.refresh(cfg)
return _config_to_response(cfg)
@router.delete("/servers/{server_id}", status_code=204)
async def delete_mcp_server(
server_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
):
"""Delete an MCP server configuration."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
await db.delete(cfg)
await db.commit()
@router.get("/servers/{server_id}/tools", response_model=McpServerToolsResponse)
async def list_server_tools(
server_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:read")),
) -> McpServerToolsResponse:
"""List tools available on a specific external MCP server."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
if not cfg.enabled:
raise HTTPException(400, detail={"detail": "MCP server is disabled", "code": "server_disabled"})
client = McpClient(base_url=cfg.url, api_token=cfg.api_token)
try:
tools_resp = await client.list_tools()
# Update last_connected_at
await db.execute(
update(McpServerConfigModel).where(McpServerConfigModel.id == sid).values(last_connected_at=datetime.utcnow())
)
await db.commit()
return tools_resp
except Exception as exc:
logger.exception("Failed to list tools from MCP server %s", cfg.name)
raise HTTPException(502, detail={"detail": f"Failed to connect: {exc}", "code": "connection_failed"})
@router.post("/servers/{server_id}/execute", response_model=McpServerExecuteResponse)
async def execute_server_tool(
server_id: str,
body: McpServerExecuteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
) -> McpServerExecuteResponse:
"""Execute a tool on a specific external MCP server."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
if not cfg.enabled:
raise HTTPException(400, detail={"detail": "MCP server is disabled", "code": "server_disabled"})
client = McpClient(base_url=cfg.url, api_token=cfg.api_token)
try:
return await client.execute_tool(body.tool_name, body.arguments)
except Exception as exc:
logger.exception("Failed to execute tool on MCP server %s", cfg.name)
return McpServerExecuteResponse(
server_name=cfg.name,
tool=body.tool_name,
success=False,
error=str(exc),
)
@@ -0,0 +1,69 @@
"""Pydantic schemas for MCP Client plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class McpServerConfigCreate(BaseModel):
"""Create a new MCP server configuration."""
name: str = Field(..., min_length=1, max_length=200)
url: str = Field(..., min_length=1, max_length=500)
api_token: str | None = None
enabled: bool = True
description: str | None = None
class McpServerConfigUpdate(BaseModel):
"""Update an existing MCP server configuration."""
name: str | None = Field(None, min_length=1, max_length=200)
url: str | None = Field(None, min_length=1, max_length=500)
api_token: str | None = None
enabled: bool | None = None
description: str | None = None
class McpServerConfigResponse(BaseModel):
"""Response model for MCP server configuration."""
id: str
name: str
url: str
api_token: str | None = None
enabled: bool
description: str | None = None
last_connected_at: datetime | None = None
created_by: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class McpServerToolInfo(BaseModel):
"""Tool info from an external MCP server."""
name: str
description: str = ""
parameters: dict = Field(default_factory=dict)
class McpServerToolsResponse(BaseModel):
"""Response listing tools from an external MCP server."""
server_name: str
server_url: str
tools: list[McpServerToolInfo]
count: int
class McpServerExecuteRequest(BaseModel):
"""Request to execute a tool on an external MCP server."""
tool_name: str
arguments: dict = Field(default_factory=dict)
class McpServerExecuteResponse(BaseModel):
"""Response from executing a tool on an external MCP server."""
server_name: str
tool: str
success: bool
result: dict | str | None = None
error: str | None = None
@@ -0,0 +1,94 @@
"""Integrates external MCP server tools into the AI Assistant tool_registry.
When the MCP Client plugin activates, it loads all enabled MCP server configs,
fetches their tool lists, and registers each tool in the global ToolRegistry.
Agents can then call external MCP tools like native tools.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
logger = logging.getLogger(__name__)
PLUGIN_NAME = "mcp_client"
def _make_tool_name(server_name: str, tool_name: str) -> str:
"""Generate a unique tool name: mcp__{server}__{tool}."""
safe_server = server_name.replace(" ", "_").replace("-", "_").lower()
return f"mcp__{safe_server}__{tool_name}"
def _make_handler(server_cfg: McpServerConfigModel, tool_name: str):
"""Create an async handler that calls the external MCP server."""
async def _handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
client = McpClient(base_url=server_cfg.url, api_token=server_cfg.api_token)
try:
resp = await client.execute_tool(tool_name, arguments)
if resp.success:
import json
result = resp.result if resp.result is not None else {}
return json.dumps(result) if isinstance(result, dict) else str(result)
return f"Error: {resp.error or 'Unknown error'}"
except Exception as exc:
logger.exception("MCP tool execution failed: %s/%s", server_cfg.name, tool_name)
return f"Error: {exc}"
return _handler
async def sync_external_tools(db: AsyncSession, tenant_id: uuid.UUID) -> int:
"""Fetch tools from all enabled MCP servers and register them in the tool registry.
Returns the number of tools registered.
"""
registry = get_tool_registry()
# Unregister previous tools from this plugin
registry.unregister_plugin(PLUGIN_NAME)
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.tenant_id == tenant_id,
McpServerConfigModel.enabled.is_(True),
)
result = await db.execute(stmt)
configs = result.scalars().all()
count = 0
for cfg in configs:
try:
client = McpClient(base_url=cfg.url, api_token=cfg.api_token, timeout=10.0)
tools_resp = await client.list_tools()
for tool in tools_resp.tools:
tool_name = _make_tool_name(cfg.name, tool.name)
registry.register(
name=tool_name,
description=f"[MCP:{cfg.name}] {tool.description}",
parameters=tool.parameters if isinstance(tool.parameters, dict) else {},
handler=_make_handler(cfg, tool.name),
plugin_name=PLUGIN_NAME,
required_permission="mcp-client:read",
category="mcp-external",
)
count += 1
logger.info("Registered %d tools from MCP server %s", len(tools_resp.tools), cfg.name)
except Exception as exc:
logger.warning("Failed to sync tools from MCP server %s: %s", cfg.name, exc)
return count
def unregister_all_external_tools() -> None:
"""Remove all MCP client tools from the registry."""
registry = get_tool_registry()
registry.unregister_plugin(PLUGIN_NAME)