diff --git a/backend/python/app/api/admin/mcp_admin.py b/backend/python/app/api/admin/mcp_admin.py new file mode 100644 index 0000000..81e4457 --- /dev/null +++ b/backend/python/app/api/admin/mcp_admin.py @@ -0,0 +1,449 @@ +""" +MCP (Model Context Protocol) Server Management API +Phase 3.3: Manage MCP servers, discover tools/resources, test connections +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy import or_, func, desc, asc, text +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +import uuid +import json +import logging + +from ...database import get_db +from ...models import gen_uuid +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() +logger = logging.getLogger("aiflow") + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class MCPServerCreate(BaseModel): + name: str + description: Optional[str] = "" + transport_type: str = "stdio" + command: Optional[str] = "" + args: Optional[Any] = None + env_vars: Optional[Dict[str, str]] = None + url: Optional[str] = "" + headers: Optional[Dict[str, str]] = None + is_enabled: Optional[bool] = True + + +class MCPServerUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + transport_type: Optional[str] = None + command: Optional[str] = None + args: Optional[Any] = None + env_vars: Optional[Dict[str, str]] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + is_enabled: Optional[bool] = None + + +class MCPServerConnectionTest(BaseModel): + transport_type: Optional[str] = None + command: Optional[str] = None + args: Optional[Any] = None + env_vars: Optional[Dict[str, str]] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _format_cst_time(dt): + """Format datetime to CST string""" + if dt is None: + return None + try: + from datetime import timezone, timedelta + CST = timezone(timedelta(hours=8)) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.astimezone(CST).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None + + +def server_to_dict(server) -> dict: + """Convert MCPServer model to dict""" + return { + "id": server.id, + "name": server.name, + "description": server.description or "", + "transport_type": server.transport_type or "stdio", + "transport_type_label": {"stdio": "STDIO", "sse": "SSE"}.get(server.transport_type, server.transport_type), + "command": server.command or "", + "args": json.loads(server.args) if server.args else {}, + "env_vars": json.loads(server.env_vars) if server.env_vars else {}, + "url": server.url or "", + "headers": json.loads(server.headers) if server.headers else {}, + "is_enabled": server.is_enabled if server.is_enabled is not None else True, + "status": server.status or "disconnected", + "last_connected_at": _format_cst_time(server.last_connected_at), + "tools_count": server.tools_count or 0, + "resources_count": server.resources_count or 0, + "created_at": _format_cst_time(server.created_at), + "updated_at": _format_cst_time(server.updated_at), + } + + +# ============================================================ +# API Endpoints +# ============================================================ + +@router.get("/stats/overview", summary="Get MCP server statistics") +async def get_mcp_stats( + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Get overview statistics for MCP servers""" + from ...models import MCPServer + total = db.query(func.count(MCPServer.id)).scalar() or 0 + enabled = db.query(func.count(MCPServer.id)).filter(MCPServer.is_enabled == True).scalar() or 0 + connected = db.query(func.count(MCPServer.id)).filter(MCPServer.status == "connected").scalar() or 0 + + # By transport type + by_transport = {} + rows = db.query(MCPServer.transport_type, func.count(MCPServer.id)).group_by(MCPServer.transport_type).all() + for t, c in rows: + by_transport[t or "unknown"] = c + + # By status + by_status = {} + rows = db.query(MCPServer.status, func.count(MCPServer.id)).group_by(MCPServer.status).all() + for s, c in rows: + by_status[s or "unknown"] = c + + return { + "code": 0, + "data": { + "total_servers": total, + "enabled_servers": enabled, + "connected_servers": connected, + "by_transport": by_transport, + "by_status": by_status, + }, + "message": "ok", + } + + +@router.get("/", summary="List MCP servers") +async def list_mcp_servers( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None), + transport_type: Optional[str] = Query(None), + status: Optional[str] = Query(None), + is_enabled: Optional[bool] = Query(None), + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """List all MCP servers with pagination and filters""" + from ...models import MCPServer + query = db.query(MCPServer) + + if search: + query = query.filter( + or_(MCPServer.name.contains(search), MCPServer.description.contains(search)) + ) + if transport_type: + query = query.filter(MCPServer.transport_type == transport_type) + if status: + query = query.filter(MCPServer.status == status) + if is_enabled is not None: + query = query.filter(MCPServer.is_enabled == is_enabled) + + total = query.count() + servers = query.order_by(desc(MCPServer.created_at)).offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [server_to_dict(s) for s in servers], + }, + "message": "ok", + } + + +@router.get("/{server_id}", summary="Get MCP server details") +async def get_mcp_server( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Get a single MCP server by ID""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + return {"code": 0, "data": server_to_dict(server), "message": "ok"} + + +@router.post("/", summary="Create MCP server") +async def create_mcp_server( + data: MCPServerCreate, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Register a new MCP server""" + from ...models import MCPServer + + # Check name uniqueness + existing = db.query(MCPServer).filter(MCPServer.name == data.name).first() + if existing: + raise HTTPException(status_code=400, detail="MCP server with this name already exists") + + now = datetime.now() + server = MCPServer( + id=gen_uuid(), + name=data.name, + description=data.description or "", + transport_type=data.transport_type, + command=data.command or "", + args=json.dumps(data.args) if data.args else "{}", + env_vars=json.dumps(data.env_vars) if data.env_vars else "{}", + url=data.url or "", + headers=json.dumps(data.headers) if data.headers else "{}", + is_enabled=data.is_enabled if data.is_enabled is not None else True, + status="disconnected", + created_at=now, + updated_at=now, + ) + db.add(server) + db.commit() + db.refresh(server) + + logger.info(f"MCP server created: {server.name} ({server.id})") + return {"code": 0, "data": server_to_dict(server), "message": "MCP server created"} + + +@router.put("/{server_id}", summary="Update MCP server") +async def update_mcp_server( + server_id: str, + data: MCPServerUpdate, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Update an existing MCP server""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + update_fields = data.model_dump(exclude_unset=True) + + # Handle JSON fields + for json_field in ["args", "env_vars", "headers"]: + if json_field in update_fields and update_fields[json_field] is not None: + update_fields[json_field] = json.dumps(update_fields[json_field]) + + for key, value in update_fields.items(): + if value is not None: + setattr(server, key, value) + + server.updated_at = datetime.now() + db.commit() + db.refresh(server) + + logger.info(f"MCP server updated: {server.name} ({server.id})") + return {"code": 0, "data": server_to_dict(server), "message": "MCP server updated"} + + +@router.delete("/{server_id}", summary="Delete MCP server") +async def delete_mcp_server( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Delete an MCP server""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + name = server.name + db.delete(server) + db.commit() + + logger.info(f"MCP server deleted: {name} ({server_id})") + return {"code": 0, "data": None, "message": "MCP server deleted"} + + +@router.put("/{server_id}/toggle", summary="Enable/Disable MCP server") +async def toggle_mcp_server( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Toggle MCP server enabled state""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + server.is_enabled = not server.is_enabled + server.updated_at = datetime.now() + db.commit() + + logger.info(f"MCP server toggled: {server.name} -> enabled={server.is_enabled}") + return { + "code": 0, + "data": {"id": server.id, "is_enabled": server.is_enabled}, + "message": "MCP server toggled", + } + + +@router.post("/{server_id}/test", summary="Test MCP server connection") +async def test_mcp_connection( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), + test_data: Optional[MCPServerConnectionTest] = None, +): + """Test connection to an MCP server""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + transport = test_data.transport_type if test_data and test_data.transport_type else server.transport_type + result = {"success": False, "message": "", "details": {}} + + try: + if transport == "sse": + sse_url = (test_data.url if test_data else None) or server.url + if not sse_url: + result["message"] = "SSE URL is required" + return {"code": 0, "data": result, "message": "ok"} + + # Test SSE endpoint with a simple HTTP request + import httpx + try: + response = httpx.get(sse_url, timeout=10.0) + result["success"] = response.status_code < 500 + result["message"] = f"SSE endpoint responded with {response.status_code}" + result["details"]["status_code"] = response.status_code + result["details"]["content_type"] = response.headers.get("content-type", "") + except httpx.ConnectError: + result["message"] = "Connection refused - server may be down" + except httpx.TimeoutException: + result["message"] = "Connection timed out" + + elif transport == "stdio": + cmd = (test_data.command if test_data else None) or server.command + if not cmd: + result["message"] = "STDIO command is required" + return {"code": 0, "data": result, "message": "ok"} + + # Basic command existence check (not actually executing MCP handshake) + import subprocess + try: + subprocess.run([cmd, "--version"], capture_output=True, timeout=10) + result["success"] = True + result["message"] = "Command found and executable" + except FileNotFoundError: + result["message"] = "Command not found in PATH" + except subprocess.TimeoutExpired: + result["message"] = "Command timed out" + except Exception as e: + result["message"] = f"Command test failed" + result["details"]["error"] = str(e) + + # Update server status + server.status = "connected" if result["success"] else "error" + if result["success"]: + server.last_connected_at = datetime.now() + server.updated_at = datetime.now() + db.commit() + + except Exception as e: + result["message"] = str(e) + result["details"]["error"] = str(e) + server.status = "error" + server.updated_at = datetime.now() + db.commit() + + return {"code": 0, "data": result, "message": "ok"} + + +@router.get("/{server_id}/tools", summary="List MCP server tools") +async def list_mcp_tools( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """List tools exposed by an MCP server (placeholder/mock)""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + # In a real implementation, this would connect to the MCP server and call tools/list + # For now, return mock data based on common MCP tools + mock_tools = [] + if server.transport_type == "stdio": + mock_tools = [ + {"name": "read_file", "description": "Read contents of a file"}, + {"name": "write_file", "description": "Write contents to a file"}, + {"name": "execute_command", "description": "Execute a shell command"}, + {"name": "search_content", "description": "Search for content in files"}, + ] + elif server.transport_type == "sse": + mock_tools = [ + {"name": "web_search", "description": "Search the web"}, + {"name": "fetch_url", "description": "Fetch content from URL"}, + {"name": "api_request", "description": "Make API requests"}, + ] + + return { + "code": 0, + "data": { + "server_name": server.name, + "tools": mock_tools, + "count": len(mock_tools), + }, + "message": "ok", + } + + +@router.get("/{server_id}/resources", summary="List MCP server resources") +async def list_mcp_resources( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """List resources exposed by an MCP server (placeholder/mock)""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + mock_resources = [ + {"uri": f"{server.name}://docs/readme", "name": "Documentation", "mime_type": "text/markdown"}, + {"uri": f"{server.name}://config/settings", "name": "Configuration", "mime_type": "application/json"}, + ] + + return { + "code": 0, + "data": { + "server_name": server.name, + "resources": mock_resources, + "count": len(mock_resources), + }, + "message": "ok", + } diff --git a/backend/python/app/models.py b/backend/python/app/models.py index c9ad3c0..c853872 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -1036,3 +1036,24 @@ class TaskExecutionLog(Base): error_message = Column(Text, default="") created_at = Column(DateTime, default=datetime.now) + + +class MCPServer(Base): + __tablename__ = "mcp_servers" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + description = Column(Text, default="") + transport_type = Column(String(20), default="stdio") + command = Column(String(500), default="") + args = Column(Text, default="") + env_vars = Column(Text, default="") + url = Column(String(500), default="") + headers = Column(Text, default="") + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + last_connected_at = Column(DateTime, default=None) + tools_count = Column(Integer, default=0) + resources_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/backend/python/main.py b/backend/python/main.py index 28ee359..8130894 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD from app.database import SessionLocal @@ -421,6 +421,7 @@ app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=[" app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) +app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ebcab1..f017da2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,6 +74,7 @@ import AdminSkills from '@/pages/admin/Skills' import AdminAgents from '@/pages/admin/Agents' import AdminKnowledge from '@/pages/admin/Knowledge'; import AdminStorage from '@/pages/admin/Storage'; +import AdminMcp from '@/pages/admin/Mcp'; import AdminAutomation from '@/pages/admin/Automation';; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 49b5505..4b5aadd 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -528,6 +528,20 @@ export const adminAPI = { const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; return get(`/admin/automation/${id}/history${query}`); }, + // MCP Server Management + getAdminMcpStats: () => get('/admin/mcp/stats/overview'), + getAdminMcpServers: (params?: any) => { + const query = new URLSearchParams(params).toString(); + return get(`/admin/mcp/${query ? '?' + query : ''}`); + }, + getAdminMcpServer: (id: string) => get(`/admin/mcp/${id}`), + createAdminMcpServer: (data: any) => post('/admin/mcp/', data), + updateAdminMcpServer: (id: string, data: any) => put(`/admin/mcp/${id}`, data), + deleteAdminMcpServer: (id: string) => del(`/admin/mcp/${id}`), + toggleAdminMcpServer: (id: string) => put(`/admin/mcp/${id}/toggle`), + testAdminMcpServer: (id: string, data?: any) => post(`/admin/mcp/${id}/test`, data || {}), + getAdminMcpServerTools: (id: string) => get(`/admin/mcp/${id}/tools`), + getAdminMcpServerResources: (id: string) => get(`/admin/mcp/${id}/resources`), }; // ===== AI自进化API ===== diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index b292d1f..24cf913 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -36,7 +36,8 @@ const adminNavItems = [ { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, { path: '/admin/storage', icon: 'storage', label: '存储配置' }, { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, - { path: '/admin/skills', icon: 'skills', label: '技能管理' },]; + { path: '/admin/skills', icon: 'skills', label: '技能管理' }, + { path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },]; diff --git a/frontend/src/pages/admin/Mcp.tsx b/frontend/src/pages/admin/Mcp.tsx new file mode 100644 index 0000000..7e2695a --- /dev/null +++ b/frontend/src/pages/admin/Mcp.tsx @@ -0,0 +1,404 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; + +const styles: Record = { + container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' }, + header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }, + title: { fontSize: 24, fontWeight: 700, color: '#1a1a2e' }, + addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 }, + statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 }, + statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }, + statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 }, + statValue: { fontSize: 28, fontWeight: 700, color: '#1a1a2e' }, + toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const }, + searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 }, + filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' }, + table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }, + th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' }, + td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' }, + badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 }, + actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' }, + pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }, + pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 }, + modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 }, + modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' }, + modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1a1a2e' }, + formGroup: { marginBottom: 16 }, + label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 }, + input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const }, + textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const }, + modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }, + cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 }, + submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 }, + toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' }, + detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 }, + detailsItem: { fontSize: 13 }, + detailsLabel: { color: '#6b7280', marginBottom: 4 }, +}; + +const TRANSPORT_TYPES = [ + { value: '', label: 'All Types' }, + { value: 'stdio', label: 'STDIO' }, + { value: 'sse', label: 'SSE' }, +]; + +const STATUS_TYPES = [ + { value: '', label: 'All Status' }, + { value: 'connected', label: 'Connected' }, + { value: 'disconnected', label: 'Disconnected' }, + { value: 'error', label: 'Error' }, +]; + +interface MCPServer { + id: string; + name: string; + description: string; + transport_type: string; + transport_type_label: string; + command: string; + args: Record; + env_vars: Record; + url: string; + headers: Record; + is_enabled: boolean; + status: string; + last_connected_at: string; + tools_count: number; + resources_count: number; + created_at: string; + updated_at: string; +} + +export default function AdminMcp() { + const [servers, setServers] = useState([]); + const [stats, setStats] = useState({}); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [search, setSearch] = useState(''); + const [filterTransport, setFilterTransport] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + const [showModal, setShowModal] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [toast, setToast] = useState<{ message: string; type: string } | null>(null); + const pageSize = 20; + + const showToast = (message: string, type: string = 'success') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + }; + + const fetchStats = useCallback(async () => { + try { + const result = await adminAPI.getAdminMcpStats() as any; + if (result.code === 0) setStats(result.data); + } catch (e) { /* ignore */ } + }, []); + + const fetchServers = useCallback(async () => { + setLoading(true); + try { + const params: any = { page, page_size: pageSize }; + if (search) params.search = search; + if (filterTransport) params.transport_type = filterTransport; + if (filterStatus) params.status = filterStatus; + const result = await adminAPI.getAdminMcpServers(params) as any; + if (result.code === 0) { + setServers(result.data.items || []); + setTotal(result.data.total || 0); + } + } catch (e) { + showToast('Failed to load servers', 'error'); + } finally { + setLoading(false); + } + }, [page, search, filterTransport, filterStatus]); + + useEffect(() => { + fetchStats(); + fetchServers(); + }, [fetchStats, fetchServers]); + + const handleCreate = () => { + setEditingServer(null); + setShowModal(true); + }; + + const handleEdit = (server: MCPServer) => { + setEditingServer(server); + setShowModal(true); + }; + + const handleDelete = async (server: MCPServer) => { + if (!confirm(`Delete MCP server "${server.name}"?`)) return; + try { + await adminAPI.deleteAdminMcpServer(server.id); + showToast('Server deleted'); + fetchServers(); + fetchStats(); + } catch (e) { + showToast('Delete failed', 'error'); + } + }; + + const handleToggle = async (server: MCPServer) => { + try { + await adminAPI.toggleAdminMcpServer(server.id); + showToast(server.is_enabled ? 'Disabled' : 'Enabled'); + fetchServers(); + fetchStats(); + } catch (e) { + showToast('Toggle failed', 'error'); + } + }; + + const handleTest = async (server: MCPServer) => { + try { + const result = await adminAPI.testAdminMcpServer(server.id) as any; + if (result.code === 0) { + showToast(result.data.success ? 'Connection successful' : `Failed: ${result.data.message}`, result.data.success ? 'success' : 'error'); + fetchServers(); + fetchStats(); + } + } catch (e) { + showToast('Test failed', 'error'); + } + }; + + const handleSubmit = async (formData: any) => { + try { + if (editingServer) { + await adminAPI.updateAdminMcpServer(editingServer.id, formData); + showToast('Server updated'); + } else { + await adminAPI.createAdminMcpServer(formData); + showToast('Server created'); + } + setShowModal(false); + fetchServers(); + fetchStats(); + } catch (e: any) { + showToast(e.message || 'Operation failed', 'error'); + } + }; + + const getStatusBadge = (status: string) => { + const colors: Record = { + connected: { bg: '#dcfce7', color: '#166534' }, + disconnected: { bg: '#f3f4f6', color: '#6b7280' }, + error: { bg: '#fee2e2', color: '#991b1b' }, + }; + const c = colors[status] || colors.disconnected; + return {status}; + }; + + const getTransportBadge = (type: string) => { + const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' }; + return {type.toUpperCase()}; + }; + + const totalPages = Math.ceil(total / pageSize); + + return ( +
+ {toast && ( +
+ {toast.message} +
+ )} + +
+

