diff --git a/app/plugins/builtins/mcp_client/__init__.py b/app/plugins/builtins/mcp_client/__init__.py new file mode 100644 index 0000000..a77591a --- /dev/null +++ b/app/plugins/builtins/mcp_client/__init__.py @@ -0,0 +1,5 @@ +"""MCP Client builtin plugin.""" + +from app.plugins.builtins.mcp_client.plugin import McpClientPlugin + +__all__ = ["McpClientPlugin"] diff --git a/app/plugins/builtins/mcp_client/client.py b/app/plugins/builtins/mcp_client/client.py new file mode 100644 index 0000000..e1e15da --- /dev/null +++ b/app/plugins/builtins/mcp_client/client.py @@ -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 diff --git a/app/plugins/builtins/mcp_client/migrations/0001_initial.sql b/app/plugins/builtins/mcp_client/migrations/0001_initial.sql new file mode 100644 index 0000000..a4df65c --- /dev/null +++ b/app/plugins/builtins/mcp_client/migrations/0001_initial.sql @@ -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); diff --git a/app/plugins/builtins/mcp_client/models.py b/app/plugins/builtins/mcp_client/models.py new file mode 100644 index 0000000..8c9e0d9 --- /dev/null +++ b/app/plugins/builtins/mcp_client/models.py @@ -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) diff --git a/app/plugins/builtins/mcp_client/plugin.py b/app/plugins/builtins/mcp_client/plugin.py new file mode 100644 index 0000000..2bf9d68 --- /dev/null +++ b/app/plugins/builtins/mcp_client/plugin.py @@ -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", + ], + ) diff --git a/app/plugins/builtins/mcp_client/routes.py b/app/plugins/builtins/mcp_client/routes.py new file mode 100644 index 0000000..d0f1d5b --- /dev/null +++ b/app/plugins/builtins/mcp_client/routes.py @@ -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), + ) diff --git a/app/plugins/builtins/mcp_client/schemas.py b/app/plugins/builtins/mcp_client/schemas.py new file mode 100644 index 0000000..9b292a7 --- /dev/null +++ b/app/plugins/builtins/mcp_client/schemas.py @@ -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 diff --git a/app/plugins/builtins/mcp_client/tool_registry_integration.py b/app/plugins/builtins/mcp_client/tool_registry_integration.py new file mode 100644 index 0000000..921c553 --- /dev/null +++ b/app/plugins/builtins/mcp_client/tool_registry_integration.py @@ -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) diff --git a/frontend/src/api/mcpClient.ts b/frontend/src/api/mcpClient.ts new file mode 100644 index 0000000..7508431 --- /dev/null +++ b/frontend/src/api/mcpClient.ts @@ -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; +} + +export interface McpServerToolsResponse { + server_name: string; + server_url: string; + tools: McpServerToolInfo[]; + count: number; +} + +export interface McpServerExecuteRequest { + tool_name: string; + arguments: Record; +} + +export interface McpServerExecuteResponse { + server_name: string; + tool: string; + success: boolean; + result: Record | string | null; + error: string | null; +} + +// ─── API Functions ───────────────────────────────────────────────────────── + +export function fetchMcpServers(): Promise { + return apiGet('/mcp-client/servers'); +} + +export function createMcpServer( + payload: McpServerConfigCreate +): Promise { + return apiPost('/mcp-client/servers', payload); +} + +export function updateMcpServer( + id: string, + payload: McpServerConfigUpdate +): Promise { + return apiPatch(`/mcp-client/servers/${id}`, payload); +} + +export function deleteMcpServer(id: string): Promise { + return apiDelete(`/mcp-client/servers/${id}`); +} + +export function fetchMcpServerTools( + serverId: string +): Promise { + return apiGet(`/mcp-client/servers/${serverId}/tools`); +} + +export function executeMcpServerTool( + serverId: string, + payload: McpServerExecuteRequest +): Promise { + return apiPost( + `/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, + }); +} diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py new file mode 100644 index 0000000..f1ebdd0 --- /dev/null +++ b/tests/test_mcp_client.py @@ -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"