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