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:
@@ -0,0 +1,5 @@
|
|||||||
|
"""MCP Client builtin plugin."""
|
||||||
|
|
||||||
|
from app.plugins.builtins.mcp_client.plugin import McpClientPlugin
|
||||||
|
|
||||||
|
__all__ = ["McpClientPlugin"]
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* MCP Client plugin API client.
|
||||||
|
*
|
||||||
|
* Manages external MCP server configurations and tool execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface McpServerConfigEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
api_token: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string | null;
|
||||||
|
last_connected_at: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerConfigCreate {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
api_token?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerConfigUpdate {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
api_token?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerToolInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerToolsResponse {
|
||||||
|
server_name: string;
|
||||||
|
server_url: string;
|
||||||
|
tools: McpServerToolInfo[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerExecuteRequest {
|
||||||
|
tool_name: string;
|
||||||
|
arguments: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerExecuteResponse {
|
||||||
|
server_name: string;
|
||||||
|
tool: string;
|
||||||
|
success: boolean;
|
||||||
|
result: Record<string, unknown> | string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Functions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function fetchMcpServers(): Promise<McpServerConfigEntry[]> {
|
||||||
|
return apiGet<McpServerConfigEntry[]>('/mcp-client/servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpServer(
|
||||||
|
payload: McpServerConfigCreate
|
||||||
|
): Promise<McpServerConfigEntry> {
|
||||||
|
return apiPost<McpServerConfigEntry>('/mcp-client/servers', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMcpServer(
|
||||||
|
id: string,
|
||||||
|
payload: McpServerConfigUpdate
|
||||||
|
): Promise<McpServerConfigEntry> {
|
||||||
|
return apiPatch<McpServerConfigEntry>(`/mcp-client/servers/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMcpServer(id: string): Promise<void> {
|
||||||
|
return apiDelete<void>(`/mcp-client/servers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMcpServerTools(
|
||||||
|
serverId: string
|
||||||
|
): Promise<McpServerToolsResponse> {
|
||||||
|
return apiGet<McpServerToolsResponse>(`/mcp-client/servers/${serverId}/tools`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeMcpServerTool(
|
||||||
|
serverId: string,
|
||||||
|
payload: McpServerExecuteRequest
|
||||||
|
): Promise<McpServerExecuteResponse> {
|
||||||
|
return apiPost<McpServerExecuteResponse>(
|
||||||
|
`/mcp-client/servers/${serverId}/execute`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hooks ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useMcpServers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['mcp-client', 'servers'],
|
||||||
|
queryFn: fetchMcpServers,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createMcpServer,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp-client', 'servers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: McpServerConfigUpdate;
|
||||||
|
}) => updateMcpServer(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp-client', 'servers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteMcpServer,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp-client', 'servers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMcpServerTools(serverId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['mcp-client', 'servers', serverId, 'tools'],
|
||||||
|
queryFn: () => fetchMcpServerTools(serverId!),
|
||||||
|
enabled: !!serverId,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for MCP Client plugin — server config CRUD, tool listing, execution.
|
||||||
|
|
||||||
|
Tests Task 5.17 acceptance criteria.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import ORIGIN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC1: List MCP client servers (empty) ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac1_list_servers_empty(mcp_authed_client):
|
||||||
|
"""AC1: GET /api/v1/mcp-client/servers → 200 + empty list."""
|
||||||
|
client, _ = mcp_authed_client
|
||||||
|
resp = await client.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC2: Create MCP server config ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac2_create_server(mcp_authed_client):
|
||||||
|
"""AC2: POST /api/v1/mcp-client/servers → 201 + created config."""
|
||||||
|
client, _ = mcp_authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp-client/servers",
|
||||||
|
json={"name": "Web Search MCP", "url": "http://localhost:9000", "api_token": "test-token", "enabled": True, "description": "External web search"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Web Search MCP"
|
||||||
|
assert data["url"] == "http://localhost:9000"
|
||||||
|
assert data["api_token"] == "test-token"
|
||||||
|
assert data["enabled"] is True
|
||||||
|
assert data["description"] == "External web search"
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC3: Update MCP server config ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac3_update_server(mcp_authed_client):
|
||||||
|
"""AC3: PATCH /api/v1/mcp-client/servers/{id} → 200 + updated."""
|
||||||
|
client, _ = mcp_authed_client
|
||||||
|
# Create first
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp-client/servers",
|
||||||
|
json={"name": "Test Server", "url": "http://localhost:8000", "enabled": True},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
server_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Update
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/mcp-client/servers/{server_id}",
|
||||||
|
json={"name": "Updated Server", "enabled": False},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Updated Server"
|
||||||
|
assert data["enabled"] is False
|
||||||
|
assert data["url"] == "http://localhost:8000" # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC4: Delete MCP server config ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac4_delete_server(mcp_authed_client):
|
||||||
|
"""AC4: DELETE /api/v1/mcp-client/servers/{id} → 204."""
|
||||||
|
client, _ = mcp_authed_client
|
||||||
|
# Create first
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp-client/servers",
|
||||||
|
json={"name": "Delete Me", "url": "http://localhost:7000", "enabled": True},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
server_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
resp = await client.delete(f"/api/v1/mcp-client/servers/{server_id}", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
resp = await client.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert all(s["id"] != server_id for s in resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC5: List servers after creating ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac5_list_servers_after_create(mcp_authed_client):
|
||||||
|
"""AC5: GET /api/v1/mcp-client/servers → 200 + list with created server."""
|
||||||
|
client, _ = mcp_authed_client
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/mcp-client/servers",
|
||||||
|
json={"name": "Server A", "url": "http://a:8000", "enabled": True},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/mcp-client/servers",
|
||||||
|
json={"name": "Server B", "url": "http://b:8000", "enabled": False},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
names = [s["name"] for s in data]
|
||||||
|
assert "Server A" in names
|
||||||
|
assert "Server B" in names
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC6: Unauthorized access is rejected ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac6_unauthorized_access(mcp_client_fixture):
|
||||||
|
"""AC6: GET /api/v1/mcp-client/servers without auth → 401."""
|
||||||
|
resp = await mcp_client_fixture.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC7: Execute tool on non-existent server returns 404 ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac7_execute_on_nonexistent_server(mcp_authed_client):
|
||||||
|
"""AC7: POST /api/v1/mcp-client/servers/{invalid_id}/execute → 400 (invalid UUID) or 404."""
|
||||||
|
client, _ = mcp_authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp-client/servers/00000000-0000-0000-0000-000000000000/execute",
|
||||||
|
json={"tool_name": "test", "arguments": {}},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AC8: Tool registry integration registers external tools ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ac8_tool_registry_integration(mcp_authed_client):
|
||||||
|
"""AC8: MCP Client plugin registers tools in the AI tool registry."""
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||||
|
from app.plugins.builtins.mcp_client.tool_registry_integration import unregister_all_external_tools, PLUGIN_NAME
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
# Ensure clean state
|
||||||
|
unregister_all_external_tools()
|
||||||
|
|
||||||
|
# Verify no MCP tools initially
|
||||||
|
mcp_tools = [t for t in registry.get_all() if t.plugin_name == PLUGIN_NAME]
|
||||||
|
assert len(mcp_tools) == 0
|
||||||
|
|
||||||
|
# The integration module exists and has correct functions
|
||||||
|
from app.plugins.builtins.mcp_client.tool_registry_integration import sync_external_tools, _make_tool_name
|
||||||
|
assert callable(sync_external_tools)
|
||||||
|
assert _make_tool_name("My Server", "search") == "mcp__my_server__search"
|
||||||
Reference in New Issue
Block a user