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
+461 -81

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,
+9 -8
View File
@@ -1,6 +1,5 @@
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' },
@@ -41,6 +40,7 @@ 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 = [
@@ -131,7 +131,7 @@ export default function AdminMcp() {
}; };
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('服务已删除');
@@ -195,8 +195,9 @@ export default function AdminMcp() {
}; };
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);
@@ -210,7 +211,7 @@ export default function AdminMcp() {
)} )}
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>MCP Client管理</h1> <h1 style={styles.title}>MCP 服务管理</h1>
{/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */} {/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */}
</div> </div>
@@ -268,7 +269,7 @@ export default function AdminMcp() {
{loading ? ( {loading ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr> <tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP Client</td></tr> <tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
) : servers.map(s => ( ) : servers.map(s => (
<tr key={s.id}> <tr key={s.id}>
<td style={styles.td}> <td style={styles.td}>
@@ -278,7 +279,7 @@ export default function AdminMcp() {
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td> <td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
<td style={styles.td}>{getStatusBadge(s.status)}</td> <td style={styles.td}>{getStatusBadge(s.status)}</td>
<td style={styles.td}>{s.tools_count || '-'}</td> <td style={styles.td}>{s.tools_count || '-'}</td>
<td style={styles.td}>{s.last_connected_at ? formatBjTime(s.last_connected_at) : '从未'}</td> <td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}> <span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
{s.is_enabled ? '是' : '否'} {s.is_enabled ? '是' : '否'}
@@ -349,7 +350,7 @@ function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onS
return ( return (
<div style={styles.modalOverlay} onClick={onClose}> <div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}> <div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalTitle}>{server ? '编辑 MCP Client' : '添加 MCP Client'}</div> <div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={styles.formGroup}> <div style={styles.formGroup}>
<label style={styles.label}>名称 *</label> <label style={styles.label}>名称 *</label>