feat: MCP streamable_http connection test + auto-test on import

This commit is contained in:
QihuAI Design committed 2026-07-16 18:38:10 +08:00
1 parent feb9865b00
commit 6331e84185
3 files changed
+859 -479

No files matched your search

+448 -73
View File
@@ -1,373 +1,732 @@
""" """
MCP (Model Context Protocol) Server Management API MCP (Model Context Protocol) Server Management API
Phase 3.3: Manage MCP servers, discover tools/resources, test connections Phase 3.3: Manage MCP servers, discover tools/resources, test connections
""" """
from fastapi import APIRouter, Depends, HTTPException, Query, Body from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy import or_, func, desc, asc, text from sqlalchemy import or_, func, desc, asc, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
import uuid import uuid
import json import json
import logging import logging
import re import re
from ...database import get_db from ...database import get_db
from ...models import gen_uuid from ...models import gen_uuid
from ...auth import get_current_admin from ...auth import get_current_admin
from ...middleware.log_service import LogService from ...middleware.log_service import LogService
router = APIRouter() router = APIRouter()
logger = logging.getLogger("aiflow") logger = logging.getLogger("aiflow")
# ============================================================ # ============================================================
# Pydantic Models # Pydantic Models
# ============================================================ # ============================================================
class MCPServerCreate(BaseModel): class MCPServerCreate(BaseModel):
name: str name: str
description: Optional[str] = "" description: Optional[str] = ""
transport_type: str = "stdio" transport_type: str = "stdio"
command: Optional[str] = "" command: Optional[str] = ""
args: Optional[Any] = None args: Optional[Any] = None
env_vars: Optional[Dict[str, str]] = None env_vars: Optional[Dict[str, str]] = None
url: Optional[str] = "" url: Optional[str] = ""
headers: Optional[Dict[str, str]] = None headers: Optional[Dict[str, str]] = None
is_enabled: Optional[bool] = True is_enabled: Optional[bool] = True
class MCPServerUpdate(BaseModel): class MCPServerUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
transport_type: Optional[str] = None transport_type: Optional[str] = None
command: Optional[str] = None command: Optional[str] = None
args: Optional[Any] = None args: Optional[Any] = None
env_vars: Optional[Dict[str, str]] = None env_vars: Optional[Dict[str, str]] = None
url: Optional[str] = None url: Optional[str] = None
headers: Optional[Dict[str, str]] = None headers: Optional[Dict[str, str]] = None
is_enabled: Optional[bool] = None is_enabled: Optional[bool] = None
class MCPServerConnectionTest(BaseModel): class MCPServerConnectionTest(BaseModel):
transport_type: Optional[str] = None transport_type: Optional[str] = None
command: Optional[str] = None command: Optional[str] = None
args: Optional[Any] = None args: Optional[Any] = None
env_vars: Optional[Dict[str, str]] = None env_vars: Optional[Dict[str, str]] = None
url: Optional[str] = None url: Optional[str] = None
headers: Optional[Dict[str, str]] = None headers: Optional[Dict[str, str]] = None
# ============================================================ # ============================================================
# Helper Functions # Helper Functions
# ============================================================ # ============================================================
def _format_cst_time(dt): def _format_cst_time(dt):
"""Format datetime to CST string""" """Format datetime to CST string"""
if dt is None: if dt is None:
return None return None
try: try:
from datetime import timezone, timedelta from datetime import timezone, timedelta
CST = timezone(timedelta(hours=8)) CST = timezone(timedelta(hours=8))
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=CST) dt = dt.replace(tzinfo=CST)
return dt.astimezone(CST).strftime("%Y-%m-%d %H:%M:%S") return dt.astimezone(CST).strftime("%Y-%m-%d %H:%M:%S")
except Exception: except Exception:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def server_to_dict(server) -> dict: def server_to_dict(server) -> dict:
"""Convert MCPServer model to dict""" """Convert MCPServer model to dict"""
return { return {
"id": server.id, "id": server.id,
"name": server.name, "name": server.name,
"description": server.description or "", "description": server.description or "",
"transport_type": server.transport_type or "stdio", "transport_type": server.transport_type or "stdio",
"transport_type_label": {"stdio": "STDIO", "sse": "SSE"}.get(server.transport_type, server.transport_type), "transport_type_label": {"stdio": "STDIO", "sse": "SSE"}.get(server.transport_type, server.transport_type),
"command": server.command or "", "command": server.command or "",
"args": json.loads(server.args) if server.args else {}, "args": json.loads(server.args) if server.args else {},
"env_vars": json.loads(server.env_vars) if server.env_vars else {}, "env_vars": json.loads(server.env_vars) if server.env_vars else {},
"url": server.url or "", "url": server.url or "",
"headers": json.loads(server.headers) if server.headers else {}, "headers": json.loads(server.headers) if server.headers else {},
"is_enabled": server.is_enabled if server.is_enabled is not None else True, "is_enabled": server.is_enabled if server.is_enabled is not None else True,
"status": server.status or "disconnected", "status": server.status or "disconnected",
"last_connected_at": _format_cst_time(server.last_connected_at), "last_connected_at": _format_cst_time(server.last_connected_at),
"tools_count": server.tools_count or 0, "tools_count": server.tools_count or 0,
"resources_count": server.resources_count or 0, "resources_count": server.resources_count or 0,
"created_at": _format_cst_time(server.created_at), "created_at": _format_cst_time(server.created_at),
"updated_at": _format_cst_time(server.updated_at), "updated_at": _format_cst_time(server.updated_at),
} }
def _extract_url_from_text(text: str) -> Optional[str]: def _extract_url_from_text(text: str) -> Optional[str]:
"""Extract first http/https URL from text.""" """Extract first http/https URL from text."""
if not text: if not text:
return None return None
match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text) match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text)
return match.group(0) if match else None 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): def _sync_market_mcp_to_servers(db: Session):
""" """
Synchronize MarketApp MCP records (type='mcp') into mcp_servers table. Synchronize MarketApp MCP records (type='mcp') into mcp_servers table.
Uses MarketApp.id as MCPServer.id so detail/edit endpoints can work directly. Uses MarketApp.id as MCPServer.id so detail/edit endpoints can work directly.
""" """
from ...models import MarketApp, MCPServer from ...models import MarketApp, MCPServer
market_mcps = db.query(MarketApp).filter( market_mcps = db.query(MarketApp).filter(
MarketApp.type == "mcp", MarketApp.type == "mcp",
MarketApp.status == "published", MarketApp.status == "published",
).all() ).all()
existing_ids = {row[0] for row in db.query(MCPServer.id).all()} existing_ids = {row[0] for row in db.query(MCPServer.id).all()}
for ma in market_mcps: for ma in market_mcps:
if ma.id in existing_ids: 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 continue
transport = ma.mcp_transport or "streamable_http" transport = ma.mcp_transport or "streamable_http"
code = ma.skill_content or "" code = ma.skill_content or ""
url = _extract_url_from_text(code) or _extract_url_from_text(ma.description or "") or "" url = _extract_url_from_text(code) or _extract_url_from_text(ma.description or "") or ""
command = "" command = ""
if not url and ma.agent_endpoint: if not url and ma.agent_endpoint:
url = ma.agent_endpoint url = ma.agent_endpoint
now = datetime.now() now = datetime.now()
server = MCPServer( server = MCPServer(
id=ma.id, id=ma.id,
name=ma.name, name=ma.name,
description=ma.description or "", description=ma.description or "",
transport_type=transport, transport_type=transport,
command=command, command=command,
args="{}", args="{}",
env_vars="{}", env_vars="{}",
url=url, url=url,
headers="{}", headers="{}",
is_enabled=True, is_enabled=True,
status="disconnected", status="disconnected",
tools_count=0, tools_count=0,
resources_count=0, resources_count=0,
created_at=ma.created_at or now, created_at=ma.created_at or now,
updated_at=ma.updated_at or now, updated_at=ma.updated_at or now,
) )
db.add(server) db.add(server)
if market_mcps: if market_mcps:
db.commit() db.commit()
# ============================================================ # ============================================================
# API Endpoints # API Endpoints
# ============================================================ # ============================================================
@router.get("/stats/overview", summary="Get MCP server statistics") @router.get("/stats/overview", summary="Get MCP server statistics")
async def get_mcp_stats( async def get_mcp_stats(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""Get overview statistics for MCP servers""" """Get overview statistics for MCP servers"""
from ...models import MCPServer from ...models import MCPServer
_sync_market_mcp_to_servers(db) _sync_market_mcp_to_servers(db)
total = db.query(func.count(MCPServer.id)).scalar() or 0 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 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 connected = db.query(func.count(MCPServer.id)).filter(MCPServer.status == "connected").scalar() or 0
# By transport type # By transport type
by_transport = {} by_transport = {}
rows = db.query(MCPServer.transport_type, func.count(MCPServer.id)).group_by(MCPServer.transport_type).all() rows = db.query(MCPServer.transport_type, func.count(MCPServer.id)).group_by(MCPServer.transport_type).all()
for t, c in rows: for t, c in rows:
by_transport[t or "unknown"] = c by_transport[t or "unknown"] = c
# By status # By status
by_status = {} by_status = {}
rows = db.query(MCPServer.status, func.count(MCPServer.id)).group_by(MCPServer.status).all() rows = db.query(MCPServer.status, func.count(MCPServer.id)).group_by(MCPServer.status).all()
for s, c in rows: for s, c in rows:
by_status[s or "unknown"] = c by_status[s or "unknown"] = c
return { return {
"code": 0, "code": 0,
"data": { "data": {
"total_servers": total, "total_servers": total,
"enabled_servers": enabled, "enabled_servers": enabled,
"connected_servers": connected, "connected_servers": connected,
"by_transport": by_transport, "by_transport": by_transport,
"by_status": by_status, "by_status": by_status,
}, },
"message": "ok", "message": "ok",
} }
@router.get("/", summary="List MCP servers") @router.get("/", summary="List MCP servers")
async def list_mcp_servers( async def list_mcp_servers(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
transport_type: Optional[str] = Query(None), transport_type: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
is_enabled: Optional[bool] = Query(None), is_enabled: Optional[bool] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""List all MCP servers with pagination and filters""" """List all MCP servers with pagination and filters"""
from ...models import MCPServer from ...models import MCPServer
_sync_market_mcp_to_servers(db) _sync_market_mcp_to_servers(db)
query = db.query(MCPServer) query = db.query(MCPServer)
if search: if search:
query = query.filter( query = query.filter(
or_(MCPServer.name.contains(search), MCPServer.description.contains(search)) or_(MCPServer.name.contains(search), MCPServer.description.contains(search))
) )
if transport_type: if transport_type:
query = query.filter(MCPServer.transport_type == transport_type) query = query.filter(MCPServer.transport_type == transport_type)
if status: if status:
query = query.filter(MCPServer.status == status) query = query.filter(MCPServer.status == status)
if is_enabled is not None: if is_enabled is not None:
query = query.filter(MCPServer.is_enabled == is_enabled) query = query.filter(MCPServer.is_enabled == is_enabled)
total = query.count() total = query.count()
servers = query.order_by(desc(MCPServer.created_at)).offset((page - 1) * page_size).limit(page_size).all() servers = query.order_by(desc(MCPServer.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return { return {
"code": 0, "code": 0,
"data": { "data": {
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"items": [server_to_dict(s) for s in servers], "items": [server_to_dict(s) for s in servers],
}, },
"message": "ok", "message": "ok",
} }
@router.get("/{server_id}", summary="Get MCP server details") @router.get("/{server_id}", summary="Get MCP server details")
async def get_mcp_server( async def get_mcp_server(
server_id: str, server_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""Get a single MCP server by ID""" """Get a single MCP server by ID"""
from ...models import MCPServer from ...models import MCPServer
_sync_market_mcp_to_servers(db) _sync_market_mcp_to_servers(db)
server = db.query(MCPServer).filter(MCPServer.id == server_id).first() server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") raise HTTPException(status_code=404, detail="MCP server not found")
return {"code": 0, "data": server_to_dict(server), "message": "ok"} return {"code": 0, "data": server_to_dict(server), "message": "ok"}
@router.post("/", summary="Create MCP server") @router.post("/", summary="Create MCP server")
async def create_mcp_server( async def create_mcp_server(
data: MCPServerCreate, data: MCPServerCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""Register a new MCP server""" """Register a new MCP server"""
from ...models import MCPServer from ...models import MCPServer
# Check name uniqueness # Check name uniqueness
existing = db.query(MCPServer).filter(MCPServer.name == data.name).first() existing = db.query(MCPServer).filter(MCPServer.name == data.name).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="MCP server with this name already exists") raise HTTPException(status_code=400, detail="MCP server with this name already exists")
now = datetime.now() now = datetime.now()
server = MCPServer( server = MCPServer(
id=gen_uuid(), id=gen_uuid(),
name=data.name, name=data.name,
description=data.description or "", description=data.description or "",
transport_type=data.transport_type, transport_type=data.transport_type,
command=data.command or "", command=data.command or "",
args=json.dumps(data.args) if data.args else "{}", args=json.dumps(data.args) if data.args else "{}",
env_vars=json.dumps(data.env_vars) if data.env_vars else "{}", env_vars=json.dumps(data.env_vars) if data.env_vars else "{}",
url=data.url or "", url=data.url or "",
headers=json.dumps(data.headers) if data.headers else "{}", headers=json.dumps(data.headers) if data.headers else "{}",
is_enabled=data.is_enabled if data.is_enabled is not None else True, is_enabled=data.is_enabled if data.is_enabled is not None else True,
status="disconnected", status="disconnected",
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
db.add(server) db.add(server)
db.commit() db.commit()
db.refresh(server) db.refresh(server)
logger.info(f"MCP server created: {server.name} ({server.id})") logger.info(f"MCP server created: {server.name} ({server.id})")
return {"code": 0, "data": server_to_dict(server), "message": "MCP server created"} return {"code": 0, "data": server_to_dict(server), "message": "MCP server created"}
@router.put("/{server_id}", summary="Update MCP server") @router.put("/{server_id}", summary="Update MCP server")
async def update_mcp_server( async def update_mcp_server(
server_id: str, server_id: str,
data: MCPServerUpdate, data: MCPServerUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""Update an existing MCP server""" """Update an existing MCP server"""
from ...models import MCPServer from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first() server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") raise HTTPException(status_code=404, detail="MCP server not found")
update_fields = data.model_dump(exclude_unset=True) update_fields = data.model_dump(exclude_unset=True)
# Handle JSON fields # Handle JSON fields
for json_field in ["args", "env_vars", "headers"]: for json_field in ["args", "env_vars", "headers"]:
if json_field in update_fields and update_fields[json_field] is not None: if json_field in update_fields and update_fields[json_field] is not None:
update_fields[json_field] = json.dumps(update_fields[json_field]) update_fields[json_field] = json.dumps(update_fields[json_field])
for key, value in update_fields.items(): for key, value in update_fields.items():
if value is not None: if value is not None:
setattr(server, key, value) setattr(server, key, value)
server.updated_at = datetime.now() server.updated_at = datetime.now()
db.commit() db.commit()
db.refresh(server) db.refresh(server)
logger.info(f"MCP server updated: {server.name} ({server.id})") logger.info(f"MCP server updated: {server.name} ({server.id})")
return {"code": 0, "data": server_to_dict(server), "message": "MCP server updated"} return {"code": 0, "data": server_to_dict(server), "message": "MCP server updated"}
@router.delete("/{server_id}", summary="Delete MCP server") @router.delete("/{server_id}", summary="Delete MCP server")
async def delete_mcp_server( async def delete_mcp_server(
server_id: str, server_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""Delete an MCP server""" """Delete an MCP server"""
from ...models import MCPServer from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first() server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") raise HTTPException(status_code=404, detail="MCP server not found")
name = server.name name = server.name
db.delete(server) db.delete(server)
db.commit() db.commit()
logger.info(f"MCP server deleted: {name} ({server_id})") logger.info(f"MCP server deleted: {name} ({server_id})")
return {"code": 0, "data": None, "message": "MCP server deleted"} return {"code": 0, "data": None, "message": "MCP server deleted"}
@router.put("/{server_id}/toggle", summary="Enable/Disable MCP server") @router.put("/{server_id}/toggle", summary="Enable/Disable MCP server")
async def toggle_mcp_server( async def toggle_mcp_server(
server_id: str, server_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""Toggle MCP server enabled state""" """Toggle MCP server enabled state"""
from ...models import MCPServer from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first() server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") raise HTTPException(status_code=404, detail="MCP server not found")
server.is_enabled = not server.is_enabled server.is_enabled = not server.is_enabled
server.updated_at = datetime.now() server.updated_at = datetime.now()
db.commit() db.commit()
logger.info(f"MCP server toggled: {server.name} -> enabled={server.is_enabled}") 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") @router.post("/{server_id}/test", summary="Test MCP server connection")
async def test_mcp_connection( async def test_mcp_connection(
@@ -382,130 +741,146 @@ async def test_mcp_connection(
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") 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 # If test_data provides an override URL/transport, apply it temporarily
result = {"success": False, "message": "", "details": {}} 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: result = _test_mcp_server(server, db)
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 # Restore original values if no override was explicitly persisted
import httpx if test_data and test_data.url is None:
try: server.url = original_url
response = httpx.get(sse_url, timeout=10.0) if test_data and test_data.transport_type is None:
result["success"] = response.status_code < 500 server.transport_type = original_transport
result["message"] = f"SSE endpoint responded with {response.status_code}" if test_data:
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() db.commit()
return {"code": 0, "data": result, "message": "ok"} return {"code": 0, "data": result, "message": "ok"}
@router.get("/{server_id}/tools", summary="List MCP server tools") @router.get("/{server_id}/tools", summary="List MCP server tools")
async def list_mcp_tools( async def list_mcp_tools(
server_id: str, server_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""List tools exposed by an MCP server (placeholder/mock)""" """List tools exposed by an MCP server (placeholder/mock)"""
from ...models import MCPServer from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first() server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") 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 # 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 # For now, return mock data based on common MCP tools
mock_tools = [] mock_tools = []
if server.transport_type == "stdio": if server.transport_type == "stdio":
mock_tools = [ mock_tools = [
{"name": "read_file", "description": "Read contents of a file"}, {"name": "read_file", "description": "Read contents of a file"},
{"name": "write_file", "description": "Write contents to a file"}, {"name": "write_file", "description": "Write contents to a file"},
{"name": "execute_command", "description": "Execute a shell command"}, {"name": "execute_command", "description": "Execute a shell command"},
{"name": "search_content", "description": "Search for content in files"}, {"name": "search_content", "description": "Search for content in files"},
] ]
elif server.transport_type == "sse": elif server.transport_type == "sse":
mock_tools = [ mock_tools = [
{"name": "web_search", "description": "Search the web"}, {"name": "web_search", "description": "Search the web"},
{"name": "fetch_url", "description": "Fetch content from URL"}, {"name": "fetch_url", "description": "Fetch content from URL"},
{"name": "api_request", "description": "Make API requests"}, {"name": "api_request", "description": "Make API requests"},
] ]
return { return {
"code": 0, "code": 0,
"data": { "data": {
"server_name": server.name, "server_name": server.name,
"tools": mock_tools, "tools": mock_tools,
"count": len(mock_tools), "count": len(mock_tools),
}, },
"message": "ok", "message": "ok",
} }
@router.get("/{server_id}/resources", summary="List MCP server resources") @router.get("/{server_id}/resources", summary="List MCP server resources")
async def list_mcp_resources( async def list_mcp_resources(
server_id: str, server_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
): ):
"""List resources exposed by an MCP server (placeholder/mock)""" """List resources exposed by an MCP server (placeholder/mock)"""
from ...models import MCPServer from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first() server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server: if not server:
raise HTTPException(status_code=404, detail="MCP server not found") raise HTTPException(status_code=404, detail="MCP server not found")
mock_resources = [ mock_resources = [
{"uri": f"{server.name}://docs/readme", "name": "Documentation", "mime_type": "text/markdown"}, {"uri": f"{server.name}://docs/readme", "name": "Documentation", "mime_type": "text/markdown"},
{"uri": f"{server.name}://config/settings", "name": "Configuration", "mime_type": "application/json"}, {"uri": f"{server.name}://config/settings", "name": "Configuration", "mime_type": "application/json"},
] ]
return { return {
"code": 0, "code": 0,
"data": { "data": {
"server_name": server.name, "server_name": server.name,
"resources": mock_resources, "resources": mock_resources,
"count": len(mock_resources), "count": len(mock_resources),
}, },
"message": "ok", "message": "ok",
} }
+4
View File
@@ -16,6 +16,7 @@ from ..models import (
) )
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from app.api.admin.mcp_admin import _test_mcp_server
router = APIRouter() router = APIRouter()
@@ -417,6 +418,9 @@ def upload_app(
db.add(server) db.add(server)
db.commit() db.commit()
# 导入后自动做一次连通性测试,状态会同步为 connected / error
_test_mcp_server(server, db)
# 用户创建/导入的应用自动标记为已安装 # 用户创建/导入的应用自动标记为已安装
existing_install = db.query(AppInstallation).filter( existing_install = db.query(AppInstallation).filter(
AppInstallation.user_id == current_user.id, AppInstallation.user_id == current_user.id,
+407 -406
View File
@@ -1,406 +1,407 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api'; import { adminAPI } from '@/api';
import { formatBjTime } from '@/utils/datetime';
const styles: Record<string, React.CSSProperties> = {
const styles: Record<string, React.CSSProperties> = { container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }, title: { fontSize: 24, fontWeight: 700, color: '#1A1F36' },
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 },
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 },
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)' },
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }, statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 }, statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' },
statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' }, toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
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 },
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' },
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)' },
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' },
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' },
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 },
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' },
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 },
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 },
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 },
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)' },
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' },
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1A1F36' }, formGroup: { marginBottom: 16 },
formGroup: { marginBottom: 16 }, label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
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 },
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 },
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 },
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 },
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 },
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' },
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 },
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 }, detailsItem: { fontSize: 13 },
detailsItem: { fontSize: 13 }, detailsLabel: { color: '#6b7280', marginBottom: 4 },
detailsLabel: { color: '#6b7280', marginBottom: 4 }, };
};
const TRANSPORT_TYPES = [
const TRANSPORT_TYPES = [ { value: '', label: '全部类型' },
{ value: '', label: '全部类型' }, { value: 'stdio', label: 'STDIO' },
{ value: 'stdio', label: 'STDIO' }, { value: 'sse', label: 'SSE' },
{ value: 'sse', label: 'SSE' }, { value: 'streamable_http', label: 'HTTP 流式' },
]; ];
const STATUS_TYPES = [ const STATUS_TYPES = [
{ value: '', label: '全部状态' }, { value: '', label: '全部状态' },
{ value: 'connected', label: '已连接' }, { value: 'connected', label: '已连接' },
{ value: 'disconnected', label: '未连接' }, { value: 'disconnected', label: '未连接' },
{ value: 'error', label: '错误' }, { value: 'error', label: '错误' },
]; ];
interface MCPServer { interface MCPServer {
id: string; id: string;
name: string; name: string;
description: string; description: string;
transport_type: string; transport_type: string;
transport_type_label: string; transport_type_label: string;
command: string; command: string;
args: Record<string, any>; args: Record<string, any>;
env_vars: Record<string, string>; env_vars: Record<string, string>;
url: string; url: string;
headers: Record<string, string>; headers: Record<string, string>;
is_enabled: boolean; is_enabled: boolean;
status: string; status: string;
last_connected_at: string; last_connected_at: string;
tools_count: number; tools_count: number;
resources_count: number; resources_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export default function AdminMcp() { export default function AdminMcp() {
const [servers, setServers] = useState<MCPServer[]>([]); const [servers, setServers] = useState<MCPServer[]>([]);
const [stats, setStats] = useState<any>({}); const [stats, setStats] = useState<any>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filterTransport, setFilterTransport] = useState(''); const [filterTransport, setFilterTransport] = useState('');
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServer | null>(null); const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
const [toast, setToast] = useState<{ message: string; type: string } | null>(null); const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
const pageSize = 20; const pageSize = 20;
const showToast = (message: string, type: string = 'success') => { const showToast = (message: string, type: string = 'success') => {
setToast({ message, type }); setToast({ message, type });
setTimeout(() => setToast(null), 3000); setTimeout(() => setToast(null), 3000);
}; };
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
try { try {
const result = await adminAPI.getAdminMcpStats() as any; const result = await adminAPI.getAdminMcpStats() as any;
if (result.code === 0) setStats(result.data); if (result.code === 0) setStats(result.data);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, []); }, []);
const fetchServers = useCallback(async () => { const fetchServers = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const params: any = { page, page_size: pageSize }; const params: any = { page, page_size: pageSize };
if (search) params.search = search; if (search) params.search = search;
if (filterTransport) params.transport_type = filterTransport; if (filterTransport) params.transport_type = filterTransport;
if (filterStatus) params.status = filterStatus; if (filterStatus) params.status = filterStatus;
const result = await adminAPI.getAdminMcpServers(params) as any; const result = await adminAPI.getAdminMcpServers(params) as any;
if (result.code === 0) { if (result.code === 0) {
setServers(result.data.items || []); setServers(result.data.items || []);
setTotal(result.data.total || 0); setTotal(result.data.total || 0);
} }
} catch (e) { } catch (e) {
showToast('加载服务失败', 'error'); showToast('加载服务失败', 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, search, filterTransport, filterStatus]); }, [page, search, filterTransport, filterStatus]);
useEffect(() => { useEffect(() => {
fetchStats(); fetchStats();
fetchServers(); fetchServers();
}, [fetchStats, fetchServers]); }, [fetchStats, fetchServers]);
const handleCreate = () => { const handleCreate = () => {
setEditingServer(null); setEditingServer(null);
setShowModal(true); setShowModal(true);
}; };
const handleEdit = (server: MCPServer) => { const handleEdit = (server: MCPServer) => {
setEditingServer(server); setEditingServer(server);
setShowModal(true); setShowModal(true);
}; };
const handleDelete = async (server: MCPServer) => { const handleDelete = async (server: MCPServer) => {
if (!confirm(`确认删除 MCP Client "${server.name}"?`)) return; if (!confirm(`确认删除 MCP 服务 "${server.name}"?`)) return;
try { try {
await adminAPI.deleteAdminMcpServer(server.id); await adminAPI.deleteAdminMcpServer(server.id);
showToast('服务已删除'); showToast('服务已删除');
fetchServers(); fetchServers();
fetchStats(); fetchStats();
} catch (e) { } catch (e) {
showToast('删除失败', 'error'); showToast('删除失败', 'error');
} }
}; };
const handleToggle = async (server: MCPServer) => { const handleToggle = async (server: MCPServer) => {
try { try {
await adminAPI.toggleAdminMcpServer(server.id); await adminAPI.toggleAdminMcpServer(server.id);
showToast(server.is_enabled ? '已禁用' : '已启用'); showToast(server.is_enabled ? '已禁用' : '已启用');
fetchServers(); fetchServers();
fetchStats(); fetchStats();
} catch (e) { } catch (e) {
showToast('切换失败', 'error'); showToast('切换失败', 'error');
} }
}; };
const handleTest = async (server: MCPServer) => { const handleTest = async (server: MCPServer) => {
try { try {
const result = await adminAPI.testAdminMcpServer(server.id) as any; const result = await adminAPI.testAdminMcpServer(server.id) as any;
if (result.code === 0) { if (result.code === 0) {
showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error'); showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error');
fetchServers(); fetchServers();
fetchStats(); fetchStats();
} }
} catch (e) { } catch (e) {
showToast('测试失败', 'error'); showToast('测试失败', 'error');
} }
}; };
const handleSubmit = async (formData: any) => { const handleSubmit = async (formData: any) => {
try { try {
if (editingServer) { if (editingServer) {
await adminAPI.updateAdminMcpServer(editingServer.id, formData); await adminAPI.updateAdminMcpServer(editingServer.id, formData);
showToast('服务已更新'); showToast('服务已更新');
} else { } else {
await adminAPI.createAdminMcpServer(formData); await adminAPI.createAdminMcpServer(formData);
showToast('服务已创建'); showToast('服务已创建');
} }
setShowModal(false); setShowModal(false);
fetchServers(); fetchServers();
fetchStats(); fetchStats();
} catch (e: any) { } catch (e: any) {
showToast(e.message || '操作失败', 'error'); showToast(e.message || '操作失败', 'error');
} }
}; };
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' }; const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' };
const colors: Record<string, { bg: string; color: string }> = { const colors: Record<string, { bg: string; color: string }> = {
connected: { bg: '#dcfce7', color: '#166534' }, connected: { bg: '#dcfce7', color: '#166534' },
disconnected: { bg: '#f3f4f6', color: '#6b7280' }, disconnected: { bg: '#f3f4f6', color: '#6b7280' },
error: { bg: '#fee2e2', color: '#991b1b' }, error: { bg: '#fee2e2', color: '#991b1b' },
}; };
const c = colors[status] || colors.disconnected; const c = colors[status] || colors.disconnected;
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>; return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>;
}; };
const getTransportBadge = (type: string) => { const getTransportBadge = (type: string) => {
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' }; const labels: Record<string, string> = { sse: 'SSE', stdio: '标准IO', streamable_http: 'HTTP 流式' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>; const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : type === 'streamable_http' ? { bg: '#d1fae5', color: '#065f46' } : { bg: '#fef3c7', color: '#92400e' };
}; return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[type] || type}</span>;
};
const totalPages = Math.ceil(total / pageSize);
const totalPages = Math.ceil(total / pageSize);
return (
<div style={styles.container}> return (
{toast && ( <div style={styles.container}>
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}> {toast && (
{toast.message} <div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
</div> {toast.message}
)} </div>
)}
<div style={styles.header}>
<h1 style={styles.title}>MCP Client管理</h1> <div style={styles.header}>
{/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */} <h1 style={styles.title}>MCP 服务管理</h1>
</div> {/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */}
</div>
<div style={styles.statsRow}>
<div style={styles.statCard}> <div style={styles.statsRow}>
<div style={styles.statLabel}>服务总数</div> <div style={styles.statCard}>
<div style={styles.statValue}>{stats.total_servers || 0}</div> <div style={styles.statLabel}>服务总数</div>
</div> <div style={styles.statValue}>{stats.total_servers || 0}</div>
<div style={styles.statCard}> </div>
<div style={styles.statLabel}>已启用</div> <div style={styles.statCard}>
<div style={styles.statValue}>{stats.enabled_servers || 0}</div> <div style={styles.statLabel}>已启用</div>
</div> <div style={styles.statValue}>{stats.enabled_servers || 0}</div>
<div style={styles.statCard}> </div>
<div style={styles.statLabel}>已连接</div> <div style={styles.statCard}>
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div> <div style={styles.statLabel}>已连接</div>
</div> <div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
<div style={styles.statCard}> </div>
<div style={styles.statLabel}>传输方式分布</div> <div style={styles.statCard}>
<div style={{ fontSize: 13, color: '#374151' }}> <div style={styles.statLabel}>传输方式分布</div>
{Object.entries(stats.by_transport || {}).map(([k, v]) => ( <div style={{ fontSize: 13, color: '#374151' }}>
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span> {Object.entries(stats.by_transport || {}).map(([k, v]) => (
))} <span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
</div> ))}
</div> </div>
</div> </div>
</div>
<div style={styles.toolbar}>
<input <div style={styles.toolbar}>
style={styles.searchInput} <input
placeholder="搜索服务..." style={styles.searchInput}
value={search} placeholder="搜索服务..."
onChange={e => { setSearch(e.target.value); setPage(1); }} value={search}
/> onChange={e => { setSearch(e.target.value); setPage(1); }}
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}> />
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)} <select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
</select> {TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}> </select>
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)} <select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
</select> {STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</div> </select>
</div>
<table style={styles.table}>
<thead> <table style={styles.table}>
<tr> <thead>
<th style={styles.th}>名称</th> <tr>
<th style={styles.th}>传输方式</th> <th style={styles.th}>名称</th>
<th style={styles.th}>状态</th> <th style={styles.th}>传输方式</th>
<th style={styles.th}>工具数</th> <th style={styles.th}>状态</th>
<th style={styles.th}>最后连接</th> <th style={styles.th}>工具数</th>
<th style={styles.th}>启用</th> <th style={styles.th}>最后连接</th>
<th style={styles.th}>操作</th> <th style={styles.th}>启用</th>
</tr> <th style={styles.th}>操作</th>
</thead> </tr>
<tbody> </thead>
{loading ? ( <tbody>
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr> {loading ? (
) : servers.length === 0 ? ( <tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP Client</td></tr> ) : servers.length === 0 ? (
) : servers.map(s => ( <tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
<tr key={s.id}> ) : servers.map(s => (
<td style={styles.td}> <tr key={s.id}>
<div style={{ fontWeight: 600 }}>{s.name}</div> <td style={styles.td}>
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div> <div style={{ fontWeight: 600 }}>{s.name}</div>
</td> <div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td> </td>
<td style={styles.td}>{getStatusBadge(s.status)}</td> <td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
<td style={styles.td}>{s.tools_count || '-'}</td> <td style={styles.td}>{getStatusBadge(s.status)}</td>
<td style={styles.td}>{s.last_connected_at ? formatBjTime(s.last_connected_at) : '从未'}</td> <td style={styles.td}>{s.tools_count || '-'}</td>
<td style={styles.td}> <td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}> <td style={styles.td}>
{s.is_enabled ? '是' : '否'} <span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
</span> {s.is_enabled ? '是' : '否'}
</td> </span>
<td style={styles.td}> </td>
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button> <td style={styles.td}>
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}> <button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button>
{s.is_enabled ? '禁用' : '启用'} <button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
</button> {s.is_enabled ? '禁用' : '启用'}
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button> </button>
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button> <button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button>
</td> <button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button>
</tr> </td>
))} </tr>
</tbody> ))}
</table> </tbody>
</table>
{totalPages > 1 && (
<div style={styles.pagination}> {totalPages > 1 && (
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button> <div style={styles.pagination}>
<span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span> <button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button> <span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span>
</div> <button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
)} </div>
)}
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
</div> {showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
); </div>
} );
}
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
const [form, setForm] = useState({ function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
name: server?.name || '', const [form, setForm] = useState({
description: server?.description || '', name: server?.name || '',
transport_type: server?.transport_type || 'stdio', description: server?.description || '',
command: server?.command || '', transport_type: server?.transport_type || 'stdio',
args: server?.args ? JSON.stringify(server.args, null, 2) : '', command: server?.command || '',
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '', args: server?.args ? JSON.stringify(server.args, null, 2) : '',
url: server?.url || '', env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
headers: server?.headers ? JSON.stringify(server.headers, 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 handleChange = (field: string, value: string) => {
}; setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); const handleSubmit = (e: React.FormEvent) => {
const data: any = { e.preventDefault();
name: form.name, const data: any = {
description: form.description, name: form.name,
transport_type: form.transport_type, description: form.description,
}; transport_type: form.transport_type,
};
if (form.transport_type === 'stdio') {
data.command = form.command; if (form.transport_type === 'stdio') {
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; } data.command = form.command;
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; } try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
} else { try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
data.url = form.url; } else {
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; } data.url = form.url;
} try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
}
onSubmit(data);
}; onSubmit(data);
};
return (
<div style={styles.modalOverlay} onClick={onClose}> return (
<div style={styles.modal} onClick={e => e.stopPropagation()}> <div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modalTitle}>{server ? '编辑 MCP Client' : '添加 MCP Client'}</div> <div style={styles.modal} onClick={e => e.stopPropagation()}>
<form onSubmit={handleSubmit}> <div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
<div style={styles.formGroup}> <form onSubmit={handleSubmit}>
<label style={styles.label}>名称 *</label> <div style={styles.formGroup}>
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" /> <label style={styles.label}>名称 *</label>
</div> <input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" />
<div style={styles.formGroup}> </div>
<label style={styles.label}>描述</label> <div style={styles.formGroup}>
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" /> <label style={styles.label}>描述</label>
</div> <textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" />
<div style={styles.formGroup}> </div>
<label style={styles.label}>传输类型</label> <div style={styles.formGroup}>
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}> <label style={styles.label}>传输类型</label>
<option value="stdio">STDIO(命令行方式)</option> <select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
<option value="sse">SSE(HTTP 方式)</option> <option value="stdio">STDIO(命令行方式)</option>
</select> <option value="sse">SSE(HTTP 方式)</option>
</div> </select>
</div>
{form.transport_type === 'stdio' ? (
<> {form.transport_type === 'stdio' ? (
<div style={styles.formGroup}> <>
<label style={styles.label}>命令</label> <div style={styles.formGroup}>
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" /> <label style={styles.label}>命令</label>
</div> <input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" />
<div style={styles.formGroup}> </div>
<label style={styles.label}>参数(JSON)</label> <div style={styles.formGroup}>
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' /> <label style={styles.label}>参数(JSON)</label>
</div> <textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
<div style={styles.formGroup}> </div>
<label style={styles.label}>环境变量(JSON)</label> <div style={styles.formGroup}>
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' /> <label style={styles.label}>环境变量(JSON)</label>
</div> <textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
</> </div>
) : ( </>
<> ) : (
<div style={styles.formGroup}> <>
<label style={styles.label}>SSE 地址</label> <div style={styles.formGroup}>
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" /> <label style={styles.label}>SSE 地址</label>
</div> <input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
<div style={styles.formGroup}> </div>
<label style={styles.label}>请求头(JSON)</label> <div style={styles.formGroup}>
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' /> <label style={styles.label}>请求头(JSON)</label>
</div> <textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
</> </div>
)} </>
)}
<div style={styles.modalActions}>
<button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button> <div style={styles.modalActions}>
<button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button> <button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button>
</div> <button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button>
</form> </div>
</div> </form>
</div> </div>
); </div>
} );
}