From 909c013e2dc677c6017475f03805c675fd33c2a7 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Mon, 6 Jul 2026 11:48:21 +0800 Subject: [PATCH] Phase G1-1~G1-6: MCP Marketplace Fusion - DB migration, mcp_market_admin.py, Deep Link generator, basic security scanner, McpMarketTab.tsx frontend, Market.tsx integration --- backend/python/main.py | 2 + .../qihuaiflow/api/admin/mcp_market_admin.py | 1224 +++++++++++++++++ frontend/src/api/index.ts | 23 + frontend/src/pages/admin/Market.tsx | 8 +- frontend/src/pages/admin/McpMarketTab.tsx | 729 ++++++++++ 5 files changed, 1985 insertions(+), 1 deletion(-) create mode 100644 backend/python/www/wwwroot/qihuaiflow/api/admin/mcp_market_admin.py create mode 100644 frontend/src/pages/admin/McpMarketTab.tsx diff --git a/backend/python/main.py b/backend/python/main.py index 7ae0175..1463a2d 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -36,6 +36,7 @@ from app.api import auth, chat, channels, knowledge, market, token_usage, paymen from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin from app.api.admin.developer_tools_admin import router as developer_tools_router +from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin import asset_admin from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD @@ -429,6 +430,7 @@ app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台- app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) +app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) diff --git a/backend/python/www/wwwroot/qihuaiflow/api/admin/mcp_market_admin.py b/backend/python/www/wwwroot/qihuaiflow/api/admin/mcp_market_admin.py new file mode 100644 index 0000000..35327b8 --- /dev/null +++ b/backend/python/www/wwwroot/qihuaiflow/api/admin/mcp_market_admin.py @@ -0,0 +1,1224 @@ +""" +MCP Tool Marketplace Admin API +Phase G1: MCP Marketplace Fusion with Goose +- MCP tool CRUD (creates MarketApp + McpToolConfig) +- Deep Link generation (goose://extension?...) +- Security audit status management +- Managed MCP server framework +- Analytics & search +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import or_, func, desc, text +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone, timedelta +import uuid +import json +import base64 +import urllib.parse +import logging + +from ...database import get_db, SessionLocal +from ...models import MarketApp, gen_uuid +from ...auth import get_current_admin + +router = APIRouter() +logger = logging.getLogger("aiflow") + +CST = timezone(timedelta(hours=8)) + +# ============================================================ +# Constants +# ============================================================ + +MCP_CATEGORIES = { + "developer": "开发工具", + "database": "数据库", + "search": "搜索检索", + "file": "文件系统", + "browser": "浏览器", + "design": "设计创意", + "data": "数据处理", + "communication": "通信协作", + "cloud": "云服务", + "productivity": "效率工具", + "other": "其他", +} + +SERVER_TYPES = { + "stdio": "本地进程 (STDIO)", + "sse": "SSE 流式", + "streamable_http": "HTTP 流式", + "builtin": "内置扩展", +} + +SECURITY_STATUSES = { + "pending": "待审核", + "scanning": "扫描中", + "approved": "已通过", + "rejected": "已拒绝", + "failed": "扫描失败", +} + +RUNTIMES = { + "node": "Node.js", + "python": "Python", + "go": "Go", + "rust": "Rust", + "binary": "二进制", + "remote": "远程服务", +} + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _format_cst(dt): + """Format datetime to CST string.""" + if dt is None: + return None + try: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return str(dt) + + +def _execute_raw(db, sql, params=None): + """Execute raw SQL and return list of dicts.""" + result = db.execute(text(sql), params or {}) + try: + rows = result.fetchall() + return [dict(row._mapping) if hasattr(row, "_mapping") else dict(row) for row in rows] + except Exception: + return [] + + +def _parse_json(val): + """Safely parse JSON field.""" + if val is None: + return None + if isinstance(val, (dict, list)): + return val + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + return None + return None + + +def _generate_deep_link(config: dict) -> str: + """ + Generate Goose deep link: goose://extension?... + Format: goose://extension?name=&cmd=&args=&env=&type= + """ + params = {} + name = config.get("name") or config.get("builtin_name") or "mcp-tool" + params["name"] = name + + st = config.get("server_type", "stdio") + if st == "builtin": + params["type"] = "builtin" + params["name"] = config.get("builtin_name", name) + elif st == "sse": + params["type"] = "sse" + if config.get("transport_url"): + params["url"] = config["transport_url"] + elif st == "streamable_http": + params["type"] = "streamable_http" + if config.get("transport_url"): + params["url"] = config["transport_url"] + else: # stdio + params["type"] = "stdio" + if config.get("command"): + params["cmd"] = config["command"] + if config.get("args"): + try: + args_val = config["args"] + if isinstance(args_val, str): + args_val = json.loads(args_val) + params["args"] = base64.b64encode( + json.dumps(args_val).encode() + ).decode() + except Exception: + pass + if config.get("env_vars"): + try: + env_val = config["env_vars"] + if isinstance(env_val, str): + env_val = json.loads(env_val) + params["env"] = base64.b64encode( + json.dumps(env_val).encode() + ).decode() + except Exception: + pass + + # Build URL + query = urllib.parse.urlencode(params, safe="/:") + return f"goose://extension?{query}" + + +def _basic_security_check(config: dict) -> dict: + """ + Basic security heuristic check (full scanner in G1-4). + Returns {status, issues, report}. + """ + issues = [] + risk_level = "low" + + command = (config.get("command") or "").lower() + args_str = json.dumps(config.get("args") or []) + env_str = json.dumps(config.get("env_vars") or {}) + + # Check for dangerous commands + dangerous_patterns = ["rm -rf", "mkfs", "dd if=", "chmod 777", "curl | sh", "wget | sh", "eval(", "exec(", "subprocess.call"] + all_text = f"{command} {args_str} {env_str}".lower() + for pat in dangerous_patterns: + if pat in all_text: + issues.append({"severity": "critical", "rule": f"dangerous_pattern:{pat}", "message": f"检测到危险命令模式: {pat}"}) + risk_level = "critical" + + # Check env vars for secrets patterns + env_vars = _parse_json(config.get("env_vars")) or {} + for key in env_vars: + kl = key.lower() + if any(s in kl for s in ["secret", "password", "token", "api_key", "private_key"]): + issues.append({"severity": "warning", "rule": "env_secret", "message": f"环境变量 {key} 可能包含敏感信息,建议使用密钥管理"}) + if risk_level == "low": + risk_level = "medium" + + # Check package source + registry = config.get("package_registry") + pkg_name = config.get("package_name") or "" + if registry == "custom" or (not registry and config.get("package_repo_url")): + issues.append({"severity": "info", "rule": "custom_registry", "message": "使用自定义包源,建议人工审核"}) + if risk_level == "low": + risk_level = "low" + + # Check network access + if config.get("transport_url") or config.get("is_managed"): + issues.append({"severity": "info", "rule": "network_access", "message": "该工具需要网络访问"}) + + # Determine status + if risk_level == "critical": + status = "rejected" + elif risk_level == "medium": + status = "pending" # needs manual review + else: + status = "approved" + + return { + "status": status, + "risk_level": risk_level, + "issues_count": len(issues), + "issues": issues, + "scanned_at": datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S"), + } + + +def _tool_config_to_dict(row: dict, app: dict = None) -> dict: + """Convert mcp_tool_config raw row to dict with parsed JSON fields.""" + d = dict(row) + for k in ["args", "env_vars", "security_report", "permissions", "config_schema", "default_config", "tools_list"]: + if k in d: + d[k] = _parse_json(d[k]) + d["created_at"] = _format_cst(d.get("created_at")) + d["updated_at"] = _format_cst(d.get("updated_at")) + d["security_scanned_at"] = _format_cst(d.get("security_scanned_at")) + if app: + d["app"] = app + return d + + +def _app_to_dict(app) -> dict: + """Convert MarketApp ORM to dict.""" + return { + "id": app.id, + "name": app.name, + "type": app.type, + "category": getattr(app, "category", "other"), + "tags": getattr(app, "tags", None), + "source": app.source, + "publisher_name": app.publisher_name, + "description": app.description, + "icon": app.icon, + "price": float(app.price) if app.price else 0.0, + "is_member_free": bool(app.is_member_free), + "featured": bool(getattr(app, "featured", False)), + "status": app.status, + "rating_avg": float(app.rating_avg) if app.rating_avg else 0.0, + "review_count": getattr(app, "review_count", 0), + "install_count": getattr(app, "install_count", 0), + "version": getattr(app, "version", "1.0.0"), + "created_at": _format_cst(app.created_at), + } + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class MCPToolCreate(BaseModel): + name: str = Field(..., max_length=200) + description: Optional[str] = "" + icon: Optional[str] = "" + category: Optional[str] = "developer" + tags: Optional[str] = "" + # MarketApp fields + price: Optional[float] = 0.0 + is_member_free: Optional[bool] = False + featured: Optional[bool] = False + # MCP Config + server_type: str = "stdio" + runtime: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env_vars: Optional[Dict[str, str]] = None + transport_url: Optional[str] = None + builtin_name: Optional[str] = None + is_managed: Optional[bool] = False + managed_port: Optional[int] = None + # Package + package_name: Optional[str] = None + package_version: Optional[str] = "latest" + package_registry: Optional[str] = None + package_repo_url: Optional[str] = None + # Metadata + permissions: Optional[Dict[str, Any]] = None + config_schema: Optional[Dict[str, Any]] = None + default_config: Optional[Dict[str, Any]] = None + install_instructions: Optional[str] = None + tools_count: Optional[int] = 0 + tools_list: Optional[List[str]] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = "MIT" + homepage_url: Optional[str] = None + + +class MCPToolUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + price: Optional[float] = None + is_member_free: Optional[bool] = None + featured: Optional[bool] = None + status: Optional[str] = None + # MCP Config + server_type: Optional[str] = None + runtime: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env_vars: Optional[Dict[str, str]] = None + transport_url: Optional[str] = None + builtin_name: Optional[str] = None + is_managed: Optional[bool] = None + managed_port: Optional[int] = None + # Package + package_name: Optional[str] = None + package_version: Optional[str] = None + package_registry: Optional[str] = None + package_repo_url: Optional[str] = None + # Metadata + permissions: Optional[Dict[str, Any]] = None + config_schema: Optional[Dict[str, Any]] = None + default_config: Optional[Dict[str, Any]] = None + install_instructions: Optional[str] = None + tools_count: Optional[int] = None + tools_list: Optional[List[str]] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = None + homepage_url: Optional[str] = None + + +class SecurityStatusUpdate(BaseModel): + security_status: str # pending/scanning/approved/rejected/failed + security_report: Optional[Dict[str, Any]] = None + + +class ManagedServerCreate(BaseModel): + name: str + config_id: str # mcp_tool_configs.id + port: Optional[int] = None + + +# ============================================================ +# Routes +# ============================================================ + +@router.get("/stats", summary="MCP 市场统计") +async def get_mcp_market_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具市场总览数据""" + # Total by status + status_rows = _execute_raw(db, """ + SELECT security_status, COUNT(*) as cnt + FROM mcp_tool_configs + GROUP BY security_status + """) + status_map = {r["security_status"]: r["cnt"] for r in status_rows} + + # Total by server_type + type_rows = _execute_raw(db, """ + SELECT server_type, COUNT(*) as cnt + FROM mcp_tool_configs + GROUP BY server_type + """) + type_map = {r["server_type"]: r["cnt"] for r in type_rows} + + # Total by category (from market_apps) + cat_rows = _execute_raw(db, """ + SELECT ma.category, COUNT(*) as cnt + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + GROUP BY ma.category + """) + cat_map = {r["category"]: r["cnt"] for r in cat_rows} + + # Managed vs non-managed + managed_count = _execute_raw(db, "SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE is_managed=1") + managed = managed_count[0]["cnt"] if managed_count else 0 + + # Published apps + published = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM market_apps WHERE type='mcp_tool' AND status='published' + """) + pub_count = published[0]["cnt"] if published else 0 + + # Pending review + pending = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE security_status IN ('pending','scanning') + """) + pending_count = pending[0]["cnt"] if pending else 0 + + total = sum(status_map.values()) + + return { + "code": 0, + "data": { + "total": total, + "published": pub_count, + "pending_review": pending_count, + "managed_servers": managed, + "by_security_status": status_map, + "by_server_type": type_map, + "by_category": cat_map, + } + } + + +@router.get("/", summary="MCP 工具列表") +async def list_mcp_tools( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: Optional[str] = Query(None), + category: Optional[str] = Query(None), + server_type: Optional[str] = Query(None), + security_status: Optional[str] = Query(None), + is_managed: Optional[bool] = Query(None), + sort: str = Query("newest"), # newest/popular/rating/name + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具列表(分页、搜索、过滤)""" + where_parts = ["ma.type = 'mcp_tool'"] + params = {} + + if keyword: + where_parts.append("(ma.name LIKE :kw OR ma.description LIKE :kw OR mc.package_name LIKE :kw)") + params["kw"] = f"%{keyword}%" + if category: + where_parts.append("ma.category = :cat") + params["cat"] = category + if server_type: + where_parts.append("mc.server_type = :st") + params["st"] = server_type + if security_status: + where_parts.append("mc.security_status = :ss") + params["ss"] = security_status + if is_managed is not None: + where_parts.append("mc.is_managed = :mg") + params["mg"] = 1 if is_managed else 0 + + where_clause = " AND ".join(where_parts) + + # Sort + order_map = { + "newest": "mc.created_at DESC", + "popular": "ma.install_count DESC", + "rating": "ma.rating_avg DESC", + "name": "ma.name ASC", + } + order = order_map.get(sort, order_map["newest"]) + + # Count + count_sql = f"SELECT COUNT(*) as cnt FROM mcp_tool_configs mc JOIN market_apps ma ON mc.app_id = ma.id WHERE {where_clause}" + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["cnt"] if count_rows else 0 + + # Query + offset = (page - 1) * page_size + query_sql = f""" + SELECT mc.*, ma.name, ma.description, ma.icon, ma.category, ma.tags, + ma.status as app_status, ma.publisher_name, ma.price, ma.is_member_free, + ma.featured, ma.rating_avg, ma.review_count, ma.install_count, + ma.version, ma.created_at as app_created_at + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE {where_clause} + ORDER BY {order} + LIMIT :limit OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = offset + rows = _execute_raw(db, query_sql, params) + + items = [] + for row in rows: + item = { + "id": row["id"], + "app_id": row["app_id"], + "name": row["name"], + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "tags": row["tags"], + "status": row["app_status"], + "publisher_name": row["publisher_name"], + "price": float(row["price"]) if row["price"] else 0.0, + "is_member_free": bool(row["is_member_free"]), + "featured": bool(row["featured"]), + "rating_avg": float(row["rating_avg"]) if row["rating_avg"] else 0.0, + "review_count": row["review_count"], + "install_count": row["install_count"], + "version": row["version"], + "server_type": row["server_type"], + "runtime": row["runtime"], + "command": row["command"], + "is_managed": bool(row["is_managed"]), + "security_status": row["security_status"], + "security_issues": row["security_issues"], + "deep_link": row["deep_link"], + "package_name": row["package_name"], + "package_registry": row["package_registry"], + "tools_count": row["tools_count"], + "created_at": _format_cst(row["created_at"]), + } + items.append(item) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + } + + +@router.get("/categories", summary="MCP 工具分类列表") +async def get_categories( + admin_user=Depends(get_current_admin), +): + """获取 MCP 工具分类及服务类型选项""" + return { + "code": 0, + "data": { + "categories": MCP_CATEGORIES, + "server_types": SERVER_TYPES, + "security_statuses": SECURITY_STATUSES, + "runtimes": RUNTIMES, + } + } + + +@router.post("/", summary="创建 MCP 工具") +async def create_mcp_tool( + body: MCPToolCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的 MCP 工具(同时创建 MarketApp + McpToolConfig)""" + # Validate server_type + if body.server_type not in SERVER_TYPES: + raise HTTPException(400, f"Invalid server_type: {body.server_type}") + + # Validate stdio requires command + if body.server_type == "stdio" and not body.command: + raise HTTPException(400, "stdio server_type requires 'command' field") + + # Validate builtin requires builtin_name + if body.server_type == "builtin" and not body.builtin_name: + raise HTTPException(400, "builtin server_type requires 'builtin_name' field") + + # Validate sse/streamable_http requires transport_url + if body.server_type in ("sse", "streamable_http") and not body.transport_url: + raise HTTPException(400, f"{body.server_type} server_type requires 'transport_url' field") + + app_id = str(uuid.uuid4()) + config_id = str(uuid.uuid4()) + now = datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S") + + # 1. Create MarketApp + db.execute(text(""" + INSERT INTO market_apps (id, name, type, category, tags, source, publisher_name, + description, icon, price, is_member_free, featured, status, version, created_at, updated_at) + VALUES (:id, :name, 'mcp_tool', :category, :tags, 'official', :publisher, + :description, :icon, :price, :member_free, :featured, 'pending', '1.0.0', NOW(), NOW()) + """), { + "id": app_id, + "name": body.name, + "category": body.category or "developer", + "tags": body.tags or "", + "publisher": body.author_name or "Platform", + "description": body.description or "", + "icon": body.icon or "", + "price": body.price or 0.0, + "member_free": 1 if body.is_member_free else 0, + "featured": 1 if body.featured else 0, + }) + + # 2. Build config dict for deep link and security check + config_for_dl = { + "name": body.name, + "server_type": body.server_type, + "command": body.command, + "args": body.args, + "env_vars": body.env_vars, + "transport_url": body.transport_url, + "builtin_name": body.builtin_name, + } + deep_link = _generate_deep_link(config_for_dl) + + # 3. Basic security check + sec_result = _basic_security_check({ + "command": body.command, + "args": body.args, + "env_vars": body.env_vars, + "package_registry": body.package_registry, + "package_name": body.package_name, + "package_repo_url": body.package_repo_url, + "transport_url": body.transport_url, + "is_managed": body.is_managed, + }) + + # 4. Create McpToolConfig + args_json = json.dumps(body.args) if body.args else None + env_json = json.dumps(body.env_vars) if body.env_vars else None + permissions_json = json.dumps(body.permissions) if body.permissions else None + config_schema_json = json.dumps(body.config_schema) if body.config_schema else None + default_config_json = json.dumps(body.default_config) if body.default_config else None + tools_list_json = json.dumps(body.tools_list) if body.tools_list else None + sec_report_json = json.dumps(sec_result) if sec_result else None + + db.execute(text(""" + INSERT INTO mcp_tool_configs ( + id, app_id, server_type, runtime, command, args, env_vars, + transport_url, builtin_name, is_managed, managed_port, + package_name, package_version, package_registry, package_repo_url, + deep_link, security_status, security_report, security_scanned_at, security_issues, + permissions, config_schema, default_config, install_instructions, + tools_count, tools_list, author_name, author_email, license, homepage_url, + created_at, updated_at + ) VALUES ( + :id, :app_id, :server_type, :runtime, :command, :args, :env_vars, + :transport_url, :builtin_name, :is_managed, :managed_port, + :package_name, :package_version, :package_registry, :package_repo_url, + :deep_link, :sec_status, :sec_report, NOW(), :sec_issues, + :permissions, :config_schema, :default_config, :install_instructions, + :tools_count, :tools_list, :author_name, :author_email, :license, :homepage_url, + NOW(), NOW() + ) + """), { + "id": config_id, + "app_id": app_id, + "server_type": body.server_type, + "runtime": body.runtime, + "command": body.command, + "args": args_json, + "env_vars": env_json, + "transport_url": body.transport_url, + "builtin_name": body.builtin_name, + "is_managed": 1 if body.is_managed else 0, + "managed_port": body.managed_port, + "package_name": body.package_name, + "package_version": body.package_version or "latest", + "package_registry": body.package_registry, + "package_repo_url": body.package_repo_url, + "deep_link": deep_link, + "sec_status": sec_result["status"], + "sec_report": sec_report_json, + "sec_issues": sec_result["issues_count"], + "permissions": permissions_json, + "config_schema": config_schema_json, + "default_config": default_config_json, + "install_instructions": body.install_instructions, + "tools_count": body.tools_count or 0, + "tools_list": tools_list_json, + "author_name": body.author_name, + "author_email": body.author_email, + "license": body.license or "MIT", + "homepage_url": body.homepage_url, + }) + + db.commit() + + return { + "code": 0, + "message": "MCP 工具创建成功", + "data": { + "app_id": app_id, + "config_id": config_id, + "deep_link": deep_link, + "security_status": sec_result["status"], + "security_issues": sec_result["issues_count"], + } + } + + +@router.get("/{config_id}", summary="MCP 工具详情") +async def get_mcp_tool_detail( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具完整详情""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name, ma.description, ma.icon, ma.category, ma.tags, + ma.source, ma.publisher_name, ma.price, ma.is_member_free, + ma.featured, ma.featured_order, ma.status as app_status, + ma.rating_avg, ma.review_count, ma.install_count, ma.version, + ma.changelog, ma.download_url, ma.tutorial, + ma.created_at as app_created_at, ma.updated_at as app_updated_at + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + result = { + "id": row["id"], + "app_id": row["app_id"], + "app": { + "name": row["name"], + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "tags": row["tags"], + "source": row["source"], + "publisher_name": row["publisher_name"], + "price": float(row["price"]) if row["price"] else 0.0, + "is_member_free": bool(row["is_member_free"]), + "featured": bool(row["featured"]), + "status": row["app_status"], + "rating_avg": float(row["rating_avg"]) if row["rating_avg"] else 0.0, + "review_count": row["review_count"], + "install_count": row["install_count"], + "version": row["version"], + "tutorial": row["tutorial"], + "changelog": row["changelog"], + "download_url": row["download_url"], + "created_at": _format_cst(row["app_created_at"]), + "updated_at": _format_cst(row["app_updated_at"]), + }, + "server_type": row["server_type"], + "runtime": row["runtime"], + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "transport_url": row["transport_url"], + "builtin_name": row["builtin_name"], + "is_managed": bool(row["is_managed"]), + "managed_url": row["managed_url"], + "managed_port": row["managed_port"], + "package_name": row["package_name"], + "package_version": row["package_version"], + "package_registry": row["package_registry"], + "package_repo_url": row["package_repo_url"], + "deep_link": row["deep_link"], + "security_status": row["security_status"], + "security_report": _parse_json(row["security_report"]), + "security_scanned_at": _format_cst(row["security_scanned_at"]), + "security_issues": row["security_issues"], + "permissions": _parse_json(row["permissions"]), + "config_schema": _parse_json(row["config_schema"]), + "default_config": _parse_json(row["default_config"]), + "install_instructions": row["install_instructions"], + "tools_count": row["tools_count"], + "tools_list": _parse_json(row["tools_list"]), + "author_name": row["author_name"], + "author_email": row["author_email"], + "license": row["license"], + "homepage_url": row["homepage_url"], + "created_at": _format_cst(row["created_at"]), + "updated_at": _format_cst(row["updated_at"]), + } + + return {"code": 0, "data": result} + + +@router.put("/{config_id}", summary="更新 MCP 工具") +async def update_mcp_tool( + config_id: str, + body: MCPToolUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新 MCP 工具配置""" + # Check exists + existing = _execute_raw(db, "SELECT id, app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + app_id = existing[0]["app_id"] + + # Update MarketApp fields + app_fields = {} + if body.name is not None: + app_fields["name"] = body.name + if body.description is not None: + app_fields["description"] = body.description + if body.icon is not None: + app_fields["icon"] = body.icon + if body.category is not None: + app_fields["category"] = body.category + if body.tags is not None: + app_fields["tags"] = body.tags + if body.price is not None: + app_fields["price"] = body.price + if body.is_member_free is not None: + app_fields["is_member_free"] = 1 if body.is_member_free else 0 + if body.featured is not None: + app_fields["featured"] = 1 if body.featured else 0 + if body.status is not None: + app_fields["status"] = body.status + + if app_fields: + set_parts = [f"{k} = :app_{k}" for k in app_fields] + app_params = {f"app_{k}": v for k, v in app_fields.items()} + app_params["aid"] = app_id + db.execute(text(f"UPDATE market_apps SET {', '.join(set_parts)}, updated_at=NOW() WHERE id = :aid"), app_params) + + # Update McpToolConfig fields + cfg_fields = {} + if body.server_type is not None: + cfg_fields["server_type"] = body.server_type + if body.runtime is not None: + cfg_fields["runtime"] = body.runtime + if body.command is not None: + cfg_fields["command"] = body.command + if body.args is not None: + cfg_fields["args"] = json.dumps(body.args) + if body.env_vars is not None: + cfg_fields["env_vars"] = json.dumps(body.env_vars) + if body.transport_url is not None: + cfg_fields["transport_url"] = body.transport_url + if body.builtin_name is not None: + cfg_fields["builtin_name"] = body.builtin_name + if body.is_managed is not None: + cfg_fields["is_managed"] = 1 if body.is_managed else 0 + if body.managed_port is not None: + cfg_fields["managed_port"] = body.managed_port + if body.package_name is not None: + cfg_fields["package_name"] = body.package_name + if body.package_version is not None: + cfg_fields["package_version"] = body.package_version + if body.package_registry is not None: + cfg_fields["package_registry"] = body.package_registry + if body.package_repo_url is not None: + cfg_fields["package_repo_url"] = body.package_repo_url + if body.permissions is not None: + cfg_fields["permissions"] = json.dumps(body.permissions) + if body.config_schema is not None: + cfg_fields["config_schema"] = json.dumps(body.config_schema) + if body.default_config is not None: + cfg_fields["default_config"] = json.dumps(body.default_config) + if body.install_instructions is not None: + cfg_fields["install_instructions"] = body.install_instructions + if body.tools_count is not None: + cfg_fields["tools_count"] = body.tools_count + if body.tools_list is not None: + cfg_fields["tools_list"] = json.dumps(body.tools_list) + if body.author_name is not None: + cfg_fields["author_name"] = body.author_name + if body.author_email is not None: + cfg_fields["author_email"] = body.author_email + if body.license is not None: + cfg_fields["license"] = body.license + if body.homepage_url is not None: + cfg_fields["homepage_url"] = body.homepage_url + + if cfg_fields: + # Regenerate deep link if transport-related fields changed + dl_fields = {"server_type", "command", "args", "env_vars", "transport_url", "builtin_name"} + if dl_fields & set(cfg_fields.keys()): + # Get current values for deep link regeneration + current = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + if current: + c = current[0] + dl_config = { + "name": body.name or c["name"], + "server_type": cfg_fields.get("server_type", c["server_type"]), + "command": cfg_fields.get("command", c["command"]), + "args": _parse_json(cfg_fields.get("args", c["args"])), + "env_vars": _parse_json(cfg_fields.get("env_vars", c["env_vars"])), + "transport_url": cfg_fields.get("transport_url", c["transport_url"]), + "builtin_name": cfg_fields.get("builtin_name", c["builtin_name"]), + } + cfg_fields["deep_link"] = _generate_deep_link(dl_config) + + set_parts = [f"{k} = :cfg_{k}" for k in cfg_fields] + cfg_params = {f"cfg_{k}": v for k, v in cfg_fields.items()} + cfg_params["cid"] = config_id + db.execute(text(f"UPDATE mcp_tool_configs SET {', '.join(set_parts)}, updated_at=NOW() WHERE id = :cid"), cfg_params) + + db.commit() + + return {"code": 0, "message": "更新成功"} + + +@router.delete("/{config_id}", summary="删除 MCP 工具") +async def delete_mcp_tool( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除 MCP 工具(同时删除 MarketApp 和 McpToolConfig)""" + existing = _execute_raw(db, "SELECT app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + app_id = existing[0]["app_id"] + + db.execute(text("DELETE FROM mcp_tool_configs WHERE id = :cid"), {"cid": config_id}) + db.execute(text("UPDATE market_apps SET status='removed', updated_at=NOW() WHERE id = :aid"), {"aid": app_id}) + + db.commit() + + return {"code": 0, "message": "删除成功"} + + +@router.post("/{config_id}/security-scan", summary="触发安全扫描") +async def trigger_security_scan( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """对 MCP 工具执行安全扫描(基础版,完整版在 G1-4)""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + + # Update status to scanning + db.execute(text("UPDATE mcp_tool_configs SET security_status='scanning' WHERE id = :cid"), {"cid": config_id}) + db.commit() + + # Run basic security check + sec_result = _basic_security_check({ + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "package_registry": row["package_registry"], + "package_name": row["package_name"], + "package_repo_url": row["package_repo_url"], + "transport_url": row["transport_url"], + "is_managed": row["is_managed"], + }) + + # Update with results + db.execute(text(""" + UPDATE mcp_tool_configs + SET security_status = :status, security_report = :report, + security_scanned_at = NOW(), security_issues = :issues, + updated_at = NOW() + WHERE id = :cid + """), { + "status": sec_result["status"], + "report": json.dumps(sec_result), + "issues": sec_result["issues_count"], + "cid": config_id, + }) + db.commit() + + return { + "code": 0, + "message": "安全扫描完成", + "data": sec_result, + } + + +@router.put("/{config_id}/security-status", summary="更新安全审核状态") +async def update_security_status( + config_id: str, + body: SecurityStatusUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """手动更新 MCP 工具的安全审核状态""" + if body.security_status not in SECURITY_STATUSES: + raise HTTPException(400, f"Invalid security_status: {body.security_status}") + + existing = _execute_raw(db, "SELECT id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + report_json = json.dumps(body.security_report) if body.security_report else None + + db.execute(text(""" + UPDATE mcp_tool_configs + SET security_status = :status, security_report = COALESCE(:report, security_report), + security_scanned_at = NOW(), updated_at = NOW() + WHERE id = :cid + """), { + "status": body.security_status, + "report": report_json, + "cid": config_id, + }) + + # Auto-publish app if approved + if body.security_status == "approved": + app_row = _execute_raw(db, "SELECT app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if app_row: + db.execute(text("UPDATE market_apps SET status='published', updated_at=NOW() WHERE id = :aid"), + {"aid": app_row[0]["app_id"]}) + + db.commit() + + return {"code": 0, "message": "安全状态已更新"} + + +@router.post("/{config_id}/deep-link", summary="重新生成 Deep Link") +async def regenerate_deep_link( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """重新生成 Goose Deep Link URL""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + config = { + "name": row["name"], + "server_type": row["server_type"], + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "transport_url": row["transport_url"], + "builtin_name": row["builtin_name"], + } + deep_link = _generate_deep_link(config) + + db.execute(text("UPDATE mcp_tool_configs SET deep_link = :dl, updated_at = NOW() WHERE id = :cid"), + {"dl": deep_link, "cid": config_id}) + db.commit() + + return {"code": 0, "data": {"deep_link": deep_link}} + + +@router.get("/managed/servers", summary="托管 MCP 服务器列表") +async def list_managed_servers( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取平台托管的 MCP 服务器列表""" + rows = _execute_raw(db, """ + SELECT mc.id, mc.managed_url, mc.managed_port, mc.server_type, + mc.tools_count, mc.tools_list, ma.name, ma.status, ma.description + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.is_managed = 1 + ORDER BY mc.created_at DESC + """) + + servers = [] + for row in rows: + servers.append({ + "id": row["id"], + "name": row["name"], + "description": row["description"], + "url": row["managed_url"], + "port": row["managed_port"], + "server_type": row["server_type"], + "status": row["status"], + "tools_count": row["tools_count"], + "tools_list": _parse_json(row["tools_list"]), + }) + + return {"code": 0, "data": {"servers": servers, "total": len(servers)}} + + +@router.post("/managed/{config_id}/start", summary="启动托管 MCP 服务器") +async def start_managed_server( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """启动一个托管 MCP 服务器(占位 - 完整实现在 G1-5)""" + rows = _execute_raw(db, "SELECT id, is_managed FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not rows: + raise HTTPException(404, "MCP 工具不存在") + if not rows[0]["is_managed"]: + raise HTTPException(400, "该工具不是托管类型") + + # Placeholder: in G1-5, this will spin up a Docker container or subprocess + managed_url = f"http://localhost:9100/mcp/{config_id[:8]}" + + db.execute(text(""" + UPDATE mcp_tool_configs SET managed_url = :url, updated_at = NOW() + WHERE id = :cid + """), {"url": managed_url, "cid": config_id}) + db.commit() + + return {"code": 0, "message": "托管服务器启动中(占位)", "data": {"url": managed_url}} + + +@router.post("/managed/{config_id}/stop", summary="停止托管 MCP 服务器") +async def stop_managed_server( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """停止一个托管 MCP 服务器(占位 - 完整实现在 G1-5)""" + rows = _execute_raw(db, "SELECT id, is_managed FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + db.execute(text("UPDATE mcp_tool_configs SET managed_url = NULL, updated_at = NOW() WHERE id = :cid"), + {"cid": config_id}) + db.commit() + + return {"code": 0, "message": "托管服务器已停止"} + + +# ============================================================ +# Seed Data: Pre-populate with popular Goose MCP extensions +# ============================================================ + +@router.post("/seed", summary="填充内置 MCP 工具") +async def seed_builtin_tools( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """填充 Goose 内置 MCP 扩展到市场(一次性操作)""" + # Check if already seeded + existing = _execute_raw(db, "SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE builtin_name IS NOT NULL") + if existing and existing[0]["cnt"] > 0: + return {"code": 0, "message": f"已有 {existing[0]['cnt']} 个内置工具,跳过填充"} + + builtin_tools = [ + { + "name": "Developer", "builtin_name": "developer", + "description": "Goose 内置开发工具 - 代码执行、文件操作、终端命令", + "category": "developer", "command": None, "server_type": "builtin", + "tools_count": 12, "tools_list": ["shell", "read_file", "write_file", "list_files", "grep", "edit_file", "patch", "undo_edit", "find_path", "text_search", "architect", "undo"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": True, "network": False}, + "install_instructions": "Goose Desktop 内置,无需安装。CLI 模式: goose session --type builtin --name developer", + }, + { + "name": "Computer Controller", "builtin_name": "computer-controller", + "description": "屏幕截图、鼠标键盘控制 - GUI 自动化操作", + "category": "productivity", "command": None, "server_type": "builtin", + "tools_count": 8, "tools_list": ["screenshot", "mouse_click", "mouse_move", "key_press", "key_type", "scroll", "wait", "find_element"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"screen": True, "input": True, "network": False}, + "install_instructions": "Goose Desktop 内置。需要授予屏幕录制和输入控制权限。", + }, + { + "name": "Memory", "builtin_name": "memory", + "description": "长期记忆存储 - 跨会话保存和检索信息", + "category": "assistant", "command": None, "server_type": "builtin", + "tools_count": 4, "tools_list": ["store", "retrieve", "search", "delete"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": False, "network": False}, + "install_instructions": "Goose Desktop 内置。记忆存储在 ~/.config/goose/memory/", + }, + { + "name": "JetBrains", "builtin_name": "jetbrains", + "description": "JetBrains IDE 集成 - 打开项目、执行操作", + "category": "developer", "command": None, "server_type": "builtin", + "tools_count": 6, "tools_list": ["open_project", "run_action", "get_open_file", "replace_in_file", "create_file", "get_errors"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": False, "network": False}, + "install_instructions": "需要安装 JetBrains MCP 插件。支持 IntelliJ IDEA, PyCharm, WebStorm, GoLand 等。", + }, + ] + + created = 0 + for tool in builtin_tools: + app_id = str(uuid.uuid4()) + config_id = str(uuid.uuid4()) + + # Create MarketApp + db.execute(text(""" + INSERT INTO market_apps (id, name, type, category, source, publisher_name, + description, price, is_member_free, featured, status, version, created_at, updated_at) + VALUES (:id, :name, 'mcp_tool', :category, 'official', :publisher, + :description, 0.0, 1, 0, 'published', '1.0.0', NOW(), NOW()) + """), { + "id": app_id, "name": tool["name"], "category": tool["category"], + "publisher": tool["author_name"], "description": tool["description"], + }) + + # Create McpToolConfig + deep_link = _generate_deep_link(tool) + sec_result = _basic_security_check(tool) + + db.execute(text(""" + INSERT INTO mcp_tool_configs ( + id, app_id, server_type, builtin_name, deep_link, + security_status, security_report, security_scanned_at, security_issues, + permissions, install_instructions, tools_count, tools_list, + author_name, license, created_at, updated_at + ) VALUES ( + :id, :app_id, 'builtin', :builtin_name, :deep_link, + :sec_status, :sec_report, NOW(), 0, + :permissions, :install_instructions, :tools_count, :tools_list, + :author_name, :license, NOW(), NOW() + ) + """), { + "id": config_id, "app_id": app_id, "builtin_name": tool["builtin_name"], + "deep_link": deep_link, "sec_status": "approved", + "sec_report": json.dumps(sec_result), + "permissions": json.dumps(tool["permissions"]), + "install_instructions": tool["install_instructions"], + "tools_count": tool["tools_count"], + "tools_list": json.dumps(tool["tools_list"]), + "author_name": tool["author_name"], "license": tool["license"], + }) + created += 1 + + db.commit() + + return {"code": 0, "message": f"成功填充 {created} 个内置 MCP 工具", "data": {"created": created}} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index eed55e0..8379315 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -626,6 +626,29 @@ export const adminAPI = { }).then(res => res.json()); }, + // MCP Market (Phase G1) + getMcpMarketStats: () => + get('/admin/mcp-market/stats'), + getMcpTools: (params?: Record) => + get('/admin/mcp-market/?' + new URLSearchParams(params || {}).toString()), + getMcpToolDetail: (id: string) => + get('/admin/mcp-market/' + id), + createMcpTool: (data: any) => + post('/admin/mcp-market/', data), + updateMcpTool: (id: string, data: any) => + put('/admin/mcp-market/' + id, data), + deleteMcpTool: (id: string) => + del('/admin/mcp-market/' + id), + scanMcpTool: (id: string) => + post('/admin/mcp-market/' + id + '/security-scan'), + updateMcpToolSecurity: (id: string, data: any) => + put('/admin/mcp-market/' + id + '/security-status', data), + regenMcpDeepLink: (id: string) => + post('/admin/mcp-market/' + id + '/deep-link'), + getMcpManagedServers: () => + get('/admin/mcp-market/managed/servers'), + seedMcpTools: () => + post('/admin/mcp-market/seed'), }; // ===== AI自进化API ===== diff --git a/frontend/src/pages/admin/Market.tsx b/frontend/src/pages/admin/Market.tsx index 363fe57..6d1d146 100644 --- a/frontend/src/pages/admin/Market.tsx +++ b/frontend/src/pages/admin/Market.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { adminAPI } from '@/api'; +import McpMarketTab from './McpMarketTab'; // ============================================================ // Types @@ -112,7 +113,7 @@ const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size? // Main Component // ============================================================ const Market: React.FC = () => { - const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews' | 'mcp'>('overview'); const [loading, setLoading] = useState(false); // Overview data @@ -262,6 +263,7 @@ const Market: React.FC = () => { { key: 'pending', label: `待审核${pendingTotal > 0 ? `(${pendingTotal})` : ''}`, icon: 'eye' }, { key: 'coupons', label: '优惠券', icon: 'tag' }, { key: 'reviews', label: '用户评价', icon: 'star' }, + { key: 'mcp', label: 'MCP工具', icon: 'tool' }, ] as const; return ( @@ -343,6 +345,10 @@ const Market: React.FC = () => { )} + {!loading && activeTab === 'mcp' && ( + + )} + {/* Edit Modal */} {editApp && ( setEditApp(null)} onSaved={() => { setEditApp(null); loadApps(); }} /> diff --git a/frontend/src/pages/admin/McpMarketTab.tsx b/frontend/src/pages/admin/McpMarketTab.tsx new file mode 100644 index 0000000..5f53cb2 --- /dev/null +++ b/frontend/src/pages/admin/McpMarketTab.tsx @@ -0,0 +1,729 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; + +// ============================================================ +// Types +// ============================================================ +interface McpTool { + id: string; + app_id: string; + name: string; + description: string | null; + icon: string | null; + category: string; + tags: string | null; + status: string; + publisher_name: string | null; + price: number; + is_member_free: boolean; + featured: boolean; + rating_avg: number; + install_count: number; + version: string; + server_type: string; + runtime: string | null; + command: string | null; + is_managed: boolean; + security_status: string; + security_issues: number; + deep_link: string | null; + package_name: string | null; + package_registry: string | null; + tools_count: number; + created_at: string | null; +} + +interface McpToolDetail extends McpTool { + app: { + name: string; + description: string | null; + category: string; + status: string; + tutorial: string | null; + }; + args: string[] | null; + env_vars: Record | null; + transport_url: string | null; + builtin_name: string | null; + managed_url: string | null; + managed_port: number | null; + package_version: string; + package_repo_url: string | null; + security_report: Record | null; + security_scanned_at: string | null; + permissions: Record | null; + config_schema: Record | null; + install_instructions: string | null; + tools_list: string[] | null; + author_name: string | null; + license: string; + homepage_url: string | null; +} + +interface McpStats { + total: number; + published: number; + pending_review: number; + managed_servers: number; + by_security_status: Record; + by_server_type: Record; + by_category: Record; +} + +const SERVER_TYPE_LABELS: Record = { + stdio: 'STDIO', + sse: 'SSE', + streamable_http: 'HTTP', + builtin: '内置', +}; + +const SECURITY_LABELS: Record = { + pending: { label: '待审核', color: '#d29922' }, + scanning: { label: '扫描中', color: '#58a6ff' }, + approved: { label: '已通过', color: '#3fb950' }, + rejected: { label: '已拒绝', color: '#f85149' }, + failed: { label: '扫描失败', color: '#f85149' }, +}; + +const MCP_CATEGORY_LABELS: Record = { + developer: '开发工具', database: '数据库', search: '搜索检索', + file: '文件系统', browser: '浏览器', design: '设计创意', + data: '数据处理', communication: '通信协作', cloud: '云服务', + productivity: '效率工具', other: '其他', +}; + +// ============================================================ +// Main Component +// ============================================================ +const McpMarketTab: React.FC = () => { + const [tools, setTools] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [keyword, setKeyword] = useState(''); + const [filterServerType, setFilterServerType] = useState(''); + const [filterSecurity, setFilterSecurity] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [detailTool, setDetailTool] = useState(null); + + const loadStats = useCallback(async () => { + try { + const res = await adminAPI.getMcpMarketStats(); + if (res?.code === 0) setStats(res.data); + } catch (e) { console.error(e); } + }, []); + + const loadTools = useCallback(async () => { + setLoading(true); + try { + const params: Record = { page, page_size: 20 }; + if (keyword) params.keyword = keyword; + if (filterServerType) params.server_type = filterServerType; + if (filterSecurity) params.security_status = filterSecurity; + const res = await adminAPI.getMcpTools(params); + if (res?.code === 0) { + setTools(res.data.items || []); + setTotal(res.data.total || 0); + } + } catch (e) { console.error(e); } + setLoading(false); + }, [page, keyword, filterServerType, filterSecurity]); + + useEffect(() => { loadStats(); }, [loadStats]); + useEffect(() => { loadTools(); }, [loadTools]); + + const handleSeed = async () => { + if (!confirm('确认填充 Goose 内置 MCP 工具到市场?')) return; + try { + const res = await adminAPI.seedMcpTools(); + if (res?.code === 0) { + alert(res.message || '填充成功'); + loadStats(); + loadTools(); + } + } catch (e) { alert('操作失败'); } + }; + + const handleScan = async (id: string) => { + try { + const res = await adminAPI.scanMcpTool(id); + if (res?.code === 0) { + alert(`扫描完成: ${res.data?.status} (${res.data?.issues_count} 个问题)`); + loadTools(); + } + } catch (e) { alert('扫描失败'); } + }; + + const handleDelete = async (id: string, name: string) => { + if (!confirm(`确认删除 MCP 工具「${name}」?`)) return; + try { + await adminAPI.deleteMcpTool(id); + loadTools(); + loadStats(); + } catch (e) { alert('删除失败'); } + }; + + const handleViewDetail = async (id: string) => { + try { + const res = await adminAPI.getMcpToolDetail(id); + if (res?.code === 0) setDetailTool(res.data); + } catch (e) { alert('加载详情失败'); } + }; + + const copyDeepLink = (link: string) => { + navigator.clipboard.writeText(link).then(() => { + alert('Deep Link 已复制到剪贴板!'); + }).catch(() => { + prompt('请手动复制:', link); + }); + }; + + const totalPages = Math.ceil(total / 20); + + return ( +
+ {/* Stats Cards */} + {stats && ( +
+ + + + +
+ )} + + {/* Toolbar */} +
+ { setKeyword(e.target.value); setPage(1); }} + style={{ + flex: 1, minWidth: 200, padding: '8px 12px', + background: 'var(--color-bg-secondary, #161b22)', + border: '1px solid var(--color-border, #30363d)', + borderRadius: 8, color: 'var(--color-text-primary, #e6edf3)', fontSize: 14, + }} + /> + + + + +
+ + {/* Tools List */} + {loading ? ( +
加载中...
+ ) : tools.length === 0 ? ( +
+ 暂无 MCP 工具。点击"填充内置工具"快速导入 Goose 内置扩展。 +
+ ) : ( +
+ {tools.map(tool => ( + handleViewDetail(tool.id)} + onScan={() => handleScan(tool.id)} + onDelete={() => handleDelete(tool.id, tool.name)} + onCopyLink={() => tool.deep_link && copyDeepLink(tool.deep_link)} + /> + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} + + {/* Create Modal */} + {showCreate && ( + setShowCreate(false)} + onSaved={() => { setShowCreate(false); loadTools(); loadStats(); }} + /> + )} + + {/* Detail Modal */} + {detailTool && ( + setDetailTool(null)} + onCopyLink={() => detailTool.deep_link && copyDeepLink(detailTool.deep_link)} + /> + )} +
+ ); +}; + +// ============================================================ +// Sub Components +// ============================================================ + +const StatCard: React.FC<{ label: string; value: number; sub: string; color: string }> = ({ label, value, sub, color }) => ( +
+
{label}
+
{value}
+
{sub}
+
+); + +const ToolCard: React.FC<{ + tool: McpTool; + onView: () => void; + onScan: () => void; + onDelete: () => void; + onCopyLink: () => void; +}> = ({ tool, onView, onScan, onDelete, onCopyLink }) => { + const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' }; + const stLabel = SERVER_TYPE_LABELS[tool.server_type] || tool.server_type; + const catLabel = MCP_CATEGORY_LABELS[tool.category] || tool.category; + + return ( +
+ {/* Icon */} +
+ {tool.name.charAt(0).toUpperCase()} +
+ + {/* Info */} +
+
+ {tool.name} + {sec.label} + {stLabel} + {tool.is_managed && 托管} + {tool.tools_count > 0 && {tool.tools_count} tools} +
+
+ {tool.description || '暂无描述'} +
+
+ {catLabel} + {tool.package_name && {tool.package_name}} + {tool.publisher_name && by {tool.publisher_name}} + {tool.security_issues > 0 && {tool.security_issues} 个安全问题} +
+
+ + {/* Actions */} +
+ + {tool.deep_link && } + + +
+
+ ); +}; + +const CreateMcpToolModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { + const [form, setForm] = useState({ + name: '', description: '', category: 'developer', + server_type: 'stdio', runtime: 'node', command: '', args: '', + package_name: '', package_registry: 'npm', package_repo_url: '', + transport_url: '', builtin_name: '', + author_name: '', license: 'MIT', tools_count: 0, + install_instructions: '', + }); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (!form.name) { alert('请输入工具名称'); return; } + if (form.server_type === 'stdio' && !form.command) { alert('STDIO 类型需要启动命令'); return; } + if (form.server_type === 'builtin' && !form.builtin_name) { alert('内置类型需要扩展名称'); return; } + if ((form.server_type === 'sse' || form.server_type === 'streamable_http') && !form.transport_url) { alert('SSE/HTTP 类型需要传输 URL'); return; } + + setSaving(true); + try { + const payload: Record = { ...form }; + if (form.args) { + try { payload.args = JSON.parse(form.args); } catch { payload.args = form.args.split(' '); } + } else { + delete payload.args; + } + if (!form.tools_count) delete payload.tools_count; + if (!form.transport_url) delete payload.transport_url; + if (!form.builtin_name) delete payload.builtin_name; + + const res = await adminAPI.createMcpTool(payload); + if (res?.code === 0) { + alert(`创建成功!安全状态: ${res.data?.security_status || 'pending'}`); + onSaved(); + } else { + alert(res?.message || '创建失败'); + } + } catch (e: any) { + alert('创建失败: ' + (e.message || '未知错误')); + } + setSaving(false); + }; + + return ( +
+
+

创建 MCP 工具

+
+ + setForm({ ...form, name: e.target.value })} placeholder="如: Filesystem MCP" /> + + +