diff --git a/backend/python/app/api/admin/mcp_admin.py b/backend/python/app/api/admin/mcp_admin.py index 4840480..8de2442 100644 --- a/backend/python/app/api/admin/mcp_admin.py +++ b/backend/python/app/api/admin/mcp_admin.py @@ -1,373 +1,732 @@ """ + 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 + import re 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), + } - def _extract_url_from_text(text: str) -> Optional[str]: + """Extract first http/https URL from text.""" + if not text: + return None + match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text) + return match.group(0) if match else None +def _test_mcp_server(server, db) -> dict: + """ + Test connectivity to an MCP server and persist the result. + Supports stdio, sse and streamable_http transports. + Returns a result dict with success/message/details. + """ + result = {"success": False, "message": "", "details": {}} + transport = (server.transport_type or "stdio").replace("-", "_") + try: + if transport in ("streamable_http", "streamablehttp"): + url = server.url + if not url: + result["message"] = "Streamable HTTP endpoint URL is required" + else: + import httpx + try: + # A real streamable-http MCP endpoint accepts POST JSON-RPC. + # Some implementations also accept GET for SSE events. + # Try GET first (cheapest), fall back to a minimal POST. + resp = httpx.get(url, timeout=10.0, follow_redirects=True) + if resp.status_code == 405: + resp = httpx.post( + url, + json={"jsonrpc": "2.0", "method": "initialize", "id": 1}, + timeout=10.0, + follow_redirects=True, + ) + # Any HTTP response (including 4xx) means the host is reachable. + result["success"] = resp.status_code < 500 + result["message"] = f"HTTP responded with {resp.status_code}" + result["details"]["status_code"] = resp.status_code + result["details"]["content_type"] = resp.headers.get("content-type", "") + except httpx.ConnectError as e: + result["message"] = f"Connection refused - server may be down: {str(e)}" + except httpx.TimeoutException: + result["message"] = "Connection timed out" + except Exception as e: + result["message"] = f"HTTP request failed: {str(e)}" + + elif transport == "sse": + sse_url = server.url + if not sse_url: + result["message"] = "SSE URL is required" + else: + import httpx + try: + response = httpx.get(sse_url, timeout=10.0, follow_redirects=True) + 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 as e: + result["message"] = f"Connection refused - server may be down: {str(e)}" + except httpx.TimeoutException: + result["message"] = "Connection timed out" + except Exception as e: + result["message"] = f"SSE request failed: {str(e)}" + + elif transport == "stdio": + cmd = server.command + if not cmd: + result["message"] = "STDIO command is required" + else: + 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"] = "Command test failed" + result["details"]["error"] = str(e) + + else: + result["message"] = f"Unsupported transport type: {transport}" + + # Persist status + if result["success"]: + server.status = "connected" + server.last_connected_at = datetime.now() + else: + server.status = "error" + 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 result def _sync_market_mcp_to_servers(db: Session): + """ + Synchronize MarketApp MCP records (type='mcp') into mcp_servers table. + Uses MarketApp.id as MCPServer.id so detail/edit endpoints can work directly. + """ + from ...models import MarketApp, MCPServer market_mcps = db.query(MarketApp).filter( + MarketApp.type == "mcp", + MarketApp.status == "published", + ).all() existing_ids = {row[0] for row in db.query(MCPServer.id).all()} for ma in market_mcps: + if ma.id in existing_ids: + # Update URL if the server record has no endpoint yet + existing = db.query(MCPServer).filter(MCPServer.id == ma.id).first() + if existing and not existing.url: + url = _extract_url_from_text(ma.skill_content or "") or _extract_url_from_text(ma.description or "") or "" + if not url and ma.agent_endpoint: + url = ma.agent_endpoint + if url: + existing.url = url + existing.updated_at = datetime.now() + db.commit() continue transport = ma.mcp_transport or "streamable_http" + code = ma.skill_content or "" + url = _extract_url_from_text(code) or _extract_url_from_text(ma.description or "") or "" + command = "" + if not url and ma.agent_endpoint: + url = ma.agent_endpoint now = datetime.now() + server = MCPServer( + id=ma.id, + name=ma.name, + description=ma.description or "", + transport_type=transport, + command=command, + args="{}", + env_vars="{}", + url=url, + headers="{}", + is_enabled=True, + status="disconnected", + tools_count=0, + resources_count=0, + created_at=ma.created_at or now, + updated_at=ma.updated_at or now, + ) + db.add(server) if market_mcps: + db.commit() - # ============================================================ + # 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 + _sync_market_mcp_to_servers(db) + 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 + _sync_market_mcp_to_servers(db) + 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 + _sync_market_mcp_to_servers(db) + 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", - } + 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( @@ -382,130 +741,146 @@ async def test_mcp_connection( 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": {}} + # If test_data provides an override URL/transport, apply it temporarily + original_url = server.url + original_transport = server.transport_type + if test_data: + if test_data.transport_type: + server.transport_type = test_data.transport_type + if test_data.url: + server.url = test_data.url + if test_data.command: + server.command = test_data.command - 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"} + result = _test_mcp_server(server, db) - # 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() + # Restore original values if no override was explicitly persisted + if test_data and test_data.url is None: + server.url = original_url + if test_data and test_data.transport_type is None: + server.transport_type = original_transport + if test_data: 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/api/market.py b/backend/python/app/api/market.py index bd48a57..e005840 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -16,6 +16,7 @@ from ..models import ( ) from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional from ..middleware.log_service import LogService +from app.api.admin.mcp_admin import _test_mcp_server router = APIRouter() @@ -417,6 +418,9 @@ def upload_app( db.add(server) db.commit() + # 导入后自动做一次连通性测试,状态会同步为 connected / error + _test_mcp_server(server, db) + # 用户创建/导入的应用自动标记为已安装 existing_install = db.query(AppInstallation).filter( AppInstallation.user_id == current_user.id, diff --git a/frontend/src/pages/admin/Mcp.tsx b/frontend/src/pages/admin/Mcp.tsx index d79bba8..a06d6d2 100644 --- a/frontend/src/pages/admin/Mcp.tsx +++ b/frontend/src/pages/admin/Mcp.tsx @@ -1,406 +1,407 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { adminAPI } from '@/api'; -import { formatBjTime } from '@/utils/datetime'; - -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: '#1A1F36' }, - 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: '#1A1F36' }, - 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: '#1A1F36' }, - 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: '全部类型' }, - { value: 'stdio', label: 'STDIO' }, - { value: 'sse', label: 'SSE' }, -]; - -const STATUS_TYPES = [ - { value: '', label: '全部状态' }, - { value: 'connected', label: '已连接' }, - { value: 'disconnected', label: '未连接' }, - { value: 'error', label: '错误' }, -]; - -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('加载服务失败', '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(`确认删除 MCP Client "${server.name}"?`)) return; - try { - await adminAPI.deleteAdminMcpServer(server.id); - showToast('服务已删除'); - fetchServers(); - fetchStats(); - } catch (e) { - showToast('删除失败', 'error'); - } - }; - - const handleToggle = async (server: MCPServer) => { - try { - await adminAPI.toggleAdminMcpServer(server.id); - showToast(server.is_enabled ? '已禁用' : '已启用'); - fetchServers(); - fetchStats(); - } catch (e) { - showToast('切换失败', 'error'); - } - }; - - const handleTest = async (server: MCPServer) => { - try { - const result = await adminAPI.testAdminMcpServer(server.id) as any; - if (result.code === 0) { - showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error'); - fetchServers(); - fetchStats(); - } - } catch (e) { - showToast('测试失败', 'error'); - } - }; - - const handleSubmit = async (formData: any) => { - try { - if (editingServer) { - await adminAPI.updateAdminMcpServer(editingServer.id, formData); - showToast('服务已更新'); - } else { - await adminAPI.createAdminMcpServer(formData); - showToast('服务已创建'); - } - setShowModal(false); - fetchServers(); - fetchStats(); - } catch (e: any) { - showToast(e.message || '操作失败', 'error'); - } - }; - - const getStatusBadge = (status: string) => { - const labels: Record = { connected: '已连接', disconnected: '未连接', error: '错误' }; - 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 {labels[status] || status}; - }; - - const getTransportBadge = (type: string) => { - const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' }; - return {type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}; - }; - - const totalPages = Math.ceil(total / pageSize); - - return ( -
- {toast && ( -
- {toast.message} -
- )} - -
-

MCP Client管理

- {/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */} -
- -
-
-
服务总数
-
{stats.total_servers || 0}
-
-
-
已启用
-
{stats.enabled_servers || 0}
-
-
-
已连接
-
{stats.connected_servers || 0}
-
-
-
传输方式分布
-
- {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 => ( - - - - - - - - - - ))} - -
名称传输方式状态工具数最后连接启用操作
加载中...
未找到 MCP Client
-
{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 ? formatBjTime(s.last_connected_at) : '从未'} - - {s.is_enabled ? '是' : '否'} - - - - - - -
- - {totalPages > 1 && ( -
- - 第 {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 ? '编辑 MCP Client' : '添加 MCP Client'}
-
-
- - handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" /> -
-
- -