MCP Servers

+ +
+ +
+
+
Total Servers
+
{stats.total_servers || 0}
+
+
+
Enabled
+
{stats.enabled_servers || 0}
+
+
+
Connected
+
{stats.connected_servers || 0}
+
+
+
By Transport
+
+ {Object.entries(stats.by_transport || {}).map(([k, v]) => ( + {k}: {v as number} + ))} +
+
+
+ +
+ { setSearch(e.target.value); setPage(1); }} + /> + + +
+ + + + + + + + + + + + + + + {loading ? ( + + ) : servers.length === 0 ? ( + + ) : servers.map(s => ( + + + + + + + + + + ))} + +
NameTransportStatusToolsLast ConnectedEnabledActions
Loading...
No MCP servers found
+
{s.name}
+
{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}
+
{getTransportBadge(s.transport_type)}{getStatusBadge(s.status)}{s.tools_count || '-'}{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : 'Never'} + + {s.is_enabled ? 'Yes' : 'No'} + + + + + + +
+ + {totalPages > 1 && ( +
+ + Page {page} / {totalPages} + +
+ )} + + {showModal && setShowModal(false)} />} +
+ ); +} + +function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) { + const [form, setForm] = useState({ + name: server?.name || '', + description: server?.description || '', + transport_type: server?.transport_type || 'stdio', + command: server?.command || '', + args: server?.args ? JSON.stringify(server.args, null, 2) : '', + env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '', + url: server?.url || '', + headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '', + }); + + const handleChange = (field: string, value: string) => { + setForm(prev => ({ ...prev, [field]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const data: any = { + name: form.name, + description: form.description, + transport_type: form.transport_type, + }; + + if (form.transport_type === 'stdio') { + data.command = form.command; + try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; } + try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; } + } else { + data.url = form.url; + try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; } + } + + onSubmit(data); + }; + + return ( +
+
e.stopPropagation()}> +
{server ? 'Edit MCP Server' : 'Add MCP Server'}
+
+
+ + handleChange('name', e.target.value)} required placeholder="e.g., Filesystem Server" /> +
+
+ +