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,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),
|
||||
)
|
||||
Reference in New Issue
Block a user