From 1ca9856fd210dbf5c65a795c24cc7cdad8efb8bf Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Tue, 10 Mar 2026 17:38:16 +0800 Subject: [PATCH 01/59] =?UTF-8?q?=E6=B7=BB=E5=8A=A0MCP=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E3=80=81=E5=88=A0=E9=99=A4=E3=80=81=E4=BF=AE=E6=94=B9=E3=80=81?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E3=80=81=E5=81=9C=E6=AD=A2=E3=80=81=E6=9F=A5?= =?UTF-8?q?=E7=9C=8BMCP=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=87=AA=E8=A1=8C=E6=B7=BB=E5=8A=A0=E5=92=8C?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E5=B8=82=E5=9C=BA=E5=BF=AB=E9=80=9F=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=EF=BC=8C=E7=9B=AE=E5=89=8D=E5=85=AC=E5=85=B1=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E5=BF=AB=E9=80=9F=E6=B7=BB=E5=8A=A0=E5=8F=AA=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=93=BE=E6=8E=A5=E5=BD=A2=E5=BC=8F=E7=9A=84MCP?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=EF=BC=9B=20=E6=B7=BB=E5=8A=A0=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E5=B8=82=E5=9C=BA=E7=9A=84=E6=B5=8F=E8=A7=88=E5=92=8C?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/config_app.py | 2 + backend/apps/mcp_management_app.py | 296 +++++ backend/database/db_models.py | 25 + backend/database/mcp_manage_db.py | 156 +++ backend/services/mcp_management_service.py | 487 +++++++ .../components/AddMcpServiceModal.tsx | 673 ++++++++++ .../components/McpServiceDetailModal.tsx | 296 +++++ .../mcp-tools/components/McpToolListModal.tsx | 102 ++ frontend/app/[locale]/mcp-tools/page.tsx | 1130 +++++++++++++++-- frontend/public/locales/en/common.json | 126 ++ frontend/public/locales/zh/common.json | 126 ++ test/backend/database/test_remote_mcp_db.py | 54 + 12 files changed, 3382 insertions(+), 91 deletions(-) create mode 100644 backend/apps/mcp_management_app.py create mode 100644 backend/database/mcp_manage_db.py create mode 100644 backend/services/mcp_management_service.py create mode 100644 frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index fb6a0a4f0..d8060fd9f 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -12,6 +12,7 @@ from apps.mock_user_management_app import router as mock_user_management_router from apps.model_managment_app import router as model_manager_router from apps.prompt_app import router as prompt_router +from apps.mcp_management_app import router as mcp_management_router from apps.remote_mcp_app import router as remote_mcp_router from apps.tenant_config_app import router as tenant_config_router from apps.tool_config_app import router as tool_config_router @@ -51,6 +52,7 @@ app.include_router(summary_router) app.include_router(prompt_router) app.include_router(tenant_config_router) +app.include_router(mcp_management_router) app.include_router(remote_mcp_router) app.include_router(tenant_router) app.include_router(group_router) diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py new file mode 100644 index 000000000..44f9ac803 --- /dev/null +++ b/backend/apps/mcp_management_app.py @@ -0,0 +1,296 @@ +import logging +from typing import Optional + +from fastapi import APIRouter, Header, HTTPException, Request +from fastapi.responses import JSONResponse +from http import HTTPStatus + +from consts.exceptions import MCPConnectionError, MCPNameIllegal +from services.mcp_management_service import ( + add_mcp_service, + check_mcp_service_health, + delete_mcp_service, + list_market_mcp_services, + list_mcp_services, + update_mcp_service, + update_mcp_service_enabled, +) +from utils.auth_utils import get_current_user_info + +router = APIRouter(prefix="/mcp-tools") +logger = logging.getLogger("mcp_management_app") + + +@router.post("/add") +async def add_mcp_service_api( + payload: dict, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + name = (payload.get("name") or "").strip() + server_url = (payload.get("server_url") or "").strip() + description = payload.get("description") + source = payload.get("source") or "本地" + server_type = payload.get("server_type") or "HTTP" + tags = payload.get("tags") + authorization_token = (payload.get("authorization_token") or "").strip() or None + container_config = payload.get("container_config") + + if not name or not server_url: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Missing required fields", + ) + + await add_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=name, + description=description, + source=source, + server_type=server_type, + server_url=server_url, + tags=tags, + authorization_token=authorization_token, + container_config=container_config, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except MCPNameIllegal as exc: + logger.error(f"MCP name conflict: {exc}") + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=str(exc), + ) + except MCPConnectionError as exc: + logger.error(f"MCP connection failed when adding service: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to add MCP service: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to add MCP service", + ) + + +@router.get("/list") +async def list_mcp_services_api( + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + _, tenant_id, _ = get_current_user_info(authorization, http_request) + services = list_mcp_services(tenant_id=tenant_id) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": services}, + ) + except Exception as exc: + logger.error(f"Failed to list MCP services: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to list MCP services", + ) + + +@router.get("/market/list") +async def list_market_mcp_services_api( + search: Optional[str] = None, + include_deleted: bool = False, + updated_since: Optional[str] = None, + version: Optional[str] = None, + cursor: Optional[str] = None, + limit: int = 30, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + # Keep auth behavior consistent with other mcp-tools APIs. + get_current_user_info(authorization, http_request) + + market_data = await list_market_mcp_services( + search=(search or "").strip() or None, + include_deleted=bool(include_deleted), + updated_since=(updated_since or "").strip() or None, + version=(version or "").strip() or None, + cursor=(cursor or "").strip() or None, + # Registry currently validates limit <= 100. + limit=max(1, min(limit, 100)), + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": market_data}, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to list market MCP services: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to list market MCP services", + ) + + +@router.put("/update") +async def update_mcp_service_api( + payload: dict, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + current_name = payload.get("current_name") + new_name = payload.get("name") + description = payload.get("description") + server_url = payload.get("server_url") + tags = payload.get("tags") + authorization_token = (payload.get("authorization_token") or "").strip() or None + + if not current_name or not new_name or not server_url: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Missing required fields", + ) + + update_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + current_name=current_name, + new_name=new_name, + description=description, + server_url=server_url, + authorization_token=authorization_token, + tags=tags, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to update MCP service: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service", + ) + + +@router.post("/manage/enable") +async def update_mcp_service_enable_api( + payload: dict, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + name = payload.get("name") + enabled = payload.get("enabled") + if name is None or enabled is None: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Missing required fields", + ) + + update_mcp_service_enabled( + tenant_id=tenant_id, + user_id=user_id, + name=name, + enabled=bool(enabled), + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to update MCP service status: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service status", + ) + + +@router.post("/healthcheck") +async def check_mcp_health_api( + payload: dict, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + name = payload.get("name") + server_url = payload.get("server_url") + if not name or not server_url: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Missing required fields", + ) + + health_status = await check_mcp_service_health( + tenant_id=tenant_id, + user_id=user_id, + name=name, + server_url=server_url, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": {"health_status": health_status}}, + ) + except MCPConnectionError as exc: + logger.error(f"MCP connection failed: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to check MCP health: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check MCP health", + ) + + +@router.delete("/delete") +async def delete_mcp_service_api( + name: str, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + if not name: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Missing required fields", + ) + + delete_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=name, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to delete MCP service: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete MCP service", + ) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 36f475f53..872cc967a 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -335,6 +335,31 @@ class McpRecord(TableBase): ) +class McpServiceManage(TableBase): + """ + MCP service management table + """ + + __tablename__ = "mcp_service_manage_t" + __table_args__ = {"schema": SCHEMA} + + manage_id = Column(Integer, Sequence("mcp_service_manage_t_manage_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Management record ID") + tenant_id = Column(String(100), doc="Tenant ID") + user_id = Column(String(100), doc="User ID") + mcp_name = Column(String(100), doc="MCP name") + mcp_server = Column(String(500), doc="MCP server address") + source_type = Column(String(30), doc="Source type: local/registry") + market_name = Column(String(200), doc="Market identifier") + mcp_version = Column(String(50), doc="MCP version") + transport_type = Column(String(30), doc="Transport type: streamable-http/sse/stdio") + config_json = Column(JSON, doc="MCP config data") + status = Column(Boolean, default=None, doc="Health status") + enabled = Column(Boolean, default=True, doc="Enabled") + tags = Column(String(200), doc="Tags") + category = Column(String(100), doc="Category") + last_sync_time = Column(TIMESTAMP(timezone=False), doc="Last sync time") + class UserTenant(TableBase): """ User and tenant relationship table diff --git a/backend/database/mcp_manage_db.py b/backend/database/mcp_manage_db.py new file mode 100644 index 000000000..d34c52ce2 --- /dev/null +++ b/backend/database/mcp_manage_db.py @@ -0,0 +1,156 @@ +import logging +from typing import Any, Dict, List + +from database.client import as_dict, get_db_session +from database.db_models import McpServiceManage + +logger = logging.getLogger("mcp_manage_db") + + +def create_mcp_manage_service( + *, + tenant_id: str, + user_id: str, + name: str, + server_url: str, + source_type: str, + transport_type: str, + tags: List[str] | None, + category: str | None, + config_json: Dict[str, Any] | None, + enabled: bool, + status: bool | None, +) -> None: + with get_db_session() as session: + new_record = McpServiceManage( + tenant_id=tenant_id, + user_id=user_id, + mcp_name=name, + mcp_server=server_url, + source_type=source_type, + transport_type=transport_type, + tags=",".join(tags or []), + category=category, + config_json=config_json, + enabled=enabled, + status=status, + created_by=user_id, + updated_by=user_id, + delete_flag="N", + ) + session.add(new_record) + + +def get_mcp_manage_records(tenant_id: str) -> List[Dict[str, Any]]: + with get_db_session() as session: + records = ( + session.query(McpServiceManage) + .filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.delete_flag != "Y", + ) + .order_by(McpServiceManage.update_time.desc()) + .all() + ) + return [as_dict(record) for record in records] + + +def get_mcp_manage_record_by_name(*, tenant_id: str, name: str) -> Dict[str, Any] | None: + with get_db_session() as session: + record = ( + session.query(McpServiceManage) + .filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.mcp_name == name, + McpServiceManage.delete_flag != "Y", + ) + .first() + ) + return as_dict(record) if record else None + + +def check_mcp_manage_name_exists(*, tenant_id: str, name: str) -> bool: + with get_db_session() as session: + record = ( + session.query(McpServiceManage) + .filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.mcp_name == name, + McpServiceManage.delete_flag != "Y", + ) + .first() + ) + return record is not None + + +def update_mcp_manage_service( + *, + tenant_id: str, + user_id: str, + current_name: str, + new_name: str, + description: str | None, + server_url: str, + config_json: Dict[str, Any] | None, + tags: List[str] | None, +) -> None: + tag_value = ",".join(tags or []) + with get_db_session() as session: + session.query(McpServiceManage).filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.mcp_name == current_name, + McpServiceManage.delete_flag != "Y", + ).update( + { + "mcp_name": new_name, + "mcp_server": server_url, + "category": description, + "config_json": config_json, + "tags": tag_value, + "updated_by": user_id, + } + ) + + +def update_mcp_manage_enabled( + *, + tenant_id: str, + user_id: str, + name: str, + enabled: bool, +) -> None: + with get_db_session() as session: + session.query(McpServiceManage).filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.mcp_name == name, + McpServiceManage.delete_flag != "Y", + ).update({"enabled": enabled, "updated_by": user_id}) + + +def update_mcp_manage_status( + *, + tenant_id: str, + user_id: str, + name: str, + status: bool, +) -> None: + with get_db_session() as session: + session.query(McpServiceManage).filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.mcp_name == name, + McpServiceManage.delete_flag != "Y", + ).update({"status": status, "updated_by": user_id}) + + +def delete_mcp_manage_service( + *, + tenant_id: str, + user_id: str, + name: str, +) -> None: + with get_db_session() as session: + session.query(McpServiceManage).filter( + McpServiceManage.tenant_id == tenant_id, + McpServiceManage.mcp_name == name, + McpServiceManage.delete_flag != "Y", + ).update({"delete_flag": "Y", "updated_by": user_id}) diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py new file mode 100644 index 000000000..85340231e --- /dev/null +++ b/backend/services/mcp_management_service.py @@ -0,0 +1,487 @@ +import logging +from datetime import datetime +from types import SimpleNamespace +from typing import Any, Dict, List +from urllib.parse import urlencode + +import aiohttp + +from consts.exceptions import MCPConnectionError, MCPNameIllegal +from database.mcp_manage_db import ( + check_mcp_manage_name_exists, + create_mcp_manage_service, + delete_mcp_manage_service, + get_mcp_manage_record_by_name, + get_mcp_manage_records, + update_mcp_manage_enabled, + update_mcp_manage_service, + update_mcp_manage_status, +) +from database.remote_mcp_db import ( + check_mcp_name_exists, + create_mcp_record, + delete_mcp_record_by_name_and_url, + update_mcp_record_by_name_and_url, +) +from services.remote_mcp_service import mcp_server_health + +logger = logging.getLogger("mcp_management_service") + +MCP_REGISTRY_BASE_URL = "https://registry.modelcontextprotocol.io/v0.1/servers" + + +def _format_time(value: Any) -> str: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M") + if value is None: + return "-" + return str(value) + + +def _split_tags(value: str | None) -> List[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _safe_config_dict(record: Dict[str, Any]) -> Dict[str, Any]: + config_json = record.get("config_json") + return config_json if isinstance(config_json, dict) else {} + + +def _is_http_transport(record: Dict[str, Any] | None) -> bool: + transport_type = str((record or {}).get("transport_type") or "").strip().lower() + return transport_type in {"streamable-http", "sse"} + + +def _extract_str(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: + server = entry.get("server") if isinstance(entry, dict) else None + if not isinstance(server, dict): + return None + + name = _extract_str(server.get("name")) + if not name: + return None + + title = _extract_str(server.get("title")) or name + version = _extract_str(server.get("version")) + website_url = _extract_str(server.get("websiteUrl")) + + description = _extract_str(server.get("description")) or "MCP 服务" + + tags: List[str] = [] + server_type = "容器" + server_url = "" + remotes_out: List[Dict[str, str]] = [] + + remotes = server.get("remotes") + if isinstance(remotes, list): + for remote in remotes: + if not isinstance(remote, dict): + continue + remote_url = _extract_str(remote.get("url")) + remote_type = _extract_str(remote.get("type")).lower() + if remote_url and remote_type in {"sse", "streamable-http", "http", ""}: + remotes_out.append({"type": remote_type or "remote", "url": remote_url}) + server_url = remote_url + server_type = "SSE" if remote_type == "sse" else "HTTP" + break + + if not server_url: + packages = server.get("packages") + if isinstance(packages, list) and packages: + first_pkg = packages[0] if isinstance(packages[0], dict) else {} + registry_type = _extract_str(first_pkg.get("registryType")) + identifier = _extract_str(first_pkg.get("identifier")) + version = _extract_str(first_pkg.get("version")) + runtime_hint = _extract_str(first_pkg.get("runtimeHint")) + transport = first_pkg.get("transport") if isinstance(first_pkg, dict) else {} + transport_url = _extract_str((transport or {}).get("url")) if isinstance(transport, dict) else "" + transport_type = _extract_str((transport or {}).get("type")).lower() if isinstance(transport, dict) else "" + + if transport_url: + server_url = transport_url + server_type = "SSE" if transport_type == "sse" else "HTTP" + remotes_out.append({"type": transport_type or "remote", "url": transport_url}) + else: + # Non-HTTP package format (npm/pypi/oci stdio etc.) is represented as container/runtime install target. + pkg_base = f"{registry_type}:{identifier}" if registry_type and identifier else identifier + if version and pkg_base: + pkg_base = f"{pkg_base}@{version}" + if runtime_hint and pkg_base: + server_url = f"{runtime_hint}://{pkg_base}" + elif pkg_base: + server_url = f"package://{pkg_base}" + else: + server_url = "package://unknown" + server_type = "容器" + + if registry_type: + tags.append(registry_type) + if runtime_hint: + tags.append(runtime_hint) + + version = _extract_str(server.get("version")) + if version: + tags.append(version) + + dedup_tags: List[str] = [] + seen = set() + for tag in tags: + normalized = tag.strip() + if not normalized: + continue + low = normalized.lower() + if low in seen: + continue + seen.add(low) + dedup_tags.append(normalized) + + official_meta = {} + if isinstance(entry.get("_meta"), dict): + official_meta = entry.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {}) or {} + + return { + "name": name, + "title": title, + "version": version, + "description": description, + "websiteUrl": website_url, + "remotes": remotes_out, + "serverUrl": server_url, + "serverType": server_type, + "tags": dedup_tags, + "status": _extract_str(official_meta.get("status")) or "active", + "isLatest": bool(official_meta.get("isLatest")), + "publishedAt": _extract_str(official_meta.get("publishedAt")), + "updatedAt": _extract_str(official_meta.get("updatedAt")), + "serverJson": server, + } + + +def _pick_latest_market_servers(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + grouped: Dict[str, Dict[str, Any]] = {} + for item in items: + key = str(item.get("name") or "").strip().lower() + if not key: + continue + existing = grouped.get(key) + if existing is None: + grouped[key] = item + continue + + existing_latest = bool(existing.get("isLatest")) + current_latest = bool(item.get("isLatest")) + if current_latest and not existing_latest: + grouped[key] = item + continue + if current_latest == existing_latest: + existing_time = _extract_str(existing.get("updatedAt")) or _extract_str(existing.get("publishedAt")) + current_time = _extract_str(item.get("updatedAt")) or _extract_str(item.get("publishedAt")) + if current_time > existing_time: + grouped[key] = item + + result = list(grouped.values()) + result.sort(key=lambda x: (_extract_str(x.get("updatedAt")) or _extract_str(x.get("publishedAt"))), reverse=True) + return result + + +async def list_market_mcp_services( + *, + search: str | None = None, + include_deleted: bool = False, + updated_since: str | None = None, + version: str | None = None, + cursor: str | None = None, + limit: int = 30, +) -> Dict[str, Any]: + params: Dict[str, Any] = {"limit": limit} + if search: + params["search"] = search + if include_deleted: + params["include_deleted"] = "true" + if updated_since: + params["updated_since"] = updated_since + if version: + params["version"] = version + if cursor: + params["cursor"] = cursor + + request_url = f"{MCP_REGISTRY_BASE_URL}?{urlencode(params)}" + timeout = aiohttp.ClientTimeout(total=20) + + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get(request_url) as response: + if response.status >= 400: + raise RuntimeError(f"Registry request failed with status {response.status}") + payload = await response.json(content_type=None) + + raw_servers = payload.get("servers") if isinstance(payload, dict) else [] + normalized: List[Dict[str, Any]] = [] + if isinstance(raw_servers, list): + for entry in raw_servers: + normalized_item = _normalize_market_server(entry) + if normalized_item: + normalized.append(normalized_item) + + metadata = payload.get("metadata") if isinstance(payload, dict) and isinstance(payload.get("metadata"), dict) else {} + + return { + "count": len(normalized), + "nextCursor": _extract_str(metadata.get("nextCursor")), + "items": normalized, + } + + +async def add_mcp_service( + *, + tenant_id: str, + user_id: str, + name: str, + description: str | None, + source: str, + server_type: str, + server_url: str, + tags: list[str] | None, + authorization_token: str | None, + container_config: Dict[str, Any] | None, +) -> None: + if check_mcp_manage_name_exists(tenant_id=tenant_id, name=name): + raise MCPNameIllegal("MCP name already exists") + + normalized_server_type = (server_type or "HTTP").strip().upper() + + # mcp-tools add flow does not perform connectivity checks. + # All newly added services remain disabled and unchecked until manual enable/health check. + status: bool | None = None + + source_type = "registry" if source == "公共市场" else "local" + if normalized_server_type == "SSE": + transport_type = "sse" + elif normalized_server_type == "HTTP": + transport_type = "streamable-http" + else: + transport_type = "stdio" + + config_json: Dict[str, Any] = {} + if authorization_token: + config_json["authorization_token"] = authorization_token + if container_config: + config_json["container_config"] = container_config + + try: + create_mcp_manage_service( + tenant_id=tenant_id, + user_id=user_id, + name=name, + server_url=server_url, + source_type=source_type, + transport_type=transport_type, + tags=tags, + category=description, + config_json=config_json or None, + enabled=False, + status=status, + ) + except Exception: + raise + + +def list_mcp_services(tenant_id: str) -> List[Dict[str, Any]]: + records = get_mcp_manage_records(tenant_id=tenant_id) + services: List[Dict[str, Any]] = [] + + for record in records: + source_type = (record.get("source_type") or "").lower() + transport_type = (record.get("transport_type") or "").lower() + enabled = bool(record.get("enabled")) + status = record.get("status") + config_json = _safe_config_dict(record) + + services.append({ + "name": record.get("mcp_name") or "未命名 MCP", + "description": record.get("category") or "MCP 服务", + "source": "公共市场" if source_type == "registry" else "本地", + "status": "已启用" if enabled else "未启用", + "updatedAt": _format_time(record.get("update_time")), + "tags": _split_tags(record.get("tags")), + "serverType": "容器" if transport_type == "stdio" else "SSE" if transport_type == "sse" else "HTTP", + "serverUrl": record.get("mcp_server") or "-", + "tools": record.get("tools") or [], + "healthStatus": "正常" if status is True else "异常" if status is False else "未检测", + "containerStatus": record.get("container_status") or None, + "authorizationToken": config_json.get("authorization_token") or "", + }) + + return services + + +def update_mcp_service( + *, + tenant_id: str, + user_id: str, + current_name: str, + new_name: str, + description: str | None, + server_url: str, + authorization_token: str | None, + tags: list[str] | None, +) -> None: + current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=current_name) + current_server_url = str((current_record or {}).get("mcp_server") or "").strip() + next_config_json = _safe_config_dict(current_record or {}) + + # Keep token in config_json as single source for mcp-tools management. + if authorization_token: + next_config_json["authorization_token"] = authorization_token + else: + next_config_json.pop("authorization_token", None) + + update_mcp_manage_service( + tenant_id=tenant_id, + user_id=user_id, + current_name=current_name, + new_name=new_name, + description=description, + server_url=server_url, + config_json=next_config_json or None, + tags=tags, + ) + + if _is_http_transport(current_record) and current_server_url: + update_mcp_record_by_name_and_url( + update_data=SimpleNamespace( + current_service_name=current_name, + current_mcp_url=current_server_url, + new_service_name=new_name, + new_mcp_url=server_url, + new_authorization_token=authorization_token, + ), + tenant_id=tenant_id, + user_id=user_id, + ) + + +def update_mcp_service_enabled( + *, + tenant_id: str, + user_id: str, + name: str, + enabled: bool, +) -> None: + current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) + + update_mcp_manage_enabled( + tenant_id=tenant_id, + user_id=user_id, + name=name, + enabled=enabled, + ) + + if not _is_http_transport(current_record): + return + + server_url = str((current_record or {}).get("mcp_server") or "").strip() + if not server_url: + return + + config_json = _safe_config_dict(current_record or {}) + authorization_token = config_json.get("authorization_token") or None + status = current_record.get("status") if isinstance(current_record, dict) else None + + if not enabled: + delete_mcp_record_by_name_and_url( + mcp_name=name, + mcp_server=server_url, + tenant_id=tenant_id, + user_id=user_id, + ) + return + + if check_mcp_name_exists(mcp_name=name, tenant_id=tenant_id): + update_mcp_record_by_name_and_url( + update_data=SimpleNamespace( + current_service_name=name, + current_mcp_url=server_url, + new_service_name=name, + new_mcp_url=server_url, + new_authorization_token=authorization_token, + ), + tenant_id=tenant_id, + user_id=user_id, + status=status, + ) + return + + create_mcp_record( + mcp_data={ + "mcp_name": name, + "mcp_server": server_url, + "status": status, + "container_id": None, + "authorization_token": authorization_token, + }, + tenant_id=tenant_id, + user_id=user_id, + ) + + +def delete_mcp_service( + *, + tenant_id: str, + user_id: str, + name: str, +) -> None: + current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) + + delete_mcp_manage_service( + tenant_id=tenant_id, + user_id=user_id, + name=name, + ) + + current_server_url = str((current_record or {}).get("mcp_server") or "").strip() + if _is_http_transport(current_record) and current_server_url: + delete_mcp_record_by_name_and_url( + mcp_name=name, + mcp_server=current_server_url, + tenant_id=tenant_id, + user_id=user_id, + ) + + +async def check_mcp_service_health( + *, + tenant_id: str, + user_id: str, + name: str, + server_url: str, +) -> str: + record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) + config_json = _safe_config_dict(record or {}) + authorization_token = config_json.get("authorization_token") + + try: + status = await mcp_server_health( + remote_mcp_server=server_url, + authorization_token=authorization_token, + ) + except BaseException as exc: + logger.error(f"MCP health check failed: {exc}") + status = False + + update_mcp_manage_status( + tenant_id=tenant_id, + user_id=user_id, + name=name, + status=status, + ) + + if not status: + raise MCPConnectionError("MCP connection failed") + + return "正常" diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx new file mode 100644 index 000000000..4e9f8df41 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -0,0 +1,673 @@ +import { useEffect, useMemo, useState } from "react"; +import { Modal, Input, Select, Button, Segmented, Tag, Upload, InputNumber, Switch, DatePicker } from "antd"; +import type { UploadFile } from "antd/es/upload/interface"; +import dayjs from "dayjs"; +import { useTranslation } from "react-i18next"; + +type McpTab = "本地" | "公共市场"; +type McpServerType = "HTTP" | "SSE" | "容器"; + +const MCP_TAB = { LOCAL: "本地", MARKET: "公共市场" } as const; +const MCP_SERVER_TYPE = { HTTP: "HTTP", SSE: "SSE", CONTAINER: "容器" } as const; +const MARKET_SERVER_STATUS = { ACTIVE: "active", DEPRECATED: "deprecated" } as const; + +type MarketMcpCard = { + name: string; + title: string; + version: string; + description: string; + publishedAt: string; + status: string; + websiteUrl: string; + remotes: Array<{ type: string; url: string }>; + serverJson: Record; + serverType: McpServerType; + serverUrl: string; +}; + +interface AddMcpServiceModalProps { + open: boolean; + addModalTab: McpTab; + marketSearchValue: string; + selectedMarketService: MarketMcpCard | null; + filteredMarketServices: MarketMcpCard[]; + marketLoading: boolean; + marketPage: number; + hasPrevMarketPage: boolean; + hasNextMarketPage: boolean; + marketVersion: string; + marketUpdatedSince: string; + marketIncludeDeleted: boolean; + newServiceName: string; + newServiceDesc: string; + newServerType: McpServerType; + newServiceUrl: string; + newServiceAuthorizationToken: string; + containerUploadFileList: UploadFile[]; + containerConfigJson: string; + containerPort: number | undefined; + containerServiceName: string; + newTagDrafts: string[]; + newTagInputValue: string; + addingService: boolean; + onClose: () => void; + onAddModalTabChange: (value: McpTab) => void; + onMarketSearchChange: (value: string) => void; + onRefreshMarket: () => void; + onPrevMarketPage: () => void; + onNextMarketPage: () => void; + onMarketVersionChange: (value: string) => void; + onMarketUpdatedSinceChange: (value: string) => void; + onMarketIncludeDeletedChange: (value: boolean) => void; + onSelectMarketService: (service: MarketMcpCard | null) => void; + onQuickAddFromMarket: (service: MarketMcpCard) => void; + onNewServiceNameChange: (value: string) => void; + onNewServiceDescChange: (value: string) => void; + onNewServerTypeChange: (value: McpServerType) => void; + onNewServiceUrlChange: (value: string) => void; + onNewServiceAuthorizationTokenChange: (value: string) => void; + onContainerUploadFileListChange: (fileList: UploadFile[]) => void; + onContainerConfigJsonChange: (value: string) => void; + onContainerPortChange: (value: number | undefined) => void; + onContainerServiceNameChange: (value: string) => void; + onAddNewTag: () => void; + onRemoveNewTag: (index: number) => void; + onNewTagInputChange: (value: string) => void; + onSaveAndAdd: () => void; +} + +export default function AddMcpServiceModal({ + open, + addModalTab, + marketSearchValue, + selectedMarketService, + filteredMarketServices, + marketLoading, + marketPage, + hasPrevMarketPage, + hasNextMarketPage, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + newServiceName, + newServiceDesc, + newServerType, + newServiceUrl, + newServiceAuthorizationToken, + containerUploadFileList, + containerConfigJson, + containerPort, + containerServiceName, + newTagDrafts, + newTagInputValue, + addingService, + onClose, + onAddModalTabChange, + onMarketSearchChange, + onRefreshMarket, + onPrevMarketPage, + onNextMarketPage, + onMarketVersionChange, + onMarketUpdatedSinceChange, + onMarketIncludeDeletedChange, + onSelectMarketService, + onQuickAddFromMarket, + onNewServiceNameChange, + onNewServiceDescChange, + onNewServerTypeChange, + onNewServiceUrlChange, + onNewServiceAuthorizationTokenChange, + onContainerUploadFileListChange, + onContainerConfigJsonChange, + onContainerPortChange, + onContainerServiceNameChange, + onAddNewTag, + onRemoveNewTag, + onNewTagInputChange, + onSaveAndAdd, +}: AddMcpServiceModalProps) { + const { t } = useTranslation("common"); + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const [marketVersionMode, setMarketVersionMode] = useState<"all" | "latest" | "custom">("latest"); + const [customVersion, setCustomVersion] = useState(""); + + const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; + + const formatMarketDate = (value: string) => { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + }; + + const formatMarketVersion = (value: string) => { + const version = (value || "").trim(); + if (!version) { + return "-"; + } + if (/^v/i.test(version)) { + return version; + } + return `v${version}`; + }; + + const getStatusClassName = (status: string) => { + if (status === MARKET_SERVER_STATUS.ACTIVE) { + return "bg-emerald-100 text-emerald-700"; + } + if (status === MARKET_SERVER_STATUS.DEPRECATED) { + return "bg-amber-100 text-amber-700"; + } + return "bg-slate-100 text-slate-600"; + }; + + const getStatusText = (status: string) => { + if (status === MARKET_SERVER_STATUS.ACTIVE) { + return t("mcpTools.market.status.active"); + } + if (status === MARKET_SERVER_STATUS.DEPRECATED) { + return t("mcpTools.market.status.deprecated"); + } + return t("mcpTools.market.status.unknown"); + }; + + const serverJsonPretty = useMemo(() => { + if (!selectedMarketService) { + return "{}"; + } + try { + return JSON.stringify(selectedMarketService.serverJson || {}, null, 2); + } catch { + return "{}"; + } + }, [selectedMarketService]); + + const updatedSinceDateValue = useMemo(() => { + if (!marketUpdatedSince) { + return null; + } + const parsed = dayjs(marketUpdatedSince); + return parsed.isValid() ? parsed : null; + }, [marketUpdatedSince]); + + const customVersionError = customVersion.trim().length > 0 && !VERSION_PATTERN.test(customVersion.trim()); + + useEffect(() => { + if (!selectedMarketService) { + setShowServerJsonModal(false); + } + }, [selectedMarketService]); + + useEffect(() => { + const value = (marketVersion || "").trim(); + if (!value) { + setMarketVersionMode("all"); + setCustomVersion(""); + return; + } + if (value.toLowerCase() === "latest") { + setMarketVersionMode("latest"); + setCustomVersion(""); + return; + } + setMarketVersionMode("custom"); + setCustomVersion(value); + }, [marketVersion]); + + const handleVersionModeChange = (mode: "all" | "latest" | "custom") => { + setMarketVersionMode(mode); + if (mode === "all") { + setCustomVersion(""); + onMarketVersionChange(""); + return; + } + if (mode === "latest") { + setCustomVersion(""); + onMarketVersionChange("latest"); + return; + } + // Custom mode starts empty and waits for valid semantic numeric version input. + setCustomVersion(""); + onMarketVersionChange(""); + }; + + const handleCustomVersionChange = (value: string) => { + setCustomVersion(value); + const trimmed = value.trim(); + if (!trimmed) { + onMarketVersionChange(""); + return; + } + if (VERSION_PATTERN.test(trimmed)) { + onMarketVersionChange(trimmed); + } + }; + + if (!open) { + return null; + } + + return ( + +
+
+
+

{t("mcpTools.addModal.title")}

+
+
+ +
+ onAddModalTabChange(value as McpTab)} + options={[ + { label: t("mcpTools.addModal.tabLocal"), value: MCP_TAB.LOCAL }, + { label: t("mcpTools.addModal.tabMarket"), value: MCP_TAB.MARKET }, + ]} + className="h-9 rounded-full border border-slate-200 bg-slate-100 p-[2px] text-sm [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-full [&_.ant-segmented-item-label]:px-4 [&_.ant-segmented-item-label]:leading-[30px] [&_.ant-segmented-thumb]:rounded-full [&_.ant-segmented-thumb]:bg-white [&_.ant-segmented-thumb]:shadow-sm [&_.ant-segmented-thumb]:top-[2px] [&_.ant-segmented-thumb]:bottom-[2px]" + /> +
+ + {addModalTab === MCP_TAB.LOCAL ? ( + <> +
+ + + + + + +
+ ) : ( +
+
+

{t("mcpTools.addModal.uploadImageTitle")}

+

{t("mcpTools.addModal.uploadImageDesc")}

+
+ onContainerUploadFileListChange(fileList)} + beforeUpload={() => false} + accept=".tar" + maxCount={1} + > + + +
+
+ + + +
+ + +
+
+ )} + +
+

{t("mcpTools.addModal.tags")}

+
+ {newTagDrafts.map((tag, index) => ( + + {tag} + + + ))} + onNewTagInputChange(event.target.value)} + onPressEnter={onAddNewTag} + onBlur={onAddNewTag} + placeholder={t("mcpTools.addModal.tagInputPlaceholder")} + className="w-40 rounded-full" + /> +
+
+
+ +
+ +
+ + ) : ( +
+
+ onMarketSearchChange(event.target.value)} + placeholder={t("mcpTools.market.searchPlaceholder")} + size="large" + className="w-full rounded-2xl" + /> + +
+ {t("mcpTools.market.pageResult", { page: marketPage, count: filteredMarketServices.length })} +
+
+ +
+ + ) : null} + + {marketLoading ? ( +
+ {t("mcpTools.market.loading")} +
+ ) : filteredMarketServices.length === 0 ? ( +
+ {t("mcpTools.market.empty")} +
+ ) : ( +
+
+ {filteredMarketServices.map((service, index) => ( +
onSelectMarketService(service)} + className="group rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" + > +
+

{service.name}

+ + {getStatusText(service.status)} + +
+ +
+ + {formatMarketVersion(service.version)} + + {formatMarketDate(service.publishedAt)} +
+ +

{service.description}

+ +
+ +
+
+ ))} +
+ +
+ + +
+
+ )} +
+ )} + + {selectedMarketService ? ( + onSelectMarketService(null)} + styles={{ + mask: { background: "rgba(15,23,42,0.4)" }, + body: { padding: 0 }, + }} + > +
+
+
+
+

{selectedMarketService.name}

+

{formatMarketVersion(selectedMarketService.version)}

+
+ + {getStatusText(selectedMarketService.status)} + +
+
+ +
+

{selectedMarketService.description}

+ +

{formatMarketDate(selectedMarketService.publishedAt)}

+ +
+
+ {t("mcpTools.market.title")} + {selectedMarketService.title || "-"} +
+
+ {t("mcpTools.market.website")} + {selectedMarketService.websiteUrl ? ( + + {selectedMarketService.websiteUrl} + + ) : ( + - + )} +
+
+ +
+

{t("mcpTools.market.remotes")}

+ {selectedMarketService.remotes.length === 0 ? ( +

{t("mcpTools.market.noRemotes")}

+ ) : ( +
+ {selectedMarketService.remotes.map((remote, index) => ( +
+

{remote.type || t("mcpTools.market.remoteFallback")}

+

{remote.url}

+
+ ))} +
+ )} +
+
+ +
+ + +
+
+
+ ) : null} + + {selectedMarketService && showServerJsonModal ? ( + setShowServerJsonModal(false)} + title={t("mcpTools.market.serverJsonTitle", { name: selectedMarketService.name })} + > +
+              {serverJsonPretty}
+            
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx new file mode 100644 index 000000000..aeb6e4fdd --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -0,0 +1,296 @@ +import { Modal, Input, Button, Tag } from "antd"; +import { useTranslation } from "react-i18next"; + +type McpTab = "本地" | "公共市场"; +type McpServerType = "HTTP" | "SSE" | "容器"; +type McpServiceStatus = "已启用" | "未启用"; +type McpHealthStatus = "正常" | "异常" | "未检测"; +type McpContainerStatus = "运行中" | "已停止" | "未知"; + +const MCP_TAB = { LOCAL: "本地", MARKET: "公共市场" } as const; +const MCP_SERVER_TYPE = { HTTP: "HTTP", SSE: "SSE", CONTAINER: "容器" } as const; +const MCP_SERVICE_STATUS = { ENABLED: "已启用", DISABLED: "未启用" } as const; +const MCP_HEALTH_STATUS = { HEALTHY: "正常", UNHEALTHY: "异常" } as const; +const MCP_CONTAINER_STATUS = { RUNNING: "运行中", STOPPED: "已停止" } as const; + +type McpCard = { + name: string; + description: string; + source: McpTab; + status: McpServiceStatus; + updatedAt: string; + tags: string[]; + serverType: McpServerType; + serverUrl: string; + tools: string[]; + healthStatus: McpHealthStatus; + containerStatus?: McpContainerStatus; + authorizationToken?: string; +}; + +interface McpServiceDetailModalProps { + open: boolean; + selectedService: McpCard | null; + draftService: McpCard | null; + tagDrafts: string[]; + tagInputValue: string; + healthCheckLoading: boolean; + loadingTools: boolean; + onClose: () => void; + onDraftServiceChange: (service: McpCard) => void; + onTagInputChange: (value: string) => void; + onAddDetailTag: () => void; + onRemoveTag: (index: number) => void; + onHealthCheck: () => void; + onViewTools: () => void; + onDeleteConfirm: (serviceName: string) => void; + onSaveUpdates: () => void; + onToggleEnable: (service: McpCard) => void; +} + +export default function McpServiceDetailModal({ + open, + selectedService, + draftService, + tagDrafts, + tagInputValue, + healthCheckLoading, + loadingTools, + onClose, + onDraftServiceChange, + onTagInputChange, + onAddDetailTag, + onRemoveTag, + onHealthCheck, + onViewTools, + onDeleteConfirm, + onSaveUpdates, + onToggleEnable, +}: McpServiceDetailModalProps) { + const { t } = useTranslation("common"); + + const getHealthStatusLabel = (status: McpHealthStatus) => { + if (status === MCP_HEALTH_STATUS.HEALTHY) { + return t("mcpTools.health.healthy"); + } + if (status === MCP_HEALTH_STATUS.UNHEALTHY) { + return t("mcpTools.health.unhealthy"); + } + return t("mcpTools.health.unchecked"); + }; + + const getContainerStatusLabel = (status?: McpContainerStatus) => { + if (status === MCP_CONTAINER_STATUS.RUNNING) { + return t("mcpTools.containerStatus.running"); + } + if (status === MCP_CONTAINER_STATUS.STOPPED) { + return t("mcpTools.containerStatus.stopped"); + } + return t("mcpTools.containerStatus.unknown"); + }; + + if (!open || !selectedService || !draftService) { + return null; + } + + return ( + +
+
+
+

{t("mcpTools.detail.title")}

+
+
+ +
+
+ + + + {draftService.serverType === MCP_SERVER_TYPE.HTTP || draftService.serverType === MCP_SERVER_TYPE.SSE ? ( + + ) : null} +
+ +
+
+ {t("mcpTools.detail.source")} + + {draftService.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.market")} + +
+
+ {t("mcpTools.detail.serverType")} + + {draftService.serverType === MCP_SERVER_TYPE.HTTP + ? t("mcpTools.serverType.http") + : draftService.serverType === MCP_SERVER_TYPE.SSE + ? t("mcpTools.serverType.sse") + : t("mcpTools.serverType.container")} + +
+
+ {t("mcpTools.detail.status")} + + {draftService.status === MCP_SERVICE_STATUS.ENABLED + ? t("mcpTools.status.enabled") + : t("mcpTools.status.disabled")} + +
+
+ {t("mcpTools.detail.health")} +
+ {getHealthStatusLabel(draftService.healthStatus)} + +
+
+ {draftService.serverType === MCP_SERVER_TYPE.CONTAINER ? ( +
+ {t("mcpTools.detail.containerStatus")} + {getContainerStatusLabel(draftService.containerStatus)} +
+ ) : null} +
+ +
+
+

{t("mcpTools.detail.tools")}

+ +
+
+ +
+

{t("mcpTools.detail.tags")}

+
+ {tagDrafts.map((tag, index) => ( + + {tag} + + + ))} + onTagInputChange(event.target.value)} + onPressEnter={onAddDetailTag} + onBlur={onAddDetailTag} + placeholder={t("mcpTools.detail.tagInputPlaceholder")} + className="w-40 rounded-full" + /> +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx new file mode 100644 index 000000000..6763c785f --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Modal, Button, Table } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import { Maximize, Minimize } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { McpTool } from "@/types/agentConfig"; + +interface McpToolListModalProps { + open: boolean; + onCancel: () => void; + loading: boolean; + tools: McpTool[]; + serverName: string; + onRefresh?: () => void; +} + +export default function McpToolListModal({ + open, + onCancel, + loading, + tools, + serverName, + onRefresh, +}: McpToolListModalProps) { + const { t } = useTranslation("common"); + const [expandedDescriptions, setExpandedDescriptions] = useState>(new Set()); + + const toggleDescription = (toolName: string) => { + const newExpanded = new Set(expandedDescriptions); + if (newExpanded.has(toolName)) { + newExpanded.delete(toolName); + } else { + newExpanded.add(toolName); + } + setExpandedDescriptions(newExpanded); + }; + + const toolColumns = [ + { title: t("mcpConfig.toolsList.column.name"), dataIndex: "name", key: "name", width: "30%" }, + { + title: t("mcpConfig.toolsList.column.description"), + dataIndex: "description", + key: "description", + width: "70%", + render: (text: string, record: McpTool) => { + const isExpanded = expandedDescriptions.has(record.name); + const maxLength = 100; + const needsExpansion = text && text.length > maxLength; + return ( +
+
+ {needsExpansion && !isExpanded ? `${text.substring(0, maxLength)}...` : text} +
+ {needsExpansion && ( + + )} +
+ ); + }, + }, + ]; + + return ( + } + onClick={onRefresh} + loading={loading} + disabled={!onRefresh} + > + {t("common.refresh")} + , + , + ]} + > + + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index 12691f8f2..90be7f33c 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -1,103 +1,1051 @@ "use client"; -import React from "react"; -import { motion } from "framer-motion"; +import React, { useEffect, useMemo, useState } from "react"; +import { App, Input, Button } from "antd"; +import type { UploadFile } from "antd/es/upload/interface"; import { useTranslation } from "react-i18next"; -import { Puzzle } from "lucide-react"; +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import McpToolListModal from "./components/McpToolListModal"; +import McpServiceDetailModal from "./components/McpServiceDetailModal"; +import AddMcpServiceModal from "./components/AddMcpServiceModal"; +import type { McpTool } from "@/types/agentConfig"; -import { useSetupFlow } from "@/hooks/useSetupFlow"; +type McpTab = "本地" | "公共市场"; +type McpServerType = "HTTP" | "SSE" | "容器"; +type McpServiceStatus = "已启用" | "未启用"; +type McpHealthStatus = "正常" | "异常" | "未检测"; +type McpContainerStatus = "运行中" | "已停止" | "未知"; -/** - * McpToolsContent - MCP tools management coming soon page - * This will allow admins to manage MCP servers and tools - */ -export default function McpToolsContent({}) { +const MCP_TAB = { LOCAL: "本地", MARKET: "公共市场" } as const; +const MCP_SERVER_TYPE = { HTTP: "HTTP", SSE: "SSE", CONTAINER: "容器" } as const; +const MCP_SERVICE_STATUS = { ENABLED: "已启用", DISABLED: "未启用" } as const; +const MCP_HEALTH_STATUS = { HEALTHY: "正常", UNHEALTHY: "异常", UNCHECKED: "未检测" } as const; + +const normalizeMcpTab = (value: unknown): McpTab => { + return value === MCP_TAB.MARKET ? MCP_TAB.MARKET : MCP_TAB.LOCAL; +}; + +const normalizeMcpServerType = (value: unknown): McpServerType => { + if (value === MCP_SERVER_TYPE.SSE) return MCP_SERVER_TYPE.SSE; + return value === MCP_SERVER_TYPE.CONTAINER ? MCP_SERVER_TYPE.CONTAINER : MCP_SERVER_TYPE.HTTP; +}; + +const normalizeMcpServiceStatus = (value: unknown): McpServiceStatus => { + return value === MCP_SERVICE_STATUS.ENABLED ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED; +}; + +const normalizeMcpHealthStatus = (value: unknown): McpHealthStatus => { + if (value === MCP_HEALTH_STATUS.HEALTHY) return MCP_HEALTH_STATUS.HEALTHY; + if (value === MCP_HEALTH_STATUS.UNHEALTHY) return MCP_HEALTH_STATUS.UNHEALTHY; + return MCP_HEALTH_STATUS.UNCHECKED; +}; + +const normalizeMcpContainerStatus = (value: unknown): McpContainerStatus => { + if (value === "运行中") return "运行中"; + if (value === "已停止") return "已停止"; + return "未知"; +}; + +const normalizeMarketServerStatus = (value: unknown): string => { + if (typeof value !== "string") return "unknown"; + const normalized = value.trim().toLowerCase(); + if (normalized === "active") return "active"; + if (normalized === "deprecated") return "deprecated"; + return "unknown"; +}; + +type McpCard = { + name: string; + description: string; + source: McpTab; + status: McpServiceStatus; + updatedAt: string; + tags: string[]; + serverType: McpServerType; + serverUrl: string; + tools: string[]; + healthStatus: McpHealthStatus; + containerStatus?: McpContainerStatus; + authorizationToken?: string; +}; + +type MarketMcpCard = { + name: string; + title: string; + version: string; + description: string; + publishedAt: string; + status: string; + websiteUrl: string; + remotes: Array<{ type: string; url: string }>; + serverJson: Record; + serverType: McpServerType; + serverUrl: string; +}; + +const getAuthHeaders = () => { + const session = typeof window !== "undefined" ? localStorage.getItem("session") : null; + const sessionObj = session ? JSON.parse(session) : null; + + return { + "Content-Type": "application/json", + "User-Agent": "AgentFrontEnd/1.0", + ...(sessionObj?.access_token && { Authorization: `Bearer ${sessionObj.access_token}` }), + }; +}; + +export default function McpToolsContent() { + const { message } = App.useApp(); const { t } = useTranslation("common"); + const { confirm } = useConfirmModal(); + const [searchValue, setSearchValue] = useState(""); + const [marketSearchValue, setMarketSearchValue] = useState(""); + const [selectedService, setSelectedService] = useState(null); + const [services, setServices] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [draftService, setDraftService] = useState(null); + const [healthCheckLoading, setHealthCheckLoading] = useState(false); + const [tagDrafts, setTagDrafts] = useState([]); + const [tagInputValue, setTagInputValue] = useState(""); + const [showAddModal, setShowAddModal] = useState(false); + const [newServiceName, setNewServiceName] = useState(""); + const [newServiceUrl, setNewServiceUrl] = useState(""); + const [newServiceDesc, setNewServiceDesc] = useState(""); + const [newServiceAuthorizationToken, setNewServiceAuthorizationToken] = useState(""); + const [newServerType, setNewServerType] = useState(MCP_SERVER_TYPE.HTTP); + const [containerConfigJson, setContainerConfigJson] = useState(""); + const [containerUploadFileList, setContainerUploadFileList] = useState([]); + const [containerPort, setContainerPort] = useState(undefined); + const [containerServiceName, setContainerServiceName] = useState(""); + const [newTagDrafts, setNewTagDrafts] = useState([]); + const [newTagInputValue, setNewTagInputValue] = useState(""); + const [addModalTab, setAddModalTab] = useState(MCP_TAB.LOCAL); + const [selectedMarketService, setSelectedMarketService] = useState(null); + const [marketServices, setMarketServices] = useState([]); + const [marketLoading, setMarketLoading] = useState(false); + const [marketCurrentCursor, setMarketCurrentCursor] = useState(null); + const [marketNextCursor, setMarketNextCursor] = useState(null); + const [marketCursorHistory, setMarketCursorHistory] = useState([]); + const [marketPage, setMarketPage] = useState(1); + const [marketVersion, setMarketVersion] = useState("latest"); + const [marketUpdatedSince, setMarketUpdatedSince] = useState(""); + const [marketIncludeDeleted, setMarketIncludeDeleted] = useState(false); + const [addingService, setAddingService] = useState(false); + const [loadingTools, setLoadingTools] = useState(false); + const [toolsModalVisible, setToolsModalVisible] = useState(false); + const [currentServerTools, setCurrentServerTools] = useState([]); + const [toolCache, setToolCache] = useState>({}); + + const getToolCacheKey = (service: Pick) => + `${service.name}@@${service.serverUrl}`; + + const syncToolNamesToCards = (service: McpCard, tools: McpTool[]) => { + const nextToolNames = tools.map((item) => item.name); + setDraftService((prev) => { + if (!prev || prev.name !== service.name || prev.serverUrl !== service.serverUrl) { + return prev; + } + return { ...prev, tools: nextToolNames }; + }); + setServices((prev) => + prev.map((item) => + item.name === service.name && item.serverUrl === service.serverUrl + ? { ...item, tools: nextToolNames } + : item + ) + ); + }; + + const resetAddForm = () => { + setNewServiceName(""); + setNewServiceUrl(""); + setNewServiceDesc(""); + setNewServiceAuthorizationToken(""); + setNewServerType(MCP_SERVER_TYPE.HTTP); + setContainerConfigJson(""); + setContainerUploadFileList([]); + setContainerPort(undefined); + setContainerServiceName(""); + setNewTagDrafts([]); + setNewTagInputValue(""); + }; + + const closeAddModal = () => { + setShowAddModal(false); + setAddModalTab(MCP_TAB.LOCAL); + setMarketSearchValue(""); + setMarketCurrentCursor(null); + setMarketNextCursor(null); + setMarketCursorHistory([]); + setMarketPage(1); + setMarketVersion("latest"); + setMarketUpdatedSince(""); + setMarketIncludeDeleted(false); + setSelectedMarketService(null); + resetAddForm(); + }; + + const fetchServices = async () => { + setIsLoading(true); + setLoadError(null); + try { + const response = await fetch("/api/mcp-tools/list", { + headers: getAuthHeaders(), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.message || t("mcpTools.list.loadFailed")); + } + + if (!Array.isArray(data?.data)) { + throw new Error(t("mcpTools.list.invalidFormat")); + } + + const normalizedCards = data.data.map((item: any) => ({ + name: typeof item?.name === "string" ? item.name : "", + description: + typeof item?.description === "string" && item.description.trim().length > 0 + ? item.description + : t("mcpTools.service.defaultDescription"), + source: normalizeMcpTab(item?.source), + status: normalizeMcpServiceStatus(item?.status), + updatedAt: typeof item?.updatedAt === "string" ? item.updatedAt : "", + tags: Array.isArray(item?.tags) ? item.tags.filter((tag: unknown) => typeof tag === "string") : [], + serverType: normalizeMcpServerType(item?.serverType), + serverUrl: typeof item?.serverUrl === "string" ? item.serverUrl : "", + tools: Array.isArray(item?.tools) ? item.tools.filter((tool: unknown) => typeof tool === "string") : [], + healthStatus: normalizeMcpHealthStatus(item?.healthStatus), + containerStatus: item?.containerStatus ? normalizeMcpContainerStatus(item.containerStatus) : undefined, + authorizationToken: typeof item?.authorizationToken === "string" ? item.authorizationToken : undefined, + } as McpCard)); + + setServices(normalizedCards); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.list.loadFailed"); + setLoadError(messageText); + message.error(messageText); + } finally { + setIsLoading(false); + } + }; + + const fetchMarketServices = async (options?: { + search?: string; + cursor?: string | null; + version?: string; + updatedSince?: string; + includeDeleted?: boolean; + }) => { + setMarketLoading(true); + try { + const params = new URLSearchParams(); + params.set("limit", "30"); + const searchValue = (options?.search ?? marketSearchValue).trim(); + if (searchValue) { + params.set("search", searchValue); + } + const versionValue = (options?.version ?? marketVersion).trim(); + if (versionValue) { + params.set("version", versionValue); + } + const updatedSinceValue = (options?.updatedSince ?? marketUpdatedSince).trim(); + if (updatedSinceValue) { + params.set("updated_since", updatedSinceValue); + } + const includeDeletedValue = options?.includeDeleted ?? marketIncludeDeleted; + params.set("include_deleted", includeDeletedValue ? "true" : "false"); + const cursorValue = options?.cursor; + if (cursorValue) { + params.set("cursor", cursorValue); + } + + const response = await fetch(`/api/mcp-tools/market/list?${params.toString()}`, { + headers: getAuthHeaders(), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.detail || data?.message || t("mcpTools.market.loadFailed")); + } + + const items = Array.isArray(data?.data?.items) ? data.data.items : []; + const normalized = items + .map((item: any) => ({ + name: typeof item?.name === "string" ? item.name : "", + title: typeof item?.title === "string" ? item.title : "", + version: typeof item?.version === "string" ? item.version : "", + description: typeof item?.description === "string" ? item.description : t("mcpTools.service.defaultDescription"), + publishedAt: typeof item?.publishedAt === "string" ? item.publishedAt : "", + status: normalizeMarketServerStatus(item?.status), + websiteUrl: typeof item?.websiteUrl === "string" ? item.websiteUrl : "", + remotes: Array.isArray(item?.remotes) + ? item.remotes + .map((remote: any) => ({ + type: typeof remote?.type === "string" ? remote.type : "", + url: typeof remote?.url === "string" ? remote.url : "", + })) + .filter((remote: { type: string; url: string }) => remote.url.length > 0) + : [], + serverJson: typeof item?.serverJson === "object" && item?.serverJson ? item.serverJson : {}, + serverType: normalizeMcpServerType(item?.serverType), + serverUrl: typeof item?.serverUrl === "string" ? item.serverUrl : "", + })) + .filter((item: MarketMcpCard) => item.name.trim().length > 0); + + setMarketServices(normalized); + setMarketNextCursor(typeof data?.data?.nextCursor === "string" && data.data.nextCursor.trim() ? data.data.nextCursor : null); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.market.loadFailed"); + message.error(messageText); + setMarketServices([]); + setMarketNextCursor(null); + } finally { + setMarketLoading(false); + } + }; + + const loadMarketFirstPage = async (search?: string) => { + setMarketCurrentCursor(null); + setMarketCursorHistory([]); + setMarketPage(1); + await fetchMarketServices({ search, cursor: null }); + }; + + const handleMarketNextPage = async () => { + if (!marketNextCursor || marketLoading) { + return; + } + const nextCursor = marketNextCursor; + const currentCursorSnapshot = marketCurrentCursor; + setMarketCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]); + setMarketCurrentCursor(nextCursor); + setMarketPage((prev) => prev + 1); + await fetchMarketServices({ cursor: nextCursor }); + }; + + const handleMarketPrevPage = async () => { + if (marketCursorHistory.length === 0 || marketLoading) { + return; + } + const previousCursor = marketCursorHistory[marketCursorHistory.length - 1] || null; + setMarketCursorHistory((prev) => prev.slice(0, -1)); + setMarketCurrentCursor(previousCursor); + setMarketPage((prev) => Math.max(1, prev - 1)); + await fetchMarketServices({ cursor: previousCursor }); + }; + + const handleAddService = async () => { + if (!newServiceName.trim()) { + message.error(t("mcpTools.add.validate.nameRequired")); + return; + } + + if ((newServerType === MCP_SERVER_TYPE.HTTP || newServerType === MCP_SERVER_TYPE.SSE) && !newServiceUrl.trim()) { + message.error(t("mcpTools.add.validate.httpUrlRequired")); + return; + } + + if (newServerType === MCP_SERVER_TYPE.CONTAINER) { + const hasConfig = containerConfigJson.trim().length > 0 || containerUploadFileList.length > 0; + if (!hasConfig) { + message.error(t("mcpTools.add.validate.containerConfigRequired")); + return; + } + if (!containerServiceName.trim() || !containerPort) { + message.error(t("mcpTools.add.validate.containerRequired")); + return; + } + } + + if (addModalTab !== MCP_TAB.LOCAL) { + message.error(t("mcpTools.add.validate.localTabOnly")); + return; + } + + const tags = newTagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); + const normalizedToken = newServiceAuthorizationToken.trim() || undefined; + + setAddingService(true); + try { + let finalServerUrl = + (newServerType === MCP_SERVER_TYPE.HTTP || newServerType === MCP_SERVER_TYPE.SSE) + ? newServiceUrl.trim() + : `container://${containerServiceName.trim()}:${containerPort}`; + const containerConfigPayload: Record = { + config_json: containerConfigJson.trim() || undefined, + service_name: containerServiceName.trim() || undefined, + port: containerPort, + }; + + if (newServerType === MCP_SERVER_TYPE.CONTAINER) { + if (containerUploadFileList.length > 0) { + const file = containerUploadFileList[0]?.originFileObj; + if (!file) { + throw new Error(t("mcpTools.add.error.imageReadFailed")); + } + const formData = new FormData(); + formData.append("file", file); + formData.append("port", String(containerPort)); + formData.append("service_name", containerServiceName.trim()); + if (normalizedToken) { + formData.append("env_vars", JSON.stringify({ authorization_token: normalizedToken })); + } + + const uploadHeaders = getAuthHeaders(); + delete (uploadHeaders as Record)["Content-Type"]; + + const uploadResponse = await fetch("/api/mcp/upload-image", { + method: "POST", + headers: uploadHeaders, + body: formData, + }); + const uploadData = await uploadResponse.json(); + if (!uploadResponse.ok || uploadData?.status !== "success") { + throw new Error(uploadData?.detail || uploadData?.message || t("mcpTools.add.error.imageUploadFailed")); + } + finalServerUrl = uploadData?.mcp_url || finalServerUrl; + containerConfigPayload.upload_result = uploadData; + } else { + let parsedConfig: any; + try { + parsedConfig = JSON.parse(containerConfigJson); + } catch { + throw new Error(t("mcpTools.add.error.containerJsonInvalid")); + } + + if (!parsedConfig?.mcpServers || typeof parsedConfig.mcpServers !== "object") { + throw new Error(t("mcpTools.add.error.containerJsonMissingServers")); + } + + const mcpServers = Object.fromEntries( + Object.entries(parsedConfig.mcpServers as Record).map(([key, value]) => [ + key, + { + ...value, + port: value?.port ?? containerPort, + }, + ]) + ); + + const addConfigResponse = await fetch("/api/mcp/add-from-config", { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ mcpServers }), + }); + const addConfigData = await addConfigResponse.json(); + if (!addConfigResponse.ok || addConfigData?.status !== "success") { + throw new Error(addConfigData?.detail || addConfigData?.message || t("mcpTools.add.error.containerAddFailed")); + } + + const firstResult = Array.isArray(addConfigData?.results) ? addConfigData.results[0] : undefined; + finalServerUrl = firstResult?.mcp_url || finalServerUrl; + containerConfigPayload.add_from_config_result = addConfigData; + } + } + + const response = await fetch("/api/mcp-tools/add", { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + name: newServiceName.trim(), + description: newServiceDesc.trim() || t("mcpTools.service.defaultDescription"), + source: addModalTab, + server_type: newServerType, + server_url: finalServerUrl, + tags, + authorization_token: normalizedToken, + container_config: newServerType === MCP_SERVER_TYPE.CONTAINER ? containerConfigPayload : undefined, + }), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.detail || data?.message || t("mcpTools.add.failed")); + } - // Use custom hook for common setup flow logic - const { pageVariants, pageTransition } = useSetupFlow(); + setShowAddModal(false); + resetAddForm(); + await fetchServices(); + message.success(t("mcpTools.add.success")); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.add.failed"); + const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; + setLoadError(displayMessage); + message.error(displayMessage); + } finally { + setAddingService(false); + } + }; + + const handleQuickAddFromMarket = async (service: MarketMcpCard) => { + const isUrlService = service.serverType === MCP_SERVER_TYPE.HTTP || service.serverType === MCP_SERVER_TYPE.SSE; + if (!isUrlService || !service.serverUrl.trim()) { + message.error(t("mcpTools.market.quickAddUnsupported")); + return; + } + + setAddingService(true); + try { + const response = await fetch("/api/mcp-tools/add", { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + name: service.name, + description: service.description || t("mcpTools.service.defaultDescription"), + source: MCP_TAB.MARKET, + server_type: service.serverType, + server_url: service.serverUrl, + tags: [], + }), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.detail || data?.message || t("mcpTools.add.failed")); + } + + await fetchServices(); + closeAddModal(); + message.success(t("mcpTools.market.quickAddSuccess")); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.add.failed"); + const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; + setLoadError(displayMessage); + message.error(displayMessage); + } finally { + setAddingService(false); + } + }; + + const normalizeTools = (tools: unknown): McpTool[] => { + if (!Array.isArray(tools)) { + return []; + } + return tools + .map((item) => { + const tool = item as Record; + const name = typeof tool.name === "string" ? tool.name : ""; + const description = typeof tool.description === "string" ? tool.description : ""; + if (!name.trim()) { + return null; + } + return { + name, + description, + parameters: tool.parameters, + } as McpTool; + }) + .filter((item): item is McpTool => item !== null); + }; + + const loadToolsForService = async ( + service: McpCard, + options?: { silent?: boolean; force?: boolean } + ) => { + if (!service.name || !service.serverUrl) { + return; + } + + const silent = options?.silent ?? false; + const force = options?.force ?? false; + const cacheKey = getToolCacheKey(service); + const cachedTools = toolCache[cacheKey]; + + if (!force && cachedTools) { + setCurrentServerTools(cachedTools); + syncToolNamesToCards(service, cachedTools); + return; + } + + setLoadingTools(true); + try { + const response = await fetch( + `/api/mcp/tools?service_name=${encodeURIComponent(service.name)}&mcp_url=${encodeURIComponent(service.serverUrl)}`, + { + method: "POST", + headers: getAuthHeaders(), + } + ); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.detail || data?.message || t("mcpTools.tools.loadFailed")); + } + + const nextTools = normalizeTools(data?.tools); + setToolCache((prev) => ({ ...prev, [cacheKey]: nextTools })); + setCurrentServerTools(nextTools); + syncToolNamesToCards(service, nextTools); + } catch (error) { + if (!silent) { + const messageText = error instanceof Error ? error.message : t("mcpTools.tools.loadFailed"); + message.error(messageText); + } + setCurrentServerTools(cachedTools ?? []); + } finally { + setLoadingTools(false); + } + }; + + useEffect(() => { + fetchServices().catch(() => undefined); + }, []); + + useEffect(() => { + if (showAddModal && addModalTab === MCP_TAB.MARKET && marketServices.length === 0 && !marketLoading) { + loadMarketFirstPage(marketSearchValue).catch(() => undefined); + } + }, [showAddModal, addModalTab, marketServices.length, marketLoading]); + + useEffect(() => { + if (!(showAddModal && addModalTab === MCP_TAB.MARKET)) { + return; + } + const timer = window.setTimeout(() => { + loadMarketFirstPage(marketSearchValue).catch(() => undefined); + }, 350); + return () => window.clearTimeout(timer); + }, [marketSearchValue, marketVersion, marketUpdatedSince, marketIncludeDeleted, showAddModal, addModalTab]); + + useEffect(() => { + if (selectedService) { + setDraftService({ ...selectedService }); + setTagDrafts(selectedService.tags); + setTagInputValue(""); + setCurrentServerTools(toolCache[getToolCacheKey(selectedService)] ?? []); + } else { + setDraftService(null); + setTagDrafts([]); + setTagInputValue(""); + setCurrentServerTools([]); + setToolsModalVisible(false); + } + }, [selectedService]); + + const handleViewTools = () => { + if (!draftService) { + return; + } + setToolsModalVisible(true); + loadToolsForService(draftService, { force: false }).catch(() => undefined); + }; + + const handleRefreshTools = () => { + if (!draftService) { + return; + } + loadToolsForService(draftService, { force: true }).catch(() => undefined); + }; + + const addDetailTag = () => { + const nextTag = tagInputValue.trim(); + if (!nextTag) return; + setTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); + setTagInputValue(""); + }; + + const addNewTag = () => { + const nextTag = newTagInputValue.trim(); + if (!nextTag) return; + setNewTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); + setNewTagInputValue(""); + }; + + const handleEnableToggle = async (service: McpCard) => { + const nextEnabled = service.status !== MCP_SERVICE_STATUS.ENABLED; + try { + const response = await fetch("/api/mcp-tools/manage/enable", { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ name: service.name, enabled: nextEnabled }), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.message || t("mcpTools.service.toggleFailed")); + } + + setServices((prev) => + prev.map((item) => + item.name === service.name + ? { ...item, status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED } + : item + ) + ); + setSelectedService((prev) => + prev && prev.name === service.name + ? { ...prev, status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED } + : prev + ); + message.success( + nextEnabled + ? t("mcpTools.service.enabled") + : t("mcpTools.service.disabled") + ); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.service.toggleFailed"); + const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; + setLoadError(displayMessage); + message.error(displayMessage); + } + }; + + const handleSaveUpdates = async () => { + if (!selectedService || !draftService) return; + const nextTags = tagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); + + try { + const response = await fetch("/api/mcp-tools/update", { + method: "PUT", + headers: getAuthHeaders(), + body: JSON.stringify({ + current_name: selectedService.name, + name: draftService.name, + description: draftService.description, + server_url: draftService.serverUrl, + authorization_token: draftService.authorizationToken ?? "", + tags: nextTags, + }), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.message || t("mcpTools.service.saveFailed")); + } + + const updatedService = { + ...draftService, + tags: nextTags, + }; + + const oldCacheKey = getToolCacheKey(selectedService); + const newCacheKey = getToolCacheKey(updatedService); + if (oldCacheKey !== newCacheKey) { + setToolCache((prev) => { + if (!prev[oldCacheKey]) { + return prev; + } + const { [oldCacheKey]: movedTools, ...rest } = prev; + return { ...rest, [newCacheKey]: movedTools }; + }); + } + + setServices((prev) => + prev.map((item) => + item.name === selectedService.name ? updatedService : item + ) + ); + setSelectedService(updatedService); + setDraftService(updatedService); + setTagDrafts(nextTags); + message.success(t("mcpTools.service.saveSuccess")); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.service.saveFailed"); + const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; + setLoadError(displayMessage); + message.error(displayMessage); + } + }; + + const handleHealthCheck = async () => { + if (!draftService) return; + setHealthCheckLoading(true); + try { + const response = await fetch("/api/mcp-tools/healthcheck", { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + name: draftService.name, + server_url: draftService.serverUrl, + }), + }); + const data = await response.json(); + + if (!response.ok || data?.status !== "success") { + throw new Error(data?.message || t("mcpTools.service.healthFailed")); + } + + const nextHealth = normalizeMcpHealthStatus(data?.data?.health_status); + setDraftService({ ...draftService, healthStatus: nextHealth }); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.service.healthFailed"); + const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; + setLoadError(displayMessage); + message.error(displayMessage); + } finally { + setHealthCheckLoading(false); + } + }; + + const handleDeleteService = async (serviceName: string) => { + try { + const response = await fetch(`/api/mcp-tools/delete?name=${encodeURIComponent(serviceName)}`, { + method: "DELETE", + headers: getAuthHeaders(), + }); + const data = await response.json(); + if (!response.ok || data?.status !== "success") { + throw new Error(data?.message || t("mcpTools.service.deleteFailed")); + } + + setServices((prev) => prev.filter((item) => item.name !== serviceName)); + setToolCache((prev) => { + const next: Record = {}; + for (const [key, value] of Object.entries(prev)) { + if (!key.startsWith(`${serviceName}@@`)) { + next[key] = value; + } + } + return next; + }); + setSelectedService(null); + message.success(t("mcpTools.service.deleted")); + } catch (error) { + const messageText = error instanceof Error ? error.message : t("mcpTools.service.deleteFailed"); + message.error(messageText); + } + }; + + const filteredServices = useMemo(() => { + const keyword = searchValue.trim().toLowerCase(); + if (!keyword) return services; + return services.filter((item) => { + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) || + item.tags.some((tag) => tag.toLowerCase().includes(keyword)) + ); + }); + }, [searchValue, services]); + + const filteredMarketServices = marketServices; return ( - <> -
- -
- {/* Icon */} - - - - - {/* Title */} - - {t("mcpTools.comingSoon.title")} - - - {/* Description */} - - {t("mcpTools.comingSoon.description")} - - - {/* Feature list */} - -
  • - - - {t("mcpTools.comingSoon.feature1")} - -
  • -
  • - - - {t("mcpTools.comingSoon.feature2")} - -
  • -
  • - - - {t("mcpTools.comingSoon.feature3")} - -
  • -
    - - {/* Coming soon badge */} - - {t("mcpTools.comingSoon.badge")} - +
    +
    +
    +
    +

    + {t("mcpTools.page.title")} +

    +

    + {t("mcpTools.page.subtitle")} +

    - + +
    +
    + +
    + setSearchValue(event.target.value)} + placeholder={t("mcpTools.page.searchPlaceholder")} + size="large" + className="w-full h-10 rounded-2xl" + /> +
    + {t("mcpTools.page.resultCount", { count: filteredServices.length })} +
    +
    +
    +
    + +
    +
    + + {isLoading ? ( +
    + {t("mcpTools.page.loading")} +
    + ) : filteredServices.length === 0 ? ( +
    + {t("mcpTools.page.empty")} +
    + ) : ( +
    + {filteredServices.map((service) => ( +
    setSelectedService(service)} + className="group rounded-3xl border border-slate-200/80 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" + > +
    +
    +

    + {service.name} +

    +

    + {service.description} +

    +
    + + {service.status === MCP_SERVICE_STATUS.ENABLED + ? t("mcpTools.status.enabled") + : t("mcpTools.status.disabled")} + +
    + +
    + + {service.source === MCP_TAB.LOCAL + ? t("mcpTools.source.local") + : t("mcpTools.source.market")} + + {service.tags.map((tag) => ( + + {tag} + + ))} +
    + +
    +
    + +
    +
    +
    + ))} +
    + )} +
    - + setSelectedService(null)} + onDraftServiceChange={setDraftService} + onTagInputChange={setTagInputValue} + onAddDetailTag={addDetailTag} + onRemoveTag={(index) => setTagDrafts((prev) => prev.filter((_, idx) => idx !== index))} + onHealthCheck={handleHealthCheck} + onViewTools={handleViewTools} + onDeleteConfirm={(serviceName) => { + confirm({ + title: t("mcpTools.delete.confirmTitle"), + content: ( +
    +

    {serviceName}

    +

    {t("mcpTools.delete.confirmDesc")}

    +
    + ), + danger: true, + onOk: () => { + handleDeleteService(serviceName).catch(() => undefined); + }, + }); + }} + onSaveUpdates={handleSaveUpdates} + onToggleEnable={(service) => { + handleEnableToggle(service).catch(() => undefined); + }} + /> + + 0} + hasNextMarketPage={Boolean(marketNextCursor)} + marketVersion={marketVersion} + marketUpdatedSince={marketUpdatedSince} + marketIncludeDeleted={marketIncludeDeleted} + newServiceName={newServiceName} + newServiceDesc={newServiceDesc} + newServerType={newServerType} + newServiceUrl={newServiceUrl} + newServiceAuthorizationToken={newServiceAuthorizationToken} + containerUploadFileList={containerUploadFileList} + containerConfigJson={containerConfigJson} + containerPort={containerPort} + containerServiceName={containerServiceName} + newTagDrafts={newTagDrafts} + newTagInputValue={newTagInputValue} + addingService={addingService} + onClose={closeAddModal} + onAddModalTabChange={setAddModalTab} + onMarketSearchChange={setMarketSearchValue} + onRefreshMarket={() => loadMarketFirstPage(marketSearchValue)} + onPrevMarketPage={handleMarketPrevPage} + onNextMarketPage={handleMarketNextPage} + onMarketVersionChange={setMarketVersion} + onMarketUpdatedSinceChange={setMarketUpdatedSince} + onMarketIncludeDeletedChange={setMarketIncludeDeleted} + onSelectMarketService={setSelectedMarketService} + onQuickAddFromMarket={handleQuickAddFromMarket} + onNewServiceNameChange={setNewServiceName} + onNewServiceDescChange={setNewServiceDesc} + onNewServerTypeChange={setNewServerType} + onNewServiceUrlChange={setNewServiceUrl} + onNewServiceAuthorizationTokenChange={setNewServiceAuthorizationToken} + onContainerUploadFileListChange={setContainerUploadFileList} + onContainerConfigJsonChange={setContainerConfigJson} + onContainerPortChange={setContainerPort} + onContainerServiceNameChange={setContainerServiceName} + onAddNewTag={addNewTag} + onRemoveNewTag={(index) => setNewTagDrafts((prev) => prev.filter((_, idx) => idx !== index))} + onNewTagInputChange={setNewTagInputValue} + onSaveAndAdd={handleAddService} + /> + + setToolsModalVisible(false)} + loading={loadingTools} + tools={currentServerTools} + serverName={draftService?.name || t("mcpTools.service.defaultName")} + onRefresh={handleRefreshTools} + /> + +
    ); -} +} \ No newline at end of file diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 775eae675..f224a30d4 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1476,6 +1476,132 @@ "mcpTools.comingSoon.feature2": "Sync, inspect, and organize MCP tools", "mcpTools.comingSoon.feature3": "Monitor MCP connectivity and usage status", "mcpTools.comingSoon.badge": "Coming Soon", + "mcpTools.page.title": "MCP Service Management", + "mcpTools.page.subtitle": "Manage local and public-market MCP services in one place, with search, add, and enable controls.", + "mcpTools.page.searchLabel": "Search MCP services", + "mcpTools.page.searchPlaceholder": "Search by MCP service name, description, or tags", + "mcpTools.page.resultCount": "{{count}} results", + "mcpTools.page.addService": "Add MCP Service", + "mcpTools.page.loading": "Loading MCP services...", + "mcpTools.page.empty": "No MCP services yet. Add or import one first.", + "mcpTools.list.loadFailed": "Failed to load MCP services", + "mcpTools.list.invalidFormat": "Invalid MCP service list format", + "mcpTools.service.defaultDescription": "MCP Service", + "mcpTools.service.enable": "Enable", + "mcpTools.service.disable": "Disable", + "mcpTools.service.enabled": "Service enabled", + "mcpTools.service.disabled": "Service disabled", + "mcpTools.service.toggleFailed": "Failed to update service status", + "mcpTools.service.saveFailed": "Failed to save changes", + "mcpTools.service.saveSuccess": "Saved successfully", + "mcpTools.service.healthFailed": "Health check failed", + "mcpTools.service.deleteFailed": "Failed to delete service", + "mcpTools.service.deleted": "Service deleted", + "mcpTools.service.defaultName": "MCP Service", + "mcpTools.status.enabled": "Enabled", + "mcpTools.status.disabled": "Disabled", + "mcpTools.source.local": "Local", + "mcpTools.source.market": "Public Market", + "mcpTools.serverType.http": "HTTP", + "mcpTools.serverType.sse": "SSE", + "mcpTools.serverType.container": "Container", + "mcpTools.health.healthy": "Healthy", + "mcpTools.health.unhealthy": "Unhealthy", + "mcpTools.health.unchecked": "Unchecked", + "mcpTools.containerStatus.running": "Running", + "mcpTools.containerStatus.stopped": "Stopped", + "mcpTools.containerStatus.unknown": "Unknown", + "mcpTools.error.connectionFailed": "MCP connection failed", + "mcpTools.delete.confirmTitle": "Delete this service?", + "mcpTools.delete.confirmDesc": "This action cannot be undone.", + "mcpTools.add.failed": "Failed to add MCP service", + "mcpTools.add.success": "MCP service added successfully", + "mcpTools.add.validate.nameRequired": "Please enter an MCP service name", + "mcpTools.add.validate.httpUrlRequired": "Please enter an HTTP service URL", + "mcpTools.add.validate.containerConfigRequired": "Please upload a container image or provide container JSON config", + "mcpTools.add.validate.containerRequired": "Please enter container service name and port", + "mcpTools.add.validate.localTabOnly": "Add local services only from the Local tab", + "mcpTools.add.error.imageReadFailed": "Failed to read container image file", + "mcpTools.add.error.imageUploadFailed": "Failed to upload container image", + "mcpTools.add.error.containerJsonInvalid": "Container JSON config is invalid", + "mcpTools.add.error.containerJsonMissingServers": "Container config must include an mcpServers object", + "mcpTools.add.error.containerAddFailed": "Failed to add container config", + "mcpTools.addModal.title": "Add MCP Service", + "mcpTools.addModal.tabLocal": "Local", + "mcpTools.addModal.tabMarket": "Public Market", + "mcpTools.addModal.name": "Name", + "mcpTools.addModal.description": "Description", + "mcpTools.addModal.serverType": "Service Type", + "mcpTools.addModal.serverUrl": "Service URL", + "mcpTools.addModal.bearerTokenOptional": "Bearer Token (Optional)", + "mcpTools.addModal.bearerTokenPlaceholder": "Bearer xxx", + "mcpTools.addModal.uploadImageTitle": "Upload Container Image (.tar)", + "mcpTools.addModal.uploadImageDesc": "Upload a tar image file, or directly paste JSON config below.", + "mcpTools.addModal.selectImage": "Select Image File", + "mcpTools.addModal.containerConfig": "Container Config (JSON)", + "mcpTools.addModal.containerConfigPlaceholder": "{\"image\": \"mcp-server:latest\", \"env\": {}}", + "mcpTools.addModal.containerPort": "Container Port", + "mcpTools.addModal.containerPortPlaceholder": "8080", + "mcpTools.addModal.containerServiceName": "Service Name", + "mcpTools.addModal.containerServiceNamePlaceholder": "mcp-container-service", + "mcpTools.addModal.tags": "Tags", + "mcpTools.addModal.removeTagAria": "Remove tag {{tag}}", + "mcpTools.addModal.tagInputPlaceholder": "Press Enter after typing a tag", + "mcpTools.addModal.saveAndAdd": "Save and Add", + "mcpTools.market.loadFailed": "Failed to load public market list", + "mcpTools.market.searchPlaceholder": "Search MCP services in public market", + "mcpTools.market.pageResult": "Page {{page}} · {{count}} results", + "mcpTools.market.versionFilter": "Version Filter", + "mcpTools.market.versionAll": "All Versions", + "mcpTools.market.versionLatest": "latest (most recent)", + "mcpTools.market.versionCustom": "Custom Version", + "mcpTools.market.updatedSince": "Updated Since (RFC3339)", + "mcpTools.market.updatedSincePlaceholder": "Select updated time", + "mcpTools.market.includeDeleted": "Include Deleted", + "mcpTools.market.includeDeletedDesc": "Include deleted servers", + "mcpTools.market.customVersion": "Custom Version", + "mcpTools.market.customVersionPlaceholder": "e.g. 1.2.3", + "mcpTools.market.customVersionError": "Numeric versions only, e.g. 1.2.3", + "mcpTools.market.loading": "Loading public market MCP services...", + "mcpTools.market.empty": "No matching public market MCP services found.", + "mcpTools.market.quickAdd": "Quick Add", + "mcpTools.market.quickAddPreview": "MCP service added from public market (frontend preview)", + "mcpTools.market.quickAddSuccess": "MCP service added from public market", + "mcpTools.market.quickAddUnsupported": "Only URL-based HTTP/SSE services are currently supported for quick add", + "mcpTools.market.prevPage": "Previous", + "mcpTools.market.nextPage": "Next", + "mcpTools.market.title": "Title:", + "mcpTools.market.website": "Website:", + "mcpTools.market.remotes": "Remotes", + "mcpTools.market.noRemotes": "No directly connectable remote endpoints", + "mcpTools.market.remoteFallback": "remote", + "mcpTools.market.viewServerJson": "View full server.json", + "mcpTools.market.serverJsonTitle": "{{name}} - server.json", + "mcpTools.market.status.active": "active", + "mcpTools.market.status.deprecated": "deprecated", + "mcpTools.market.status.unknown": "unknown", + "mcpTools.tools.loadFailed": "Failed to load tools", + "mcpTools.detail.title": "MCP Service Details", + "mcpTools.detail.name": "Name", + "mcpTools.detail.description": "Description", + "mcpTools.detail.serverUrl": "Service URL", + "mcpTools.detail.bearerTokenOptional": "Bearer Token (Optional)", + "mcpTools.detail.bearerTokenPlaceholder": "Bearer xxx", + "mcpTools.detail.source": "Source", + "mcpTools.detail.serverType": "Service Type", + "mcpTools.detail.status": "Status", + "mcpTools.detail.health": "Connectivity", + "mcpTools.detail.healthChecking": "Checking", + "mcpTools.detail.healthCheck": "Run Health Check", + "mcpTools.detail.containerStatus": "Container Status", + "mcpTools.detail.tools": "Tools", + "mcpTools.detail.viewTools": "View Tools", + "mcpTools.detail.tags": "Tags", + "mcpTools.detail.removeTagAria": "Remove tag {{tag}}", + "mcpTools.detail.tagInputPlaceholder": "Press Enter after typing a tag", + "mcpTools.detail.save": "Save Changes", + "mcpTools.detail.disable": "Disable Service", + "mcpTools.detail.enable": "Enable Service", "monitoring.comingSoon.title": "Monitoring & Operations Coming Soon", "monitoring.comingSoon.description": "Unified monitoring and operations center for your Agents. Track health, performance, and incidents in real time.", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 88ef18fdc..9a9820db7 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1634,6 +1634,132 @@ "mcpTools.comingSoon.feature2": "同步、查看和组织 MCP 工具列表", "mcpTools.comingSoon.feature3": "监控 MCP 连接状态和使用情况", "mcpTools.comingSoon.badge": "即将推出", + "mcpTools.page.title": "MCP 服务管理", + "mcpTools.page.subtitle": "统一管理本地与公共市场的 MCP 服务,支持搜索、添加与启用配置。", + "mcpTools.page.searchLabel": "搜索 MCP 服务", + "mcpTools.page.searchPlaceholder": "搜索 MCP 服务名称、描述或标签", + "mcpTools.page.resultCount": "{{count}} 个结果", + "mcpTools.page.addService": "添加 MCP 服务", + "mcpTools.page.loading": "正在加载 MCP 服务列表...", + "mcpTools.page.empty": "暂无 MCP 服务数据,请先添加或导入。", + "mcpTools.list.loadFailed": "获取 MCP 服务列表失败", + "mcpTools.list.invalidFormat": "MCP 服务列表格式不正确", + "mcpTools.service.defaultDescription": "MCP 服务", + "mcpTools.service.enable": "启用", + "mcpTools.service.disable": "关闭", + "mcpTools.service.enabled": "服务已启用", + "mcpTools.service.disabled": "服务已关闭", + "mcpTools.service.toggleFailed": "更新服务状态失败", + "mcpTools.service.saveFailed": "保存修改失败", + "mcpTools.service.saveSuccess": "保存成功", + "mcpTools.service.healthFailed": "连通性校验失败", + "mcpTools.service.deleteFailed": "删除服务失败", + "mcpTools.service.deleted": "服务已删除", + "mcpTools.service.defaultName": "MCP 服务", + "mcpTools.status.enabled": "已启用", + "mcpTools.status.disabled": "未启用", + "mcpTools.source.local": "本地", + "mcpTools.source.market": "公共市场", + "mcpTools.serverType.http": "HTTP", + "mcpTools.serverType.sse": "SSE", + "mcpTools.serverType.container": "容器", + "mcpTools.health.healthy": "正常", + "mcpTools.health.unhealthy": "异常", + "mcpTools.health.unchecked": "未检测", + "mcpTools.containerStatus.running": "运行中", + "mcpTools.containerStatus.stopped": "已停止", + "mcpTools.containerStatus.unknown": "未知", + "mcpTools.error.connectionFailed": "MCP 连接失败", + "mcpTools.delete.confirmTitle": "确认删除该服务?", + "mcpTools.delete.confirmDesc": "删除后不可恢复。", + "mcpTools.add.failed": "添加 MCP 服务失败", + "mcpTools.add.success": "MCP 服务添加成功", + "mcpTools.add.validate.nameRequired": "请填写 MCP 名称", + "mcpTools.add.validate.httpUrlRequired": "请填写 HTTP 服务地址", + "mcpTools.add.validate.containerConfigRequired": "请上传容器镜像或填写容器配置", + "mcpTools.add.validate.containerRequired": "请填写容器服务名和端口", + "mcpTools.add.validate.localTabOnly": "请在本地标签页中添加本地服务", + "mcpTools.add.error.imageReadFailed": "容器镜像文件读取失败", + "mcpTools.add.error.imageUploadFailed": "容器镜像上传失败", + "mcpTools.add.error.containerJsonInvalid": "容器配置 JSON 格式不正确", + "mcpTools.add.error.containerJsonMissingServers": "容器配置必须包含 mcpServers 对象", + "mcpTools.add.error.containerAddFailed": "容器配置添加失败", + "mcpTools.addModal.title": "添加 MCP 服务", + "mcpTools.addModal.tabLocal": "本地", + "mcpTools.addModal.tabMarket": "公共市场", + "mcpTools.addModal.name": "名称", + "mcpTools.addModal.description": "描述", + "mcpTools.addModal.serverType": "服务类型", + "mcpTools.addModal.serverUrl": "服务地址", + "mcpTools.addModal.bearerTokenOptional": "Bearer Token(可选)", + "mcpTools.addModal.bearerTokenPlaceholder": "Bearer xxx", + "mcpTools.addModal.uploadImageTitle": "上传容器镜像 (.tar)", + "mcpTools.addModal.uploadImageDesc": "可上传镜像 tar 文件,或在下方直接填写容器配置 JSON。", + "mcpTools.addModal.selectImage": "选择镜像文件", + "mcpTools.addModal.containerConfig": "容器配置 (JSON)", + "mcpTools.addModal.containerConfigPlaceholder": "{\"image\": \"mcp-server:latest\", \"env\": {}}", + "mcpTools.addModal.containerPort": "容器端口", + "mcpTools.addModal.containerPortPlaceholder": "8080", + "mcpTools.addModal.containerServiceName": "服务名", + "mcpTools.addModal.containerServiceNamePlaceholder": "mcp-container-service", + "mcpTools.addModal.tags": "标签", + "mcpTools.addModal.removeTagAria": "删除标签 {{tag}}", + "mcpTools.addModal.tagInputPlaceholder": "输入标签后回车", + "mcpTools.addModal.saveAndAdd": "保存并添加", + "mcpTools.market.loadFailed": "获取公共市场列表失败", + "mcpTools.market.searchPlaceholder": "搜索公共市场 MCP", + "mcpTools.market.pageResult": "第 {{page}} 页 · {{count}} 个结果", + "mcpTools.market.versionFilter": "版本筛选", + "mcpTools.market.versionAll": "全部版本", + "mcpTools.market.versionLatest": "latest(最新版本)", + "mcpTools.market.versionCustom": "自定义版本", + "mcpTools.market.updatedSince": "更新时间下限 (RFC3339)", + "mcpTools.market.updatedSincePlaceholder": "选择更新时间", + "mcpTools.market.includeDeleted": "包含已删除", + "mcpTools.market.includeDeletedDesc": "包含已删除服务器", + "mcpTools.market.customVersion": "自定义版本号", + "mcpTools.market.customVersionPlaceholder": "例如 1.2.3", + "mcpTools.market.customVersionError": "仅支持数字版本格式,例如 1.2.3", + "mcpTools.market.loading": "正在加载公共市场 MCP...", + "mcpTools.market.empty": "未找到匹配的公共市场 MCP。", + "mcpTools.market.quickAdd": "快速添加", + "mcpTools.market.quickAddPreview": "已从公共市场添加 MCP 服务(前端预览)", + "mcpTools.market.quickAddSuccess": "已从公共市场添加 MCP 服务", + "mcpTools.market.quickAddUnsupported": "当前快速添加仅支持 URL 形式的 HTTP/SSE 服务", + "mcpTools.market.prevPage": "上一页", + "mcpTools.market.nextPage": "下一页", + "mcpTools.market.title": "标题:", + "mcpTools.market.website": "网站:", + "mcpTools.market.remotes": "远程地址", + "mcpTools.market.noRemotes": "无可直接连接的远端地址", + "mcpTools.market.remoteFallback": "远程", + "mcpTools.market.viewServerJson": "查看完整 server.json", + "mcpTools.market.serverJsonTitle": "{{name}} - server.json", + "mcpTools.market.status.active": "active", + "mcpTools.market.status.deprecated": "deprecated", + "mcpTools.market.status.unknown": "unknown", + "mcpTools.tools.loadFailed": "获取工具列表失败", + "mcpTools.detail.title": "MCP 服务详情", + "mcpTools.detail.name": "名称", + "mcpTools.detail.description": "描述", + "mcpTools.detail.serverUrl": "服务地址", + "mcpTools.detail.bearerTokenOptional": "Bearer Token(可选)", + "mcpTools.detail.bearerTokenPlaceholder": "Bearer xxx", + "mcpTools.detail.source": "来源", + "mcpTools.detail.serverType": "服务类型", + "mcpTools.detail.status": "状态", + "mcpTools.detail.health": "连通性", + "mcpTools.detail.healthChecking": "检测中", + "mcpTools.detail.healthCheck": "连通性校验", + "mcpTools.detail.containerStatus": "容器状态", + "mcpTools.detail.tools": "工具", + "mcpTools.detail.viewTools": "查看工具", + "mcpTools.detail.tags": "标签", + "mcpTools.detail.removeTagAria": "删除标签 {{tag}}", + "mcpTools.detail.tagInputPlaceholder": "输入标签后回车", + "mcpTools.detail.save": "保存修改", + "mcpTools.detail.disable": "关闭服务", + "mcpTools.detail.enable": "启用服务", "monitoring.comingSoon.title": "监控与运维中心即将推出", "monitoring.comingSoon.description": "面向智能体的统一监控与运维中心,用于实时跟踪健康状态、性能指标与异常事件。", diff --git a/test/backend/database/test_remote_mcp_db.py b/test/backend/database/test_remote_mcp_db.py index a46fe857a..bacdeec15 100644 --- a/test/backend/database/test_remote_mcp_db.py +++ b/test/backend/database/test_remote_mcp_db.py @@ -68,6 +68,7 @@ from backend.database.remote_mcp_db import ( create_mcp_record, delete_mcp_record_by_name_and_url, + restore_mcp_record_by_name_and_url, delete_mcp_record_by_container_id, update_mcp_status_by_name_and_url, update_mcp_record_by_name_and_url, @@ -209,6 +210,59 @@ def test_delete_mcp_record_by_name_and_url_failure(monkeypatch, mock_session): "test_mcp", "http://test.server.com", "tenant1", "user1") +def test_restore_mcp_record_by_name_and_url_success(monkeypatch, mock_session): + """Test successful restoration of a soft-deleted MCP record""" + session, query = mock_session + mock_filter = MagicMock() + mock_filter.update = MagicMock(return_value=1) + query.filter.return_value = mock_filter + + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = session + mock_ctx.__exit__.return_value = None + monkeypatch.setattr( + "backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + + updated_rows = restore_mcp_record_by_name_and_url( + "test_mcp", + "http://test.server.com", + "tenant1", + "user1", + status=True, + authorization_token="token_123", + ) + + assert updated_rows == 1 + mock_filter.update.assert_called_once_with({ + "delete_flag": "N", + "updated_by": "user1", + "status": True, + "authorization_token": "token_123", + }) + + +def test_restore_mcp_record_by_name_and_url_failure(monkeypatch, mock_session): + """Test failure of MCP record restoration - exception should propagate""" + from sqlalchemy.exc import SQLAlchemyError + + session, query = mock_session + query.filter.side_effect = SQLAlchemyError("Database error") + + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = session + mock_ctx.__exit__.return_value = None + monkeypatch.setattr( + "backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + + with pytest.raises(SQLAlchemyError): + restore_mcp_record_by_name_and_url( + "test_mcp", + "http://test.server.com", + "tenant1", + "user1", + ) + + def test_delete_mcp_record_by_container_id_success(monkeypatch, mock_session): """Test successful deletion of MCP record by container ID""" session, query = mock_session From 6ab44f10ba9324a88c3c5d8efe38d9c69b92d57e Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Wed, 18 Mar 2026 17:53:38 +0800 Subject: [PATCH 02/59] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=BB=A5=E7=AC=A6=E5=90=88=E9=A1=B9=E7=9B=AE=E8=A7=84=E8=8C=83?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E5=90=88=E7=90=86=E5=88=92=E5=88=86?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=92=8C=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/mcp_management_app.py | 111 +- backend/services/mcp_management_service.py | 29 +- .../components/AddMcpServiceLocalSection.tsx | 159 +++ .../components/AddMcpServiceMarketSection.tsx | 59 + .../components/AddMcpServiceModal.tsx | 662 +--------- .../mcp-tools/components/McpMarketCard.tsx | 68 + .../components/McpMarketCardList.tsx | 68 + .../components/McpMarketDetailModal.tsx | 139 ++ .../mcp-tools/components/McpMarketToolbar.tsx | 172 +++ .../mcp-tools/components/McpServiceCard.tsx | 80 ++ .../components/McpServiceDetailModal.tsx | 145 +-- ....tsx => McpServiceDetailToolListModal.tsx} | 2 +- frontend/app/[locale]/mcp-tools/page.tsx | 1123 +++-------------- frontend/const/mcpTools.ts | 22 + .../hooks/mcpTools/useMcpToolsAddLocal.ts | 202 +++ .../hooks/mcpTools/useMcpToolsAddMarket.ts | 239 ++++ frontend/hooks/mcpTools/useMcpToolsDetail.ts | 249 ++++ frontend/hooks/mcpTools/useMcpToolsToggle.ts | 50 + frontend/lib/mcpTools.ts | 64 + frontend/services/api.ts | 9 + frontend/services/mcpToolsService.ts | 367 ++++++ frontend/types/mcpTools.ts | 178 +++ 22 files changed, 2471 insertions(+), 1726 deletions(-) create mode 100644 frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx rename frontend/app/[locale]/mcp-tools/components/{McpToolListModal.tsx => McpServiceDetailToolListModal.tsx} (98%) create mode 100644 frontend/const/mcpTools.ts create mode 100644 frontend/hooks/mcpTools/useMcpToolsAddLocal.ts create mode 100644 frontend/hooks/mcpTools/useMcpToolsAddMarket.ts create mode 100644 frontend/hooks/mcpTools/useMcpToolsDetail.ts create mode 100644 frontend/hooks/mcpTools/useMcpToolsToggle.ts create mode 100644 frontend/lib/mcpTools.ts create mode 100644 frontend/services/mcpToolsService.ts create mode 100644 frontend/types/mcpTools.ts diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py index 44f9ac803..ae25c74b6 100644 --- a/backend/apps/mcp_management_app.py +++ b/backend/apps/mcp_management_app.py @@ -1,9 +1,10 @@ import logging -from typing import Optional +from typing import Any, Literal, Optional -from fastapi import APIRouter, Header, HTTPException, Request +from fastapi import APIRouter, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse from http import HTTPStatus +from pydantic import BaseModel, Field from consts.exceptions import MCPConnectionError, MCPNameIllegal from services.mcp_management_service import ( @@ -21,28 +22,52 @@ logger = logging.getLogger("mcp_management_app") +class AddMcpServiceRequest(BaseModel): + name: str = Field(min_length=1) + server_url: str = Field(min_length=1) + description: Optional[str] = None + source: Literal["local", "market"] = "local" + server_type: Literal["http", "sse", "container"] = "http" + tags: Optional[list[str]] = None + authorization_token: Optional[str] = None + container_config: Optional[dict[str, Any]] = None + + +class UpdateMcpServiceRequest(BaseModel): + current_name: str = Field(min_length=1) + name: str = Field(min_length=1) + description: Optional[str] = None + server_url: str = Field(min_length=1) + tags: Optional[list[str]] = None + authorization_token: Optional[str] = None + + +class EnableMcpServiceRequest(BaseModel): + name: str = Field(min_length=1) + enabled: bool + + +class HealthcheckMcpServiceRequest(BaseModel): + name: str = Field(min_length=1) + server_url: str = Field(min_length=1) + + @router.post("/add") async def add_mcp_service_api( - payload: dict, + payload: AddMcpServiceRequest, authorization: Optional[str] = Header(None), http_request: Request = None, ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - name = (payload.get("name") or "").strip() - server_url = (payload.get("server_url") or "").strip() - description = payload.get("description") - source = payload.get("source") or "本地" - server_type = payload.get("server_type") or "HTTP" - tags = payload.get("tags") - authorization_token = (payload.get("authorization_token") or "").strip() or None - container_config = payload.get("container_config") - - if not name or not server_url: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Missing required fields", - ) + name = payload.name.strip() + server_url = payload.server_url.strip() + description = payload.description + source = payload.source + server_type = payload.server_type + tags = payload.tags + authorization_token = (payload.authorization_token or "").strip() or None + container_config = payload.container_config await add_mcp_service( tenant_id=tenant_id, @@ -142,24 +167,18 @@ async def list_market_mcp_services_api( @router.put("/update") async def update_mcp_service_api( - payload: dict, + payload: UpdateMcpServiceRequest, authorization: Optional[str] = Header(None), http_request: Request = None, ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - current_name = payload.get("current_name") - new_name = payload.get("name") - description = payload.get("description") - server_url = payload.get("server_url") - tags = payload.get("tags") - authorization_token = (payload.get("authorization_token") or "").strip() or None - - if not current_name or not new_name or not server_url: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Missing required fields", - ) + current_name = payload.current_name + new_name = payload.name + description = payload.description + server_url = payload.server_url + tags = payload.tags + authorization_token = (payload.authorization_token or "").strip() or None update_mcp_service( tenant_id=tenant_id, @@ -187,25 +206,20 @@ async def update_mcp_service_api( @router.post("/manage/enable") async def update_mcp_service_enable_api( - payload: dict, + payload: EnableMcpServiceRequest, authorization: Optional[str] = Header(None), http_request: Request = None, ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - name = payload.get("name") - enabled = payload.get("enabled") - if name is None or enabled is None: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Missing required fields", - ) + name = payload.name + enabled = payload.enabled update_mcp_service_enabled( tenant_id=tenant_id, user_id=user_id, name=name, - enabled=bool(enabled), + enabled=enabled, ) return JSONResponse( status_code=HTTPStatus.OK, @@ -223,19 +237,14 @@ async def update_mcp_service_enable_api( @router.post("/healthcheck") async def check_mcp_health_api( - payload: dict, + payload: HealthcheckMcpServiceRequest, authorization: Optional[str] = Header(None), http_request: Request = None, ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - name = payload.get("name") - server_url = payload.get("server_url") - if not name or not server_url: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Missing required fields", - ) + name = payload.name + server_url = payload.server_url health_status = await check_mcp_service_health( tenant_id=tenant_id, @@ -265,18 +274,12 @@ async def check_mcp_health_api( @router.delete("/delete") async def delete_mcp_service_api( - name: str, + name: str = Query(min_length=1), authorization: Optional[str] = Header(None), http_request: Request = None, ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - if not name: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Missing required fields", - ) - delete_mcp_service( tenant_id=tenant_id, user_id=user_id, diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py index 85340231e..e734001c7 100644 --- a/backend/services/mcp_management_service.py +++ b/backend/services/mcp_management_service.py @@ -253,19 +253,22 @@ async def add_mcp_service( if check_mcp_manage_name_exists(tenant_id=tenant_id, name=name): raise MCPNameIllegal("MCP name already exists") - normalized_server_type = (server_type or "HTTP").strip().upper() + normalized_source = (source or "local").strip().lower() + normalized_server_type = (server_type or "http").strip().lower() # mcp-tools add flow does not perform connectivity checks. # All newly added services remain disabled and unchecked until manual enable/health check. status: bool | None = None - source_type = "registry" if source == "公共市场" else "local" - if normalized_server_type == "SSE": + source_type = "registry" if normalized_source == "market" else "local" + if normalized_server_type == "sse": transport_type = "sse" - elif normalized_server_type == "HTTP": + elif normalized_server_type == "http": transport_type = "streamable-http" - else: + elif normalized_server_type == "container": transport_type = "stdio" + else: + raise ValueError(f"Invalid server_type: {server_type}") config_json: Dict[str, Any] = {} if authorization_token: @@ -303,16 +306,16 @@ def list_mcp_services(tenant_id: str) -> List[Dict[str, Any]]: config_json = _safe_config_dict(record) services.append({ - "name": record.get("mcp_name") or "未命名 MCP", - "description": record.get("category") or "MCP 服务", - "source": "公共市场" if source_type == "registry" else "本地", - "status": "已启用" if enabled else "未启用", + "name": record.get("mcp_name") or "Unnamed MCP", + "description": record.get("category") or "MCP service", + "source": "market" if source_type == "registry" else "local", + "status": "enabled" if enabled else "disabled", "updatedAt": _format_time(record.get("update_time")), "tags": _split_tags(record.get("tags")), - "serverType": "容器" if transport_type == "stdio" else "SSE" if transport_type == "sse" else "HTTP", + "serverType": "container" if transport_type == "stdio" else "sse" if transport_type == "sse" else "http", "serverUrl": record.get("mcp_server") or "-", "tools": record.get("tools") or [], - "healthStatus": "正常" if status is True else "异常" if status is False else "未检测", + "healthStatus": "healthy" if status is True else "unhealthy" if status is False else "unchecked", "containerStatus": record.get("container_status") or None, "authorizationToken": config_json.get("authorization_token") or "", }) @@ -470,7 +473,7 @@ async def check_mcp_service_health( remote_mcp_server=server_url, authorization_token=authorization_token, ) - except BaseException as exc: + except Exception as exc: logger.error(f"MCP health check failed: {exc}") status = False @@ -484,4 +487,4 @@ async def check_mcp_service_health( if not status: raise MCPConnectionError("MCP connection failed") - return "正常" + return "healthy" diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx new file mode 100644 index 000000000..aace738e6 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx @@ -0,0 +1,159 @@ +import { Button, Input, InputNumber, Select, Tag, Upload } from "antd"; +import { MCP_SERVER_TYPE } from "@/const/mcpTools"; +import type { AddMcpLocalActions, AddMcpLocalState } from "@/types/mcpTools"; + +interface Props { + state: AddMcpLocalState; + actions: AddMcpLocalActions; + t: (key: string, params?: Record) => string; +} + +export default function AddMcpServiceLocalSection({ state, actions, t }: Props) { + return ( + <> +
    + + + + + + +
    + ) : ( +
    +
    +

    {t("mcpTools.addModal.uploadImageTitle")}

    +

    {t("mcpTools.addModal.uploadImageDesc")}

    +
    + actions.onContainerUploadFileListChange(fileList)} + beforeUpload={() => false} + accept=".tar" + maxCount={1} + > + + +
    +
    + + + +
    + + +
    +
    + )} + +
    +

    {t("mcpTools.addModal.tags")}

    +
    + {state.newTagDrafts.map((tag, index) => ( + + {tag} + + + ))} + actions.onNewTagInputChange(event.target.value)} + onPressEnter={actions.onAddNewTag} + onBlur={actions.onAddNewTag} + placeholder={t("mcpTools.addModal.tagInputPlaceholder")} + className="w-40 rounded-full" + /> +
    +
    +
    + +
    + +
    + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx new file mode 100644 index 000000000..56335d511 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx @@ -0,0 +1,59 @@ +import McpMarketToolbar from "./McpMarketToolbar"; +import McpMarketCardList from "./McpMarketCardList"; +import McpMarketDetailModal from "./McpMarketDetailModal"; +import type { AddMcpMarketActions, AddMcpMarketState } from "@/types/mcpTools"; + +interface Props { + state: AddMcpMarketState; + actions: AddMcpMarketActions; + t: (key: string, params?: Record) => string; +} + +export default function AddMcpServiceMarketSection({ state, actions, t }: Props) { + const toolbarState = { + marketSearchValue: state.marketSearchValue, + marketLoading: state.marketLoading, + marketPage: state.marketPage, + resultCount: state.filteredMarketServices.length, + marketVersion: state.marketVersion, + marketUpdatedSince: state.marketUpdatedSince, + marketIncludeDeleted: state.marketIncludeDeleted, + }; + + const toolbarActions = { + onMarketSearchChange: actions.onMarketSearchChange, + onRefreshMarket: actions.onRefreshMarket, + onMarketVersionChange: actions.onMarketVersionChange, + onMarketUpdatedSinceChange: actions.onMarketUpdatedSinceChange, + onMarketIncludeDeletedChange: actions.onMarketIncludeDeletedChange, + }; + + return ( + <> +
    + + + +
    + + {state.selectedMarketService ? ( + actions.onSelectMarketService(null)} + onQuickAddFromMarket={actions.onQuickAddFromMarket} + /> + ) : null} + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx index 4e9f8df41..9b877cfb0 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -1,251 +1,55 @@ -import { useEffect, useMemo, useState } from "react"; -import { Modal, Input, Select, Button, Segmented, Tag, Upload, InputNumber, Switch, DatePicker } from "antd"; -import type { UploadFile } from "antd/es/upload/interface"; -import dayjs from "dayjs"; +import { useEffect, useState } from "react"; +import { App, Modal, Segmented } from "antd"; import { useTranslation } from "react-i18next"; - -type McpTab = "本地" | "公共市场"; -type McpServerType = "HTTP" | "SSE" | "容器"; - -const MCP_TAB = { LOCAL: "本地", MARKET: "公共市场" } as const; -const MCP_SERVER_TYPE = { HTTP: "HTTP", SSE: "SSE", CONTAINER: "容器" } as const; -const MARKET_SERVER_STATUS = { ACTIVE: "active", DEPRECATED: "deprecated" } as const; - -type MarketMcpCard = { - name: string; - title: string; - version: string; - description: string; - publishedAt: string; - status: string; - websiteUrl: string; - remotes: Array<{ type: string; url: string }>; - serverJson: Record; - serverType: McpServerType; - serverUrl: string; -}; +import { MCP_TAB } from "@/const/mcpTools"; +import type { McpTab } from "@/types/mcpTools"; +import { useMcpToolsAddLocal } from "@/hooks/mcpTools/useMcpToolsAddLocal"; +import { useMcpToolsAddMarket } from "@/hooks/mcpTools/useMcpToolsAddMarket"; +import AddMcpServiceLocalSection from "./AddMcpServiceLocalSection"; +import AddMcpServiceMarketSection from "./AddMcpServiceMarketSection"; interface AddMcpServiceModalProps { open: boolean; - addModalTab: McpTab; - marketSearchValue: string; - selectedMarketService: MarketMcpCard | null; - filteredMarketServices: MarketMcpCard[]; - marketLoading: boolean; - marketPage: number; - hasPrevMarketPage: boolean; - hasNextMarketPage: boolean; - marketVersion: string; - marketUpdatedSince: string; - marketIncludeDeleted: boolean; - newServiceName: string; - newServiceDesc: string; - newServerType: McpServerType; - newServiceUrl: string; - newServiceAuthorizationToken: string; - containerUploadFileList: UploadFile[]; - containerConfigJson: string; - containerPort: number | undefined; - containerServiceName: string; - newTagDrafts: string[]; - newTagInputValue: string; - addingService: boolean; + onServiceAdded: () => Promise; onClose: () => void; - onAddModalTabChange: (value: McpTab) => void; - onMarketSearchChange: (value: string) => void; - onRefreshMarket: () => void; - onPrevMarketPage: () => void; - onNextMarketPage: () => void; - onMarketVersionChange: (value: string) => void; - onMarketUpdatedSinceChange: (value: string) => void; - onMarketIncludeDeletedChange: (value: boolean) => void; - onSelectMarketService: (service: MarketMcpCard | null) => void; - onQuickAddFromMarket: (service: MarketMcpCard) => void; - onNewServiceNameChange: (value: string) => void; - onNewServiceDescChange: (value: string) => void; - onNewServerTypeChange: (value: McpServerType) => void; - onNewServiceUrlChange: (value: string) => void; - onNewServiceAuthorizationTokenChange: (value: string) => void; - onContainerUploadFileListChange: (fileList: UploadFile[]) => void; - onContainerConfigJsonChange: (value: string) => void; - onContainerPortChange: (value: number | undefined) => void; - onContainerServiceNameChange: (value: string) => void; - onAddNewTag: () => void; - onRemoveNewTag: (index: number) => void; - onNewTagInputChange: (value: string) => void; - onSaveAndAdd: () => void; } export default function AddMcpServiceModal({ open, - addModalTab, - marketSearchValue, - selectedMarketService, - filteredMarketServices, - marketLoading, - marketPage, - hasPrevMarketPage, - hasNextMarketPage, - marketVersion, - marketUpdatedSince, - marketIncludeDeleted, - newServiceName, - newServiceDesc, - newServerType, - newServiceUrl, - newServiceAuthorizationToken, - containerUploadFileList, - containerConfigJson, - containerPort, - containerServiceName, - newTagDrafts, - newTagInputValue, - addingService, + onServiceAdded, onClose, - onAddModalTabChange, - onMarketSearchChange, - onRefreshMarket, - onPrevMarketPage, - onNextMarketPage, - onMarketVersionChange, - onMarketUpdatedSinceChange, - onMarketIncludeDeletedChange, - onSelectMarketService, - onQuickAddFromMarket, - onNewServiceNameChange, - onNewServiceDescChange, - onNewServerTypeChange, - onNewServiceUrlChange, - onNewServiceAuthorizationTokenChange, - onContainerUploadFileListChange, - onContainerConfigJsonChange, - onContainerPortChange, - onContainerServiceNameChange, - onAddNewTag, - onRemoveNewTag, - onNewTagInputChange, - onSaveAndAdd, }: AddMcpServiceModalProps) { + const { message } = App.useApp(); const { t } = useTranslation("common"); - const [showServerJsonModal, setShowServerJsonModal] = useState(false); - const [marketVersionMode, setMarketVersionMode] = useState<"all" | "latest" | "custom">("latest"); - const [customVersion, setCustomVersion] = useState(""); - - const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; - - const formatMarketDate = (value: string) => { - if (!value) { - return "-"; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; - }; - - const formatMarketVersion = (value: string) => { - const version = (value || "").trim(); - if (!version) { - return "-"; - } - if (/^v/i.test(version)) { - return version; - } - return `v${version}`; - }; - - const getStatusClassName = (status: string) => { - if (status === MARKET_SERVER_STATUS.ACTIVE) { - return "bg-emerald-100 text-emerald-700"; - } - if (status === MARKET_SERVER_STATUS.DEPRECATED) { - return "bg-amber-100 text-amber-700"; - } - return "bg-slate-100 text-slate-600"; - }; - - const getStatusText = (status: string) => { - if (status === MARKET_SERVER_STATUS.ACTIVE) { - return t("mcpTools.market.status.active"); - } - if (status === MARKET_SERVER_STATUS.DEPRECATED) { - return t("mcpTools.market.status.deprecated"); - } - return t("mcpTools.market.status.unknown"); - }; - - const serverJsonPretty = useMemo(() => { - if (!selectedMarketService) { - return "{}"; - } - try { - return JSON.stringify(selectedMarketService.serverJson || {}, null, 2); - } catch { - return "{}"; - } - }, [selectedMarketService]); - - const updatedSinceDateValue = useMemo(() => { - if (!marketUpdatedSince) { - return null; - } - const parsed = dayjs(marketUpdatedSince); - return parsed.isValid() ? parsed : null; - }, [marketUpdatedSince]); - - const customVersionError = customVersion.trim().length > 0 && !VERSION_PATTERN.test(customVersion.trim()); - - useEffect(() => { - if (!selectedMarketService) { - setShowServerJsonModal(false); - } - }, [selectedMarketService]); + const [addModalTab, setAddModalTab] = useState(MCP_TAB.LOCAL); + + const local = useMcpToolsAddLocal({ + addModalTab, + t: (key) => String(t(key)), + message, + onServiceAdded, + onClose, + }); + + const market = useMcpToolsAddMarket({ + open, + addModalTab, + t: (key) => String(t(key)), + message, + onServiceAdded, + onClose, + }); + + const { reset: resetLocal } = local; + const { reset: resetMarket } = market; useEffect(() => { - const value = (marketVersion || "").trim(); - if (!value) { - setMarketVersionMode("all"); - setCustomVersion(""); - return; - } - if (value.toLowerCase() === "latest") { - setMarketVersionMode("latest"); - setCustomVersion(""); - return; - } - setMarketVersionMode("custom"); - setCustomVersion(value); - }, [marketVersion]); - - const handleVersionModeChange = (mode: "all" | "latest" | "custom") => { - setMarketVersionMode(mode); - if (mode === "all") { - setCustomVersion(""); - onMarketVersionChange(""); - return; + if (!open) { + setAddModalTab(MCP_TAB.LOCAL); + resetLocal(); + resetMarket(); } - if (mode === "latest") { - setCustomVersion(""); - onMarketVersionChange("latest"); - return; - } - // Custom mode starts empty and waits for valid semantic numeric version input. - setCustomVersion(""); - onMarketVersionChange(""); - }; - - const handleCustomVersionChange = (value: string) => { - setCustomVersion(value); - const trimmed = value.trim(); - if (!trimmed) { - onMarketVersionChange(""); - return; - } - if (VERSION_PATTERN.test(trimmed)) { - onMarketVersionChange(trimmed); - } - }; + }, [open, resetLocal, resetMarket]); if (!open) { return null; @@ -275,7 +79,7 @@ export default function AddMcpServiceModal({
    onAddModalTabChange(value as McpTab)} + onChange={(value) => setAddModalTab(value as McpTab)} options={[ { label: t("mcpTools.addModal.tabLocal"), value: MCP_TAB.LOCAL }, { label: t("mcpTools.addModal.tabMarket"), value: MCP_TAB.MARKET }, @@ -285,388 +89,18 @@ export default function AddMcpServiceModal({
    {addModalTab === MCP_TAB.LOCAL ? ( - <> -
    - - - - - - -
    - ) : ( -
    -
    -

    {t("mcpTools.addModal.uploadImageTitle")}

    -

    {t("mcpTools.addModal.uploadImageDesc")}

    -
    - onContainerUploadFileListChange(fileList)} - beforeUpload={() => false} - accept=".tar" - maxCount={1} - > - - -
    -
    - - - -
    - - -
    -
    - )} - -
    -

    {t("mcpTools.addModal.tags")}

    -
    - {newTagDrafts.map((tag, index) => ( - - {tag} - - - ))} - onNewTagInputChange(event.target.value)} - onPressEnter={onAddNewTag} - onBlur={onAddNewTag} - placeholder={t("mcpTools.addModal.tagInputPlaceholder")} - className="w-40 rounded-full" - /> -
    -
    -
    - -
    - -
    - + String(t(key, params))} + /> ) : ( -
    -
    - onMarketSearchChange(event.target.value)} - placeholder={t("mcpTools.market.searchPlaceholder")} - size="large" - className="w-full rounded-2xl" - /> - -
    - {t("mcpTools.market.pageResult", { page: marketPage, count: filteredMarketServices.length })} -
    -
    - -
    - - ) : null} - - {marketLoading ? ( -
    - {t("mcpTools.market.loading")} -
    - ) : filteredMarketServices.length === 0 ? ( -
    - {t("mcpTools.market.empty")} -
    - ) : ( -
    -
    - {filteredMarketServices.map((service, index) => ( -
    onSelectMarketService(service)} - className="group rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" - > -
    -

    {service.name}

    - - {getStatusText(service.status)} - -
    - -
    - - {formatMarketVersion(service.version)} - - {formatMarketDate(service.publishedAt)} -
    - -

    {service.description}

    - -
    - -
    -
    - ))} -
    - -
    - - -
    -
    - )} -
    + String(t(key, params))} + /> )} - - {selectedMarketService ? ( - onSelectMarketService(null)} - styles={{ - mask: { background: "rgba(15,23,42,0.4)" }, - body: { padding: 0 }, - }} - > -
    -
    -
    -
    -

    {selectedMarketService.name}

    -

    {formatMarketVersion(selectedMarketService.version)}

    -
    - - {getStatusText(selectedMarketService.status)} - -
    -
    - -
    -

    {selectedMarketService.description}

    - -

    {formatMarketDate(selectedMarketService.publishedAt)}

    - -
    -
    - {t("mcpTools.market.title")} - {selectedMarketService.title || "-"} -
    -
    - {t("mcpTools.market.website")} - {selectedMarketService.websiteUrl ? ( - - {selectedMarketService.websiteUrl} - - ) : ( - - - )} -
    -
    - -
    -

    {t("mcpTools.market.remotes")}

    - {selectedMarketService.remotes.length === 0 ? ( -

    {t("mcpTools.market.noRemotes")}

    - ) : ( -
    - {selectedMarketService.remotes.map((remote, index) => ( -
    -

    {remote.type || t("mcpTools.market.remoteFallback")}

    -

    {remote.url}

    -
    - ))} -
    - )} -
    -
    - -
    - - -
    -
    -
    - ) : null} - - {selectedMarketService && showServerJsonModal ? ( - setShowServerJsonModal(false)} - title={t("mcpTools.market.serverJsonTitle", { name: selectedMarketService.name })} - > -
    -              {serverJsonPretty}
    -            
    -
    - ) : null}
    ); diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx new file mode 100644 index 000000000..2da2260c1 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import { MARKET_SERVER_STATUS } from "@/const/mcpTools"; +import { formatMarketDate, formatMarketVersion } from "@/lib/mcpTools"; +import type { MarketMcpCard } from "@/types/mcpTools"; + +interface Props { + service: MarketMcpCard; + t: (key: string, params?: Record) => string; + onSelectMarketService: (service: MarketMcpCard) => void; + onQuickAddFromMarket: (service: MarketMcpCard) => void; +} + +export default function McpMarketCard({ + service, + t, + onSelectMarketService, + onQuickAddFromMarket, +}: Props) { + const statusClassName = + service.status === MARKET_SERVER_STATUS.ACTIVE + ? "bg-emerald-100 text-emerald-700" + : service.status === MARKET_SERVER_STATUS.DEPRECATED + ? "bg-amber-100 text-amber-700" + : "bg-slate-100 text-slate-600"; + const statusTextKey = + service.status === MARKET_SERVER_STATUS.ACTIVE + ? "mcpTools.market.status.active" + : service.status === MARKET_SERVER_STATUS.DEPRECATED + ? "mcpTools.market.status.deprecated" + : "mcpTools.market.status.unknown"; + + return ( +
    onSelectMarketService(service)} + className="group rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" + > +
    +

    {service.name}

    + + {t(statusTextKey)} + +
    + +
    + + {formatMarketVersion(service.version)} + + {formatMarketDate(service.publishedAt)} +
    + +

    {service.description}

    + +
    + +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx b/frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx new file mode 100644 index 000000000..4897116e1 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import type { MarketMcpCard } from "@/types/mcpTools"; +import McpMarketCard from "./McpMarketCard"; + +interface Props { + marketLoading: boolean; + services: MarketMcpCard[]; + hasPrevMarketPage: boolean; + hasNextMarketPage: boolean; + onPrevMarketPage: () => void; + onNextMarketPage: () => void; + onSelectMarketService: (service: MarketMcpCard) => void; + onQuickAddFromMarket: (service: MarketMcpCard) => void; + t: (key: string, params?: Record) => string; +} + +export default function McpMarketCardList({ + marketLoading, + services, + hasPrevMarketPage, + hasNextMarketPage, + onPrevMarketPage, + onNextMarketPage, + onSelectMarketService, + onQuickAddFromMarket, + t, +}: Props) { + if (marketLoading) { + return ( +
    + {t("mcpTools.market.loading")} +
    + ); + } + + if (services.length === 0) { + return ( +
    + {t("mcpTools.market.empty")} +
    + ); + } + + return ( +
    +
    + {services.map((service, index) => ( + + ))} +
    + +
    + + +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx new file mode 100644 index 000000000..7131810d0 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx @@ -0,0 +1,139 @@ +import { useMemo, useState } from "react"; +import { Button, Modal } from "antd"; +import { MARKET_SERVER_STATUS } from "@/const/mcpTools"; +import { formatMarketDate, formatMarketVersion } from "@/lib/mcpTools"; +import type { MarketMcpCard } from "@/types/mcpTools"; + +interface Props { + service: MarketMcpCard; + t: (key: string, params?: Record) => string; + onClose: () => void; + onQuickAddFromMarket: (service: MarketMcpCard) => void; +} + +export default function McpMarketDetailModal({ + service, + t, + onClose, + onQuickAddFromMarket, +}: Props) { + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + + const serverJsonPretty = useMemo(() => { + return JSON.stringify(service.serverJson || {}, null, 2); + }, [service.serverJson]); + + const statusClassName = + service.status === MARKET_SERVER_STATUS.ACTIVE + ? "bg-emerald-100 text-emerald-700" + : service.status === MARKET_SERVER_STATUS.DEPRECATED + ? "bg-amber-100 text-amber-700" + : "bg-slate-100 text-slate-600"; + const statusTextKey = + service.status === MARKET_SERVER_STATUS.ACTIVE + ? "mcpTools.market.status.active" + : service.status === MARKET_SERVER_STATUS.DEPRECATED + ? "mcpTools.market.status.deprecated" + : "mcpTools.market.status.unknown"; + + return ( + <> + +
    +
    +
    +
    +

    {service.name}

    +

    {formatMarketVersion(service.version)}

    +
    + + {t(statusTextKey)} + +
    +
    + +
    +

    {service.description}

    + +

    {formatMarketDate(service.publishedAt)}

    + +
    +
    + {t("mcpTools.market.title")} + {service.title || "-"} +
    +
    + {t("mcpTools.market.website")} + {service.websiteUrl ? ( + + {service.websiteUrl} + + ) : ( + - + )} +
    +
    + +
    +

    {t("mcpTools.market.remotes")}

    + {service.remotes.length === 0 ? ( +

    {t("mcpTools.market.noRemotes")}

    + ) : ( +
    + {service.remotes.map((remote, index) => ( +
    +

    {remote.type || t("mcpTools.market.remoteFallback")}

    +

    {remote.url}

    +
    + ))} +
    + )} +
    +
    + +
    + + +
    +
    +
    + + {showServerJsonModal ? ( + setShowServerJsonModal(false)} + title={t("mcpTools.market.serverJsonTitle", { name: service.name })} + > +
    +            {serverJsonPretty}
    +          
    +
    + ) : null} + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx b/frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx new file mode 100644 index 000000000..1c25a7d81 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx @@ -0,0 +1,172 @@ +import { useEffect, useMemo, useState } from "react"; +import { Button, DatePicker, Input, Select, Switch } from "antd"; +import dayjs from "dayjs"; +import { VERSION_PATTERN } from "@/lib/mcpTools"; + +interface Props { + state: { + marketSearchValue: string; + marketLoading: boolean; + marketPage: number; + resultCount: number; + marketVersion: string; + marketUpdatedSince: string; + marketIncludeDeleted: boolean; + }; + actions: { + onMarketSearchChange: (value: string) => void; + onRefreshMarket: () => void; + onMarketVersionChange: (value: string) => void; + onMarketUpdatedSinceChange: (value: string) => void; + onMarketIncludeDeletedChange: (value: boolean) => void; + }; + t: (key: string, params?: Record) => string; +} + +export default function McpMarketToolbar({ + state, + actions, + t, +}: Props) { + const { + marketSearchValue, + marketLoading, + marketPage, + resultCount, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + } = state; + const { + onMarketSearchChange, + onRefreshMarket, + onMarketVersionChange, + onMarketUpdatedSinceChange, + onMarketIncludeDeletedChange, + } = actions; + + const [marketVersionMode, setMarketVersionMode] = useState<"all" | "latest" | "custom">("latest"); + const [customVersion, setCustomVersion] = useState(""); + + const updatedSinceDateValue = useMemo(() => { + if (!marketUpdatedSince) return null; + const parsed = dayjs(marketUpdatedSince); + return parsed.isValid() ? parsed : null; + }, [marketUpdatedSince]); + + const customVersionError = customVersion.trim().length > 0 && !VERSION_PATTERN.test(customVersion.trim()); + + useEffect(() => { + const value = (marketVersion || "").trim(); + if (!value) { + setMarketVersionMode("all"); + setCustomVersion(""); + return; + } + if (value.toLowerCase() === "latest") { + setMarketVersionMode("latest"); + setCustomVersion(""); + return; + } + setMarketVersionMode("custom"); + setCustomVersion(value); + }, [marketVersion]); + + const handleVersionModeChange = (mode: "all" | "latest" | "custom") => { + setMarketVersionMode(mode); + if (mode === "all") { + setCustomVersion(""); + onMarketVersionChange(""); + return; + } + if (mode === "latest") { + setCustomVersion(""); + onMarketVersionChange("latest"); + return; + } + setCustomVersion(""); + onMarketVersionChange(""); + }; + + const handleCustomVersionChange = (value: string) => { + setCustomVersion(value); + const trimmed = value.trim(); + if (!trimmed) { + onMarketVersionChange(""); + return; + } + if (VERSION_PATTERN.test(trimmed)) { + onMarketVersionChange(trimmed); + } + }; + + return ( +
    +
    + onMarketSearchChange(event.target.value)} + placeholder={t("mcpTools.market.searchPlaceholder")} + size="large" + className="w-full rounded-2xl" + /> + +
    + {t("mcpTools.market.pageResult", { page: marketPage, count: resultCount })} +
    +
    + +
    + + ) : null} +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx new file mode 100644 index 000000000..356ea4eda --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx @@ -0,0 +1,80 @@ +import { Button } from "antd"; +import { MCP_SERVICE_STATUS, MCP_TAB } from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; + +type Translate = (key: string, options?: Record) => React.ReactNode; + +interface Props { + service: McpServiceItem; + t: Translate; + onSelectService: (service: McpServiceItem) => void; + onToggleEnable: (service: McpServiceItem) => void; +} + +export default function McpServiceCard({ + service, + t, + onSelectService, + onToggleEnable, +}: Props) { + return ( +
    onSelectService(service)} + className="group rounded-3xl border border-slate-200/80 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" + > +
    +
    +

    + {service.name} +

    +

    + {service.description} +

    +
    + + {service.status === MCP_SERVICE_STATUS.ENABLED + ? t("mcpTools.status.enabled") + : t("mcpTools.status.disabled")} + +
    + +
    + + {service.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.market")} + + {service.tags.map((tag) => ( + + {tag} + + ))} +
    + +
    +
    + +
    +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx index aeb6e4fdd..a2a431442 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -1,72 +1,62 @@ import { Modal, Input, Button, Tag } from "antd"; import { useTranslation } from "react-i18next"; - -type McpTab = "本地" | "公共市场"; -type McpServerType = "HTTP" | "SSE" | "容器"; -type McpServiceStatus = "已启用" | "未启用"; -type McpHealthStatus = "正常" | "异常" | "未检测"; -type McpContainerStatus = "运行中" | "已停止" | "未知"; - -const MCP_TAB = { LOCAL: "本地", MARKET: "公共市场" } as const; -const MCP_SERVER_TYPE = { HTTP: "HTTP", SSE: "SSE", CONTAINER: "容器" } as const; -const MCP_SERVICE_STATUS = { ENABLED: "已启用", DISABLED: "未启用" } as const; -const MCP_HEALTH_STATUS = { HEALTHY: "正常", UNHEALTHY: "异常" } as const; -const MCP_CONTAINER_STATUS = { RUNNING: "运行中", STOPPED: "已停止" } as const; - -type McpCard = { - name: string; - description: string; - source: McpTab; - status: McpServiceStatus; - updatedAt: string; - tags: string[]; - serverType: McpServerType; - serverUrl: string; - tools: string[]; - healthStatus: McpHealthStatus; - containerStatus?: McpContainerStatus; - authorizationToken?: string; -}; +import { + MCP_CONTAINER_STATUS, + MCP_HEALTH_STATUS, + MCP_SERVER_TYPE, + MCP_SERVICE_STATUS, + MCP_TAB, +} from "@/const/mcpTools"; +import { + type McpContainerStatus, + type McpServiceDetailActions, + type McpServiceDetailState, + type McpHealthStatus, + type McpServiceItem, + type McpServerType, + type McpServiceStatus, + type McpTab, +} from "@/types/mcpTools"; +import McpServiceDetailToolListModal from "./McpServiceDetailToolListModal"; interface McpServiceDetailModalProps { open: boolean; - selectedService: McpCard | null; - draftService: McpCard | null; - tagDrafts: string[]; - tagInputValue: string; - healthCheckLoading: boolean; - loadingTools: boolean; - onClose: () => void; - onDraftServiceChange: (service: McpCard) => void; - onTagInputChange: (value: string) => void; - onAddDetailTag: () => void; - onRemoveTag: (index: number) => void; - onHealthCheck: () => void; - onViewTools: () => void; + detailState: McpServiceDetailState; + detailActions: McpServiceDetailActions; onDeleteConfirm: (serviceName: string) => void; - onSaveUpdates: () => void; - onToggleEnable: (service: McpCard) => void; + onToggleEnable: (service: McpServiceItem) => void; + onClose: () => void; } export default function McpServiceDetailModal({ open, - selectedService, - draftService, - tagDrafts, - tagInputValue, - healthCheckLoading, - loadingTools, - onClose, - onDraftServiceChange, - onTagInputChange, - onAddDetailTag, - onRemoveTag, - onHealthCheck, - onViewTools, + detailState, + detailActions, onDeleteConfirm, - onSaveUpdates, onToggleEnable, + onClose, }: McpServiceDetailModalProps) { + const { + selectedService, + draftService, + tagDrafts, + tagInputValue, + healthCheckLoading, + loadingTools, + toolsModalVisible, + currentServerTools, + } = detailState; + const { + onDraftServiceChange, + onTagInputChange, + onAddDetailTag, + onRemoveTag, + onHealthCheck, + onViewTools, + onSaveUpdates, + onCloseToolsModal, + onRefreshTools, + } = detailActions; const { t } = useTranslation("common"); const getHealthStatusLabel = (status: McpHealthStatus) => { @@ -94,20 +84,21 @@ export default function McpServiceDetailModal({ } return ( - -
    + <> + +

    {t("mcpTools.detail.title")}

    @@ -290,7 +281,17 @@ export default function McpServiceDetailModal({ : t("mcpTools.detail.enable")}
    -
    - +
    +
    + + + ); } diff --git a/frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailToolListModal.tsx similarity index 98% rename from frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx rename to frontend/app/[locale]/mcp-tools/components/McpServiceDetailToolListModal.tsx index 6763c785f..daedc1656 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpToolListModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailToolListModal.tsx @@ -14,7 +14,7 @@ interface McpToolListModalProps { onRefresh?: () => void; } -export default function McpToolListModal({ +export default function McpServiceDetailToolListModal({ open, onCancel, loading, diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index 90be7f33c..5107f60ea 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -1,838 +1,134 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; -import { App, Input, Button } from "antd"; -import type { UploadFile } from "antd/es/upload/interface"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { App, Button, Input } from "antd"; import { useTranslation } from "react-i18next"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import McpToolListModal from "./components/McpToolListModal"; -import McpServiceDetailModal from "./components/McpServiceDetailModal"; -import AddMcpServiceModal from "./components/AddMcpServiceModal"; +import { motion } from "framer-motion"; +import log from "@/lib/logger"; +import { filterServiceCards } from "@/lib/mcpTools"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; import type { McpTool } from "@/types/agentConfig"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { listMcpTools } from "@/services/mcpToolsService"; +import AddMcpServiceModal from "./components/AddMcpServiceModal"; +import McpServiceCard from "./components/McpServiceCard"; +import McpServiceDetailModal from "./components/McpServiceDetailModal"; +import { useMcpToolsDetail } from "../../../hooks/mcpTools/useMcpToolsDetail"; +import { useMcpToolsToggle } from "../../../hooks/mcpTools/useMcpToolsToggle"; -type McpTab = "本地" | "公共市场"; -type McpServerType = "HTTP" | "SSE" | "容器"; -type McpServiceStatus = "已启用" | "未启用"; -type McpHealthStatus = "正常" | "异常" | "未检测"; -type McpContainerStatus = "运行中" | "已停止" | "未知"; - -const MCP_TAB = { LOCAL: "本地", MARKET: "公共市场" } as const; -const MCP_SERVER_TYPE = { HTTP: "HTTP", SSE: "SSE", CONTAINER: "容器" } as const; -const MCP_SERVICE_STATUS = { ENABLED: "已启用", DISABLED: "未启用" } as const; -const MCP_HEALTH_STATUS = { HEALTHY: "正常", UNHEALTHY: "异常", UNCHECKED: "未检测" } as const; - -const normalizeMcpTab = (value: unknown): McpTab => { - return value === MCP_TAB.MARKET ? MCP_TAB.MARKET : MCP_TAB.LOCAL; -}; - -const normalizeMcpServerType = (value: unknown): McpServerType => { - if (value === MCP_SERVER_TYPE.SSE) return MCP_SERVER_TYPE.SSE; - return value === MCP_SERVER_TYPE.CONTAINER ? MCP_SERVER_TYPE.CONTAINER : MCP_SERVER_TYPE.HTTP; -}; - -const normalizeMcpServiceStatus = (value: unknown): McpServiceStatus => { - return value === MCP_SERVICE_STATUS.ENABLED ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED; -}; - -const normalizeMcpHealthStatus = (value: unknown): McpHealthStatus => { - if (value === MCP_HEALTH_STATUS.HEALTHY) return MCP_HEALTH_STATUS.HEALTHY; - if (value === MCP_HEALTH_STATUS.UNHEALTHY) return MCP_HEALTH_STATUS.UNHEALTHY; - return MCP_HEALTH_STATUS.UNCHECKED; -}; - -const normalizeMcpContainerStatus = (value: unknown): McpContainerStatus => { - if (value === "运行中") return "运行中"; - if (value === "已停止") return "已停止"; - return "未知"; -}; - -const normalizeMarketServerStatus = (value: unknown): string => { - if (typeof value !== "string") return "unknown"; - const normalized = value.trim().toLowerCase(); - if (normalized === "active") return "active"; - if (normalized === "deprecated") return "deprecated"; - return "unknown"; -}; - -type McpCard = { - name: string; - description: string; - source: McpTab; - status: McpServiceStatus; - updatedAt: string; - tags: string[]; - serverType: McpServerType; - serverUrl: string; - tools: string[]; - healthStatus: McpHealthStatus; - containerStatus?: McpContainerStatus; - authorizationToken?: string; -}; - -type MarketMcpCard = { - name: string; - title: string; - version: string; - description: string; - publishedAt: string; - status: string; - websiteUrl: string; - remotes: Array<{ type: string; url: string }>; - serverJson: Record; - serverType: McpServerType; - serverUrl: string; -}; - -const getAuthHeaders = () => { - const session = typeof window !== "undefined" ? localStorage.getItem("session") : null; - const sessionObj = session ? JSON.parse(session) : null; - - return { - "Content-Type": "application/json", - "User-Agent": "AgentFrontEnd/1.0", - ...(sessionObj?.access_token && { Authorization: `Bearer ${sessionObj.access_token}` }), - }; -}; - -export default function McpToolsContent() { - const { message } = App.useApp(); +export default function McpToolsPage() { + const { message, modal } = App.useApp(); const { t } = useTranslation("common"); - const { confirm } = useConfirmModal(); + const { pageVariants, pageTransition } = useSetupFlow(); + const [searchValue, setSearchValue] = useState(""); - const [marketSearchValue, setMarketSearchValue] = useState(""); - const [selectedService, setSelectedService] = useState(null); - const [services, setServices] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const [draftService, setDraftService] = useState(null); - const [healthCheckLoading, setHealthCheckLoading] = useState(false); - const [tagDrafts, setTagDrafts] = useState([]); - const [tagInputValue, setTagInputValue] = useState(""); + const [services, setServices] = useState([]); + const [loadingServices, setLoadingServices] = useState(false); + const [selectedService, setSelectedService] = useState(null); const [showAddModal, setShowAddModal] = useState(false); - const [newServiceName, setNewServiceName] = useState(""); - const [newServiceUrl, setNewServiceUrl] = useState(""); - const [newServiceDesc, setNewServiceDesc] = useState(""); - const [newServiceAuthorizationToken, setNewServiceAuthorizationToken] = useState(""); - const [newServerType, setNewServerType] = useState(MCP_SERVER_TYPE.HTTP); - const [containerConfigJson, setContainerConfigJson] = useState(""); - const [containerUploadFileList, setContainerUploadFileList] = useState([]); - const [containerPort, setContainerPort] = useState(undefined); - const [containerServiceName, setContainerServiceName] = useState(""); - const [newTagDrafts, setNewTagDrafts] = useState([]); - const [newTagInputValue, setNewTagInputValue] = useState(""); - const [addModalTab, setAddModalTab] = useState(MCP_TAB.LOCAL); - const [selectedMarketService, setSelectedMarketService] = useState(null); - const [marketServices, setMarketServices] = useState([]); - const [marketLoading, setMarketLoading] = useState(false); - const [marketCurrentCursor, setMarketCurrentCursor] = useState(null); - const [marketNextCursor, setMarketNextCursor] = useState(null); - const [marketCursorHistory, setMarketCursorHistory] = useState([]); - const [marketPage, setMarketPage] = useState(1); - const [marketVersion, setMarketVersion] = useState("latest"); - const [marketUpdatedSince, setMarketUpdatedSince] = useState(""); - const [marketIncludeDeleted, setMarketIncludeDeleted] = useState(false); - const [addingService, setAddingService] = useState(false); - const [loadingTools, setLoadingTools] = useState(false); - const [toolsModalVisible, setToolsModalVisible] = useState(false); - const [currentServerTools, setCurrentServerTools] = useState([]); - const [toolCache, setToolCache] = useState>({}); - - const getToolCacheKey = (service: Pick) => - `${service.name}@@${service.serverUrl}`; - const syncToolNamesToCards = (service: McpCard, tools: McpTool[]) => { - const nextToolNames = tools.map((item) => item.name); - setDraftService((prev) => { - if (!prev || prev.name !== service.name || prev.serverUrl !== service.serverUrl) { - return prev; - } - return { ...prev, tools: nextToolNames }; - }); - setServices((prev) => - prev.map((item) => - item.name === service.name && item.serverUrl === service.serverUrl - ? { ...item, tools: nextToolNames } - : item - ) - ); - }; - - const resetAddForm = () => { - setNewServiceName(""); - setNewServiceUrl(""); - setNewServiceDesc(""); - setNewServiceAuthorizationToken(""); - setNewServerType(MCP_SERVER_TYPE.HTTP); - setContainerConfigJson(""); - setContainerUploadFileList([]); - setContainerPort(undefined); - setContainerServiceName(""); - setNewTagDrafts([]); - setNewTagInputValue(""); - }; - - const closeAddModal = () => { - setShowAddModal(false); - setAddModalTab(MCP_TAB.LOCAL); - setMarketSearchValue(""); - setMarketCurrentCursor(null); - setMarketNextCursor(null); - setMarketCursorHistory([]); - setMarketPage(1); - setMarketVersion("latest"); - setMarketUpdatedSince(""); - setMarketIncludeDeleted(false); - setSelectedMarketService(null); - resetAddForm(); - }; - - const fetchServices = async () => { - setIsLoading(true); - setLoadError(null); + const loadServerList = async () => { + setLoadingServices(true); try { - const response = await fetch("/api/mcp-tools/list", { - headers: getAuthHeaders(), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.message || t("mcpTools.list.loadFailed")); + const result = await listMcpTools(); + if (!result.success) { + throw new Error(result.message || t("mcpTools.list.loadFailed")); } - - if (!Array.isArray(data?.data)) { - throw new Error(t("mcpTools.list.invalidFormat")); - } - - const normalizedCards = data.data.map((item: any) => ({ - name: typeof item?.name === "string" ? item.name : "", - description: - typeof item?.description === "string" && item.description.trim().length > 0 - ? item.description - : t("mcpTools.service.defaultDescription"), - source: normalizeMcpTab(item?.source), - status: normalizeMcpServiceStatus(item?.status), - updatedAt: typeof item?.updatedAt === "string" ? item.updatedAt : "", - tags: Array.isArray(item?.tags) ? item.tags.filter((tag: unknown) => typeof tag === "string") : [], - serverType: normalizeMcpServerType(item?.serverType), - serverUrl: typeof item?.serverUrl === "string" ? item.serverUrl : "", - tools: Array.isArray(item?.tools) ? item.tools.filter((tool: unknown) => typeof tool === "string") : [], - healthStatus: normalizeMcpHealthStatus(item?.healthStatus), - containerStatus: item?.containerStatus ? normalizeMcpContainerStatus(item.containerStatus) : undefined, - authorizationToken: typeof item?.authorizationToken === "string" ? item.authorizationToken : undefined, - } as McpCard)); - - setServices(normalizedCards); + setServices(result.data); + return { success: true }; } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.list.loadFailed"); - setLoadError(messageText); - message.error(messageText); + log.error("[McpToolsPage] Failed to load managed MCP service list", { error }); + message.error(error instanceof Error ? error.message : t("mcpTools.list.loadFailed")); + return { success: false }; } finally { - setIsLoading(false); - } - }; - - const fetchMarketServices = async (options?: { - search?: string; - cursor?: string | null; - version?: string; - updatedSince?: string; - includeDeleted?: boolean; - }) => { - setMarketLoading(true); - try { - const params = new URLSearchParams(); - params.set("limit", "30"); - const searchValue = (options?.search ?? marketSearchValue).trim(); - if (searchValue) { - params.set("search", searchValue); - } - const versionValue = (options?.version ?? marketVersion).trim(); - if (versionValue) { - params.set("version", versionValue); - } - const updatedSinceValue = (options?.updatedSince ?? marketUpdatedSince).trim(); - if (updatedSinceValue) { - params.set("updated_since", updatedSinceValue); - } - const includeDeletedValue = options?.includeDeleted ?? marketIncludeDeleted; - params.set("include_deleted", includeDeletedValue ? "true" : "false"); - const cursorValue = options?.cursor; - if (cursorValue) { - params.set("cursor", cursorValue); - } - - const response = await fetch(`/api/mcp-tools/market/list?${params.toString()}`, { - headers: getAuthHeaders(), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.detail || data?.message || t("mcpTools.market.loadFailed")); - } - - const items = Array.isArray(data?.data?.items) ? data.data.items : []; - const normalized = items - .map((item: any) => ({ - name: typeof item?.name === "string" ? item.name : "", - title: typeof item?.title === "string" ? item.title : "", - version: typeof item?.version === "string" ? item.version : "", - description: typeof item?.description === "string" ? item.description : t("mcpTools.service.defaultDescription"), - publishedAt: typeof item?.publishedAt === "string" ? item.publishedAt : "", - status: normalizeMarketServerStatus(item?.status), - websiteUrl: typeof item?.websiteUrl === "string" ? item.websiteUrl : "", - remotes: Array.isArray(item?.remotes) - ? item.remotes - .map((remote: any) => ({ - type: typeof remote?.type === "string" ? remote.type : "", - url: typeof remote?.url === "string" ? remote.url : "", - })) - .filter((remote: { type: string; url: string }) => remote.url.length > 0) - : [], - serverJson: typeof item?.serverJson === "object" && item?.serverJson ? item.serverJson : {}, - serverType: normalizeMcpServerType(item?.serverType), - serverUrl: typeof item?.serverUrl === "string" ? item.serverUrl : "", - })) - .filter((item: MarketMcpCard) => item.name.trim().length > 0); - - setMarketServices(normalized); - setMarketNextCursor(typeof data?.data?.nextCursor === "string" && data.data.nextCursor.trim() ? data.data.nextCursor : null); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.market.loadFailed"); - message.error(messageText); - setMarketServices([]); - setMarketNextCursor(null); - } finally { - setMarketLoading(false); - } - }; - - const loadMarketFirstPage = async (search?: string) => { - setMarketCurrentCursor(null); - setMarketCursorHistory([]); - setMarketPage(1); - await fetchMarketServices({ search, cursor: null }); - }; - - const handleMarketNextPage = async () => { - if (!marketNextCursor || marketLoading) { - return; - } - const nextCursor = marketNextCursor; - const currentCursorSnapshot = marketCurrentCursor; - setMarketCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]); - setMarketCurrentCursor(nextCursor); - setMarketPage((prev) => prev + 1); - await fetchMarketServices({ cursor: nextCursor }); - }; - - const handleMarketPrevPage = async () => { - if (marketCursorHistory.length === 0 || marketLoading) { - return; - } - const previousCursor = marketCursorHistory[marketCursorHistory.length - 1] || null; - setMarketCursorHistory((prev) => prev.slice(0, -1)); - setMarketCurrentCursor(previousCursor); - setMarketPage((prev) => Math.max(1, prev - 1)); - await fetchMarketServices({ cursor: previousCursor }); - }; - - const handleAddService = async () => { - if (!newServiceName.trim()) { - message.error(t("mcpTools.add.validate.nameRequired")); - return; - } - - if ((newServerType === MCP_SERVER_TYPE.HTTP || newServerType === MCP_SERVER_TYPE.SSE) && !newServiceUrl.trim()) { - message.error(t("mcpTools.add.validate.httpUrlRequired")); - return; - } - - if (newServerType === MCP_SERVER_TYPE.CONTAINER) { - const hasConfig = containerConfigJson.trim().length > 0 || containerUploadFileList.length > 0; - if (!hasConfig) { - message.error(t("mcpTools.add.validate.containerConfigRequired")); - return; - } - if (!containerServiceName.trim() || !containerPort) { - message.error(t("mcpTools.add.validate.containerRequired")); - return; - } - } - - if (addModalTab !== MCP_TAB.LOCAL) { - message.error(t("mcpTools.add.validate.localTabOnly")); - return; - } - - const tags = newTagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); - const normalizedToken = newServiceAuthorizationToken.trim() || undefined; - - setAddingService(true); - try { - let finalServerUrl = - (newServerType === MCP_SERVER_TYPE.HTTP || newServerType === MCP_SERVER_TYPE.SSE) - ? newServiceUrl.trim() - : `container://${containerServiceName.trim()}:${containerPort}`; - const containerConfigPayload: Record = { - config_json: containerConfigJson.trim() || undefined, - service_name: containerServiceName.trim() || undefined, - port: containerPort, - }; - - if (newServerType === MCP_SERVER_TYPE.CONTAINER) { - if (containerUploadFileList.length > 0) { - const file = containerUploadFileList[0]?.originFileObj; - if (!file) { - throw new Error(t("mcpTools.add.error.imageReadFailed")); - } - const formData = new FormData(); - formData.append("file", file); - formData.append("port", String(containerPort)); - formData.append("service_name", containerServiceName.trim()); - if (normalizedToken) { - formData.append("env_vars", JSON.stringify({ authorization_token: normalizedToken })); - } - - const uploadHeaders = getAuthHeaders(); - delete (uploadHeaders as Record)["Content-Type"]; - - const uploadResponse = await fetch("/api/mcp/upload-image", { - method: "POST", - headers: uploadHeaders, - body: formData, - }); - const uploadData = await uploadResponse.json(); - if (!uploadResponse.ok || uploadData?.status !== "success") { - throw new Error(uploadData?.detail || uploadData?.message || t("mcpTools.add.error.imageUploadFailed")); - } - finalServerUrl = uploadData?.mcp_url || finalServerUrl; - containerConfigPayload.upload_result = uploadData; - } else { - let parsedConfig: any; - try { - parsedConfig = JSON.parse(containerConfigJson); - } catch { - throw new Error(t("mcpTools.add.error.containerJsonInvalid")); - } - - if (!parsedConfig?.mcpServers || typeof parsedConfig.mcpServers !== "object") { - throw new Error(t("mcpTools.add.error.containerJsonMissingServers")); - } - - const mcpServers = Object.fromEntries( - Object.entries(parsedConfig.mcpServers as Record).map(([key, value]) => [ - key, - { - ...value, - port: value?.port ?? containerPort, - }, - ]) - ); - - const addConfigResponse = await fetch("/api/mcp/add-from-config", { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ mcpServers }), - }); - const addConfigData = await addConfigResponse.json(); - if (!addConfigResponse.ok || addConfigData?.status !== "success") { - throw new Error(addConfigData?.detail || addConfigData?.message || t("mcpTools.add.error.containerAddFailed")); - } - - const firstResult = Array.isArray(addConfigData?.results) ? addConfigData.results[0] : undefined; - finalServerUrl = firstResult?.mcp_url || finalServerUrl; - containerConfigPayload.add_from_config_result = addConfigData; - } - } - - const response = await fetch("/api/mcp-tools/add", { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - name: newServiceName.trim(), - description: newServiceDesc.trim() || t("mcpTools.service.defaultDescription"), - source: addModalTab, - server_type: newServerType, - server_url: finalServerUrl, - tags, - authorization_token: normalizedToken, - container_config: newServerType === MCP_SERVER_TYPE.CONTAINER ? containerConfigPayload : undefined, - }), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.detail || data?.message || t("mcpTools.add.failed")); - } - - setShowAddModal(false); - resetAddForm(); - await fetchServices(); - message.success(t("mcpTools.add.success")); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.add.failed"); - const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; - setLoadError(displayMessage); - message.error(displayMessage); - } finally { - setAddingService(false); - } - }; - - const handleQuickAddFromMarket = async (service: MarketMcpCard) => { - const isUrlService = service.serverType === MCP_SERVER_TYPE.HTTP || service.serverType === MCP_SERVER_TYPE.SSE; - if (!isUrlService || !service.serverUrl.trim()) { - message.error(t("mcpTools.market.quickAddUnsupported")); - return; - } - - setAddingService(true); - try { - const response = await fetch("/api/mcp-tools/add", { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - name: service.name, - description: service.description || t("mcpTools.service.defaultDescription"), - source: MCP_TAB.MARKET, - server_type: service.serverType, - server_url: service.serverUrl, - tags: [], - }), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.detail || data?.message || t("mcpTools.add.failed")); - } - - await fetchServices(); - closeAddModal(); - message.success(t("mcpTools.market.quickAddSuccess")); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.add.failed"); - const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; - setLoadError(displayMessage); - message.error(displayMessage); - } finally { - setAddingService(false); - } - }; - - const normalizeTools = (tools: unknown): McpTool[] => { - if (!Array.isArray(tools)) { - return []; - } - return tools - .map((item) => { - const tool = item as Record; - const name = typeof tool.name === "string" ? tool.name : ""; - const description = typeof tool.description === "string" ? tool.description : ""; - if (!name.trim()) { - return null; - } - return { - name, - description, - parameters: tool.parameters, - } as McpTool; - }) - .filter((item): item is McpTool => item !== null); - }; - - const loadToolsForService = async ( - service: McpCard, - options?: { silent?: boolean; force?: boolean } - ) => { - if (!service.name || !service.serverUrl) { - return; - } - - const silent = options?.silent ?? false; - const force = options?.force ?? false; - const cacheKey = getToolCacheKey(service); - const cachedTools = toolCache[cacheKey]; - - if (!force && cachedTools) { - setCurrentServerTools(cachedTools); - syncToolNamesToCards(service, cachedTools); - return; - } - - setLoadingTools(true); - try { - const response = await fetch( - `/api/mcp/tools?service_name=${encodeURIComponent(service.name)}&mcp_url=${encodeURIComponent(service.serverUrl)}`, - { - method: "POST", - headers: getAuthHeaders(), - } - ); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.detail || data?.message || t("mcpTools.tools.loadFailed")); - } - - const nextTools = normalizeTools(data?.tools); - setToolCache((prev) => ({ ...prev, [cacheKey]: nextTools })); - setCurrentServerTools(nextTools); - syncToolNamesToCards(service, nextTools); - } catch (error) { - if (!silent) { - const messageText = error instanceof Error ? error.message : t("mcpTools.tools.loadFailed"); - message.error(messageText); - } - setCurrentServerTools(cachedTools ?? []); - } finally { - setLoadingTools(false); + setLoadingServices(false); } }; useEffect(() => { - fetchServices().catch(() => undefined); + loadServerList().catch(() => undefined); }, []); - useEffect(() => { - if (showAddModal && addModalTab === MCP_TAB.MARKET && marketServices.length === 0 && !marketLoading) { - loadMarketFirstPage(marketSearchValue).catch(() => undefined); - } - }, [showAddModal, addModalTab, marketServices.length, marketLoading]); - - useEffect(() => { - if (!(showAddModal && addModalTab === MCP_TAB.MARKET)) { - return; - } - const timer = window.setTimeout(() => { - loadMarketFirstPage(marketSearchValue).catch(() => undefined); - }, 350); - return () => window.clearTimeout(timer); - }, [marketSearchValue, marketVersion, marketUpdatedSince, marketIncludeDeleted, showAddModal, addModalTab]); - - useEffect(() => { - if (selectedService) { - setDraftService({ ...selectedService }); - setTagDrafts(selectedService.tags); - setTagInputValue(""); - setCurrentServerTools(toolCache[getToolCacheKey(selectedService)] ?? []); - } else { - setDraftService(null); - setTagDrafts([]); - setTagInputValue(""); - setCurrentServerTools([]); - setToolsModalVisible(false); - } - }, [selectedService]); - - const handleViewTools = () => { - if (!draftService) { - return; - } - setToolsModalVisible(true); - loadToolsForService(draftService, { force: false }).catch(() => undefined); - }; - - const handleRefreshTools = () => { - if (!draftService) { - return; - } - loadToolsForService(draftService, { force: true }).catch(() => undefined); - }; - - const addDetailTag = () => { - const nextTag = tagInputValue.trim(); - if (!nextTag) return; - setTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); - setTagInputValue(""); - }; - - const addNewTag = () => { - const nextTag = newTagInputValue.trim(); - if (!nextTag) return; - setNewTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); - setNewTagInputValue(""); - }; - - const handleEnableToggle = async (service: McpCard) => { - const nextEnabled = service.status !== MCP_SERVICE_STATUS.ENABLED; - try { - const response = await fetch("/api/mcp-tools/manage/enable", { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ name: service.name, enabled: nextEnabled }), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.message || t("mcpTools.service.toggleFailed")); - } - - setServices((prev) => - prev.map((item) => - item.name === service.name - ? { ...item, status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED } - : item - ) - ); - setSelectedService((prev) => - prev && prev.name === service.name - ? { ...prev, status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED } - : prev - ); - message.success( - nextEnabled - ? t("mcpTools.service.enabled") - : t("mcpTools.service.disabled") - ); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.service.toggleFailed"); - const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; - setLoadError(displayMessage); - message.error(displayMessage); - } - }; - - const handleSaveUpdates = async () => { - if (!selectedService || !draftService) return; - const nextTags = tagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); - - try { - const response = await fetch("/api/mcp-tools/update", { - method: "PUT", - headers: getAuthHeaders(), - body: JSON.stringify({ - current_name: selectedService.name, - name: draftService.name, - description: draftService.description, - server_url: draftService.serverUrl, - authorization_token: draftService.authorizationToken ?? "", - tags: nextTags, - }), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.message || t("mcpTools.service.saveFailed")); - } - - const updatedService = { - ...draftService, - tags: nextTags, - }; - - const oldCacheKey = getToolCacheKey(selectedService); - const newCacheKey = getToolCacheKey(updatedService); - if (oldCacheKey !== newCacheKey) { - setToolCache((prev) => { - if (!prev[oldCacheKey]) { - return prev; - } - const { [oldCacheKey]: movedTools, ...rest } = prev; - return { ...rest, [newCacheKey]: movedTools }; - }); - } + const filteredServices = useMemo(() => { + return filterServiceCards(services, searchValue); + }, [searchValue, services]); - setServices((prev) => - prev.map((item) => - item.name === selectedService.name ? updatedService : item - ) - ); - setSelectedService(updatedService); - setDraftService(updatedService); - setTagDrafts(nextTags); - message.success(t("mcpTools.service.saveSuccess")); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.service.saveFailed"); - const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; - setLoadError(displayMessage); - message.error(displayMessage); - } + const isSameToolNames = (left: string[] = [], right: string[] = []) => { + if (left.length !== right.length) return false; + return left.every((item, index) => item === right[index]); }; - const handleHealthCheck = async () => { - if (!draftService) return; - setHealthCheckLoading(true); - try { - const response = await fetch("/api/mcp-tools/healthcheck", { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - name: draftService.name, - server_url: draftService.serverUrl, - }), - }); - const data = await response.json(); - - if (!response.ok || data?.status !== "success") { - throw new Error(data?.message || t("mcpTools.service.healthFailed")); + const syncToolNamesToCards = useCallback((service: Pick, tools: McpTool[]) => { + const nextToolNames = tools.map((item) => item.name); + setSelectedService((prev) => { + if (!prev || prev.name !== service.name || prev.serverUrl !== service.serverUrl) { + return prev; } - - const nextHealth = normalizeMcpHealthStatus(data?.data?.health_status); - setDraftService({ ...draftService, healthStatus: nextHealth }); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.service.healthFailed"); - const displayMessage = messageText === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : messageText; - setLoadError(displayMessage); - message.error(displayMessage); - } finally { - setHealthCheckLoading(false); - } - }; - - const handleDeleteService = async (serviceName: string) => { - try { - const response = await fetch(`/api/mcp-tools/delete?name=${encodeURIComponent(serviceName)}`, { - method: "DELETE", - headers: getAuthHeaders(), - }); - const data = await response.json(); - if (!response.ok || data?.status !== "success") { - throw new Error(data?.message || t("mcpTools.service.deleteFailed")); + if (isSameToolNames(prev.tools, nextToolNames)) { + return prev; } - - setServices((prev) => prev.filter((item) => item.name !== serviceName)); - setToolCache((prev) => { - const next: Record = {}; - for (const [key, value] of Object.entries(prev)) { - if (!key.startsWith(`${serviceName}@@`)) { - next[key] = value; - } + return { ...prev, tools: nextToolNames }; + }); + setServices((prev) => { + let changed = false; + const next = prev.map((item) => { + if (item.name !== service.name || item.serverUrl !== service.serverUrl) { + return item; + } + if (isSameToolNames(item.tools, nextToolNames)) { + return item; } - return next; + changed = true; + return { ...item, tools: nextToolNames }; }); - setSelectedService(null); - message.success(t("mcpTools.service.deleted")); - } catch (error) { - const messageText = error instanceof Error ? error.message : t("mcpTools.service.deleteFailed"); - message.error(messageText); - } - }; - - const filteredServices = useMemo(() => { - const keyword = searchValue.trim().toLowerCase(); - if (!keyword) return services; - return services.filter((item) => { - return ( - item.name.toLowerCase().includes(keyword) || - item.description.toLowerCase().includes(keyword) || - item.tags.some((tag) => tag.toLowerCase().includes(keyword)) - ); + return changed ? next : prev; }); - }, [searchValue, services]); + }, []); - const filteredMarketServices = marketServices; + const { toggleServiceStatus } = useMcpToolsToggle({ + loadServerList, + setSelectedService, + t: (key: string) => String(t(key)), + message, + }); + + const { state: detailState, actions: detailActions } = useMcpToolsDetail({ + selectedService, + onSelectedServiceChange: setSelectedService, + onServicesReload: loadServerList, + onSyncToolNames: syncToolNamesToCards, + t: (key: string) => String(t(key)), + message, + }); + + const handleDeleteConfirm = (serviceName: string) => { + modal.confirm({ + title: t("mcpTools.delete.confirmTitle"), + content: ( +
    +

    {serviceName}

    +

    {t("mcpTools.delete.confirmDesc")}

    +
    + ), + okButtonProps: { danger: true }, + onOk: () => detailActions.onDeleteService(serviceName), + }); + }; return ( -
    -
    +
    +
    -

    - {t("mcpTools.page.title")} -

    -

    - {t("mcpTools.page.subtitle")} -

    +

    {t("mcpTools.page.title")}

    +

    {t("mcpTools.page.subtitle")}

    @@ -845,7 +141,7 @@ export default function McpToolsContent() { id="mcp-search" value={searchValue} onChange={(event) => setSearchValue(event.target.value)} - placeholder={t("mcpTools.page.searchPlaceholder")} + placeholder={String(t("mcpTools.page.searchPlaceholder"))} size="large" className="w-full h-10 rounded-2xl" /> @@ -859,10 +155,7 @@ export default function McpToolsContent() { type="primary" size="large" block - onClick={() => { - setShowAddModal(true); - setAddModalTab(MCP_TAB.LOCAL); - }} + onClick={() => setShowAddModal(true)} className="w-full h-10 rounded-full bg-gradient-to-r from-emerald-600 via-teal-600 to-cyan-600 px-6 text-white font-semibold shadow-lg shadow-emerald-200/50 transition hover:translate-y-[-1px] hover:shadow-emerald-300/70" > {t("mcpTools.page.addService")} @@ -870,7 +163,7 @@ export default function McpToolsContent() {
    - {isLoading ? ( + {loadingServices ? (
    {t("mcpTools.page.loading")}
    @@ -880,172 +173,58 @@ export default function McpToolsContent() {
    ) : (
    - {filteredServices.map((service) => ( -
    setSelectedService(service)} - className="group rounded-3xl border border-slate-200/80 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" - > -
    -
    -

    - {service.name} -

    -

    - {service.description} -

    -
    - - {service.status === MCP_SERVICE_STATUS.ENABLED - ? t("mcpTools.status.enabled") - : t("mcpTools.status.disabled")} - -
    - -
    - - {service.source === MCP_TAB.LOCAL - ? t("mcpTools.source.local") - : t("mcpTools.source.market")} - - {service.tags.map((tag) => ( - - {tag} - - ))} -
    - -
    -
    - -
    -
    -
    - ))} + {filteredServices.map((service) => { + const isSelected = + selectedService?.name === service.name && + selectedService?.serverUrl === service.serverUrl; + + return ( + { + toggleServiceStatus(item).catch((error) => { + log.error("[McpToolsPage] Failed to toggle service status from card", { + error, + serviceName: item.name, + serverUrl: item.serverUrl, + }); + }); + }} + /> + ); + })}
    )} -
    -
    - setSelectedService(null)} - onDraftServiceChange={setDraftService} - onTagInputChange={setTagInputValue} - onAddDetailTag={addDetailTag} - onRemoveTag={(index) => setTagDrafts((prev) => prev.filter((_, idx) => idx !== index))} - onHealthCheck={handleHealthCheck} - onViewTools={handleViewTools} - onDeleteConfirm={(serviceName) => { - confirm({ - title: t("mcpTools.delete.confirmTitle"), - content: ( -
    -

    {serviceName}

    -

    {t("mcpTools.delete.confirmDesc")}

    -
    - ), - danger: true, - onOk: () => { - handleDeleteService(serviceName).catch(() => undefined); - }, - }); - }} - onSaveUpdates={handleSaveUpdates} - onToggleEnable={(service) => { - handleEnableToggle(service).catch(() => undefined); - }} - /> - - 0} - hasNextMarketPage={Boolean(marketNextCursor)} - marketVersion={marketVersion} - marketUpdatedSince={marketUpdatedSince} - marketIncludeDeleted={marketIncludeDeleted} - newServiceName={newServiceName} - newServiceDesc={newServiceDesc} - newServerType={newServerType} - newServiceUrl={newServiceUrl} - newServiceAuthorizationToken={newServiceAuthorizationToken} - containerUploadFileList={containerUploadFileList} - containerConfigJson={containerConfigJson} - containerPort={containerPort} - containerServiceName={containerServiceName} - newTagDrafts={newTagDrafts} - newTagInputValue={newTagInputValue} - addingService={addingService} - onClose={closeAddModal} - onAddModalTabChange={setAddModalTab} - onMarketSearchChange={setMarketSearchValue} - onRefreshMarket={() => loadMarketFirstPage(marketSearchValue)} - onPrevMarketPage={handleMarketPrevPage} - onNextMarketPage={handleMarketNextPage} - onMarketVersionChange={setMarketVersion} - onMarketUpdatedSinceChange={setMarketUpdatedSince} - onMarketIncludeDeletedChange={setMarketIncludeDeleted} - onSelectMarketService={setSelectedMarketService} - onQuickAddFromMarket={handleQuickAddFromMarket} - onNewServiceNameChange={setNewServiceName} - onNewServiceDescChange={setNewServiceDesc} - onNewServerTypeChange={setNewServerType} - onNewServiceUrlChange={setNewServiceUrl} - onNewServiceAuthorizationTokenChange={setNewServiceAuthorizationToken} - onContainerUploadFileListChange={setContainerUploadFileList} - onContainerConfigJsonChange={setContainerConfigJson} - onContainerPortChange={setContainerPort} - onContainerServiceNameChange={setContainerServiceName} - onAddNewTag={addNewTag} - onRemoveNewTag={(index) => setNewTagDrafts((prev) => prev.filter((_, idx) => idx !== index))} - onNewTagInputChange={setNewTagInputValue} - onSaveAndAdd={handleAddService} - /> - - setToolsModalVisible(false)} - loading={loadingTools} - tools={currentServerTools} - serverName={draftService?.name || t("mcpTools.service.defaultName")} - onRefresh={handleRefreshTools} - /> + {selectedService ? ( + { + toggleServiceStatus(item).catch((error) => { + log.error("[McpToolsPage] Failed to toggle service status from detail modal", { + error, + serviceName: item.name, + serverUrl: item.serverUrl, + }); + }); + }} + onClose={detailActions.onCloseDetail} + /> + ) : null} + + setShowAddModal(false)} + /> +
    +
    ); } \ No newline at end of file diff --git a/frontend/const/mcpTools.ts b/frontend/const/mcpTools.ts new file mode 100644 index 000000000..879a6729c --- /dev/null +++ b/frontend/const/mcpTools.ts @@ -0,0 +1,22 @@ +import { + McpContainerStatus, + McpHealthStatus, + McpServerType, + McpServiceStatus, + McpTab, +} from "@/types/mcpTools"; + +export const MCP_TAB = { LOCAL: McpTab.LOCAL, MARKET: McpTab.MARKET } as const; +export const MCP_SERVER_TYPE = { HTTP: McpServerType.HTTP, SSE: McpServerType.SSE, CONTAINER: McpServerType.CONTAINER } as const; +export const MCP_SERVICE_STATUS = { ENABLED: McpServiceStatus.ENABLED, DISABLED: McpServiceStatus.DISABLED } as const; +export const MCP_HEALTH_STATUS = { + HEALTHY: McpHealthStatus.HEALTHY, + UNHEALTHY: McpHealthStatus.UNHEALTHY, + UNCHECKED: McpHealthStatus.UNCHECKED, +} as const; +export const MCP_CONTAINER_STATUS = { + RUNNING: McpContainerStatus.RUNNING, + STOPPED: McpContainerStatus.STOPPED, + UNKNOWN: McpContainerStatus.UNKNOWN, +} as const; +export const MARKET_SERVER_STATUS = { ACTIVE: "active", DEPRECATED: "deprecated", UNKNOWN: "unknown" } as const; diff --git a/frontend/hooks/mcpTools/useMcpToolsAddLocal.ts b/frontend/hooks/mcpTools/useMcpToolsAddLocal.ts new file mode 100644 index 000000000..e81f176d9 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpToolsAddLocal.ts @@ -0,0 +1,202 @@ +import { useCallback, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import type { MessageInstance } from "antd/es/message/interface"; +import type { UploadFile } from "antd/es/upload/interface"; +import log from "@/lib/logger"; +import { MCP_SERVER_TYPE, MCP_TAB } from "@/const/mcpTools"; +import { addMcpToolService, resolveContainerServerInfo } from "@/services/mcpToolsService"; +import { + type AddMcpLocalActions, + type AddMcpLocalState, + type McpServerType, + type McpTab, +} from "@/types/mcpTools"; + +type UseMcpToolsAddLocalParams = { + addModalTab: McpTab; + t: (key: string) => string; + message: MessageInstance; + onServiceAdded: () => Promise; + onClose: () => void; +}; + +export function useMcpToolsAddLocal({ + addModalTab, + t, + message, + onServiceAdded, + onClose, +}: UseMcpToolsAddLocalParams) { + const [newServiceName, setNewServiceName] = useState(""); + const [newServiceUrl, setNewServiceUrl] = useState(""); + const [newServiceDesc, setNewServiceDesc] = useState(""); + const [newServiceAuthorizationToken, setNewServiceAuthorizationToken] = useState(""); + const [newServerType, setNewServerType] = useState(MCP_SERVER_TYPE.HTTP); + const [containerConfigJson, setContainerConfigJson] = useState(""); + const [containerUploadFileList, setContainerUploadFileList] = useState([]); + const [containerPort, setContainerPort] = useState(undefined); + const [containerServiceName, setContainerServiceName] = useState(""); + const [newTagDrafts, setNewTagDrafts] = useState([]); + const [newTagInputValue, setNewTagInputValue] = useState(""); + const [addingService, setAddingService] = useState(false); + + const addMutation = useMutation({ mutationFn: addMcpToolService }); + + const reset = useCallback(() => { + setNewServiceName(""); + setNewServiceUrl(""); + setNewServiceDesc(""); + setNewServiceAuthorizationToken(""); + setNewServerType(MCP_SERVER_TYPE.HTTP); + setContainerConfigJson(""); + setContainerUploadFileList([]); + setContainerPort(undefined); + setContainerServiceName(""); + setNewTagDrafts([]); + setNewTagInputValue(""); + setAddingService(false); + }, []); + + const validateLocalAdd = () => { + if (!newServiceName.trim()) return t("mcpTools.add.validate.nameRequired"); + if ((newServerType === MCP_SERVER_TYPE.HTTP || newServerType === MCP_SERVER_TYPE.SSE) && !newServiceUrl.trim()) { + return t("mcpTools.add.validate.httpUrlRequired"); + } + if (newServerType === MCP_SERVER_TYPE.CONTAINER) { + const hasConfig = containerConfigJson.trim().length > 0 || containerUploadFileList.length > 0; + if (!hasConfig) return t("mcpTools.add.validate.containerConfigRequired"); + if (!containerServiceName.trim() || !containerPort) { + return t("mcpTools.add.validate.containerRequired"); + } + } + if (addModalTab !== MCP_TAB.LOCAL) return t("mcpTools.add.validate.localTabOnly"); + return null; + }; + + const handleAddService = async () => { + const validationError = validateLocalAdd(); + if (validationError) { + log.error("[useMcpToolsAddLocal] Local add validation failed", { + validationError, + addModalTab, + serverType: newServerType, + }); + message.error(validationError); + return; + } + + const tags = newTagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); + const normalizedToken = newServiceAuthorizationToken.trim() || undefined; + + setAddingService(true); + try { + const resolvedServerInfo = await resolveContainerServerInfo({ + serverType: newServerType, + serviceUrl: newServiceUrl, + containerServiceName, + containerPort, + containerConfigJson, + containerUploadFileList, + authorizationToken: normalizedToken, + t, + }); + if (!resolvedServerInfo.success || !resolvedServerInfo.data) { + throw new Error(resolvedServerInfo.message || t("mcpTools.add.failed")); + } + + const result = await addMutation.mutateAsync({ + name: newServiceName.trim(), + description: newServiceDesc.trim() || t("mcpTools.service.defaultDescription"), + source: addModalTab, + server_type: newServerType, + server_url: resolvedServerInfo.data.finalServerUrl, + tags, + authorization_token: normalizedToken, + container_config: resolvedServerInfo.data.containerConfig, + }); + + if (!result.success) throw new Error(result.message || t("mcpTools.add.failed")); + await onServiceAdded(); + message.success(t("mcpTools.add.success")); + onClose(); + } catch (error) { + const msg = error instanceof Error ? error.message : t("mcpTools.add.failed"); + log.error("[useMcpToolsAddLocal] Failed to add MCP service", { + error, + serviceName: newServiceName, + serverType: newServerType, + addModalTab, + }); + message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + } finally { + setAddingService(false); + } + }; + + const addNewTag = () => { + const nextTag = newTagInputValue.trim(); + if (!nextTag) return; + setNewTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); + setNewTagInputValue(""); + }; + + const removeNewTag = useCallback((index: number) => { + setNewTagDrafts((prev) => prev.filter((_, idx) => idx !== index)); + }, []); + + const state: AddMcpLocalState = useMemo( + () => ({ + newServiceName, + newServiceDesc, + newServerType, + newServiceUrl, + newServiceAuthorizationToken, + containerUploadFileList, + containerConfigJson, + containerPort, + containerServiceName, + newTagDrafts, + newTagInputValue, + addingService, + }), + [ + newServiceName, + newServiceDesc, + newServerType, + newServiceUrl, + newServiceAuthorizationToken, + containerUploadFileList, + containerConfigJson, + containerPort, + containerServiceName, + newTagDrafts, + newTagInputValue, + addingService, + ] + ); + + const actions: AddMcpLocalActions = useMemo( + () => ({ + onNewServiceNameChange: setNewServiceName, + onNewServiceDescChange: setNewServiceDesc, + onNewServerTypeChange: setNewServerType, + onNewServiceUrlChange: setNewServiceUrl, + onNewServiceAuthorizationTokenChange: setNewServiceAuthorizationToken, + onContainerUploadFileListChange: setContainerUploadFileList, + onContainerConfigJsonChange: setContainerConfigJson, + onContainerPortChange: setContainerPort, + onContainerServiceNameChange: setContainerServiceName, + onAddNewTag: addNewTag, + onRemoveNewTag: removeNewTag, + onNewTagInputChange: setNewTagInputValue, + onSaveAndAdd: handleAddService, + }), + [removeNewTag] + ); + + return { + state, + actions, + reset, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts b/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts new file mode 100644 index 000000000..4234d3f3f --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { MessageInstance } from "antd/es/message/interface"; +import log from "@/lib/logger"; +import { MCP_SERVER_TYPE, MCP_TAB } from "@/const/mcpTools"; +import { addMcpToolService, fetchMarketMcpCards, type MarketMcpCard } from "@/services/mcpToolsService"; +import { + type AddMcpMarketActions, + type AddMcpMarketState, + type McpTab, +} from "@/types/mcpTools"; + +type UseMcpToolsAddMarketParams = { + open: boolean; + addModalTab: McpTab; + t: (key: string) => string; + message: MessageInstance; + onServiceAdded: () => Promise; + onClose: () => void; +}; + +export function useMcpToolsAddMarket({ + open, + addModalTab, + t, + message, + onServiceAdded, + onClose, +}: UseMcpToolsAddMarketParams) { + const [marketSearchValue, setMarketSearchValue] = useState(""); + const [selectedMarketService, setSelectedMarketService] = useState(null); + const [marketServices, setMarketServices] = useState([]); + const [marketCurrentCursor, setMarketCurrentCursor] = useState(null); + const [marketNextCursor, setMarketNextCursor] = useState(null); + const [marketCursorHistory, setMarketCursorHistory] = useState([]); + const [marketPage, setMarketPage] = useState(1); + const [marketVersion, setMarketVersion] = useState("latest"); + const [marketUpdatedSince, setMarketUpdatedSince] = useState(""); + const [marketIncludeDeleted, setMarketIncludeDeleted] = useState(false); + const [addingService, setAddingService] = useState(false); + + const addMutation = useMutation({ mutationFn: addMcpToolService }); + + const reset = useCallback(() => { + setMarketSearchValue(""); + setMarketCurrentCursor(null); + setMarketNextCursor(null); + setMarketCursorHistory([]); + setMarketPage(1); + setMarketVersion("latest"); + setMarketUpdatedSince(""); + setMarketIncludeDeleted(false); + setSelectedMarketService(null); + setMarketServices([]); + setAddingService(false); + }, []); + + const loadMarketFirstPage = useCallback(() => { + setMarketCurrentCursor(null); + setMarketCursorHistory([]); + setMarketPage(1); + }, []); + + useEffect(() => { + if (!(open && addModalTab === MCP_TAB.MARKET)) return; + const timer = window.setTimeout(() => { + loadMarketFirstPage(); + }, 350); + return () => window.clearTimeout(timer); + }, [ + open, + addModalTab, + marketSearchValue, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + loadMarketFirstPage, + ]); + + const marketQuery = useQuery<{ items: MarketMcpCard[]; nextCursor: string | null }>({ + queryKey: [ + "mcp-tools", + "market", + marketSearchValue, + marketCurrentCursor, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + ], + enabled: open && addModalTab === MCP_TAB.MARKET, + retry: false, + queryFn: async () => { + const result = await fetchMarketMcpCards({ + search: marketSearchValue, + cursor: marketCurrentCursor, + version: marketVersion, + updatedSince: marketUpdatedSince, + includeDeleted: marketIncludeDeleted, + }); + if (!result.success) throw new Error(result.message || t("mcpTools.market.loadFailed")); + return result.data; + }, + }); + + useEffect(() => { + if (!marketQuery.data) return; + setMarketServices(marketQuery.data.items); + setMarketNextCursor(marketQuery.data.nextCursor); + }, [marketQuery.data]); + + useEffect(() => { + if (!(marketQuery.error instanceof Error)) return; + log.error("[useMcpToolsAddMarket] Failed to load market MCP cards", { + error: marketQuery.error, + search: marketSearchValue, + cursor: marketCurrentCursor, + version: marketVersion, + updatedSince: marketUpdatedSince, + includeDeleted: marketIncludeDeleted, + }); + message.error(marketQuery.error.message); + setMarketServices([]); + setMarketNextCursor(null); + }, [ + marketQuery.error, + marketSearchValue, + marketCurrentCursor, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + message, + ]); + + const handleMarketNextPage = () => { + if (!marketNextCursor || marketQuery.isFetching) return; + const currentCursorSnapshot = marketCurrentCursor; + setMarketCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]); + setMarketCurrentCursor(marketNextCursor); + setMarketPage((prev) => prev + 1); + }; + + const handleMarketPrevPage = () => { + if (marketCursorHistory.length === 0 || marketQuery.isFetching) return; + const previousCursor = marketCursorHistory[marketCursorHistory.length - 1] || null; + setMarketCursorHistory((prev) => prev.slice(0, -1)); + setMarketCurrentCursor(previousCursor); + setMarketPage((prev) => Math.max(1, prev - 1)); + }; + + const handleQuickAddFromMarket = async (service: MarketMcpCard) => { + const isUrlService = service.serverType === MCP_SERVER_TYPE.HTTP || service.serverType === MCP_SERVER_TYPE.SSE; + if (!isUrlService || !service.serverUrl.trim()) { + log.error("[useMcpToolsAddMarket] Quick add is unsupported for selected market service", { + serviceName: service.name, + serverType: service.serverType, + serverUrl: service.serverUrl, + }); + message.error(t("mcpTools.market.quickAddUnsupported")); + return; + } + + setAddingService(true); + try { + const result = await addMutation.mutateAsync({ + name: service.name, + description: service.description || t("mcpTools.service.defaultDescription"), + source: MCP_TAB.MARKET, + server_type: service.serverType, + server_url: service.serverUrl, + tags: [], + }); + + if (!result.success) throw new Error(result.message || t("mcpTools.add.failed")); + await onServiceAdded(); + message.success(t("mcpTools.market.quickAddSuccess")); + onClose(); + } catch (error) { + const msg = error instanceof Error ? error.message : t("mcpTools.add.failed"); + log.error("[useMcpToolsAddMarket] Failed to quick add market service", { + error, + serviceName: service.name, + serverType: service.serverType, + serverUrl: service.serverUrl, + }); + message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + } finally { + setAddingService(false); + } + }; + + const state: AddMcpMarketState = useMemo( + () => ({ + marketSearchValue, + selectedMarketService, + filteredMarketServices: marketServices, + marketLoading: marketQuery.isFetching, + marketPage, + hasPrevMarketPage: marketCursorHistory.length > 0, + hasNextMarketPage: Boolean(marketNextCursor), + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + }), + [ + marketSearchValue, + selectedMarketService, + marketServices, + marketQuery.isFetching, + marketPage, + marketCursorHistory.length, + marketNextCursor, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + ] + ); + + const actions: AddMcpMarketActions = useMemo( + () => ({ + onMarketSearchChange: setMarketSearchValue, + onRefreshMarket: loadMarketFirstPage, + onPrevMarketPage: handleMarketPrevPage, + onNextMarketPage: handleMarketNextPage, + onMarketVersionChange: setMarketVersion, + onMarketUpdatedSinceChange: setMarketUpdatedSince, + onMarketIncludeDeletedChange: setMarketIncludeDeleted, + onSelectMarketService: setSelectedMarketService, + onQuickAddFromMarket: handleQuickAddFromMarket, + }), + [loadMarketFirstPage] + ); + + return { + state, + actions, + addingService, + reset, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpToolsDetail.ts b/frontend/hooks/mcpTools/useMcpToolsDetail.ts new file mode 100644 index 000000000..234c1fefa --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpToolsDetail.ts @@ -0,0 +1,249 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { MessageInstance } from "antd/es/message/interface"; +import log from "@/lib/logger"; +import { MCP_HEALTH_STATUS } from "@/const/mcpTools"; +import type { McpTool } from "@/types/agentConfig"; +import { + type McpServiceDetailActions, + type McpServiceDetailState, + type McpServiceItem, +} from "@/types/mcpTools"; +import { + deleteMcpToolService, + healthcheckMcpToolService, + listMcpRuntimeTools, + updateMcpToolService, +} from "@/services/mcpToolsService"; + +function isSameStringArray(left: string[] = [], right: string[] = []) { + if (left.length !== right.length) return false; + return left.every((item, index) => item === right[index]); +} + +type UseMcpToolsDetailParams = { + selectedService: McpServiceItem | null; + onSelectedServiceChange: (service: McpServiceItem | null) => void; + onServicesReload: () => Promise; + onSyncToolNames: (service: Pick, tools: McpTool[]) => void; + t: (key: string) => string; + message: MessageInstance; +}; + +export function useMcpToolsDetail({ + selectedService, + onSelectedServiceChange, + onServicesReload, + onSyncToolNames, + t, + message, +}: UseMcpToolsDetailParams) { + const queryClient = useQueryClient(); + const [draftService, setDraftService] = useState(null); + const [tagDrafts, setTagDrafts] = useState([]); + const [tagInputValue, setTagInputValue] = useState(""); + const [healthCheckLoading, setHealthCheckLoading] = useState(false); + const [toolsModalVisible, setToolsModalVisible] = useState(false); + const [currentServerTools, setCurrentServerTools] = useState([]); + + useEffect(() => { + if (selectedService) { + setDraftService({ ...selectedService }); + setTagDrafts(selectedService.tags); + setTagInputValue(""); + setCurrentServerTools([]); + return; + } + + setDraftService(null); + setTagDrafts([]); + setTagInputValue(""); + setCurrentServerTools([]); + setToolsModalVisible(false); + }, [selectedService?.name, selectedService?.serverUrl]); + + const updateMutation = useMutation({ mutationFn: updateMcpToolService }); + const deleteMutation = useMutation({ mutationFn: deleteMcpToolService }); + const healthcheckMutation = useMutation({ mutationFn: healthcheckMcpToolService }); + + const toolsQueryKey = ["mcp-tools", "runtime-tools", draftService?.name, draftService?.serverUrl]; + + const toolsQuery = useQuery({ + queryKey: toolsQueryKey, + enabled: false, + retry: false, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + queryFn: async () => { + if (!draftService) { + throw new Error(t("mcpTools.tools.loadFailed")); + } + const result = await listMcpRuntimeTools(draftService.name, draftService.serverUrl); + if (!result.success) { + throw new Error(result.message || t("mcpTools.tools.loadFailed")); + } + return result.data; + }, + }); + + useEffect(() => { + if (!draftService || !toolsQuery.data) return; + const nextToolNames = toolsQuery.data.map((item) => item.name); + setCurrentServerTools((prev) => (isSameStringArray(prev.map((item) => item.name), nextToolNames) ? prev : toolsQuery.data)); + onSyncToolNames( + { name: draftService.name, serverUrl: draftService.serverUrl }, + toolsQuery.data + ); + setDraftService((prev) => + !prev || prev.name !== draftService.name || prev.serverUrl !== draftService.serverUrl + ? prev + : isSameStringArray(prev.tools, nextToolNames) + ? prev + : { ...prev, tools: nextToolNames } + ); + }, [draftService?.name, draftService?.serverUrl, toolsQuery.data, onSyncToolNames]); + + const loadTools = async () => { + if (!draftService) return; + const result = await toolsQuery.refetch(); + if (result.error) { + log.error("[useMcpToolsDetail] Failed to load runtime tools", { + error: result.error, + serviceName: draftService.name, + serverUrl: draftService.serverUrl, + }); + const msg = result.error instanceof Error ? result.error.message : t("mcpTools.tools.loadFailed"); + message.error(msg); + } + }; + + const handleViewTools = () => { + if (!draftService) return; + setToolsModalVisible(true); + + const cachedTools = queryClient.getQueryData(toolsQueryKey); + if (cachedTools && cachedTools.length > 0) { + setCurrentServerTools(cachedTools); + return; + } + + loadTools().catch(() => undefined); + }; + + const handleRefreshTools = () => { + if (!draftService) return; + loadTools().catch(() => undefined); + }; + + const handleSaveUpdates = async () => { + if (!selectedService || !draftService) return; + const nextTags = tagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); + try { + const result = await updateMutation.mutateAsync({ + current_name: selectedService.name, + name: draftService.name, + description: draftService.description, + server_url: draftService.serverUrl, + authorization_token: draftService.authorizationToken ?? "", + tags: nextTags, + }); + if (!result.success) throw new Error(result.message || t("mcpTools.service.saveFailed")); + const updatedService = { ...draftService, tags: nextTags }; + await onServicesReload(); + onSelectedServiceChange(updatedService); + setDraftService(updatedService); + setTagDrafts(nextTags); + message.success(t("mcpTools.service.saveSuccess")); + } catch (error) { + const msg = error instanceof Error ? error.message : t("mcpTools.service.saveFailed"); + log.error("[useMcpToolsDetail] Failed to save service updates", { + error, + selectedServiceName: selectedService.name, + selectedServiceUrl: selectedService.serverUrl, + draftServiceName: draftService.name, + draftServiceUrl: draftService.serverUrl, + }); + message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + } + }; + + const handleHealthCheck = async () => { + if (!draftService) return; + setHealthCheckLoading(true); + try { + const result = await healthcheckMutation.mutateAsync({ + name: draftService.name, + server_url: draftService.serverUrl, + }); + if (!result.success || !result.data) throw new Error(result.message || t("mcpTools.service.healthFailed")); + setDraftService({ ...draftService, healthStatus: result.data.health_status }); + } catch (error) { + setDraftService((prev) => (prev ? { ...prev, healthStatus: MCP_HEALTH_STATUS.UNHEALTHY } : prev)); + const msg = error instanceof Error ? error.message : t("mcpTools.service.healthFailed"); + log.error("[useMcpToolsDetail] Failed to run health check", { + error, + serviceName: draftService.name, + serverUrl: draftService.serverUrl, + }); + message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + } finally { + setHealthCheckLoading(false); + } + }; + + const onDeleteService = async (serviceName: string) => { + try { + const result = await deleteMutation.mutateAsync(serviceName); + if (!result.success) throw new Error(result.message || t("mcpTools.service.deleteFailed")); + await onServicesReload(); + onSelectedServiceChange(null); + message.success(t("mcpTools.service.deleted")); + } catch (error) { + log.error("[useMcpToolsDetail] Failed to delete service", { + error, + serviceName, + }); + message.error(error instanceof Error ? error.message : t("mcpTools.service.deleteFailed")); + } + }; + + const addDetailTag = () => { + const nextTag = tagInputValue.trim(); + if (!nextTag) return; + setTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); + setTagInputValue(""); + }; + + const state: McpServiceDetailState = { + selectedService, + draftService, + tagDrafts, + tagInputValue, + healthCheckLoading, + loadingTools: toolsQuery.isFetching, + toolsModalVisible, + currentServerTools, + }; + + const actions: McpServiceDetailActions & { + onDeleteService: (serviceName: string) => Promise; + onCloseDetail: () => void; + } = { + onDraftServiceChange: setDraftService, + onTagInputChange: setTagInputValue, + onAddDetailTag: addDetailTag, + onRemoveTag: (index: number) => setTagDrafts((prev) => prev.filter((_, idx) => idx !== index)), + onHealthCheck: handleHealthCheck, + onViewTools: handleViewTools, + onSaveUpdates: handleSaveUpdates, + onCloseToolsModal: () => setToolsModalVisible(false), + onRefreshTools: handleRefreshTools, + onDeleteService, + onCloseDetail: () => onSelectedServiceChange(null), + }; + + return { + state, + actions, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpToolsToggle.ts b/frontend/hooks/mcpTools/useMcpToolsToggle.ts new file mode 100644 index 000000000..236d25ac1 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpToolsToggle.ts @@ -0,0 +1,50 @@ +import { useMutation } from "@tanstack/react-query"; +import type { MessageInstance } from "antd/es/message/interface"; +import { enableMcpToolService } from "@/services/mcpToolsService"; +import { MCP_SERVICE_STATUS } from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; + +type UseMcpToolsToggleParams = { + loadServerList: () => Promise; + setSelectedService: React.Dispatch>; + t: (key: string) => string; + message: MessageInstance; +}; + +export function useMcpToolsToggle({ + loadServerList, + setSelectedService, + t, + message, +}: UseMcpToolsToggleParams) { + const toggleMutation = useMutation({ mutationFn: enableMcpToolService }); + + const toggleServiceStatus = async (service: McpServiceItem) => { + const nextEnabled = service.status !== MCP_SERVICE_STATUS.ENABLED; + const result = await toggleMutation.mutateAsync({ + name: service.name, + enabled: nextEnabled, + }); + + if (!result.success) { + throw new Error(t("mcpTools.service.toggleFailed")); + } + + await loadServerList(); + setSelectedService((prev) => + prev && prev.name === service.name + ? { + ...prev, + status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED, + } + : prev + ); + + message.success(nextEnabled ? t("mcpTools.service.enabled") : t("mcpTools.service.disabled")); + }; + + return { + toggleServiceStatus, + toggleMutation, + }; +} diff --git a/frontend/lib/mcpTools.ts b/frontend/lib/mcpTools.ts new file mode 100644 index 000000000..d748f08de --- /dev/null +++ b/frontend/lib/mcpTools.ts @@ -0,0 +1,64 @@ +import type { McpServer } from "@/types/agentConfig"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { + MCP_HEALTH_STATUS, + MCP_SERVER_TYPE, + MCP_SERVICE_STATUS, + MCP_TAB, +} from "@/const/mcpTools"; + +export const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; + +export const mapServersToServiceCards = ( + serverList: McpServer[] | undefined, + t: (key: string) => string +): McpServiceItem[] => { + return (serverList ?? []).map((server) => { + const normalizedUrl = typeof server.mcp_url === "string" ? server.mcp_url : ""; + const inferredType = normalizedUrl.startsWith("container://") + ? MCP_SERVER_TYPE.CONTAINER + : MCP_SERVER_TYPE.HTTP; + + return { + name: typeof server.service_name === "string" ? server.service_name : "", + description: t("mcpTools.service.defaultDescription"), + source: MCP_TAB.LOCAL, + status: server.status ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED, + updatedAt: "", + tags: [], + serverType: inferredType, + serverUrl: normalizedUrl, + tools: [], + healthStatus: server.status ? MCP_HEALTH_STATUS.HEALTHY : MCP_HEALTH_STATUS.UNCHECKED, + authorizationToken: typeof server.authorization_token === "string" ? server.authorization_token : undefined, + }; + }); +}; + +export const filterServiceCards = (services: McpServiceItem[], searchValue: string): McpServiceItem[] => { + const keyword = searchValue.trim().toLowerCase(); + if (!keyword) { + return services; + } + + return services.filter((item) => { + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) || + item.tags.some((tag) => tag.toLowerCase().includes(keyword)) + ); + }); +}; + +export const formatMarketDate = (value: string): string => { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; +}; + +export const formatMarketVersion = (value: string): string => { + const version = (value || "").trim(); + if (!version) return "-"; + return /^v/i.test(version) ? version : `v${version}`; +}; diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 31174e830..d3284ceaa 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -216,6 +216,15 @@ export const API_ENDPOINTS = { `${API_BASE_URL}/mcp/container/${containerId}`, record: (mcpId: number) => `${API_BASE_URL}/mcp/record/${mcpId}`, }, + mcpTools: { + list: `${API_BASE_URL}/mcp-tools/list`, + add: `${API_BASE_URL}/mcp-tools/add`, + update: `${API_BASE_URL}/mcp-tools/v2/update`, + delete: `${API_BASE_URL}/mcp-tools/v2/delete`, + enable: `${API_BASE_URL}/mcp-tools/v2/manage/enable`, + healthcheck: `${API_BASE_URL}/mcp-tools/v2/healthcheck`, + marketList: `${API_BASE_URL}/mcp-tools/market/list`, + }, memory: { // ---------------- Memory configuration ---------------- config: { diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts new file mode 100644 index 000000000..083e30174 --- /dev/null +++ b/frontend/services/mcpToolsService.ts @@ -0,0 +1,367 @@ +import log from "@/lib/logger"; +import { fetchWithAuth } from "@/lib/auth"; +import { MCP_SERVER_TYPE } from "@/const/mcpTools"; +import { API_ENDPOINTS } from "@/services/api"; +import type { + AddMcpRuntimeFromConfigPayload, + AddMcpServicePayload, + HealthcheckMcpServicePayload, + MarketMcpCard, + McpHealthStatus, + McpServiceItem, + McpServerType, + ToggleMcpServicePayload, + UpdateMcpServicePayload, +} from "@/types/mcpTools"; +import type { McpTool } from "@/types/agentConfig"; + +export type McpToolsApiResult = { + success: boolean; + data: T; + message?: string; +}; + +export type { MarketMcpCard } from "@/types/mcpTools"; + +type ApiEnvelope = { + status: string; + message?: string; + detail?: string; + data: T; + tools?: McpTool[]; + results?: Array<{ mcp_url?: string }>; + mcp_url?: string; +}; + +const parseJson = async (response: Response): Promise => { + return (await response.json()) as T; +}; + +type HealthcheckPayload = { + health_status: McpHealthStatus; +}; + +export const fetchMarketMcpCards = async (params: { + search?: string; + cursor?: string | null; + version?: string; + updatedSince?: string; + includeDeleted?: boolean; +}) => { + const query = new URLSearchParams(); + query.set("limit", "30"); + if (params.search?.trim()) { + query.set("search", params.search.trim()); + } + if (params.version?.trim()) { + query.set("version", params.version.trim()); + } + if (params.updatedSince?.trim()) { + query.set("updated_since", params.updatedSince.trim()); + } + query.set("include_deleted", params.includeDeleted ? "true" : "false"); + if (params.cursor) { + query.set("cursor", params.cursor); + } + + const result = await listMarketMcpTools(query); + if (!result.success || !result.data) { + return { + success: false, + data: { items: [], nextCursor: null as string | null }, + message: result.message, + } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>; + } + + const payload = result.data; + + return { + success: true, + data: { + items: payload.items, + nextCursor: payload.nextCursor ?? null, + }, + } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>; +}; + +export const resolveContainerServerInfo = async (params: { + serverType: McpServerType; + serviceUrl: string; + containerServiceName: string; + containerPort: number | undefined; + containerConfigJson: string; + containerUploadFileList: Array<{ originFileObj?: File }>; + authorizationToken?: string; + t: (key: string) => string; +}) => { + if (params.serverType !== MCP_SERVER_TYPE.CONTAINER) { + return { + success: true, + data: { + finalServerUrl: params.serviceUrl.trim(), + containerConfig: undefined, + }, + } as McpToolsApiResult<{ + finalServerUrl: string; + containerConfig?: Record; + }>; + } + + let finalServerUrl = `container://${params.containerServiceName.trim()}:${params.containerPort}`; + const containerConfigPayload: Record = { + config_json: params.containerConfigJson.trim() || undefined, + service_name: params.containerServiceName.trim() || undefined, + port: params.containerPort, + }; + + if (params.containerUploadFileList.length > 0) { + const file = params.containerUploadFileList[0]?.originFileObj; + if (!file) { + return { success: false, data: null, message: params.t("mcpTools.add.error.imageReadFailed") } as McpToolsApiResult; + } + + const formData = new FormData(); + formData.append("file", file); + formData.append("port", String(params.containerPort)); + formData.append("service_name", params.containerServiceName.trim()); + if (params.authorizationToken) { + formData.append("env_vars", JSON.stringify({ authorization_token: params.authorizationToken })); + } + + const uploadResult = await uploadMcpRuntimeImage(formData); + if (!uploadResult.success) { + return { + success: false, + data: null, + message: uploadResult.message || params.t("mcpTools.add.error.imageUploadFailed"), + } as McpToolsApiResult; + } + + const uploadData = uploadResult.data; + const uploadedMcpUrl = uploadData && typeof uploadData.mcp_url === "string" ? uploadData.mcp_url : undefined; + finalServerUrl = uploadedMcpUrl || finalServerUrl; + containerConfigPayload.upload_result = uploadData; + + return { + success: true, + data: { + finalServerUrl, + containerConfig: containerConfigPayload, + }, + } as McpToolsApiResult<{ finalServerUrl: string; containerConfig: Record }>; + } + + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(params.containerConfigJson); + } catch { + return { success: false, data: null, message: params.t("mcpTools.add.error.containerJsonInvalid") } as McpToolsApiResult; + } + + const parsedMcpServers = (parsedConfig as { mcpServers?: Record }).mcpServers; + if (!parsedMcpServers || typeof parsedMcpServers !== "object") { + return { success: false, data: null, message: params.t("mcpTools.add.error.containerJsonMissingServers") } as McpToolsApiResult; + } + + const mcpServers = Object.fromEntries( + Object.entries(parsedMcpServers).map(([key, value]) => { + return [ + key, + { + ...value, + port: typeof value.port === "number" ? value.port : params.containerPort, + }, + ]; + }) + ); + + const addConfigResult = await addMcpRuntimeFromConfig({ mcpServers }); + if (!addConfigResult.success) { + return { + success: false, + data: null, + message: addConfigResult.message || params.t("mcpTools.add.error.containerAddFailed"), + } as McpToolsApiResult; + } + + const addConfigData = addConfigResult.data; + const firstResultMcpUrl = addConfigData?.results?.[0]?.mcp_url; + finalServerUrl = firstResultMcpUrl || finalServerUrl; + containerConfigPayload.add_from_config_result = addConfigData ?? {}; + + return { + success: true, + data: { + finalServerUrl, + containerConfig: containerConfigPayload, + }, + } as McpToolsApiResult<{ finalServerUrl: string; containerConfig: Record }>; +}; + +export const listMcpTools = async () => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.list); + const data = await parseJson>(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: [], message: data.message || "Failed to load MCP services" } as McpToolsApiResult; + } + return { success: true, data: data.data } as McpToolsApiResult; + } catch (error) { + log.error("listMcpTools failed", error); + return { success: false, data: [], message: "Failed to load MCP services" } as McpToolsApiResult; + } +}; + +export const listMarketMcpTools = async (query: URLSearchParams) => { + try { + const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.marketList}?${query.toString()}`); + const data = await parseJson>(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.detail || data.message || "Failed to load market list" } as McpToolsApiResult; + } + return { success: true, data: data.data } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>; + } catch (error) { + log.error("listMarketMcpTools failed", error); + return { success: false, data: null, message: "Failed to load market list" } as McpToolsApiResult; + } +}; + +export const addMcpToolService = async (payload: AddMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.add, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.detail || data.message || "Failed to add MCP service" } as McpToolsApiResult; + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("addMcpToolService failed", error); + return { success: false, data: null, message: "Failed to add MCP service" } as McpToolsApiResult; + } +}; + +export const updateMcpToolService = async (payload: UpdateMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.update, { + method: "PUT", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.message || "Failed to update MCP service" } as McpToolsApiResult; + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("updateMcpToolService failed", error); + return { success: false, data: null, message: "Failed to update MCP service" } as McpToolsApiResult; + } +}; + +export const enableMcpToolService = async (payload: ToggleMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.enable, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.message || "Failed to update service status" } as McpToolsApiResult; + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("enableMcpToolService failed", error); + return { success: false, data: null, message: "Failed to update service status" } as McpToolsApiResult; + } +}; + +export const healthcheckMcpToolService = async (payload: HealthcheckMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.healthcheck, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson>( + response + ); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.message || "Health check failed" } as McpToolsApiResult; + } + return { success: true, data: data.data } as McpToolsApiResult; + } catch (error) { + log.error("healthcheckMcpToolService failed", error); + return { success: false, data: null, message: "Health check failed" } as McpToolsApiResult; + } +}; + +export const deleteMcpToolService = async (name: string) => { + try { + const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.delete}?name=${encodeURIComponent(name)}`, { + method: "DELETE", + }); + const data = await parseJson(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.message || "Failed to delete service" } as McpToolsApiResult; + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("deleteMcpToolService failed", error); + return { success: false, data: null, message: "Failed to delete service" } as McpToolsApiResult; + } +}; + +export const listMcpRuntimeTools = async (serviceName: string, mcpUrl: string) => { + try { + const query = new URLSearchParams({ + service_name: serviceName, + mcp_url: mcpUrl, + }); + const response = await fetchWithAuth(`${API_ENDPOINTS.mcp.tools}?${query.toString()}`, { + method: "POST", + }); + const data = await parseJson(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: [], message: data.detail || data.message || "Failed to load MCP tools" } as McpToolsApiResult; + } + return { success: true, data: data.tools as McpTool[] } as McpToolsApiResult; + } catch (error) { + log.error("listMcpRuntimeTools failed", error); + return { success: false, data: [], message: "Failed to load MCP tools" } as McpToolsApiResult; + } +}; + +export const addMcpRuntimeFromConfig = async (payload: AddMcpRuntimeFromConfigPayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.addFromConfig, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson }>>(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.detail || data.message || "Failed to add MCP from config" } as McpToolsApiResult; + } + return { success: true, data: data.data } as McpToolsApiResult<{ results?: Array<{ mcp_url?: string }> }>; + } catch (error) { + log.error("addMcpRuntimeFromConfig failed", error); + return { success: false, data: null, message: "Failed to add MCP from config" } as McpToolsApiResult; + } +}; + +export const uploadMcpRuntimeImage = async (formData: FormData) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.uploadImage, { + method: "POST", + body: formData, + }); + const data = await parseJson>(response); + if (!response.ok || data.status !== "success") { + return { success: false, data: null, message: data.detail || data.message || "Failed to upload image" } as McpToolsApiResult; + } + return { success: true, data: data.data } as McpToolsApiResult<{ mcp_url?: string }>; + } catch (error) { + log.error("uploadMcpRuntimeImage failed", error); + return { success: false, data: null, message: "Failed to upload image" } as McpToolsApiResult; + } +}; diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts new file mode 100644 index 000000000..a49c6ac7a --- /dev/null +++ b/frontend/types/mcpTools.ts @@ -0,0 +1,178 @@ +import type { UploadFile } from "antd/es/upload/interface"; +import type { McpTool } from "@/types/agentConfig"; + +export enum McpTab { + LOCAL = "local", + MARKET = "market", +} + +export enum McpServerType { + HTTP = "http", + SSE = "sse", + CONTAINER = "container", +} + +export enum McpServiceStatus { + ENABLED = "enabled", + DISABLED = "disabled", +} + +export enum McpHealthStatus { + HEALTHY = "healthy", + UNHEALTHY = "unhealthy", + UNCHECKED = "unchecked", +} + +export enum McpContainerStatus { + RUNNING = "running", + STOPPED = "stopped", + UNKNOWN = "unknown", +} + +export interface MarketMcpCard { + name: string; + title: string; + version: string; + description: string; + publishedAt: string; + status: string; + websiteUrl: string; + remotes: Array<{ type: string; url: string }>; + serverJson: Record; + serverType: McpServerType; + serverUrl: string; +} + +export interface AddMcpLocalState { + newServiceName: string; + newServiceDesc: string; + newServerType: McpServerType; + newServiceUrl: string; + newServiceAuthorizationToken: string; + containerUploadFileList: UploadFile[]; + containerConfigJson: string; + containerPort: number | undefined; + containerServiceName: string; + newTagDrafts: string[]; + newTagInputValue: string; + addingService: boolean; +} + +export interface AddMcpMarketState { + marketSearchValue: string; + selectedMarketService: MarketMcpCard | null; + filteredMarketServices: MarketMcpCard[]; + marketLoading: boolean; + marketPage: number; + hasPrevMarketPage: boolean; + hasNextMarketPage: boolean; + marketVersion: string; + marketUpdatedSince: string; + marketIncludeDeleted: boolean; +} + +export interface AddMcpLocalActions { + onNewServiceNameChange: (value: string) => void; + onNewServiceDescChange: (value: string) => void; + onNewServerTypeChange: (value: McpServerType) => void; + onNewServiceUrlChange: (value: string) => void; + onNewServiceAuthorizationTokenChange: (value: string) => void; + onContainerUploadFileListChange: (fileList: UploadFile[]) => void; + onContainerConfigJsonChange: (value: string) => void; + onContainerPortChange: (value: number | undefined) => void; + onContainerServiceNameChange: (value: string) => void; + onAddNewTag: () => void; + onRemoveNewTag: (index: number) => void; + onNewTagInputChange: (value: string) => void; + onSaveAndAdd: () => void; +} + +export interface AddMcpMarketActions { + onMarketSearchChange: (value: string) => void; + onRefreshMarket: () => void; + onPrevMarketPage: () => void; + onNextMarketPage: () => void; + onMarketVersionChange: (value: string) => void; + onMarketUpdatedSinceChange: (value: string) => void; + onMarketIncludeDeletedChange: (value: boolean) => void; + onSelectMarketService: (service: MarketMcpCard | null) => void; + onQuickAddFromMarket: (service: MarketMcpCard) => void; +} + +export interface McpServiceDetailState { + selectedService: McpServiceItem | null; + draftService: McpServiceItem | null; + tagDrafts: string[]; + tagInputValue: string; + healthCheckLoading: boolean; + loadingTools: boolean; + toolsModalVisible: boolean; + currentServerTools: McpTool[]; +} + +export interface McpServiceDetailActions { + onDraftServiceChange: (service: McpServiceItem) => void; + onTagInputChange: (value: string) => void; + onAddDetailTag: () => void; + onRemoveTag: (index: number) => void; + onHealthCheck: () => void; + onViewTools: () => void; + onSaveUpdates: () => void; + onCloseToolsModal: () => void; + onRefreshTools: () => void; +} + +export interface McpServiceItem { + name: string; + description: string; + source: McpTab; + status: McpServiceStatus; + updatedAt: string; + tags: string[]; + serverType: McpServerType; + serverUrl: string; + tools: string[]; + healthStatus: McpHealthStatus; + containerStatus?: McpContainerStatus; + authorizationToken?: string; +} + +export interface AddMcpServicePayload { + name: string; + description: string; + source: McpTab; + server_type: McpServerType; + server_url: string; + tags: string[]; + authorization_token?: string; + container_config?: Record; +} + +export interface UpdateMcpServicePayload { + current_name: string; + name: string; + description: string; + server_url: string; + tags: string[]; + authorization_token?: string; +} + +export interface ToggleMcpServicePayload { + name: string; + enabled: boolean; +} + +export interface HealthcheckMcpServicePayload { + name: string; + server_url: string; +} + +export interface AddMcpRuntimeServerPayload { + port?: number; + [key: string]: unknown; +} + +export interface AddMcpRuntimeFromConfigPayload { + mcpServers: Record; +} + From b3a7bafaaadfe2445ef8b0f37202459951490139 Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Tue, 24 Mar 2026 16:50:33 +0800 Subject: [PATCH 03/59] feat: Enhance MCP Tools functionality and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new service enabling and disabling messages in English and Chinese localization files. - Updated API endpoints for enabling and disabling MCP tools. - Introduced new container service addition functionality in the MCP tools service. - Refactored mcpToolsService to handle container services and improve error handling. - Updated types for MCP tools to reflect new transport types and service details. - Created a new SQL migration script to extend the mcp_record_t table for additional MCP tool attributes. - Implemented a custom hook for managing MCP tools page state and interactions. 功能亮点:增强 MCP 工具的功能与用户界面 - 在英文和中文本地化文件中添加了启用和禁用服务的提示信息。 - 更新了用于启用和禁用 MCP 工具的 API 接口。 - 在 MCP 工具服务中引入了新增容器服务的功能。 - 重构了 mcpToolsService 以处理容器服务并改进错误处理机制。 - 更新了 MCP 工具的类型,以反映新的传输类型和服务详情。 - 创建了新的 SQL 迁移脚本,用于扩展 mcp_record_t 表以支持 MCP 工具的额外属性。 - 实现了自定义钩子,用于管理 MCP 工具页面的状态和交互。 --- backend/apps/mcp_management_app.py | 452 +++++++++++++- backend/database/db_models.py | 11 +- backend/database/remote_mcp_db.py | 102 ++++ backend/services/mcp_container_service.py | 79 +++ backend/services/mcp_management_service.py | 556 ++++++++++++------ docker/sql/v1.8.2_0318_expand_mcp_record.sql | 44 ++ .../components/AddMcpServiceLocalSection.tsx | 151 ++--- .../components/AddMcpServiceMarketSection.tsx | 177 ++++-- .../components/AddMcpServiceModal.tsx | 56 +- .../components/McpMarketDetailModal.tsx | 107 ++-- .../mcp-tools/components/McpMarketToolbar.tsx | 60 +- .../mcp-tools/components/McpServiceCard.tsx | 15 +- .../components/McpServiceDetailModal.tsx | 245 ++++++-- .../McpServiceDetailToolListModal.tsx | 2 +- frontend/app/[locale]/mcp-tools/page.tsx | 139 ++--- frontend/components/mcp/McpToolListModal.tsx | 2 +- frontend/const/mcpTools.ts | 6 +- .../hooks/mcpTools/useMcpToolsAddLocal.ts | 195 +++--- .../hooks/mcpTools/useMcpToolsAddMarket.ts | 343 ++++++++--- frontend/hooks/mcpTools/useMcpToolsDetail.ts | 127 ++-- frontend/hooks/mcpTools/useMcpToolsPage.ts | 126 ++++ frontend/hooks/mcpTools/useMcpToolsToggle.ts | 92 ++- frontend/lib/mcpTools.ts | 31 +- frontend/public/locales/en/common.json | 28 +- frontend/public/locales/zh/common.json | 40 +- frontend/services/api.ts | 5 +- frontend/services/mcpToolsService.ts | 262 ++++----- frontend/types/mcpTools.ts | 122 +--- 28 files changed, 2470 insertions(+), 1105 deletions(-) create mode 100644 docker/sql/v1.8.2_0318_expand_mcp_record.sql create mode 100644 frontend/hooks/mcpTools/useMcpToolsPage.ts diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py index ae25c74b6..b10445555 100644 --- a/backend/apps/mcp_management_app.py +++ b/backend/apps/mcp_management_app.py @@ -6,15 +6,24 @@ from http import HTTPStatus from pydantic import BaseModel, Field -from consts.exceptions import MCPConnectionError, MCPNameIllegal +from consts.const import NEXENT_MCP_DOCKER_IMAGE +from consts.exceptions import MCPConnectionError, MCPContainerError +from consts.model import MCPConfigRequest +from database.remote_mcp_db import check_mcp_name_exists +from services.mcp_container_service import MCPContainerManager from services.mcp_management_service import ( add_mcp_service, check_mcp_service_health, + check_mcp_service_health_legacy, delete_mcp_service, + delete_mcp_service_legacy, list_market_mcp_services, + list_mcp_service_tools_by_id, list_mcp_services, update_mcp_service, + update_mcp_service_legacy, update_mcp_service_enabled, + update_mcp_service_enabled_legacy, ) from utils.auth_utils import get_current_user_info @@ -26,11 +35,22 @@ class AddMcpServiceRequest(BaseModel): name: str = Field(min_length=1) server_url: str = Field(min_length=1) description: Optional[str] = None - source: Literal["local", "market"] = "local" - server_type: Literal["http", "sse", "container"] = "http" + source: Literal["local", "mcp_registry", "market"] = "local" + transport_type: Literal["http", "sse", "stdio", "container"] = "http" tags: Optional[list[str]] = None authorization_token: Optional[str] = None container_config: Optional[dict[str, Any]] = None + version: Optional[str] = None + mcp_registry_json: Optional[dict[str, Any]] = None + + +class AddContainerMcpServiceRequest(BaseModel): + name: str = Field(min_length=1) + description: Optional[str] = None + tags: Optional[list[str]] = None + authorization_token: Optional[str] = None + port: int = Field(..., ge=1, le=65535) + mcp_config: MCPConfigRequest class UpdateMcpServiceRequest(BaseModel): @@ -42,16 +62,41 @@ class UpdateMcpServiceRequest(BaseModel): authorization_token: Optional[str] = None +class UpdateMcpServiceByIdRequest(BaseModel): + mcp_id: int + name: str = Field(min_length=1) + description: Optional[str] = None + server_url: str = Field(min_length=1) + tags: Optional[list[str]] = None + authorization_token: Optional[str] = None + + class EnableMcpServiceRequest(BaseModel): name: str = Field(min_length=1) enabled: bool +class EnableMcpServiceByIdRequest(BaseModel): + mcp_id: int + + +class DisableMcpServiceByIdRequest(BaseModel): + mcp_id: int + + class HealthcheckMcpServiceRequest(BaseModel): name: str = Field(min_length=1) server_url: str = Field(min_length=1) +class HealthcheckMcpServiceByIdRequest(BaseModel): + mcp_id: int + + +class ListMcpToolsByIdRequest(BaseModel): + mcp_id: int + + @router.post("/add") async def add_mcp_service_api( payload: AddMcpServiceRequest, @@ -64,10 +109,12 @@ async def add_mcp_service_api( server_url = payload.server_url.strip() description = payload.description source = payload.source - server_type = payload.server_type + transport_type = payload.transport_type tags = payload.tags - authorization_token = (payload.authorization_token or "").strip() or None + authorization_token = (payload.authorization_token or "").strip() container_config = payload.container_config + version = (payload.version or "").strip() + mcp_registry_json = payload.mcp_registry_json await add_mcp_service( tenant_id=tenant_id, @@ -75,22 +122,18 @@ async def add_mcp_service_api( name=name, description=description, source=source, - server_type=server_type, + transport_type=transport_type, server_url=server_url, tags=tags, authorization_token=authorization_token, container_config=container_config, + version=version, + mcp_registry_json=mcp_registry_json, ) return JSONResponse( status_code=HTTPStatus.OK, content={"status": "success"}, ) - except MCPNameIllegal as exc: - logger.error(f"MCP name conflict: {exc}") - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail=str(exc), - ) except MCPConnectionError as exc: logger.error(f"MCP connection failed when adding service: {exc}") raise HTTPException( @@ -107,6 +150,135 @@ async def add_mcp_service_api( ) +@router.post("/container/add") +async def add_container_mcp_service_api( + payload: AddContainerMcpServiceRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + + service_name = payload.name.strip() + if check_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="MCP name already exists", + ) + + servers = payload.mcp_config.mcpServers + if len(servers) != 1: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Exactly one mcpServers entry is required", + ) + + _, config = next(iter(servers.items())) + command = (config.command or "").strip() + if not command: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="command is required", + ) + + port = payload.port + + env_vars = dict(config.env or {}) + auth_token = (payload.authorization_token or "").strip() + if auth_token: + env_vars["authorization_token"] = auth_token + + full_command = [ + "python", + "-m", + "mcp_proxy", + "--host", + "0.0.0.0", + "--port", + str(port), + "--transport", + "streamablehttp", + "--", + command, + *(config.args or []), + ] + + container_manager = MCPContainerManager() + container_info = await container_manager.start_mcp_container( + service_name=service_name, + tenant_id=tenant_id, + user_id=user_id, + env_vars=env_vars, + host_port=port, + image=config.image or NEXENT_MCP_DOCKER_IMAGE, + full_command=full_command, + ) + started_container_id: Optional[str] = None + started_container_id = container_info.get("container_id") + + container_config = payload.mcp_config.model_dump() + + try: + await add_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=service_name, + description=payload.description, + source="local", + transport_type="stdio", + server_url=container_info["mcp_url"], + tags=payload.tags, + authorization_token=auth_token, + container_config=container_config, + version=None, + mcp_registry_json=None, + enabled=True, + container_id=container_info.get("container_id"), + ) + except MCPConnectionError: + if started_container_id: + try: + cleanup_manager = MCPContainerManager() + await cleanup_manager.stop_mcp_container(started_container_id) + except Exception as cleanup_exc: + logger.warning(f"Failed to cleanup container {started_container_id}: {cleanup_exc}") + raise + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "status": "success", + "data": { + "service_name": service_name, + "mcp_url": container_info.get("mcp_url"), + "container_id": container_info.get("container_id"), + "container_name": container_info.get("container_name"), + "host_port": container_info.get("host_port"), + }, + }, + ) + except HTTPException: + raise + except MCPContainerError as exc: + logger.error(f"Failed to start MCP container service: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="Docker service unavailable", + ) + except MCPConnectionError as exc: + logger.error(f"MCP connection failed when adding container service: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except Exception as exc: + logger.error(f"Failed to add container MCP service: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to add container MCP service", + ) + + @router.get("/list") async def list_mcp_services_api( authorization: Optional[str] = Header(None), @@ -165,6 +337,43 @@ async def list_market_mcp_services_api( ) +@router.post("/tools") +async def list_mcp_tools_api( + payload: ListMcpToolsByIdRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + _, tenant_id, _ = get_current_user_info(authorization, http_request) + tools = await list_mcp_service_tools_by_id( + tenant_id=tenant_id, + mcp_id=payload.mcp_id, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "tools": tools}, + ) + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc), + ) + except MCPConnectionError as exc: + logger.error(f"Failed to get tools from MCP service: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to list MCP tools by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to get tools from MCP service", + ) + + @router.put("/update") async def update_mcp_service_api( payload: UpdateMcpServiceRequest, @@ -178,9 +387,9 @@ async def update_mcp_service_api( description = payload.description server_url = payload.server_url tags = payload.tags - authorization_token = (payload.authorization_token or "").strip() or None + authorization_token = (payload.authorization_token or "").strip() - update_mcp_service( + update_mcp_service_legacy( tenant_id=tenant_id, user_id=user_id, current_name=current_name, @@ -204,6 +413,38 @@ async def update_mcp_service_api( ) +@router.put("/v2/update") +async def update_mcp_service_by_id_api( + payload: UpdateMcpServiceByIdRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + update_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + mcp_id=payload.mcp_id, + new_name=payload.name, + description=payload.description, + server_url=payload.server_url, + authorization_token=(payload.authorization_token or "").strip(), + tags=payload.tags, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to update MCP service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service", + ) + + @router.post("/manage/enable") async def update_mcp_service_enable_api( payload: EnableMcpServiceRequest, @@ -215,7 +456,7 @@ async def update_mcp_service_enable_api( name = payload.name enabled = payload.enabled - update_mcp_service_enabled( + update_mcp_service_enabled_legacy( tenant_id=tenant_id, user_id=user_id, name=name, @@ -235,6 +476,118 @@ async def update_mcp_service_enable_api( ) +@router.post("/v2/manage/enable") +async def update_mcp_service_enable_by_id_api( + payload: EnableMcpServiceByIdRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + # Backward-compatible route. Prefer /enable or /disable. + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + await update_mcp_service_enabled( + tenant_id=tenant_id, + user_id=user_id, + mcp_id=payload.mcp_id, + enabled=True, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc), + ) + except MCPConnectionError as exc: + logger.error(f"MCP connection failed while enabling service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to enable MCP service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service status", + ) + + +@router.post("/enable") +async def enable_mcp_service_by_id_api( + payload: EnableMcpServiceByIdRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + await update_mcp_service_enabled( + tenant_id=tenant_id, + user_id=user_id, + mcp_id=payload.mcp_id, + enabled=True, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc), + ) + except MCPConnectionError as exc: + logger.error(f"MCP connection failed while enabling service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to enable MCP service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service status", + ) + + +@router.post("/disable") +async def disable_mcp_service_by_id_api( + payload: DisableMcpServiceByIdRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + await update_mcp_service_enabled( + tenant_id=tenant_id, + user_id=user_id, + mcp_id=payload.mcp_id, + enabled=False, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc), + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to disable MCP service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service status", + ) + + @router.post("/healthcheck") async def check_mcp_health_api( payload: HealthcheckMcpServiceRequest, @@ -243,14 +596,44 @@ async def check_mcp_health_api( ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - name = payload.name - server_url = payload.server_url + health_status = await check_mcp_service_health_legacy( + tenant_id=tenant_id, + user_id=user_id, + name=payload.name, + server_url=payload.server_url, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": {"health_status": health_status}}, + ) + except MCPConnectionError as exc: + logger.error(f"MCP connection failed: {exc}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to check MCP health: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check MCP health", + ) + +@router.post("/v2/healthcheck") +async def check_mcp_health_by_id_api( + payload: HealthcheckMcpServiceByIdRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) health_status = await check_mcp_service_health( tenant_id=tenant_id, user_id=user_id, - name=name, - server_url=server_url, + mcp_id=payload.mcp_id, ) return JSONResponse( status_code=HTTPStatus.OK, @@ -265,7 +648,7 @@ async def check_mcp_health_api( except HTTPException: raise except Exception as exc: - logger.error(f"Failed to check MCP health: {exc}") + logger.error(f"Failed to check MCP health by id: {exc}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to check MCP health", @@ -280,7 +663,7 @@ async def delete_mcp_service_api( ): try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - delete_mcp_service( + delete_mcp_service_legacy( tenant_id=tenant_id, user_id=user_id, name=name, @@ -297,3 +680,30 @@ async def delete_mcp_service_api( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to delete MCP service", ) + + +@router.delete("/v2/delete") +async def delete_mcp_service_by_id_api( + mcp_id: int = Query(gt=0), + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + await delete_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + mcp_id=mcp_id, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to delete MCP service by id: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete MCP service", + ) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 72f148210..d19efefa8 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -334,6 +334,16 @@ class McpRecord(TableBase): doc="Authorization token for MCP server authentication (e.g., Bearer token)", default=None, ) + souce = Column(String(30), doc="Source type: local/mcp_registry") + market_name = Column(String(200), doc="Market identifier") + version = Column(String(50), doc="MCP version") + mcp_registry_json = Column(JSONB, doc="Full MCP registry server.json snapshot") + transport_type = Column(String(30), doc="Transport type: streamable-http/sse/stdio") + config_json = Column(JSON, doc="MCP config data") + enabled = Column(Boolean, default=True, doc="Enabled") + tags = Column(String(200), doc="Tags") + description = Column(String(100), doc="Description") + last_sync_time = Column(TIMESTAMP(timezone=False), doc="Last sync time") class McpServiceManage(TableBase): @@ -352,7 +362,6 @@ class McpServiceManage(TableBase): mcp_server = Column(String(500), doc="MCP server address") source_type = Column(String(30), doc="Source type: local/registry") market_name = Column(String(200), doc="Market identifier") - mcp_version = Column(String(50), doc="MCP version") transport_type = Column(String(30), doc="Transport type: streamable-http/sse/stdio") config_json = Column(JSON, doc="MCP config data") status = Column(Boolean, default=None, doc="Health status") diff --git a/backend/database/remote_mcp_db.py b/backend/database/remote_mcp_db.py index d535f9fba..84ee83840 100644 --- a/backend/database/remote_mcp_db.py +++ b/backend/database/remote_mcp_db.py @@ -96,6 +96,108 @@ def get_mcp_records_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: return [as_dict(record) for record in mcp_records] +def update_mcp_record_manage_fields_by_id( + *, + mcp_id: int, + tenant_id: str, + user_id: str, + name: str, + server_url: str, + description: str | None, + tags: List[str] | None, + souce: str | None, + transport_type: str | None, + authorization_token: str | None, + config_json: Dict[str, Any] | None, +) -> None: + with get_db_session() as session: + session.query(McpRecord).filter( + McpRecord.mcp_id == mcp_id, + McpRecord.tenant_id == tenant_id, + McpRecord.delete_flag != 'Y' + ).update( + { + "mcp_name": name, + "mcp_server": server_url, + "description": description, + "tags": ",".join(tags or []), + "souce": souce, + "transport_type": transport_type, + "authorization_token": authorization_token, + "config_json": config_json, + "updated_by": user_id, + } + ) + + +def update_mcp_record_enabled_by_id( + *, + mcp_id: int, + tenant_id: str, + user_id: str, + enabled: bool, +) -> None: + with get_db_session() as session: + session.query(McpRecord).filter( + McpRecord.mcp_id == mcp_id, + McpRecord.tenant_id == tenant_id, + McpRecord.delete_flag != 'Y' + ).update({"enabled": enabled, "updated_by": user_id}) + + +def update_mcp_record_status_by_id( + *, + mcp_id: int, + tenant_id: str, + user_id: str, + status: bool, +) -> None: + with get_db_session() as session: + session.query(McpRecord).filter( + McpRecord.mcp_id == mcp_id, + McpRecord.tenant_id == tenant_id, + McpRecord.delete_flag != 'Y' + ).update({"status": status, "updated_by": user_id}) + + +def update_mcp_record_runtime_fields_by_id( + *, + mcp_id: int, + tenant_id: str, + user_id: str, + container_id: str | None, + mcp_server: str, + status: bool | None, +) -> None: + with get_db_session() as session: + session.query(McpRecord).filter( + McpRecord.mcp_id == mcp_id, + McpRecord.tenant_id == tenant_id, + McpRecord.delete_flag != 'Y' + ).update( + { + "container_id": container_id, + "mcp_server": mcp_server, + "status": status, + "updated_by": user_id, + } + ) + + +def delete_mcp_record_by_id( + *, + mcp_id: int, + tenant_id: str, + user_id: str, +) -> None: + with get_db_session() as session: + session.query(McpRecord).filter( + McpRecord.mcp_id == mcp_id, + McpRecord.tenant_id == tenant_id, + McpRecord.delete_flag != 'Y' + ).update({"delete_flag": "Y", "updated_by": user_id}) + + def get_mcp_server_by_name_and_tenant(mcp_name: str, tenant_id: str) -> str: """ Get MCP server address by name and tenant ID diff --git a/backend/services/mcp_container_service.py b/backend/services/mcp_container_service.py index 4c16dedd8..b38a336bd 100644 --- a/backend/services/mcp_container_service.py +++ b/backend/services/mcp_container_service.py @@ -207,6 +207,85 @@ async def stop_mcp_container(self, container_id: str) -> bool: logger.error(f"Failed to stop or remove container: {e}") raise MCPContainerError(f"Failed to stop container: {e}") + async def stop_mcp_container_only(self, container_id: str) -> bool: + """ + Stop MCP container without removing it. + + Args: + container_id: Container ID or name + + Returns: + True if container was stopped, False if not found + + Raises: + MCPContainerError: If container stop fails + """ + try: + return await self.client.stop_container(container_id) + except ContainerError as e: + logger.error(f"Failed to stop container: {e}") + raise MCPContainerError(f"Failed to stop container: {e}") + + async def remove_mcp_container(self, container_id: str) -> bool: + """ + Remove MCP container without stopping logic. + + Args: + container_id: Container ID or name + + Returns: + True if container was removed, False if not found + + Raises: + MCPContainerError: If container removal fails + """ + try: + return await self.client.remove_container(container_id) + except ContainerError as e: + logger.error(f"Failed to remove container: {e}") + raise MCPContainerError(f"Failed to remove container: {e}") + + async def start_existing_mcp_container(self, container_id: str) -> Dict[str, str]: + """ + Start an existing container by container ID and return runtime access fields. + + Args: + container_id: Existing container ID or name + + Returns: + Dictionary with container_id, mcp_url, host_port, status, container_name + + Raises: + MCPContainerError: If startup fails or runtime info cannot be resolved + """ + try: + # SDK Docker client exposes the native docker client as `client`. + container = self.client.client.containers.get(container_id) + container.start() + container.reload() + if container.status != "running": + raise MCPContainerError( + f"Container {container_id} is not running after start: {container.status}" + ) + + status = self.client.get_container_status(container_id) + if not status: + raise MCPContainerError("Container status is unavailable after start") + + return { + "container_id": status.get("container_id") or container.id, + "mcp_url": status.get("service_url") or "", + "host_port": status.get("host_port") or "", + "status": status.get("status") or "running", + "container_name": status.get("name") or container.name, + } + except ContainerError as e: + logger.error(f"Failed to start existing container: {e}") + raise MCPContainerError(f"Failed to start existing container: {e}") + except Exception as e: + logger.error(f"Failed to start existing container: {e}") + raise MCPContainerError(f"Failed to start existing container: {e}") + def list_mcp_containers(self, tenant_id: Optional[str] = None) -> List[Dict[str, any]]: """ List all MCP containers, optionally filtered by tenant diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py index e734001c7..322d04151 100644 --- a/backend/services/mcp_management_service.py +++ b/backend/services/mcp_management_service.py @@ -1,29 +1,32 @@ import logging +import asyncio from datetime import datetime -from types import SimpleNamespace from typing import Any, Dict, List from urllib.parse import urlencode import aiohttp -from consts.exceptions import MCPConnectionError, MCPNameIllegal +from consts.exceptions import MCPConnectionError, MCPContainerError from database.mcp_manage_db import ( - check_mcp_manage_name_exists, - create_mcp_manage_service, delete_mcp_manage_service, get_mcp_manage_record_by_name, - get_mcp_manage_records, update_mcp_manage_enabled, update_mcp_manage_service, update_mcp_manage_status, ) from database.remote_mcp_db import ( - check_mcp_name_exists, + delete_mcp_record_by_id, create_mcp_record, - delete_mcp_record_by_name_and_url, - update_mcp_record_by_name_and_url, + get_mcp_record_by_id_and_tenant, + get_mcp_records_by_tenant, + update_mcp_record_enabled_by_id, + update_mcp_record_manage_fields_by_id, + update_mcp_record_runtime_fields_by_id, + update_mcp_record_status_by_id, ) +from services.mcp_container_service import MCPContainerManager from services.remote_mcp_service import mcp_server_health +from services.tool_configuration_service import get_tool_from_remote_mcp_server logger = logging.getLogger("mcp_management_service") @@ -49,15 +52,46 @@ def _safe_config_dict(record: Dict[str, Any]) -> Dict[str, Any]: return config_json if isinstance(config_json, dict) else {} -def _is_http_transport(record: Dict[str, Any] | None) -> bool: - transport_type = str((record or {}).get("transport_type") or "").strip().lower() - return transport_type in {"streamable-http", "sse"} - - def _extract_str(value: Any) -> str: return value.strip() if isinstance(value, str) else "" +def _is_container_record(record: Dict[str, Any] | None) -> bool: + return str((record or {}).get("transport_type") or "").strip().lower() == "stdio" + + +async def _stop_container_without_remove_if_exists(container_id: str | None) -> None: + if not container_id: + return + try: + manager = MCPContainerManager() + await manager.stop_mcp_container_only(container_id) + except Exception as exc: + logger.warning(f"Skip stopping container {container_id}: {exc}") + + +async def _remove_container_if_exists(container_id: str | None) -> None: + if not container_id: + return + try: + manager = MCPContainerManager() + await manager.remove_mcp_container(container_id) + except Exception as exc: + logger.warning(f"Skip removing container {container_id}: {exc}") + + +async def _start_container_by_id_for_record(record: Dict[str, Any]) -> Dict[str, Any]: + container_id = _extract_str(record.get("container_id")) + if not container_id: + raise ValueError("Container ID is missing") + + manager = MCPContainerManager() + container_info = await manager.start_existing_mcp_container(container_id) + if not _extract_str(container_info.get("mcp_url")): + raise ValueError("Container runtime URL is missing") + return container_info + + def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: server = entry.get("server") if isinstance(entry, dict) else None if not isinstance(server, dict): @@ -67,16 +101,11 @@ def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: if not name: return None - title = _extract_str(server.get("title")) or name version = _extract_str(server.get("version")) - website_url = _extract_str(server.get("websiteUrl")) + description = _extract_str(server.get("description")) - description = _extract_str(server.get("description")) or "MCP 服务" - - tags: List[str] = [] - server_type = "容器" - server_url = "" remotes_out: List[Dict[str, str]] = [] + packages_out: List[Dict[str, Any]] = [] remotes = server.get("remotes") if isinstance(remotes, list): @@ -85,61 +114,28 @@ def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: continue remote_url = _extract_str(remote.get("url")) remote_type = _extract_str(remote.get("type")).lower() - if remote_url and remote_type in {"sse", "streamable-http", "http", ""}: - remotes_out.append({"type": remote_type or "remote", "url": remote_url}) - server_url = remote_url - server_type = "SSE" if remote_type == "sse" else "HTTP" - break + if remote_url: + remotes_out.append({"type": remote_type, "url": remote_url}) - if not server_url: - packages = server.get("packages") - if isinstance(packages, list) and packages: - first_pkg = packages[0] if isinstance(packages[0], dict) else {} - registry_type = _extract_str(first_pkg.get("registryType")) - identifier = _extract_str(first_pkg.get("identifier")) - version = _extract_str(first_pkg.get("version")) - runtime_hint = _extract_str(first_pkg.get("runtimeHint")) - transport = first_pkg.get("transport") if isinstance(first_pkg, dict) else {} - transport_url = _extract_str((transport or {}).get("url")) if isinstance(transport, dict) else "" - transport_type = _extract_str((transport or {}).get("type")).lower() if isinstance(transport, dict) else "" - - if transport_url: - server_url = transport_url - server_type = "SSE" if transport_type == "sse" else "HTTP" - remotes_out.append({"type": transport_type or "remote", "url": transport_url}) - else: - # Non-HTTP package format (npm/pypi/oci stdio etc.) is represented as container/runtime install target. - pkg_base = f"{registry_type}:{identifier}" if registry_type and identifier else identifier - if version and pkg_base: - pkg_base = f"{pkg_base}@{version}" - if runtime_hint and pkg_base: - server_url = f"{runtime_hint}://{pkg_base}" - elif pkg_base: - server_url = f"package://{pkg_base}" - else: - server_url = "package://unknown" - server_type = "容器" - - if registry_type: - tags.append(registry_type) - if runtime_hint: - tags.append(runtime_hint) + packages = server.get("packages") + if isinstance(packages, list): + for package in packages: + if not isinstance(package, dict): + continue - version = _extract_str(server.get("version")) - if version: - tags.append(version) + transport_raw = package.get("transport") + transport = { + "type": _extract_str(transport_raw.get("type")) if isinstance(transport_raw, dict) else "", + "url": _extract_str(transport_raw.get("url")) if isinstance(transport_raw, dict) else "", + } - dedup_tags: List[str] = [] - seen = set() - for tag in tags: - normalized = tag.strip() - if not normalized: - continue - low = normalized.lower() - if low in seen: - continue - seen.add(low) - dedup_tags.append(normalized) + packages_out.append({ + "registryType": _extract_str(package.get("registryType")), + "identifier": _extract_str(package.get("identifier")), + "version": _extract_str(package.get("version")), + "runtimeHint": _extract_str(package.get("runtimeHint")), + "transport": transport, + }) official_meta = {} if isinstance(entry.get("_meta"), dict): @@ -147,15 +143,11 @@ def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: return { "name": name, - "title": title, "version": version, "description": description, - "websiteUrl": website_url, "remotes": remotes_out, - "serverUrl": server_url, - "serverType": server_type, - "tags": dedup_tags, - "status": _extract_str(official_meta.get("status")) or "active", + "packages": packages_out, + "status": _extract_str(official_meta.get("status")), "isLatest": bool(official_meta.get("isLatest")), "publishedAt": _extract_str(official_meta.get("publishedAt")), "updatedAt": _extract_str(official_meta.get("updatedAt")), @@ -244,86 +236,169 @@ async def add_mcp_service( name: str, description: str | None, source: str, - server_type: str, + transport_type: str, server_url: str, tags: list[str] | None, authorization_token: str | None, container_config: Dict[str, Any] | None, + version: str | None, + mcp_registry_json: Dict[str, Any] | None, + enabled: bool = False, + container_id: str | None = None, ) -> None: - if check_mcp_manage_name_exists(tenant_id=tenant_id, name=name): - raise MCPNameIllegal("MCP name already exists") - normalized_source = (source or "local").strip().lower() - normalized_server_type = (server_type or "http").strip().lower() + normalized_source = "mcp_registry" if normalized_source in {"market", "registry"} else normalized_source + if normalized_source not in {"local", "mcp_registry"}: + raise ValueError(f"Invalid source: {source}") + + normalized_transport_type = (transport_type or "http").strip().lower() + normalized_transport_type = "stdio" if normalized_transport_type == "container" else normalized_transport_type # mcp-tools add flow does not perform connectivity checks. - # All newly added services remain disabled and unchecked until manual enable/health check. + # Health status remains unchecked until manual health check. status: bool | None = None - source_type = "registry" if normalized_source == "market" else "local" - if normalized_server_type == "sse": - transport_type = "sse" - elif normalized_server_type == "http": - transport_type = "streamable-http" - elif normalized_server_type == "container": - transport_type = "stdio" - else: - raise ValueError(f"Invalid server_type: {server_type}") + if normalized_transport_type not in {"http", "sse", "stdio"}: + raise ValueError(f"Invalid transport_type: {transport_type}") - config_json: Dict[str, Any] = {} - if authorization_token: - config_json["authorization_token"] = authorization_token - if container_config: - config_json["container_config"] = container_config + normalized_container_id = container_id if isinstance(container_id, str) and container_id else None + config_json = container_config if normalized_transport_type == "stdio" and isinstance(container_config, dict) else None - try: - create_mcp_manage_service( - tenant_id=tenant_id, - user_id=user_id, - name=name, - server_url=server_url, - source_type=source_type, - transport_type=transport_type, - tags=tags, - category=description, - config_json=config_json or None, - enabled=False, - status=status, - ) - except Exception: - raise + create_mcp_record( + mcp_data={ + "mcp_name": name, + "mcp_server": server_url, + "status": status, + "container_id": normalized_container_id, + "authorization_token": authorization_token, + "souce": normalized_source, + "version": version, + "mcp_registry_json": mcp_registry_json, + "transport_type": normalized_transport_type, + "enabled": enabled, + "tags": ",".join(tags or []), + "description": description, + "config_json": config_json, + }, + tenant_id=tenant_id, + user_id=user_id, + ) def list_mcp_services(tenant_id: str) -> List[Dict[str, Any]]: - records = get_mcp_manage_records(tenant_id=tenant_id) + records = get_mcp_records_by_tenant(tenant_id=tenant_id) services: List[Dict[str, Any]] = [] + container_status_map: Dict[str, str] = {} + try: + manager = MCPContainerManager() + for container in manager.list_mcp_containers(tenant_id=tenant_id): + container_id = _extract_str(container.get("container_id")) + status = _extract_str(container.get("status")).lower() + if not container_id: + continue + if status == "running": + container_status_map[container_id] = "running" + elif status: + container_status_map[container_id] = "stopped" + except Exception as exc: + logger.warning(f"Failed to load container runtime status: {exc}") + for record in records: - source_type = (record.get("source_type") or "").lower() + souce = (record.get("souce") or record.get("source_type") or "").lower() transport_type = (record.get("transport_type") or "").lower() enabled = bool(record.get("enabled")) status = record.get("status") - config_json = _safe_config_dict(record) + registry_json = record.get("mcp_registry_json") if isinstance(record.get("mcp_registry_json"), dict) else None + raw_config_json = record.get("config_json") if isinstance(record.get("config_json"), dict) else None + + container_id = _extract_str(record.get("container_id")) + normalized_transport_type = "stdio" if transport_type in {"stdio", "container"} else "sse" if transport_type == "sse" else "http" + record_config_json = raw_config_json if normalized_transport_type == "stdio" else None + container_status = None + if normalized_transport_type == "stdio": + if container_id: + container_status = container_status_map.get(container_id, "stopped") + else: + container_status = "stopped" services.append({ - "name": record.get("mcp_name") or "Unnamed MCP", - "description": record.get("category") or "MCP service", - "source": "market" if source_type == "registry" else "local", + "mcpId": record.get("mcp_id"), + "containerId": container_id or None, + "name": record.get("mcp_name"), + "description": record.get("description") or record.get("category") or "", + "source": souce or "local", "status": "enabled" if enabled else "disabled", "updatedAt": _format_time(record.get("update_time")), "tags": _split_tags(record.get("tags")), - "serverType": "container" if transport_type == "stdio" else "sse" if transport_type == "sse" else "http", - "serverUrl": record.get("mcp_server") or "-", + "transportType": normalized_transport_type, + "serverUrl": _extract_str(record.get("mcp_server")), + "version": _extract_str(record.get("version")), + "mcpRegistryJson": registry_json, + "configJson": record_config_json, "tools": record.get("tools") or [], "healthStatus": "healthy" if status is True else "unhealthy" if status is False else "unchecked", - "containerStatus": record.get("container_status") or None, - "authorizationToken": config_json.get("authorization_token") or "", + "containerStatus": container_status, + "authorizationToken": record.get("authorization_token") or "", }) return services +async def list_mcp_service_tools_by_id(*, tenant_id: str, mcp_id: int) -> List[Dict[str, Any]]: + record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not record: + raise ValueError("MCP record not found") + + service_name = _extract_str(record.get("mcp_name")) + server_url = _extract_str(record.get("mcp_server")) + if not service_name or not server_url: + raise ValueError("MCP record is missing runtime connection fields") + + tools_info = await get_tool_from_remote_mcp_server( + mcp_server_name=service_name, + remote_mcp_server=server_url, + tenant_id=tenant_id, + ) + return [tool.__dict__ for tool in tools_info] + + def update_mcp_service( + *, + tenant_id: str, + user_id: str, + mcp_id: int, + new_name: str, + description: str | None, + server_url: str, + authorization_token: str | None, + tags: list[str] | None, +) -> None: + current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not current_record: + raise ValueError("MCP record not found") + + current_transport_type = (current_record.get("transport_type") or "").strip().lower() + config_json = None + if current_transport_type in {"stdio", "container"}: + config_json = current_record.get("config_json") if isinstance(current_record.get("config_json"), dict) else None + + update_mcp_record_manage_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + name=new_name, + description=description, + server_url=server_url, + souce=(current_record.get("souce") or current_record.get("source_type") or "local"), + transport_type=(current_record.get("transport_type") or "streamable-http"), + authorization_token=authorization_token, + config_json=config_json, + tags=tags, + ) + + +def update_mcp_service_legacy( *, tenant_id: str, user_id: str, @@ -335,14 +410,12 @@ def update_mcp_service( tags: list[str] | None, ) -> None: current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=current_name) - current_server_url = str((current_record or {}).get("mcp_server") or "").strip() - next_config_json = _safe_config_dict(current_record or {}) + config_json = _safe_config_dict(current_record or {}) - # Keep token in config_json as single source for mcp-tools management. if authorization_token: - next_config_json["authorization_token"] = authorization_token + config_json["authorization_token"] = authorization_token else: - next_config_json.pop("authorization_token", None) + config_json.pop("authorization_token", None) update_mcp_manage_service( tenant_id=tenant_id, @@ -351,33 +424,117 @@ def update_mcp_service( new_name=new_name, description=description, server_url=server_url, - config_json=next_config_json or None, + config_json=config_json or None, tags=tags, ) - if _is_http_transport(current_record) and current_server_url: - update_mcp_record_by_name_and_url( - update_data=SimpleNamespace( - current_service_name=current_name, - current_mcp_url=current_server_url, - new_service_name=new_name, - new_mcp_url=server_url, - new_authorization_token=authorization_token, - ), +async def update_mcp_service_enabled( + *, + tenant_id: str, + user_id: str, + mcp_id: int, + enabled: bool, +) -> None: + current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not current_record: + raise ValueError("MCP record not found") + + if enabled: + current_name = str((current_record or {}).get("mcp_name") or "").strip() + if current_name: + records = get_mcp_records_by_tenant(tenant_id=tenant_id) + current_name_lower = current_name.lower() + for record in records: + if int(record.get("mcp_id") or 0) == mcp_id: + continue + + record_name = str(record.get("mcp_name") or "").strip().lower() + is_enabled = bool(record.get("enabled")) + if is_enabled and record_name == current_name_lower: + raise ValueError("An enabled service already uses this name") + + authorization_token = current_record.get("authorization_token") + + if _is_container_record(current_record): + if enabled: + container_info = await _start_container_by_id_for_record(current_record) + next_server_url = _extract_str(container_info.get("mcp_url")) + next_container_id = _extract_str(container_info.get("container_id")) or _extract_str(current_record.get("container_id")) or None + + health_ok = False + for attempt in range(10): + try: + health_ok = await mcp_server_health( + remote_mcp_server=next_server_url, + authorization_token=authorization_token, + ) + except MCPConnectionError: + health_ok = False + if health_ok: + break + if attempt < 9: + await asyncio.sleep(1) + if not health_ok: + await _stop_container_without_remove_if_exists(next_container_id) + update_mcp_record_runtime_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + container_id=next_container_id, + mcp_server=next_server_url, + status=False, + ) + raise MCPConnectionError("MCP connection failed") + + update_mcp_record_runtime_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + container_id=next_container_id, + mcp_server=next_server_url, + status=True, + ) + else: + current_container_id = _extract_str(current_record.get("container_id")) or None + await _stop_container_without_remove_if_exists(current_container_id) + update_mcp_record_runtime_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + container_id=current_container_id, + mcp_server=_extract_str(current_record.get("mcp_server")), + status=None, + ) + elif enabled: + server_url = _extract_str(current_record.get("mcp_server")) + health_ok = await mcp_server_health( + remote_mcp_server=server_url, + authorization_token=authorization_token, + ) + update_mcp_record_status_by_id( + mcp_id=mcp_id, tenant_id=tenant_id, user_id=user_id, + status=bool(health_ok), ) + if not health_ok: + raise MCPConnectionError("MCP connection failed") + + update_mcp_record_enabled_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + enabled=enabled, + ) -def update_mcp_service_enabled( +def update_mcp_service_enabled_legacy( *, tenant_id: str, user_id: str, name: str, enabled: bool, ) -> None: - current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) - update_mcp_manage_enabled( tenant_id=tenant_id, user_id=user_id, @@ -385,87 +542,96 @@ def update_mcp_service_enabled( enabled=enabled, ) - if not _is_http_transport(current_record): - return - - server_url = str((current_record or {}).get("mcp_server") or "").strip() - if not server_url: - return - - config_json = _safe_config_dict(current_record or {}) - authorization_token = config_json.get("authorization_token") or None - status = current_record.get("status") if isinstance(current_record, dict) else None - if not enabled: - delete_mcp_record_by_name_and_url( - mcp_name=name, - mcp_server=server_url, - tenant_id=tenant_id, - user_id=user_id, - ) - return +async def delete_mcp_service( + *, + tenant_id: str, + user_id: str, + mcp_id: int, +) -> None: + current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not current_record: + raise ValueError("MCP record not found") - if check_mcp_name_exists(mcp_name=name, tenant_id=tenant_id): - update_mcp_record_by_name_and_url( - update_data=SimpleNamespace( - current_service_name=name, - current_mcp_url=server_url, - new_service_name=name, - new_mcp_url=server_url, - new_authorization_token=authorization_token, - ), - tenant_id=tenant_id, - user_id=user_id, - status=status, - ) - return + if _is_container_record(current_record): + current_container_id = _extract_str(current_record.get("container_id")) or None + await _stop_container_without_remove_if_exists(current_container_id) + await _remove_container_if_exists(current_container_id) - create_mcp_record( - mcp_data={ - "mcp_name": name, - "mcp_server": server_url, - "status": status, - "container_id": None, - "authorization_token": authorization_token, - }, + delete_mcp_record_by_id( + mcp_id=mcp_id, tenant_id=tenant_id, user_id=user_id, ) -def delete_mcp_service( +def delete_mcp_service_legacy( *, tenant_id: str, user_id: str, name: str, ) -> None: - current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) - delete_mcp_manage_service( tenant_id=tenant_id, user_id=user_id, name=name, ) - current_server_url = str((current_record or {}).get("mcp_server") or "").strip() - if _is_http_transport(current_record) and current_server_url: - delete_mcp_record_by_name_and_url( - mcp_name=name, - mcp_server=current_server_url, - tenant_id=tenant_id, - user_id=user_id, + +async def check_mcp_service_health( + *, + tenant_id: str, + user_id: str, + mcp_id: int, +) -> str: + record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not record: + raise ValueError("MCP record not found") + + server_url = str((record or {}).get("mcp_server") or "").strip() + if not server_url: + raise ValueError("MCP server URL is empty") + + authorization_token = record.get("authorization_token") + + try: + status = await mcp_server_health( + remote_mcp_server=server_url, + authorization_token=authorization_token, ) + except Exception as exc: + logger.error(f"MCP health check failed: {exc}") + status = False + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=status, + ) -async def check_mcp_service_health( + if not status: + raise MCPConnectionError("MCP connection failed") + + return "healthy" + + +async def check_mcp_service_health_legacy( *, tenant_id: str, user_id: str, name: str, server_url: str, ) -> str: - record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) - config_json = _safe_config_dict(record or {}) + current_record = get_mcp_manage_record_by_name(tenant_id=tenant_id, name=name) + if not current_record: + raise ValueError("MCP record not found") + + target_server_url = str(current_record.get("mcp_server") or "").strip() + if target_server_url and target_server_url != server_url: + raise ValueError("MCP record and server_url mismatch") + + config_json = _safe_config_dict(current_record or {}) authorization_token = config_json.get("authorization_token") try: diff --git a/docker/sql/v1.8.2_0318_expand_mcp_record.sql b/docker/sql/v1.8.2_0318_expand_mcp_record.sql new file mode 100644 index 000000000..6c852917e --- /dev/null +++ b/docker/sql/v1.8.2_0318_expand_mcp_record.sql @@ -0,0 +1,44 @@ +-- Migration: Extend mcp_record_t for MCP tools (direct schema) +-- Date: 2026-03-18 +-- Description: One-step schema extension for mcp_record_t. No table merge, no data migration. + +SET search_path TO nexent; + +BEGIN; + +-- 1) Extend mcp_record_t with final column names (idempotent) +ALTER TABLE IF EXISTS nexent.mcp_record_t + ADD COLUMN IF NOT EXISTS souce VARCHAR(30), + ADD COLUMN IF NOT EXISTS market_name VARCHAR(200), + ADD COLUMN IF NOT EXISTS version VARCHAR(50), + ADD COLUMN IF NOT EXISTS mcp_registry_json JSONB, + ADD COLUMN IF NOT EXISTS transport_type VARCHAR(30), + ADD COLUMN IF NOT EXISTS config_json JSON, + ADD COLUMN IF NOT EXISTS enabled BOOLEAN DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS tags VARCHAR(200), + ADD COLUMN IF NOT EXISTS description VARCHAR(100), + ADD COLUMN IF NOT EXISTS last_sync_time TIMESTAMP WITHOUT TIME ZONE; + +-- 2) Add comments for new columns +COMMENT ON COLUMN nexent.mcp_record_t.souce IS 'Source type: local/mcp_registry'; +COMMENT ON COLUMN nexent.mcp_record_t.market_name IS 'Market identifier'; +COMMENT ON COLUMN nexent.mcp_record_t.version IS 'MCP version'; +COMMENT ON COLUMN nexent.mcp_record_t.mcp_registry_json IS 'Full MCP registry server.json snapshot'; +COMMENT ON COLUMN nexent.mcp_record_t.transport_type IS 'Transport type: streamable-http/sse/stdio'; +COMMENT ON COLUMN nexent.mcp_record_t.config_json IS 'MCP config data'; +COMMENT ON COLUMN nexent.mcp_record_t.enabled IS 'Enabled'; +COMMENT ON COLUMN nexent.mcp_record_t.tags IS 'Tags'; +COMMENT ON COLUMN nexent.mcp_record_t.description IS 'Description'; +COMMENT ON COLUMN nexent.mcp_record_t.last_sync_time IS 'Last sync time'; + +-- 3) Add indexes for common management queries +CREATE INDEX IF NOT EXISTS idx_mcp_record_t_tenant_delete + ON nexent.mcp_record_t (tenant_id, delete_flag); + +CREATE INDEX IF NOT EXISTS idx_mcp_record_t_tenant_name + ON nexent.mcp_record_t (tenant_id, mcp_name, delete_flag); + +CREATE INDEX IF NOT EXISTS idx_mcp_record_t_tenant_server + ON nexent.mcp_record_t (tenant_id, mcp_server, delete_flag); + +COMMIT; diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx index aace738e6..ea8a84401 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx @@ -1,22 +1,64 @@ -import { Button, Input, InputNumber, Select, Tag, Upload } from "antd"; -import { MCP_SERVER_TYPE } from "@/const/mcpTools"; -import type { AddMcpLocalActions, AddMcpLocalState } from "@/types/mcpTools"; +import { Button, Input, InputNumber, Select, Tag } from "antd"; +import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; +import type { McpTransportType } from "@/types/mcpTools"; interface Props { - state: AddMcpLocalState; - actions: AddMcpLocalActions; + newServiceName: string; + newServiceDesc: string; + newTransportType: McpTransportType; + newServiceUrl: string; + newServiceAuthorizationToken: string; + containerConfigJson: string; + containerPort: number | undefined; + newTagDrafts: string[]; + newTagInputValue: string; + addingService: boolean; + setNewServiceName: (value: string) => void; + setNewServiceDesc: (value: string) => void; + setNewTransportType: (value: McpTransportType) => void; + setNewServiceUrl: (value: string) => void; + setNewServiceAuthorizationToken: (value: string) => void; + setContainerConfigJson: (value: string) => void; + setContainerPort: (value: number | undefined) => void; + addNewTag: () => void; + removeNewTag: (index: number) => void; + setNewTagInputValue: (value: string) => void; + handleAddService: () => void; t: (key: string, params?: Record) => string; } -export default function AddMcpServiceLocalSection({ state, actions, t }: Props) { +export default function AddMcpServiceLocalSection({ + newServiceName, + newServiceDesc, + newTransportType, + newServiceUrl, + newServiceAuthorizationToken, + containerConfigJson, + containerPort, + newTagDrafts, + newTagInputValue, + addingService, + setNewServiceName, + setNewServiceDesc, + setNewTransportType, + setNewServiceUrl, + setNewServiceAuthorizationToken, + setContainerConfigJson, + setContainerPort, + addNewTag, + removeNewTag, + setNewTagInputValue, + handleAddService, + t, +}: Props) { return ( <>
    @@ -24,8 +66,8 @@ export default function AddMcpServiceLocalSection({ state, actions, t }: Props) @@ -33,24 +75,24 @@ export default function AddMcpServiceLocalSection({ state, actions, t }: Props)
    ) : (
    -
    -

    {t("mcpTools.addModal.uploadImageTitle")}

    -

    {t("mcpTools.addModal.uploadImageDesc")}

    -
    - actions.onContainerUploadFileListChange(fileList)} - beforeUpload={() => false} - accept=".tar" - maxCount={1} - > - - -
    -
    - -
    - - -
    +
    )}

    {t("mcpTools.addModal.tags")}

    - {state.newTagDrafts.map((tag, index) => ( + {newTagDrafts.map((tag, index) => ( {tag}
    -
    diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx index 56335d511..dd88f092d 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx @@ -1,59 +1,166 @@ +import { Button, Modal, Radio } from "antd"; import McpMarketToolbar from "./McpMarketToolbar"; import McpMarketCardList from "./McpMarketCardList"; import McpMarketDetailModal from "./McpMarketDetailModal"; -import type { AddMcpMarketActions, AddMcpMarketState } from "@/types/mcpTools"; +import type { MarketMcpCard, MarketQuickAddOption } from "@/types/mcpTools"; interface Props { - state: AddMcpMarketState; - actions: AddMcpMarketActions; + marketSearchValue: string; + selectedMarketService: MarketMcpCard | null; + filteredMarketServices: MarketMcpCard[]; + marketLoading: boolean; + marketPage: number; + hasPrevMarketPage: boolean; + hasNextMarketPage: boolean; + marketVersion: string; + marketUpdatedSince: string; + marketIncludeDeleted: boolean; + quickAddPickerVisible: boolean; + quickAddCandidateService: MarketMcpCard | null; + quickAddOptions: MarketQuickAddOption[]; + selectedQuickAddOptionKey: string; + quickAddSubmitting: boolean; + setMarketSearchValue: (value: string) => void; + setSelectedMarketService: (service: MarketMcpCard | null) => void; + setMarketVersion: (value: string) => void; + setMarketUpdatedSince: (value: string) => void; + setMarketIncludeDeleted: (value: boolean) => void; + setSelectedQuickAddOptionKey: (value: string) => void; + handleMarketPrevPage: () => void; + handleMarketNextPage: () => void; + handleQuickAddFromMarket: (service: MarketMcpCard) => void; + handleCloseQuickAddPicker: () => void; + handleConfirmQuickAddOption: () => Promise; t: (key: string, params?: Record) => string; } -export default function AddMcpServiceMarketSection({ state, actions, t }: Props) { - const toolbarState = { - marketSearchValue: state.marketSearchValue, - marketLoading: state.marketLoading, - marketPage: state.marketPage, - resultCount: state.filteredMarketServices.length, - marketVersion: state.marketVersion, - marketUpdatedSince: state.marketUpdatedSince, - marketIncludeDeleted: state.marketIncludeDeleted, - }; - - const toolbarActions = { - onMarketSearchChange: actions.onMarketSearchChange, - onRefreshMarket: actions.onRefreshMarket, - onMarketVersionChange: actions.onMarketVersionChange, - onMarketUpdatedSinceChange: actions.onMarketUpdatedSinceChange, - onMarketIncludeDeletedChange: actions.onMarketIncludeDeletedChange, - }; - +export default function AddMcpServiceMarketSection({ + marketSearchValue, + selectedMarketService, + filteredMarketServices, + marketLoading, + marketPage, + hasPrevMarketPage, + hasNextMarketPage, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + quickAddPickerVisible, + quickAddCandidateService, + quickAddOptions, + selectedQuickAddOptionKey, + quickAddSubmitting, + setMarketSearchValue, + setSelectedMarketService, + setMarketVersion, + setMarketUpdatedSince, + setMarketIncludeDeleted, + setSelectedQuickAddOptionKey, + handleMarketPrevPage, + handleMarketNextPage, + handleQuickAddFromMarket, + handleCloseQuickAddPicker, + handleConfirmQuickAddOption, + t, +}: Props) { return ( <>
    - +
    - {state.selectedMarketService ? ( + {selectedMarketService ? ( actions.onSelectMarketService(null)} - onQuickAddFromMarket={actions.onQuickAddFromMarket} + onClose={() => setSelectedMarketService(null)} + onQuickAddFromMarket={handleQuickAddFromMarket} /> ) : null} + + +
    +

    + {t("mcpTools.market.quickAddPicker.description", { + name: quickAddCandidateService?.name || "-", + })} +

    + + setSelectedQuickAddOptionKey(String(event.target.value || ""))} + className="flex w-full flex-col gap-2" + > + {quickAddOptions.map((option) => { + const sourceLabel = + option.sourceType === "remote" + ? t("mcpTools.market.quickAddPicker.sourceRemote") + : t("mcpTools.market.quickAddPicker.sourcePackage"); + + return ( + +
    +

    {sourceLabel}

    +

    {option.sourceLabel}

    +
    +
    + ); + })} +
    + +
    + + +
    +
    +
    ); } diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx index 9b877cfb0..00270bf98 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -60,9 +60,8 @@ export default function AddMcpServiceModal({ open footer={null} closable - maskClosable={false} centered - width={addModalTab === MCP_TAB.MARKET ? 1200 : 900} + width={addModalTab === MCP_TAB.MCP_REGISTRY ? 1200 : 900} onCancel={onClose} styles={{ mask: { background: "rgba(15,23,42,0.6)", backdropFilter: "blur(2px)" }, @@ -82,7 +81,7 @@ export default function AddMcpServiceModal({ onChange={(value) => setAddModalTab(value as McpTab)} options={[ { label: t("mcpTools.addModal.tabLocal"), value: MCP_TAB.LOCAL }, - { label: t("mcpTools.addModal.tabMarket"), value: MCP_TAB.MARKET }, + { label: t("mcpTools.addModal.tabMarket"), value: MCP_TAB.MCP_REGISTRY }, ]} className="h-9 rounded-full border border-slate-200 bg-slate-100 p-[2px] text-sm [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-full [&_.ant-segmented-item-label]:px-4 [&_.ant-segmented-item-label]:leading-[30px] [&_.ant-segmented-thumb]:rounded-full [&_.ant-segmented-thumb]:bg-white [&_.ant-segmented-thumb]:shadow-sm [&_.ant-segmented-thumb]:top-[2px] [&_.ant-segmented-thumb]:bottom-[2px]" /> @@ -90,14 +89,57 @@ export default function AddMcpServiceModal({ {addModalTab === MCP_TAB.LOCAL ? ( String(t(key, params))} /> ) : ( String(t(key, params))} /> )} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx index 7131810d0..c76eb0d69 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx @@ -1,7 +1,12 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Button, Modal } from "antd"; import { MARKET_SERVER_STATUS } from "@/const/mcpTools"; -import { formatMarketDate, formatMarketVersion } from "@/lib/mcpTools"; +import { + extractRegistryLinks, + formatMarketDate, + formatMarketVersion, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; import type { MarketMcpCard } from "@/types/mcpTools"; interface Props { @@ -18,10 +23,9 @@ export default function McpMarketDetailModal({ onQuickAddFromMarket, }: Props) { const [showServerJsonModal, setShowServerJsonModal] = useState(false); - - const serverJsonPretty = useMemo(() => { - return JSON.stringify(service.serverJson || {}, null, 2); - }, [service.serverJson]); + const { websiteUrl, repositoryUrl } = extractRegistryLinks(service.serverJson); + const serverJsonPretty = toPrettyRegistryJson(service.serverJson); + const hasServerJson = Boolean(service.serverJson && Object.keys(service.serverJson).length > 0); const statusClassName = service.status === MARKET_SERVER_STATUS.ACTIVE @@ -42,7 +46,6 @@ export default function McpMarketDetailModal({ open footer={null} closable - maskClosable={false} centered width={900} onCancel={onClose} @@ -69,33 +72,41 @@ export default function McpMarketDetailModal({

    {formatMarketDate(service.publishedAt)}

    -
    -
    - {t("mcpTools.market.title")} - {service.title || "-"} -
    -
    - {t("mcpTools.market.website")} - {service.websiteUrl ? ( - - {service.websiteUrl} - - ) : ( - - - )} + {websiteUrl || repositoryUrl ? ( +
    + {websiteUrl ? ( +
    + {t("mcpTools.market.website")} + + {websiteUrl} + +
    + ) : null} + + {repositoryUrl ? ( +
    + {t("mcpTools.market.repository")} + + {repositoryUrl} + +
    + ) : null}
    -
    + ) : null} -
    -

    {t("mcpTools.market.remotes")}

    - {service.remotes.length === 0 ? ( -

    {t("mcpTools.market.noRemotes")}

    - ) : ( + {service.remotes.length > 0 ? ( +
    +

    {t("mcpTools.market.remotes")}

    {service.remotes.map((remote, index) => (
    @@ -104,14 +115,34 @@ export default function McpMarketDetailModal({
    ))}
    - )} -
    +
    + ) : null} + + {service.packages.length > 0 ? ( +
    +

    {t("mcpTools.market.packages")}

    +
    + {service.packages.map((pkg, index) => ( +
    +

    {pkg.identifier || "-"}

    +

    {pkg.registryType || "-"}{pkg.version ? `@${pkg.version}` : ""}

    + {pkg.runtimeHint ?

    {pkg.runtimeHint}

    : null} + {pkg.transport?.url ? ( +

    {pkg.transport.type || t("mcpTools.market.remoteFallback")}: {pkg.transport.url}

    + ) : null} +
    + ))} +
    +
    + ) : null}
    - + {hasServerJson ? ( + + ) : null} @@ -119,7 +150,7 @@ export default function McpMarketDetailModal({
    - {showServerJsonModal ? ( + {showServerJsonModal && hasServerJson ? ( void; - onRefreshMarket: () => void; - onMarketVersionChange: (value: string) => void; - onMarketUpdatedSinceChange: (value: string) => void; - onMarketIncludeDeletedChange: (value: boolean) => void; - }; + marketSearchValue: string; + marketPage: number; + resultCount: number; + marketVersion: string; + marketUpdatedSince: string; + marketIncludeDeleted: boolean; + onMarketSearchChange: (value: string) => void; + onMarketVersionChange: (value: string) => void; + onMarketUpdatedSinceChange: (value: string) => void; + onMarketIncludeDeletedChange: (value: boolean) => void; t: (key: string, params?: Record) => string; } export default function McpMarketToolbar({ - state, - actions, + marketSearchValue, + marketPage, + resultCount, + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + onMarketSearchChange, + onMarketVersionChange, + onMarketUpdatedSinceChange, + onMarketIncludeDeletedChange, t, }: Props) { - const { - marketSearchValue, - marketLoading, - marketPage, - resultCount, - marketVersion, - marketUpdatedSince, - marketIncludeDeleted, - } = state; - const { - onMarketSearchChange, - onRefreshMarket, - onMarketVersionChange, - onMarketUpdatedSinceChange, - onMarketIncludeDeletedChange, - } = actions; - const [marketVersionMode, setMarketVersionMode] = useState<"all" | "latest" | "custom">("latest"); const [customVersion, setCustomVersion] = useState(""); @@ -110,9 +95,6 @@ export default function McpMarketToolbar({ size="large" className="w-full rounded-2xl" /> -
    {t("mcpTools.market.pageResult", { page: marketPage, count: resultCount })}
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx index 356ea4eda..150f37bd2 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx @@ -1,5 +1,5 @@ import { Button } from "antd"; -import { MCP_SERVICE_STATUS, MCP_TAB } from "@/const/mcpTools"; +import { MCP_TRANSPORT_TYPE, MCP_SERVICE_STATUS, MCP_TAB } from "@/const/mcpTools"; import type { McpServiceItem } from "@/types/mcpTools"; type Translate = (key: string, options?: Record) => React.ReactNode; @@ -9,6 +9,7 @@ interface Props { t: Translate; onSelectService: (service: McpServiceItem) => void; onToggleEnable: (service: McpServiceItem) => void; + toggleLoading?: boolean; } export default function McpServiceCard({ @@ -16,6 +17,7 @@ export default function McpServiceCard({ t, onSelectService, onToggleEnable, + toggleLoading = false, }: Props) { return (
    - {service.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.market")} + {service.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.mcp_registry")} + + + {service.transportType === MCP_TRANSPORT_TYPE.HTTP + ? t("mcpTools.serverType.http") + : service.transportType === MCP_TRANSPORT_TYPE.SSE + ? t("mcpTools.serverType.sse") + : t("mcpTools.serverType.stdio")} {service.tags.map((tag) => ( { event.stopPropagation(); onToggleEnable(service); diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx index a2a431442..4a0c6884c 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -1,63 +1,83 @@ import { Modal, Input, Button, Tag } from "antd"; import { useTranslation } from "react-i18next"; +import { useState } from "react"; import { MCP_CONTAINER_STATUS, MCP_HEALTH_STATUS, - MCP_SERVER_TYPE, + MCP_TRANSPORT_TYPE, MCP_SERVICE_STATUS, MCP_TAB, } from "@/const/mcpTools"; import { type McpContainerStatus, - type McpServiceDetailActions, - type McpServiceDetailState, type McpHealthStatus, type McpServiceItem, - type McpServerType, - type McpServiceStatus, - type McpTab, } from "@/types/mcpTools"; +import { extractRegistryLinks, toPrettyRegistryJson } from "@/lib/mcpTools"; import McpServiceDetailToolListModal from "./McpServiceDetailToolListModal"; +import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; interface McpServiceDetailModalProps { open: boolean; - detailState: McpServiceDetailState; - detailActions: McpServiceDetailActions; + selectedService: McpServiceItem | null; + draftService: McpServiceItem | null; + tagDrafts: string[]; + tagInputValue: string; + healthCheckLoading: boolean; + loadingTools: boolean; + toolsModalVisible: boolean; + currentServerTools: any[]; + setDraftService: (service: McpServiceItem) => void; + setTagInputValue: (value: string) => void; + addDetailTag: () => void; + removeTag: (index: number) => void; + handleHealthCheck: () => void; + handleViewTools: () => void; + handleSaveUpdates: () => void; + closeToolsModal: () => void; + handleRefreshTools: () => void; onDeleteConfirm: (serviceName: string) => void; onToggleEnable: (service: McpServiceItem) => void; + toggleLoading?: boolean; onClose: () => void; } export default function McpServiceDetailModal({ open, - detailState, - detailActions, + selectedService, + draftService, + tagDrafts, + tagInputValue, + healthCheckLoading, + loadingTools, + toolsModalVisible, + currentServerTools, + setDraftService, + setTagInputValue, + addDetailTag, + removeTag, + handleHealthCheck, + handleViewTools, + handleSaveUpdates, + closeToolsModal, + handleRefreshTools, onDeleteConfirm, onToggleEnable, + toggleLoading = false, onClose, }: McpServiceDetailModalProps) { - const { - selectedService, - draftService, - tagDrafts, - tagInputValue, - healthCheckLoading, - loadingTools, - toolsModalVisible, - currentServerTools, - } = detailState; - const { - onDraftServiceChange, - onTagInputChange, - onAddDetailTag, - onRemoveTag, - onHealthCheck, - onViewTools, - onSaveUpdates, - onCloseToolsModal, - onRefreshTools, - } = detailActions; const { t } = useTranslation("common"); + const [logsModalOpen, setLogsModalOpen] = useState(false); + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const hasRegistryJson = Boolean(draftService?.mcpRegistryJson); + const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + const hasConfigJson = Boolean(draftService?.configJson); + + const { websiteUrl: registryWebsiteUrl, repositoryUrl: registryRepositoryUrl } = extractRegistryLinks( + draftService?.mcpRegistryJson + ); + const registryJsonPretty = toPrettyRegistryJson(draftService?.mcpRegistryJson); + const configJsonPretty = toPrettyRegistryJson(draftService?.configJson); const getHealthStatusLabel = (status: McpHealthStatus) => { if (status === MCP_HEALTH_STATUS.HEALTHY) { @@ -89,7 +109,6 @@ export default function McpServiceDetailModal({ open footer={null} closable - maskClosable={false} centered width={900} onCancel={onClose} @@ -112,7 +131,7 @@ export default function McpServiceDetailModal({ - onDraftServiceChange({ + setDraftService({ ...draftService, name: event.target.value, }) @@ -125,7 +144,7 @@ export default function McpServiceDetailModal({ - onDraftServiceChange({ + setDraftService({ ...draftService, description: event.target.value, }) @@ -138,7 +157,7 @@ export default function McpServiceDetailModal({ - onDraftServiceChange({ + setDraftService({ ...draftService, serverUrl: event.target.value, }) @@ -146,13 +165,13 @@ export default function McpServiceDetailModal({ className="mt-2 w-full rounded-2xl" /> - {draftService.serverType === MCP_SERVER_TYPE.HTTP || draftService.serverType === MCP_SERVER_TYPE.SSE ? ( + {draftService.transportType === MCP_TRANSPORT_TYPE.HTTP || draftService.transportType === MCP_TRANSPORT_TYPE.SSE ? (
    - {draftService.serverType === MCP_SERVER_TYPE.CONTAINER ? ( + {draftService.transportType === MCP_TRANSPORT_TYPE.STDIO ? (
    {t("mcpTools.detail.containerStatus")} {getContainerStatusLabel(draftService.containerStatus)} @@ -216,16 +267,48 @@ export default function McpServiceDetailModal({
    -

    {t("mcpTools.detail.tools")}

    - + {t("mcpTools.detail.tools")} +
    + {draftService.transportType === MCP_TRANSPORT_TYPE.STDIO && draftService.containerId ? ( + + ) : null} + {hasRegistryJson ? ( + + ) : null} + {hasConfigJson ? ( + + ) : null} + +
    @@ -237,7 +320,7 @@ export default function McpServiceDetailModal({ {tag} -
    String(t(key)), [t]); - const [searchValue, setSearchValue] = useState(""); - const [services, setServices] = useState([]); - const [loadingServices, setLoadingServices] = useState(false); - const [selectedService, setSelectedService] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); - - const loadServerList = async () => { - setLoadingServices(true); - try { - const result = await listMcpTools(); - if (!result.success) { - throw new Error(result.message || t("mcpTools.list.loadFailed")); - } - setServices(result.data); - return { success: true }; - } catch (error) { - log.error("[McpToolsPage] Failed to load managed MCP service list", { error }); - message.error(error instanceof Error ? error.message : t("mcpTools.list.loadFailed")); - return { success: false }; - } finally { - setLoadingServices(false); - } - }; - - useEffect(() => { - loadServerList().catch(() => undefined); - }, []); - - const filteredServices = useMemo(() => { - return filterServiceCards(services, searchValue); - }, [searchValue, services]); - - const isSameToolNames = (left: string[] = [], right: string[] = []) => { - if (left.length !== right.length) return false; - return left.every((item, index) => item === right[index]); - }; - - const syncToolNamesToCards = useCallback((service: Pick, tools: McpTool[]) => { - const nextToolNames = tools.map((item) => item.name); - setSelectedService((prev) => { - if (!prev || prev.name !== service.name || prev.serverUrl !== service.serverUrl) { - return prev; - } - if (isSameToolNames(prev.tools, nextToolNames)) { - return prev; - } - return { ...prev, tools: nextToolNames }; - }); - setServices((prev) => { - let changed = false; - const next = prev.map((item) => { - if (item.name !== service.name || item.serverUrl !== service.serverUrl) { - return item; - } - if (isSameToolNames(item.tools, nextToolNames)) { - return item; - } - changed = true; - return { ...item, tools: nextToolNames }; - }); - return changed ? next : prev; - }); - }, []); - - const { toggleServiceStatus } = useMcpToolsToggle({ - loadServerList, - setSelectedService, - t: (key: string) => String(t(key)), - message, - }); - - const { state: detailState, actions: detailActions } = useMcpToolsDetail({ + const { + searchValue, + setSearchValue, + loadingServices, selectedService, - onSelectedServiceChange: setSelectedService, - onServicesReload: loadServerList, - onSyncToolNames: syncToolNamesToCards, - t: (key: string) => String(t(key)), + setSelectedService, + showAddModal, + setShowAddModal, + loadServerList, + filteredServices, + toggleServiceStatus, + togglingServiceId, + detail, + } = useMcpToolsPage({ + t: translate, message, }); - const handleDeleteConfirm = (serviceName: string) => { + const handleDeleteConfirm = (mcpId: number, serviceName: string) => { modal.confirm({ title: t("mcpTools.delete.confirmTitle"), content: ( @@ -110,8 +44,10 @@ export default function McpToolsPage() {

    {t("mcpTools.delete.confirmDesc")}

    ), + okText: t("mcpTools.delete.confirmOk"), + cancelText: t("mcpTools.delete.confirmCancel"), okButtonProps: { danger: true }, - onOk: () => detailActions.onDeleteService(serviceName), + onOk: () => detail.onDeleteService(mcpId, serviceName), }); }; @@ -174,16 +110,13 @@ export default function McpToolsPage() { ) : (
    {filteredServices.map((service) => { - const isSelected = - selectedService?.name === service.name && - selectedService?.serverUrl === service.serverUrl; - return ( { toggleServiceStatus(item).catch((error) => { log.error("[McpToolsPage] Failed to toggle service status from card", { @@ -201,10 +134,26 @@ export default function McpToolsPage() { {selectedService ? ( handleDeleteConfirm(detail.selectedService!.mcpId, serviceName)} + toggleLoading={togglingServiceId === detail.selectedService?.mcpId} onToggleEnable={(item) => { toggleServiceStatus(item).catch((error) => { log.error("[McpToolsPage] Failed to toggle service status from detail modal", { @@ -214,7 +163,7 @@ export default function McpToolsPage() { }); }); }} - onClose={detailActions.onCloseDetail} + onClose={detail.closeDetail} /> ) : null} diff --git a/frontend/components/mcp/McpToolListModal.tsx b/frontend/components/mcp/McpToolListModal.tsx index 7dc10ddb9..e0b1f8386 100644 --- a/frontend/components/mcp/McpToolListModal.tsx +++ b/frontend/components/mcp/McpToolListModal.tsx @@ -74,7 +74,7 @@ export default function McpToolListModal({ footer={[]} >
    (MCP_SERVER_TYPE.HTTP); + const [newTransportType, setNewTransportType] = useState(MCP_TRANSPORT_TYPE.HTTP); const [containerConfigJson, setContainerConfigJson] = useState(""); - const [containerUploadFileList, setContainerUploadFileList] = useState([]); const [containerPort, setContainerPort] = useState(undefined); - const [containerServiceName, setContainerServiceName] = useState(""); const [newTagDrafts, setNewTagDrafts] = useState([]); const [newTagInputValue, setNewTagInputValue] = useState(""); const [addingService, setAddingService] = useState(false); @@ -47,39 +43,47 @@ export function useMcpToolsAddLocal({ setNewServiceUrl(""); setNewServiceDesc(""); setNewServiceAuthorizationToken(""); - setNewServerType(MCP_SERVER_TYPE.HTTP); + setNewTransportType(MCP_TRANSPORT_TYPE.HTTP); setContainerConfigJson(""); - setContainerUploadFileList([]); setContainerPort(undefined); - setContainerServiceName(""); setNewTagDrafts([]); setNewTagInputValue(""); setAddingService(false); }, []); - const validateLocalAdd = () => { - if (!newServiceName.trim()) return t("mcpTools.add.validate.nameRequired"); - if ((newServerType === MCP_SERVER_TYPE.HTTP || newServerType === MCP_SERVER_TYPE.SSE) && !newServiceUrl.trim()) { + const validateLocalAdd = useCallback(() => { + if (!newServiceName.trim()) { + return t("mcpTools.add.validate.nameRequired"); + } + if ((newTransportType === MCP_TRANSPORT_TYPE.HTTP || newTransportType === MCP_TRANSPORT_TYPE.SSE) && !newServiceUrl.trim()) { return t("mcpTools.add.validate.httpUrlRequired"); } - if (newServerType === MCP_SERVER_TYPE.CONTAINER) { - const hasConfig = containerConfigJson.trim().length > 0 || containerUploadFileList.length > 0; + if (newTransportType === MCP_TRANSPORT_TYPE.STDIO) { + const hasConfig = containerConfigJson.trim().length > 0; if (!hasConfig) return t("mcpTools.add.validate.containerConfigRequired"); - if (!containerServiceName.trim() || !containerPort) { + if (!containerPort) { return t("mcpTools.add.validate.containerRequired"); } } if (addModalTab !== MCP_TAB.LOCAL) return t("mcpTools.add.validate.localTabOnly"); return null; - }; + }, [ + addModalTab, + containerConfigJson, + containerPort, + newTransportType, + newServiceName, + newServiceUrl, + t, + ]); - const handleAddService = async () => { + const handleAddService = useCallback(async () => { const validationError = validateLocalAdd(); if (validationError) { log.error("[useMcpToolsAddLocal] Local add validation failed", { validationError, addModalTab, - serverType: newServerType, + transportType: newTransportType, }); message.error(validationError); return; @@ -91,112 +95,101 @@ export function useMcpToolsAddLocal({ setAddingService(true); try { const resolvedServerInfo = await resolveContainerServerInfo({ - serverType: newServerType, + transportType: newTransportType, serviceUrl: newServiceUrl, - containerServiceName, containerPort, containerConfigJson, - containerUploadFileList, - authorizationToken: normalizedToken, - t, }); - if (!resolvedServerInfo.success || !resolvedServerInfo.data) { - throw new Error(resolvedServerInfo.message || t("mcpTools.add.failed")); - } - const result = await addMutation.mutateAsync({ - name: newServiceName.trim(), - description: newServiceDesc.trim() || t("mcpTools.service.defaultDescription"), - source: addModalTab, - server_type: newServerType, - server_url: resolvedServerInfo.data.finalServerUrl, - tags, - authorization_token: normalizedToken, - container_config: resolvedServerInfo.data.containerConfig, - }); + const resolvedServiceName = newServiceName.trim(); + + if (newTransportType === MCP_TRANSPORT_TYPE.STDIO && resolvedServerInfo.data.mcpConfig) { + await addContainerMcpToolService({ + name: resolvedServiceName, + description: newServiceDesc.trim(), + tags, + authorization_token: normalizedToken, + port: containerPort as number, + mcp_config: resolvedServerInfo.data.mcpConfig, + }); + } else { + await addMutation.mutateAsync({ + name: resolvedServiceName, + description: newServiceDesc.trim(), + source: addModalTab, + transport_type: newTransportType, + server_url: resolvedServerInfo.data.finalServerUrl, + tags, + authorization_token: normalizedToken, + container_config: resolvedServerInfo.data.containerConfig, + }); + } - if (!result.success) throw new Error(result.message || t("mcpTools.add.failed")); await onServiceAdded(); message.success(t("mcpTools.add.success")); onClose(); } catch (error) { - const msg = error instanceof Error ? error.message : t("mcpTools.add.failed"); log.error("[useMcpToolsAddLocal] Failed to add MCP service", { error, serviceName: newServiceName, - serverType: newServerType, + transportType: newTransportType, addModalTab, }); - message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + message.error(t("mcpTools.add.failed")); } finally { setAddingService(false); } - }; + }, [ + addModalTab, + addMutation, + containerConfigJson, + containerPort, + message, + newTransportType, + newServiceAuthorizationToken, + newServiceDesc, + newServiceName, + newServiceUrl, + newTagDrafts, + onClose, + onServiceAdded, + t, + validateLocalAdd, + ]); - const addNewTag = () => { + const addNewTag = useCallback(() => { const nextTag = newTagInputValue.trim(); if (!nextTag) return; setNewTagDrafts((prev) => (prev.includes(nextTag) ? prev : [...prev, nextTag])); setNewTagInputValue(""); - }; + }, [newTagInputValue]); const removeNewTag = useCallback((index: number) => { setNewTagDrafts((prev) => prev.filter((_, idx) => idx !== index)); }, []); - const state: AddMcpLocalState = useMemo( - () => ({ - newServiceName, - newServiceDesc, - newServerType, - newServiceUrl, - newServiceAuthorizationToken, - containerUploadFileList, - containerConfigJson, - containerPort, - containerServiceName, - newTagDrafts, - newTagInputValue, - addingService, - }), - [ - newServiceName, - newServiceDesc, - newServerType, - newServiceUrl, - newServiceAuthorizationToken, - containerUploadFileList, - containerConfigJson, - containerPort, - containerServiceName, - newTagDrafts, - newTagInputValue, - addingService, - ] - ); - - const actions: AddMcpLocalActions = useMemo( - () => ({ - onNewServiceNameChange: setNewServiceName, - onNewServiceDescChange: setNewServiceDesc, - onNewServerTypeChange: setNewServerType, - onNewServiceUrlChange: setNewServiceUrl, - onNewServiceAuthorizationTokenChange: setNewServiceAuthorizationToken, - onContainerUploadFileListChange: setContainerUploadFileList, - onContainerConfigJsonChange: setContainerConfigJson, - onContainerPortChange: setContainerPort, - onContainerServiceNameChange: setContainerServiceName, - onAddNewTag: addNewTag, - onRemoveNewTag: removeNewTag, - onNewTagInputChange: setNewTagInputValue, - onSaveAndAdd: handleAddService, - }), - [removeNewTag] - ); - return { - state, - actions, + newServiceName, + newServiceDesc, + newTransportType, + newServiceUrl, + newServiceAuthorizationToken, + containerConfigJson, + containerPort, + newTagDrafts, + newTagInputValue, + addingService, + setNewServiceName, + setNewServiceDesc, + setNewTransportType, + setNewServiceUrl, + setNewServiceAuthorizationToken, + setContainerConfigJson, + setContainerPort, + addNewTag, + removeNewTag, + setNewTagInputValue, + handleAddService, reset, }; } diff --git a/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts b/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts index 4234d3f3f..08a76cf7d 100644 --- a/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts +++ b/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts @@ -1,12 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import type { MessageInstance } from "antd/es/message/interface"; import log from "@/lib/logger"; -import { MCP_SERVER_TYPE, MCP_TAB } from "@/const/mcpTools"; -import { addMcpToolService, fetchMarketMcpCards, type MarketMcpCard } from "@/services/mcpToolsService"; +import { MCP_TRANSPORT_TYPE, MCP_TAB } from "@/const/mcpTools"; import { - type AddMcpMarketActions, - type AddMcpMarketState, + addContainerMcpToolService, + addMcpToolService, + fetchMarketMcpCards, + type MarketMcpCard, +} from "@/services/mcpToolsService"; +import { + type MarketQuickAddOption, type McpTab, } from "@/types/mcpTools"; @@ -19,6 +23,120 @@ type UseMcpToolsAddMarketParams = { onClose: () => void; }; +const resolveQuickAddTarget = (type?: string | null, url?: string | null): { transportType: "http" | "sse"; serverUrl: string } | null => { + const serverUrl = (url || "").trim(); + if (!serverUrl) return null; + + const normalizedType = (type || "").trim().toLowerCase(); + if (normalizedType.includes("sse")) { + return { transportType: "sse", serverUrl }; + } + if (normalizedType.includes("http")) { + return { transportType: "http", serverUrl }; + } + if (/^https?:\/\//i.test(serverUrl)) { + return { transportType: "http", serverUrl }; + } + + return null; +}; + +const normalizeServerKey = (raw: string): string => { + const normalized = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return normalized || "market-mcp"; +}; + +const inferStdioCommand = (registryType?: string): string | null => { + const normalized = (registryType || "").trim().toLowerCase(); + if (normalized === "npm") return "npx"; + if (normalized === "pypi") return "uvx"; + return null; +}; + +const inferStdioArgs = (registryType?: string, identifier?: string): string[] => { + const packageId = (identifier || "").trim(); + const normalized = (registryType || "").trim().toLowerCase(); + if (!packageId) return []; + if (normalized === "npm") return ["-y", packageId]; + return [packageId]; +}; + +const pickQuickAddPort = (): number => { + const seed = Date.now() % 1000; + return 5500 + seed; +}; + +const extractPackageEnvTemplate = (service: MarketMcpCard, pkgIdentifier?: string): Record => { + if (!pkgIdentifier) return {}; + const rawPackages = (service.serverJson as { packages?: unknown[] } | undefined)?.packages; + if (!Array.isArray(rawPackages)) return {}; + + const targetPackage = rawPackages.find((entry) => { + if (!entry || typeof entry !== "object") return false; + const identifier = String((entry as { identifier?: unknown }).identifier || "").trim(); + return identifier === pkgIdentifier; + }) as { environmentVariables?: Array<{ name?: string; default?: string }> } | undefined; + + const environmentVariables = targetPackage?.environmentVariables; + if (!Array.isArray(environmentVariables)) return {}; + + return environmentVariables.reduce>((acc, item) => { + const envName = String(item?.name || "").trim(); + if (!envName) return acc; + acc[envName] = String(item?.default || ""); + return acc; + }, {}); +}; + +const resolveQuickAddOptions = (service: MarketMcpCard): MarketQuickAddOption[] => { + const options: MarketQuickAddOption[] = []; + + (service.remotes || []).forEach((remote, index) => { + const remoteTarget = resolveQuickAddTarget(remote.type, remote.url); + if (!remoteTarget) return; + + options.push({ + key: `remote-${index}`, + sourceType: "remote", + sourceLabel: `${remote.type || "remote"} - ${remote.url}`, + transportType: remoteTarget.transportType, + serverUrl: remoteTarget.serverUrl, + }); + }); + + (service.packages || []).forEach((pkg, index) => { + const packageId = pkg.identifier || "package"; + const transportType = pkg.transport?.type || "remote"; + const transportUrl = pkg.transport?.url || ""; + + const packageTarget = resolveQuickAddTarget(pkg.transport?.type, pkg.transport?.url); + if (packageTarget) { + options.push({ + key: `package-${index}`, + sourceType: "package", + sourceLabel: `${packageId} - ${transportType} - ${transportUrl}`, + transportType: packageTarget.transportType, + serverUrl: packageTarget.serverUrl, + }); + return; + } + + if ((pkg.transport?.type || "").trim().toLowerCase() === "stdio") { + options.push({ + key: `package-${index}`, + sourceType: "package", + sourceLabel: `${packageId} - stdio`, + transportType: "stdio", + packageIdentifier: pkg.identifier, + packageRegistryType: pkg.registryType, + packageEnvTemplate: extractPackageEnvTemplate(service, pkg.identifier), + }); + } + }); + + return options; +}; + export function useMcpToolsAddMarket({ open, addModalTab, @@ -29,14 +147,16 @@ export function useMcpToolsAddMarket({ }: UseMcpToolsAddMarketParams) { const [marketSearchValue, setMarketSearchValue] = useState(""); const [selectedMarketService, setSelectedMarketService] = useState(null); - const [marketServices, setMarketServices] = useState([]); const [marketCurrentCursor, setMarketCurrentCursor] = useState(null); - const [marketNextCursor, setMarketNextCursor] = useState(null); const [marketCursorHistory, setMarketCursorHistory] = useState([]); const [marketPage, setMarketPage] = useState(1); const [marketVersion, setMarketVersion] = useState("latest"); const [marketUpdatedSince, setMarketUpdatedSince] = useState(""); const [marketIncludeDeleted, setMarketIncludeDeleted] = useState(false); + const [quickAddPickerVisible, setQuickAddPickerVisible] = useState(false); + const [quickAddCandidateService, setQuickAddCandidateService] = useState(null); + const [quickAddOptions, setQuickAddOptions] = useState([]); + const [selectedQuickAddOptionKey, setSelectedQuickAddOptionKey] = useState(""); const [addingService, setAddingService] = useState(false); const addMutation = useMutation({ mutationFn: addMcpToolService }); @@ -44,14 +164,16 @@ export function useMcpToolsAddMarket({ const reset = useCallback(() => { setMarketSearchValue(""); setMarketCurrentCursor(null); - setMarketNextCursor(null); setMarketCursorHistory([]); setMarketPage(1); setMarketVersion("latest"); setMarketUpdatedSince(""); setMarketIncludeDeleted(false); setSelectedMarketService(null); - setMarketServices([]); + setQuickAddPickerVisible(false); + setQuickAddCandidateService(null); + setQuickAddOptions([]); + setSelectedQuickAddOptionKey(""); setAddingService(false); }, []); @@ -62,7 +184,7 @@ export function useMcpToolsAddMarket({ }, []); useEffect(() => { - if (!(open && addModalTab === MCP_TAB.MARKET)) return; + if (!(open && addModalTab === MCP_TAB.MCP_REGISTRY)) return; const timer = window.setTimeout(() => { loadMarketFirstPage(); }, 350); @@ -87,8 +209,11 @@ export function useMcpToolsAddMarket({ marketUpdatedSince, marketIncludeDeleted, ], - enabled: open && addModalTab === MCP_TAB.MARKET, + enabled: open && addModalTab === MCP_TAB.MCP_REGISTRY, retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchOnMount: false, queryFn: async () => { const result = await fetchMarketMcpCards({ search: marketSearchValue, @@ -97,16 +222,12 @@ export function useMcpToolsAddMarket({ updatedSince: marketUpdatedSince, includeDeleted: marketIncludeDeleted, }); - if (!result.success) throw new Error(result.message || t("mcpTools.market.loadFailed")); return result.data; }, }); - useEffect(() => { - if (!marketQuery.data) return; - setMarketServices(marketQuery.data.items); - setMarketNextCursor(marketQuery.data.nextCursor); - }, [marketQuery.data]); + const marketServices = marketQuery.data?.items ?? []; + const marketNextCursor = marketQuery.data?.nextCursor ?? null; useEffect(() => { if (!(marketQuery.error instanceof Error)) return; @@ -118,9 +239,7 @@ export function useMcpToolsAddMarket({ updatedSince: marketUpdatedSince, includeDeleted: marketIncludeDeleted, }); - message.error(marketQuery.error.message); - setMarketServices([]); - setMarketNextCursor(null); + message.error(t("mcpTools.market.loadFailed")); }, [ marketQuery.error, marketSearchValue, @@ -131,108 +250,152 @@ export function useMcpToolsAddMarket({ message, ]); - const handleMarketNextPage = () => { + const handleMarketNextPage = useCallback(() => { if (!marketNextCursor || marketQuery.isFetching) return; const currentCursorSnapshot = marketCurrentCursor; setMarketCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]); setMarketCurrentCursor(marketNextCursor); setMarketPage((prev) => prev + 1); - }; + }, [marketCurrentCursor, marketNextCursor, marketQuery.isFetching]); - const handleMarketPrevPage = () => { + const handleMarketPrevPage = useCallback(() => { if (marketCursorHistory.length === 0 || marketQuery.isFetching) return; const previousCursor = marketCursorHistory[marketCursorHistory.length - 1] || null; setMarketCursorHistory((prev) => prev.slice(0, -1)); setMarketCurrentCursor(previousCursor); setMarketPage((prev) => Math.max(1, prev - 1)); - }; + }, [marketCursorHistory, marketQuery.isFetching]); + + const handleCloseQuickAddPicker = useCallback(() => { + setQuickAddPickerVisible(false); + setQuickAddCandidateService(null); + setQuickAddOptions([]); + setSelectedQuickAddOptionKey(""); + }, []); - const handleQuickAddFromMarket = async (service: MarketMcpCard) => { - const isUrlService = service.serverType === MCP_SERVER_TYPE.HTTP || service.serverType === MCP_SERVER_TYPE.SSE; - if (!isUrlService || !service.serverUrl.trim()) { - log.error("[useMcpToolsAddMarket] Quick add is unsupported for selected market service", { + const handleQuickAddFromMarket = useCallback((service: MarketMcpCard) => { + const quickAddOptionsForService = resolveQuickAddOptions(service); + if (quickAddOptionsForService.length === 0) { + log.warn("[useMcpToolsAddMarket] Quick add is unsupported for selected market service", { serviceName: service.name, - serverType: service.serverType, - serverUrl: service.serverUrl, + remotes: service.remotes, + packages: service.packages, }); - message.error(t("mcpTools.market.quickAddUnsupported")); + message.warning(t("mcpTools.market.quickAddUnsupported")); + return; + } + + setQuickAddCandidateService(service); + setQuickAddOptions(quickAddOptionsForService); + setSelectedQuickAddOptionKey(quickAddOptionsForService[0]?.key || ""); + setQuickAddPickerVisible(true); + }, [message, t]); + + const handleConfirmQuickAddOption = useCallback(async () => { + const service = quickAddCandidateService; + if (!service) return; + + const selectedOption = quickAddOptions.find((option) => option.key === selectedQuickAddOptionKey); + if (!selectedOption) { + message.warning(t("mcpTools.market.quickAddUnsupported")); return; } setAddingService(true); try { - const result = await addMutation.mutateAsync({ - name: service.name, - description: service.description || t("mcpTools.service.defaultDescription"), - source: MCP_TAB.MARKET, - server_type: service.serverType, - server_url: service.serverUrl, - tags: [], - }); + if (selectedOption.transportType === "stdio") { + const packageIdentifier = (selectedOption.packageIdentifier || "").trim(); + const command = inferStdioCommand(selectedOption.packageRegistryType); + if (!packageIdentifier || !command) { + message.warning(t("mcpTools.market.quickAddUnsupported")); + return; + } + + const serverKey = normalizeServerKey(packageIdentifier); + const containerPort = pickQuickAddPort(); + await addContainerMcpToolService({ + name: quickAddCandidateService.name, + description: quickAddCandidateService.description, + tags: [], + port: containerPort, + mcp_config: { + mcpServers: { + [serverKey]: { + command, + args: inferStdioArgs(selectedOption.packageRegistryType, packageIdentifier), + env: selectedOption.packageEnvTemplate || {}, + }, + }, + }, + }); + } else { + await addMutation.mutateAsync({ + name: quickAddCandidateService.name, + description: quickAddCandidateService.description, + source: MCP_TAB.MCP_REGISTRY, + transport_type: selectedOption.transportType === "sse" ? MCP_TRANSPORT_TYPE.SSE : MCP_TRANSPORT_TYPE.HTTP, + server_url: selectedOption.serverUrl || "", + tags: [], + version: quickAddCandidateService.version || undefined, + mcp_registry_json: quickAddCandidateService.serverJson || undefined, + }); + } - if (!result.success) throw new Error(result.message || t("mcpTools.add.failed")); await onServiceAdded(); message.success(t("mcpTools.market.quickAddSuccess")); + handleCloseQuickAddPicker(); onClose(); } catch (error) { - const msg = error instanceof Error ? error.message : t("mcpTools.add.failed"); log.error("[useMcpToolsAddMarket] Failed to quick add market service", { error, - serviceName: service.name, - serverType: service.serverType, - serverUrl: service.serverUrl, + serviceName: quickAddCandidateService.name, + remotes: quickAddCandidateService.remotes, + packages: quickAddCandidateService.packages, + quickAddOption: selectedOption, }); - message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + message.error(t("mcpTools.add.failed")); } finally { setAddingService(false); } - }; - - const state: AddMcpMarketState = useMemo( - () => ({ - marketSearchValue, - selectedMarketService, - filteredMarketServices: marketServices, - marketLoading: marketQuery.isFetching, - marketPage, - hasPrevMarketPage: marketCursorHistory.length > 0, - hasNextMarketPage: Boolean(marketNextCursor), - marketVersion, - marketUpdatedSince, - marketIncludeDeleted, - }), - [ - marketSearchValue, - selectedMarketService, - marketServices, - marketQuery.isFetching, - marketPage, - marketCursorHistory.length, - marketNextCursor, - marketVersion, - marketUpdatedSince, - marketIncludeDeleted, - ] - ); - - const actions: AddMcpMarketActions = useMemo( - () => ({ - onMarketSearchChange: setMarketSearchValue, - onRefreshMarket: loadMarketFirstPage, - onPrevMarketPage: handleMarketPrevPage, - onNextMarketPage: handleMarketNextPage, - onMarketVersionChange: setMarketVersion, - onMarketUpdatedSinceChange: setMarketUpdatedSince, - onMarketIncludeDeletedChange: setMarketIncludeDeleted, - onSelectMarketService: setSelectedMarketService, - onQuickAddFromMarket: handleQuickAddFromMarket, - }), - [loadMarketFirstPage] - ); + }, [ + addMutation, + handleCloseQuickAddPicker, + message, + onClose, + onServiceAdded, + quickAddCandidateService, + quickAddOptions, + selectedQuickAddOptionKey, + t, + ]); return { - state, - actions, + marketSearchValue, + selectedMarketService, + filteredMarketServices: marketServices, + marketLoading: marketQuery.isFetching, + marketPage, + hasPrevMarketPage: marketCursorHistory.length > 0, + hasNextMarketPage: Boolean(marketNextCursor), + marketVersion, + marketUpdatedSince, + marketIncludeDeleted, + quickAddPickerVisible, + quickAddCandidateService, + quickAddOptions, + selectedQuickAddOptionKey, + quickAddSubmitting: addingService, + setMarketSearchValue, + setSelectedMarketService, + setMarketVersion, + setMarketUpdatedSince, + setMarketIncludeDeleted, + setSelectedQuickAddOptionKey, + handleMarketPrevPage, + handleMarketNextPage, + handleQuickAddFromMarket, + handleCloseQuickAddPicker, + handleConfirmQuickAddOption, addingService, reset, }; diff --git a/frontend/hooks/mcpTools/useMcpToolsDetail.ts b/frontend/hooks/mcpTools/useMcpToolsDetail.ts index 234c1fefa..b8abc9e2e 100644 --- a/frontend/hooks/mcpTools/useMcpToolsDetail.ts +++ b/frontend/hooks/mcpTools/useMcpToolsDetail.ts @@ -1,14 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { MessageInstance } from "antd/es/message/interface"; import log from "@/lib/logger"; import { MCP_HEALTH_STATUS } from "@/const/mcpTools"; import type { McpTool } from "@/types/agentConfig"; -import { - type McpServiceDetailActions, - type McpServiceDetailState, - type McpServiceItem, -} from "@/types/mcpTools"; +import { type McpServiceItem } from "@/types/mcpTools"; import { deleteMcpToolService, healthcheckMcpToolService, @@ -25,7 +21,7 @@ type UseMcpToolsDetailParams = { selectedService: McpServiceItem | null; onSelectedServiceChange: (service: McpServiceItem | null) => void; onServicesReload: () => Promise; - onSyncToolNames: (service: Pick, tools: McpTool[]) => void; + onSyncToolNames: (service: Pick, tools: McpTool[]) => void; t: (key: string) => string; message: MessageInstance; }; @@ -45,28 +41,53 @@ export function useMcpToolsDetail({ const [healthCheckLoading, setHealthCheckLoading] = useState(false); const [toolsModalVisible, setToolsModalVisible] = useState(false); const [currentServerTools, setCurrentServerTools] = useState([]); + const previousSelectedServiceIdRef = useRef(null); useEffect(() => { if (selectedService) { - setDraftService({ ...selectedService }); - setTagDrafts(selectedService.tags); - setTagInputValue(""); - setCurrentServerTools([]); + const previousId = previousSelectedServiceIdRef.current; + const isSameService = previousId === selectedService.mcpId; + previousSelectedServiceIdRef.current = selectedService.mcpId; + + if (isSameService) { + // Keep local editing/tool modal state when only metadata updates for the same service. + setDraftService((prev) => { + if (!prev) { + return { ...selectedService }; + } + return { + ...prev, + status: selectedService.status, + healthStatus: selectedService.healthStatus, + containerStatus: selectedService.containerStatus, + updatedAt: selectedService.updatedAt, + version: selectedService.version, + mcpRegistryJson: selectedService.mcpRegistryJson, + configJson: selectedService.configJson, + }; + }); + } else { + setDraftService({ ...selectedService }); + setTagDrafts(selectedService.tags); + setTagInputValue(""); + setCurrentServerTools([]); + } return; } + previousSelectedServiceIdRef.current = null; setDraftService(null); setTagDrafts([]); setTagInputValue(""); setCurrentServerTools([]); setToolsModalVisible(false); - }, [selectedService?.name, selectedService?.serverUrl]); + }, [selectedService]); const updateMutation = useMutation({ mutationFn: updateMcpToolService }); const deleteMutation = useMutation({ mutationFn: deleteMcpToolService }); const healthcheckMutation = useMutation({ mutationFn: healthcheckMcpToolService }); - const toolsQueryKey = ["mcp-tools", "runtime-tools", draftService?.name, draftService?.serverUrl]; + const toolsQueryKey = ["mcp-tools", "runtime-tools", draftService?.mcpId]; const toolsQuery = useQuery({ queryKey: toolsQueryKey, @@ -78,10 +99,7 @@ export function useMcpToolsDetail({ if (!draftService) { throw new Error(t("mcpTools.tools.loadFailed")); } - const result = await listMcpRuntimeTools(draftService.name, draftService.serverUrl); - if (!result.success) { - throw new Error(result.message || t("mcpTools.tools.loadFailed")); - } + const result = await listMcpRuntimeTools(draftService.mcpId); return result.data; }, }); @@ -91,17 +109,17 @@ export function useMcpToolsDetail({ const nextToolNames = toolsQuery.data.map((item) => item.name); setCurrentServerTools((prev) => (isSameStringArray(prev.map((item) => item.name), nextToolNames) ? prev : toolsQuery.data)); onSyncToolNames( - { name: draftService.name, serverUrl: draftService.serverUrl }, + { mcpId: draftService.mcpId, name: draftService.name, serverUrl: draftService.serverUrl }, toolsQuery.data ); setDraftService((prev) => - !prev || prev.name !== draftService.name || prev.serverUrl !== draftService.serverUrl + !prev || prev.mcpId !== draftService.mcpId ? prev : isSameStringArray(prev.tools, nextToolNames) ? prev : { ...prev, tools: nextToolNames } ); - }, [draftService?.name, draftService?.serverUrl, toolsQuery.data, onSyncToolNames]); + }, [draftService?.mcpId, draftService?.name, draftService?.serverUrl, toolsQuery.data, onSyncToolNames]); const loadTools = async () => { if (!draftService) return; @@ -109,11 +127,14 @@ export function useMcpToolsDetail({ if (result.error) { log.error("[useMcpToolsDetail] Failed to load runtime tools", { error: result.error, - serviceName: draftService.name, - serverUrl: draftService.serverUrl, + mcpId: draftService.mcpId, }); - const msg = result.error instanceof Error ? result.error.message : t("mcpTools.tools.loadFailed"); - message.error(msg); + message.error(t("mcpTools.tools.loadFailed")); + return; + } + + if (result.data && result.data.length === 0) { + message.info(t("mcpConfig.toolsList.empty")); } }; @@ -140,14 +161,13 @@ export function useMcpToolsDetail({ const nextTags = tagDrafts.map((tag) => tag.trim()).filter((tag) => tag.length > 0); try { const result = await updateMutation.mutateAsync({ - current_name: selectedService.name, + mcp_id: selectedService.mcpId, name: draftService.name, description: draftService.description, server_url: draftService.serverUrl, authorization_token: draftService.authorizationToken ?? "", tags: nextTags, }); - if (!result.success) throw new Error(result.message || t("mcpTools.service.saveFailed")); const updatedService = { ...draftService, tags: nextTags }; await onServicesReload(); onSelectedServiceChange(updatedService); @@ -155,15 +175,16 @@ export function useMcpToolsDetail({ setTagDrafts(nextTags); message.success(t("mcpTools.service.saveSuccess")); } catch (error) { - const msg = error instanceof Error ? error.message : t("mcpTools.service.saveFailed"); log.error("[useMcpToolsDetail] Failed to save service updates", { error, selectedServiceName: selectedService.name, + selectedServiceId: selectedService.mcpId, selectedServiceUrl: selectedService.serverUrl, draftServiceName: draftService.name, + draftServiceId: draftService.mcpId, draftServiceUrl: draftService.serverUrl, }); - message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + message.error(t("mcpTools.service.saveFailed")); } }; @@ -172,38 +193,37 @@ export function useMcpToolsDetail({ setHealthCheckLoading(true); try { const result = await healthcheckMutation.mutateAsync({ - name: draftService.name, - server_url: draftService.serverUrl, + mcp_id: draftService.mcpId, }); - if (!result.success || !result.data) throw new Error(result.message || t("mcpTools.service.healthFailed")); + if (!result.data) throw new Error(t("mcpTools.service.healthFailed")); setDraftService({ ...draftService, healthStatus: result.data.health_status }); } catch (error) { setDraftService((prev) => (prev ? { ...prev, healthStatus: MCP_HEALTH_STATUS.UNHEALTHY } : prev)); - const msg = error instanceof Error ? error.message : t("mcpTools.service.healthFailed"); log.error("[useMcpToolsDetail] Failed to run health check", { error, + serviceId: draftService.mcpId, serviceName: draftService.name, serverUrl: draftService.serverUrl, }); - message.error(msg === "MCP connection failed" ? t("mcpTools.error.connectionFailed") : msg); + message.error(t("mcpTools.service.healthFailed")); } finally { setHealthCheckLoading(false); } }; - const onDeleteService = async (serviceName: string) => { + const onDeleteService = async (mcpId: number, serviceName: string) => { try { - const result = await deleteMutation.mutateAsync(serviceName); - if (!result.success) throw new Error(result.message || t("mcpTools.service.deleteFailed")); + await deleteMutation.mutateAsync(mcpId); await onServicesReload(); onSelectedServiceChange(null); message.success(t("mcpTools.service.deleted")); } catch (error) { log.error("[useMcpToolsDetail] Failed to delete service", { error, + serviceId: mcpId, serviceName, }); - message.error(error instanceof Error ? error.message : t("mcpTools.service.deleteFailed")); + message.error(t("mcpTools.service.deleteFailed")); } }; @@ -214,7 +234,7 @@ export function useMcpToolsDetail({ setTagInputValue(""); }; - const state: McpServiceDetailState = { + return { selectedService, draftService, tagDrafts, @@ -223,27 +243,16 @@ export function useMcpToolsDetail({ loadingTools: toolsQuery.isFetching, toolsModalVisible, currentServerTools, - }; - - const actions: McpServiceDetailActions & { - onDeleteService: (serviceName: string) => Promise; - onCloseDetail: () => void; - } = { - onDraftServiceChange: setDraftService, - onTagInputChange: setTagInputValue, - onAddDetailTag: addDetailTag, - onRemoveTag: (index: number) => setTagDrafts((prev) => prev.filter((_, idx) => idx !== index)), - onHealthCheck: handleHealthCheck, - onViewTools: handleViewTools, - onSaveUpdates: handleSaveUpdates, - onCloseToolsModal: () => setToolsModalVisible(false), - onRefreshTools: handleRefreshTools, + setDraftService, + setTagInputValue, + addDetailTag, + removeTag: (index: number) => setTagDrafts((prev) => prev.filter((_, idx) => idx !== index)), + handleHealthCheck, + handleViewTools, + handleSaveUpdates, + closeToolsModal: () => setToolsModalVisible(false), + handleRefreshTools, onDeleteService, - onCloseDetail: () => onSelectedServiceChange(null), - }; - - return { - state, - actions, + closeDetail: () => onSelectedServiceChange(null), }; } diff --git a/frontend/hooks/mcpTools/useMcpToolsPage.ts b/frontend/hooks/mcpTools/useMcpToolsPage.ts new file mode 100644 index 000000000..cc6806fa5 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpToolsPage.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { MessageInstance } from "antd/es/message/interface"; +import log from "@/lib/logger"; +import { filterServiceCards } from "@/lib/mcpTools"; +import type { McpTool } from "@/types/agentConfig"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { listMcpTools } from "@/services/mcpToolsService"; +import { useMcpToolsDetail } from "./useMcpToolsDetail"; +import { useMcpToolsToggle } from "./useMcpToolsToggle"; + +type UseMcpToolsPageParams = { + t: (key: string) => string; + message: MessageInstance; +}; + +function isSameToolNames(left: string[] = [], right: string[] = []) { + if (left.length !== right.length) return false; + return left.every((item, index) => item === right[index]); +} + +export function useMcpToolsPage({ t, message }: UseMcpToolsPageParams) { + const [searchValue, setSearchValue] = useState(""); + const [services, setServices] = useState([]); + const [selectedService, setSelectedService] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + + const listQuery = useQuery({ + queryKey: ["mcp-tools", "list"], + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchOnMount: false, + queryFn: async () => { + const result = await listMcpTools(); + return result.data; + }, + }); + + useEffect(() => { + if (!listQuery.data) return; + setServices(listQuery.data); + }, [listQuery.data]); + + useEffect(() => { + if (!(listQuery.error instanceof Error)) return; + log.error("[useMcpToolsPage] Failed to load managed MCP service list", { error: listQuery.error }); + message.error(t("mcpTools.list.loadFailed")); + }, [listQuery.error, message, t]); + + const loadServerList = useCallback(async () => { + const result = await listQuery.refetch(); + if (result.error || !result.data) { + log.error("[useMcpToolsPage] Failed to refresh managed MCP service list", { error: result.error }); + message.error(t("mcpTools.list.loadFailed")); + return { success: false, data: undefined }; + } + + setServices(result.data); + return { success: true, data: result.data }; + }, [listQuery, message, t]); + + const filteredServices = useMemo(() => { + return filterServiceCards(services, searchValue); + }, [searchValue, services]); + + const syncToolNamesToCards = useCallback((service: Pick, tools: McpTool[]) => { + const nextToolNames = tools.map((item) => item.name); + setSelectedService((prev) => { + if (!prev || prev.mcpId !== service.mcpId) { + return prev; + } + if (isSameToolNames(prev.tools, nextToolNames)) { + return prev; + } + return { ...prev, tools: nextToolNames }; + }); + + setServices((prev) => { + let changed = false; + const next = prev.map((item) => { + if (item.mcpId !== service.mcpId) { + return item; + } + if (isSameToolNames(item.tools, nextToolNames)) { + return item; + } + changed = true; + return { ...item, tools: nextToolNames }; + }); + return changed ? next : prev; + }); + }, []); + + const { toggleServiceStatus, togglingServiceId } = useMcpToolsToggle({ + loadServerList, + setSelectedService, + t, + message, + }); + + const detail = useMcpToolsDetail({ + selectedService, + onSelectedServiceChange: setSelectedService, + onServicesReload: loadServerList, + onSyncToolNames: syncToolNamesToCards, + t, + message, + }); + + return { + searchValue, + setSearchValue, + services, + loadingServices: listQuery.isFetching && services.length === 0, + selectedService, + setSelectedService, + showAddModal, + setShowAddModal, + loadServerList, + filteredServices, + toggleServiceStatus, + togglingServiceId, + detail, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpToolsToggle.ts b/frontend/hooks/mcpTools/useMcpToolsToggle.ts index 236d25ac1..7404a922e 100644 --- a/frontend/hooks/mcpTools/useMcpToolsToggle.ts +++ b/frontend/hooks/mcpTools/useMcpToolsToggle.ts @@ -1,11 +1,12 @@ -import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; import type { MessageInstance } from "antd/es/message/interface"; -import { enableMcpToolService } from "@/services/mcpToolsService"; +import { disableMcpToolService, enableMcpToolService } from "@/services/mcpToolsService"; +import { ApiError } from "@/services/api"; import { MCP_SERVICE_STATUS } from "@/const/mcpTools"; import type { McpServiceItem } from "@/types/mcpTools"; type UseMcpToolsToggleParams = { - loadServerList: () => Promise; + loadServerList: () => Promise<{ success: boolean; data?: McpServiceItem[] }>; setSelectedService: React.Dispatch>; t: (key: string) => string; message: MessageInstance; @@ -17,34 +18,81 @@ export function useMcpToolsToggle({ t, message, }: UseMcpToolsToggleParams) { - const toggleMutation = useMutation({ mutationFn: enableMcpToolService }); + const [togglingServiceId, setTogglingServiceId] = useState(null); + + const resolveToggleErrorMessage = (error: unknown, nextEnabled: boolean) => { + if (!nextEnabled) { + return t("mcpTools.service.toggleFailed"); + } + + if (error instanceof ApiError) { + const code = String(error.code); + const text = String(error.message || "").toLowerCase(); + + if (code === "503" || text.includes("mcp connection failed")) { + return t("mcpTools.error.connectionFailed"); + } + if (text.includes("already uses this name") || text.includes("name already exists")) { + return t("mcpTools.service.enableNameConflict"); + } + } + + return t("mcpTools.service.toggleFailed"); + }; const toggleServiceStatus = async (service: McpServiceItem) => { + if (togglingServiceId === service.mcpId) { + return; + } + const nextEnabled = service.status !== MCP_SERVICE_STATUS.ENABLED; - const result = await toggleMutation.mutateAsync({ - name: service.name, - enabled: nextEnabled, + const toastKey = `mcp-tools-toggle-${service.mcpId}`; + + setTogglingServiceId(service.mcpId); + message.open({ + key: toastKey, + type: "loading", + content: nextEnabled ? t("mcpTools.service.enabling") : t("mcpTools.service.disabling"), + duration: 0, }); - if (!result.success) { - throw new Error(t("mcpTools.service.toggleFailed")); - } + try { + const mutationFn = nextEnabled ? enableMcpToolService : disableMcpToolService; + await mutationFn({ + mcp_id: service.mcpId, + enabled: nextEnabled, + }); + + const listResult = await loadServerList(); + const latestService = listResult.data?.find((item) => item.mcpId === service.mcpId); + setSelectedService((prev) => + prev && prev.mcpId === service.mcpId + ? latestService ?? { + ...prev, + status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED, + } + : prev + ); - await loadServerList(); - setSelectedService((prev) => - prev && prev.name === service.name - ? { - ...prev, - status: nextEnabled ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED, - } - : prev - ); - - message.success(nextEnabled ? t("mcpTools.service.enabled") : t("mcpTools.service.disabled")); + message.open({ + key: toastKey, + type: "success", + content: nextEnabled ? t("mcpTools.service.enabled") : t("mcpTools.service.disabled"), + }); + } catch (error) { + message.open({ + key: toastKey, + type: "error", + content: resolveToggleErrorMessage(error, nextEnabled), + }); + throw error; + } finally { + setTogglingServiceId(null); + } }; return { toggleServiceStatus, - toggleMutation, + togglingServiceId, }; } diff --git a/frontend/lib/mcpTools.ts b/frontend/lib/mcpTools.ts index d748f08de..a5f0e7769 100644 --- a/frontend/lib/mcpTools.ts +++ b/frontend/lib/mcpTools.ts @@ -2,7 +2,7 @@ import type { McpServer } from "@/types/agentConfig"; import type { McpServiceItem } from "@/types/mcpTools"; import { MCP_HEALTH_STATUS, - MCP_SERVER_TYPE, + MCP_TRANSPORT_TYPE, MCP_SERVICE_STATUS, MCP_TAB, } from "@/const/mcpTools"; @@ -16,17 +16,18 @@ export const mapServersToServiceCards = ( return (serverList ?? []).map((server) => { const normalizedUrl = typeof server.mcp_url === "string" ? server.mcp_url : ""; const inferredType = normalizedUrl.startsWith("container://") - ? MCP_SERVER_TYPE.CONTAINER - : MCP_SERVER_TYPE.HTTP; + ? MCP_TRANSPORT_TYPE.STDIO + : MCP_TRANSPORT_TYPE.HTTP; return { + mcpId: typeof server.mcp_id === "number" ? server.mcp_id : -1, name: typeof server.service_name === "string" ? server.service_name : "", description: t("mcpTools.service.defaultDescription"), source: MCP_TAB.LOCAL, status: server.status ? MCP_SERVICE_STATUS.ENABLED : MCP_SERVICE_STATUS.DISABLED, updatedAt: "", tags: [], - serverType: inferredType, + transportType: inferredType, serverUrl: normalizedUrl, tools: [], healthStatus: server.status ? MCP_HEALTH_STATUS.HEALTHY : MCP_HEALTH_STATUS.UNCHECKED, @@ -62,3 +63,25 @@ export const formatMarketVersion = (value: string): string => { if (!version) return "-"; return /^v/i.test(version) ? version : `v${version}`; }; + +export const extractRegistryLinks = (registryJson?: Record | null) => { + if (!registryJson || typeof registryJson !== "object") { + return { websiteUrl: "", repositoryUrl: "" }; + } + + const websiteUrlRaw = registryJson.websiteUrl; + const websiteUrl = typeof websiteUrlRaw === "string" ? websiteUrlRaw : ""; + + const repositoryRaw = registryJson.repository; + let repositoryUrl = ""; + if (repositoryRaw && typeof repositoryRaw === "object") { + const repositoryUrlRaw = (repositoryRaw as Record).url; + repositoryUrl = typeof repositoryUrlRaw === "string" ? repositoryUrlRaw : ""; + } + + return { websiteUrl, repositoryUrl }; +}; + +export const toPrettyRegistryJson = (registryJson?: Record | null) => { + return JSON.stringify(registryJson || {}, null, 2); +}; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 10fae63f7..8c5cd4309 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1504,7 +1504,10 @@ "mcpTools.service.enable": "Enable", "mcpTools.service.disable": "Disable", "mcpTools.service.enabled": "Service enabled", + "mcpTools.service.enabling": "Enabling service, please wait...", + "mcpTools.service.enableNameConflict": "Enable failed: another enabled service already uses this name. Please rename first.", "mcpTools.service.disabled": "Service disabled", + "mcpTools.service.disabling": "Disabling service, please wait...", "mcpTools.service.toggleFailed": "Failed to update service status", "mcpTools.service.saveFailed": "Failed to save changes", "mcpTools.service.saveSuccess": "Saved successfully", @@ -1515,9 +1518,11 @@ "mcpTools.status.enabled": "Enabled", "mcpTools.status.disabled": "Disabled", "mcpTools.source.local": "Local", + "mcpTools.source.mcp_registry": "MCP Registry", "mcpTools.source.market": "Public Market", "mcpTools.serverType.http": "HTTP", "mcpTools.serverType.sse": "SSE", + "mcpTools.serverType.stdio": "STDIO", "mcpTools.serverType.container": "Container", "mcpTools.health.healthy": "Healthy", "mcpTools.health.unhealthy": "Unhealthy", @@ -1528,12 +1533,14 @@ "mcpTools.error.connectionFailed": "MCP connection failed", "mcpTools.delete.confirmTitle": "Delete this service?", "mcpTools.delete.confirmDesc": "This action cannot be undone.", + "mcpTools.delete.confirmOk": "OK", + "mcpTools.delete.confirmCancel": "Cancel", "mcpTools.add.failed": "Failed to add MCP service", "mcpTools.add.success": "MCP service added successfully", "mcpTools.add.validate.nameRequired": "Please enter an MCP service name", "mcpTools.add.validate.httpUrlRequired": "Please enter an HTTP service URL", - "mcpTools.add.validate.containerConfigRequired": "Please upload a container image or provide container JSON config", - "mcpTools.add.validate.containerRequired": "Please enter container service name and port", + "mcpTools.add.validate.containerConfigRequired": "Please provide container JSON config", + "mcpTools.add.validate.containerRequired": "Please enter container port", "mcpTools.add.validate.localTabOnly": "Add local services only from the Local tab", "mcpTools.add.error.imageReadFailed": "Failed to read container image file", "mcpTools.add.error.imageUploadFailed": "Failed to upload container image", @@ -1581,13 +1588,18 @@ "mcpTools.market.quickAdd": "Quick Add", "mcpTools.market.quickAddPreview": "MCP service added from public market (frontend preview)", "mcpTools.market.quickAddSuccess": "MCP service added from public market", - "mcpTools.market.quickAddUnsupported": "Only URL-based HTTP/SSE services are currently supported for quick add", + "mcpTools.market.quickAddUnsupported": "Quick add currently supports URL-based HTTP/SSE, or package-based stdio container services", + "mcpTools.market.quickAddPicker.title": "Select Quick Add Target", + "mcpTools.market.quickAddPicker.description": "Choose one address or package for quick add in {{name}}.", + "mcpTools.market.quickAddPicker.sourceRemote": "Source: Remote", + "mcpTools.market.quickAddPicker.sourcePackage": "Source: Package", + "mcpTools.market.quickAddPicker.confirm": "Confirm Add", "mcpTools.market.prevPage": "Previous", "mcpTools.market.nextPage": "Next", - "mcpTools.market.title": "Title:", "mcpTools.market.website": "Website:", + "mcpTools.market.repository": "Repository:", "mcpTools.market.remotes": "Remotes", - "mcpTools.market.noRemotes": "No directly connectable remote endpoints", + "mcpTools.market.packages": "Packages", "mcpTools.market.remoteFallback": "remote", "mcpTools.market.viewServerJson": "View full server.json", "mcpTools.market.serverJsonTitle": "{{name}} - server.json", @@ -1603,12 +1615,18 @@ "mcpTools.detail.bearerTokenPlaceholder": "Bearer xxx", "mcpTools.detail.source": "Source", "mcpTools.detail.serverType": "Service Type", + "mcpTools.detail.version": "Version", + "mcpTools.detail.website": "Website", + "mcpTools.detail.repository": "Repository", "mcpTools.detail.status": "Status", "mcpTools.detail.health": "Connectivity", "mcpTools.detail.healthChecking": "Checking", "mcpTools.detail.healthCheck": "Run Health Check", + "mcpTools.detail.viewContainerLogs": "View Container Logs", "mcpTools.detail.containerStatus": "Container Status", "mcpTools.detail.tools": "Tools", + "mcpTools.detail.viewConfigJson": "View configuration JSON", + "mcpTools.detail.configJsonTitle": "{{name}} - configuration JSON", "mcpTools.detail.viewTools": "View Tools", "mcpTools.detail.tags": "Tags", "mcpTools.detail.removeTagAria": "Remove tag {{tag}}", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 088e5ad13..0101390aa 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1663,6 +1663,9 @@ "mcpTools.service.disable": "关闭", "mcpTools.service.enabled": "服务已启用", "mcpTools.service.disabled": "服务已关闭", + "mcpTools.service.enabling": "正在启用服务,请稍候...", + "mcpTools.service.disabling": "正在关闭服务,请稍候...", + "mcpTools.service.enableNameConflict": "启用失败:已存在同名的启用服务,请先改名", "mcpTools.service.toggleFailed": "更新服务状态失败", "mcpTools.service.saveFailed": "保存修改失败", "mcpTools.service.saveSuccess": "保存成功", @@ -1672,10 +1675,12 @@ "mcpTools.service.defaultName": "MCP 服务", "mcpTools.status.enabled": "已启用", "mcpTools.status.disabled": "未启用", - "mcpTools.source.local": "本地", + "mcpTools.source.local": "自定义", + "mcpTools.source.mcp_registry": "MCP Registry", "mcpTools.source.market": "公共市场", "mcpTools.serverType.http": "HTTP", "mcpTools.serverType.sse": "SSE", + "mcpTools.serverType.stdio": "STDIO", "mcpTools.serverType.container": "容器", "mcpTools.health.healthy": "正常", "mcpTools.health.unhealthy": "异常", @@ -1686,12 +1691,14 @@ "mcpTools.error.connectionFailed": "MCP 连接失败", "mcpTools.delete.confirmTitle": "确认删除该服务?", "mcpTools.delete.confirmDesc": "删除后不可恢复。", + "mcpTools.delete.confirmOk": "确认", + "mcpTools.delete.confirmCancel": "取消", "mcpTools.add.failed": "添加 MCP 服务失败", "mcpTools.add.success": "MCP 服务添加成功", "mcpTools.add.validate.nameRequired": "请填写 MCP 名称", "mcpTools.add.validate.httpUrlRequired": "请填写 HTTP 服务地址", - "mcpTools.add.validate.containerConfigRequired": "请上传容器镜像或填写容器配置", - "mcpTools.add.validate.containerRequired": "请填写容器服务名和端口", + "mcpTools.add.validate.containerConfigRequired": "请填写容器配置 JSON", + "mcpTools.add.validate.containerRequired": "请填写容器端口", "mcpTools.add.validate.localTabOnly": "请在本地标签页中添加本地服务", "mcpTools.add.error.imageReadFailed": "容器镜像文件读取失败", "mcpTools.add.error.imageUploadFailed": "容器镜像上传失败", @@ -1699,7 +1706,7 @@ "mcpTools.add.error.containerJsonMissingServers": "容器配置必须包含 mcpServers 对象", "mcpTools.add.error.containerAddFailed": "容器配置添加失败", "mcpTools.addModal.title": "添加 MCP 服务", - "mcpTools.addModal.tabLocal": "本地", + "mcpTools.addModal.tabLocal": "自定义", "mcpTools.addModal.tabMarket": "公共市场", "mcpTools.addModal.name": "名称", "mcpTools.addModal.description": "描述", @@ -1725,7 +1732,7 @@ "mcpTools.market.pageResult": "第 {{page}} 页 · {{count}} 个结果", "mcpTools.market.versionFilter": "版本筛选", "mcpTools.market.versionAll": "全部版本", - "mcpTools.market.versionLatest": "latest(最新版本)", + "mcpTools.market.versionLatest": "最新版本", "mcpTools.market.versionCustom": "自定义版本", "mcpTools.market.updatedSince": "更新时间下限 (RFC3339)", "mcpTools.market.updatedSincePlaceholder": "选择更新时间", @@ -1739,19 +1746,24 @@ "mcpTools.market.quickAdd": "快速添加", "mcpTools.market.quickAddPreview": "已从公共市场添加 MCP 服务(前端预览)", "mcpTools.market.quickAddSuccess": "已从公共市场添加 MCP 服务", - "mcpTools.market.quickAddUnsupported": "当前快速添加仅支持 URL 形式的 HTTP/SSE 服务", + "mcpTools.market.quickAddUnsupported": "当前快速添加仅支持 URL 形式的 HTTP/SSE,或 package 形式的 stdio 容器服务", + "mcpTools.market.quickAddPicker.title": "选择快速添加目标", + "mcpTools.market.quickAddPicker.description": "为 {{name}} 选择一个要快速添加的地址或安装包。", + "mcpTools.market.quickAddPicker.sourceRemote": "来源: 远程地址", + "mcpTools.market.quickAddPicker.sourcePackage": "来源: 安装包", + "mcpTools.market.quickAddPicker.confirm": "确认添加", "mcpTools.market.prevPage": "上一页", "mcpTools.market.nextPage": "下一页", - "mcpTools.market.title": "标题:", "mcpTools.market.website": "网站:", + "mcpTools.market.repository": "仓库:", "mcpTools.market.remotes": "远程地址", - "mcpTools.market.noRemotes": "无可直接连接的远端地址", + "mcpTools.market.packages": "安装包", "mcpTools.market.remoteFallback": "远程", "mcpTools.market.viewServerJson": "查看完整 server.json", "mcpTools.market.serverJsonTitle": "{{name}} - server.json", - "mcpTools.market.status.active": "active", - "mcpTools.market.status.deprecated": "deprecated", - "mcpTools.market.status.unknown": "unknown", + "mcpTools.market.status.active": "活动", + "mcpTools.market.status.deprecated": "弃用", + "mcpTools.market.status.unknown": "未知", "mcpTools.tools.loadFailed": "获取工具列表失败", "mcpTools.detail.title": "MCP 服务详情", "mcpTools.detail.name": "名称", @@ -1761,12 +1773,18 @@ "mcpTools.detail.bearerTokenPlaceholder": "Bearer xxx", "mcpTools.detail.source": "来源", "mcpTools.detail.serverType": "服务类型", + "mcpTools.detail.version": "版本", + "mcpTools.detail.website": "网站", + "mcpTools.detail.repository": "仓库", "mcpTools.detail.status": "状态", "mcpTools.detail.health": "连通性", "mcpTools.detail.healthChecking": "检测中", "mcpTools.detail.healthCheck": "连通性校验", + "mcpTools.detail.viewContainerLogs": "查看容器日志", "mcpTools.detail.containerStatus": "容器状态", "mcpTools.detail.tools": "工具", + "mcpTools.detail.viewConfigJson": "查看容器配置", + "mcpTools.detail.configJsonTitle": "{{name}} - 容器配置", "mcpTools.detail.viewTools": "查看工具", "mcpTools.detail.tags": "标签", "mcpTools.detail.removeTagAria": "删除标签 {{tag}}", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 4c2d939ef..447ffb159 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -224,10 +224,13 @@ export const API_ENDPOINTS = { }, mcpTools: { list: `${API_BASE_URL}/mcp-tools/list`, + tools: `${API_BASE_URL}/mcp-tools/tools`, add: `${API_BASE_URL}/mcp-tools/add`, + addContainer: `${API_BASE_URL}/mcp-tools/container/add`, update: `${API_BASE_URL}/mcp-tools/v2/update`, delete: `${API_BASE_URL}/mcp-tools/v2/delete`, - enable: `${API_BASE_URL}/mcp-tools/v2/manage/enable`, + enable: `${API_BASE_URL}/mcp-tools/enable`, + disable: `${API_BASE_URL}/mcp-tools/disable`, healthcheck: `${API_BASE_URL}/mcp-tools/v2/healthcheck`, marketList: `${API_BASE_URL}/mcp-tools/market/list`, }, diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts index 083e30174..c45ad24d0 100644 --- a/frontend/services/mcpToolsService.ts +++ b/frontend/services/mcpToolsService.ts @@ -1,6 +1,6 @@ import log from "@/lib/logger"; import { fetchWithAuth } from "@/lib/auth"; -import { MCP_SERVER_TYPE } from "@/const/mcpTools"; +import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; import { API_ENDPOINTS } from "@/services/api"; import type { AddMcpRuntimeFromConfigPayload, @@ -9,7 +9,7 @@ import type { MarketMcpCard, McpHealthStatus, McpServiceItem, - McpServerType, + McpTransportType, ToggleMcpServicePayload, UpdateMcpServicePayload, } from "@/types/mcpTools"; @@ -18,7 +18,6 @@ import type { McpTool } from "@/types/agentConfig"; export type McpToolsApiResult = { success: boolean; data: T; - message?: string; }; export type { MarketMcpCard } from "@/types/mcpTools"; @@ -33,6 +32,22 @@ type ApiEnvelope = { mcp_url?: string; }; +type AddFromConfigApiResult = { + status: string; + message?: string; + results?: Array<{ service_name?: string; mcp_url?: string }>; + errors?: string[] | null; +}; + +type AddContainerMcpToolPayload = { + name: string; + description: string; + tags: string[]; + authorization_token?: string; + port: number; + mcp_config: AddMcpRuntimeFromConfigPayload; +}; + const parseJson = async (response: Response): Promise => { return (await response.json()) as T; }; @@ -65,14 +80,6 @@ export const fetchMarketMcpCards = async (params: { } const result = await listMarketMcpTools(query); - if (!result.success || !result.data) { - return { - success: false, - data: { items: [], nextCursor: null as string | null }, - message: result.message, - } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>; - } - const payload = result.data; return { @@ -85,130 +92,90 @@ export const fetchMarketMcpCards = async (params: { }; export const resolveContainerServerInfo = async (params: { - serverType: McpServerType; + transportType: McpTransportType; serviceUrl: string; - containerServiceName: string; containerPort: number | undefined; containerConfigJson: string; - containerUploadFileList: Array<{ originFileObj?: File }>; - authorizationToken?: string; - t: (key: string) => string; -}) => { - if (params.serverType !== MCP_SERVER_TYPE.CONTAINER) { +}): Promise< + McpToolsApiResult<{ + finalServerUrl: string; + containerConfig?: Record; + runtimeService?: { name?: string; url?: string }; + mcpConfig?: AddMcpRuntimeFromConfigPayload; + }> +> => { + if (params.transportType !== MCP_TRANSPORT_TYPE.STDIO) { return { success: true, data: { finalServerUrl: params.serviceUrl.trim(), containerConfig: undefined, + runtimeService: undefined, + mcpConfig: undefined, }, - } as McpToolsApiResult<{ - finalServerUrl: string; - containerConfig?: Record; - }>; + }; } - let finalServerUrl = `container://${params.containerServiceName.trim()}:${params.containerPort}`; + let finalServerUrl = `container://mcp-container:${params.containerPort}`; const containerConfigPayload: Record = { config_json: params.containerConfigJson.trim() || undefined, - service_name: params.containerServiceName.trim() || undefined, port: params.containerPort, }; - if (params.containerUploadFileList.length > 0) { - const file = params.containerUploadFileList[0]?.originFileObj; - if (!file) { - return { success: false, data: null, message: params.t("mcpTools.add.error.imageReadFailed") } as McpToolsApiResult; - } - - const formData = new FormData(); - formData.append("file", file); - formData.append("port", String(params.containerPort)); - formData.append("service_name", params.containerServiceName.trim()); - if (params.authorizationToken) { - formData.append("env_vars", JSON.stringify({ authorization_token: params.authorizationToken })); - } - - const uploadResult = await uploadMcpRuntimeImage(formData); - if (!uploadResult.success) { - return { - success: false, - data: null, - message: uploadResult.message || params.t("mcpTools.add.error.imageUploadFailed"), - } as McpToolsApiResult; - } - - const uploadData = uploadResult.data; - const uploadedMcpUrl = uploadData && typeof uploadData.mcp_url === "string" ? uploadData.mcp_url : undefined; - finalServerUrl = uploadedMcpUrl || finalServerUrl; - containerConfigPayload.upload_result = uploadData; - - return { - success: true, - data: { - finalServerUrl, - containerConfig: containerConfigPayload, - }, - } as McpToolsApiResult<{ finalServerUrl: string; containerConfig: Record }>; - } - let parsedConfig: unknown; try { parsedConfig = JSON.parse(params.containerConfigJson); } catch { - return { success: false, data: null, message: params.t("mcpTools.add.error.containerJsonInvalid") } as McpToolsApiResult; + throw new Error("Invalid container config JSON"); } const parsedMcpServers = (parsedConfig as { mcpServers?: Record }).mcpServers; if (!parsedMcpServers || typeof parsedMcpServers !== "object") { - return { success: false, data: null, message: params.t("mcpTools.add.error.containerJsonMissingServers") } as McpToolsApiResult; + throw new Error("Missing mcpServers in container config"); } - const mcpServers = Object.fromEntries( - Object.entries(parsedMcpServers).map(([key, value]) => { - return [ - key, - { - ...value, - port: typeof value.port === "number" ? value.port : params.containerPort, - }, - ]; - }) - ); - - const addConfigResult = await addMcpRuntimeFromConfig({ mcpServers }); - if (!addConfigResult.success) { - return { - success: false, - data: null, - message: addConfigResult.message || params.t("mcpTools.add.error.containerAddFailed"), - } as McpToolsApiResult; - } - - const addConfigData = addConfigResult.data; - const firstResultMcpUrl = addConfigData?.results?.[0]?.mcp_url; - finalServerUrl = firstResultMcpUrl || finalServerUrl; - containerConfigPayload.add_from_config_result = addConfigData ?? {}; + const mcpConfigPayload = parsedConfig as AddMcpRuntimeFromConfigPayload; + containerConfigPayload.mcp_config = mcpConfigPayload; return { success: true, data: { finalServerUrl, containerConfig: containerConfigPayload, + runtimeService: undefined, + mcpConfig: mcpConfigPayload, }, - } as McpToolsApiResult<{ finalServerUrl: string; containerConfig: Record }>; + }; +}; + +export const addContainerMcpToolService = async (payload: AddContainerMcpToolPayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.addContainer, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + if (data.status !== "success") { + throw new Error("Failed to add container MCP service"); + } + return { success: true, data: data.data } as McpToolsApiResult; + } catch (error) { + log.error("addContainerMcpToolService failed", error); + throw error; + } }; export const listMcpTools = async () => { try { const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.list); const data = await parseJson>(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: [], message: data.message || "Failed to load MCP services" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to load MCP services"); } return { success: true, data: data.data } as McpToolsApiResult; } catch (error) { log.error("listMcpTools failed", error); - return { success: false, data: [], message: "Failed to load MCP services" } as McpToolsApiResult; + throw error; } }; @@ -216,13 +183,13 @@ export const listMarketMcpTools = async (query: URLSearchParams) => { try { const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.marketList}?${query.toString()}`); const data = await parseJson>(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.detail || data.message || "Failed to load market list" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to load market list"); } return { success: true, data: data.data } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>; } catch (error) { log.error("listMarketMcpTools failed", error); - return { success: false, data: null, message: "Failed to load market list" } as McpToolsApiResult; + throw error; } }; @@ -233,13 +200,13 @@ export const addMcpToolService = async (payload: AddMcpServicePayload) => { body: JSON.stringify(payload), }); const data = await parseJson(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.detail || data.message || "Failed to add MCP service" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to add MCP service"); } return { success: true, data: null } as McpToolsApiResult; } catch (error) { log.error("addMcpToolService failed", error); - return { success: false, data: null, message: "Failed to add MCP service" } as McpToolsApiResult; + throw error; } }; @@ -250,13 +217,13 @@ export const updateMcpToolService = async (payload: UpdateMcpServicePayload) => body: JSON.stringify(payload), }); const data = await parseJson(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.message || "Failed to update MCP service" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to update MCP service"); } return { success: true, data: null } as McpToolsApiResult; } catch (error) { log.error("updateMcpToolService failed", error); - return { success: false, data: null, message: "Failed to update MCP service" } as McpToolsApiResult; + throw error; } }; @@ -264,16 +231,33 @@ export const enableMcpToolService = async (payload: ToggleMcpServicePayload) => try { const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.enable, { method: "POST", - body: JSON.stringify(payload), + body: JSON.stringify({ mcp_id: payload.mcp_id }), }); const data = await parseJson(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.message || "Failed to update service status" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to update service status"); } return { success: true, data: null } as McpToolsApiResult; } catch (error) { log.error("enableMcpToolService failed", error); - return { success: false, data: null, message: "Failed to update service status" } as McpToolsApiResult; + throw error; + } +}; + +export const disableMcpToolService = async (payload: ToggleMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.disable, { + method: "POST", + body: JSON.stringify({ mcp_id: payload.mcp_id }), + }); + const data = await parseJson(response); + if (data.status !== "success") { + throw new Error("Failed to update service status"); + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("disableMcpToolService failed", error); + throw error; } }; @@ -286,82 +270,50 @@ export const healthcheckMcpToolService = async (payload: HealthcheckMcpServicePa const data = await parseJson>( response ); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.message || "Health check failed" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Health check failed"); } return { success: true, data: data.data } as McpToolsApiResult; } catch (error) { log.error("healthcheckMcpToolService failed", error); - return { success: false, data: null, message: "Health check failed" } as McpToolsApiResult; + throw error; } }; -export const deleteMcpToolService = async (name: string) => { +export const deleteMcpToolService = async (mcpId: number) => { try { - const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.delete}?name=${encodeURIComponent(name)}`, { + const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.delete}?mcp_id=${encodeURIComponent(String(mcpId))}`, { method: "DELETE", }); const data = await parseJson(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.message || "Failed to delete service" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to delete service"); } return { success: true, data: null } as McpToolsApiResult; } catch (error) { log.error("deleteMcpToolService failed", error); - return { success: false, data: null, message: "Failed to delete service" } as McpToolsApiResult; + throw error; } }; -export const listMcpRuntimeTools = async (serviceName: string, mcpUrl: string) => { +export const listMcpRuntimeTools = async (mcpId: number) => { try { - const query = new URLSearchParams({ - service_name: serviceName, - mcp_url: mcpUrl, - }); - const response = await fetchWithAuth(`${API_ENDPOINTS.mcp.tools}?${query.toString()}`, { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.tools, { method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ mcp_id: mcpId }), }); const data = await parseJson(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: [], message: data.detail || data.message || "Failed to load MCP tools" } as McpToolsApiResult; + if (data.status !== "success") { + throw new Error("Failed to load MCP tools"); } return { success: true, data: data.tools as McpTool[] } as McpToolsApiResult; } catch (error) { log.error("listMcpRuntimeTools failed", error); - return { success: false, data: [], message: "Failed to load MCP tools" } as McpToolsApiResult; + throw error; } }; -export const addMcpRuntimeFromConfig = async (payload: AddMcpRuntimeFromConfigPayload) => { - try { - const response = await fetchWithAuth(API_ENDPOINTS.mcp.addFromConfig, { - method: "POST", - body: JSON.stringify(payload), - }); - const data = await parseJson }>>(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.detail || data.message || "Failed to add MCP from config" } as McpToolsApiResult; - } - return { success: true, data: data.data } as McpToolsApiResult<{ results?: Array<{ mcp_url?: string }> }>; - } catch (error) { - log.error("addMcpRuntimeFromConfig failed", error); - return { success: false, data: null, message: "Failed to add MCP from config" } as McpToolsApiResult; - } -}; - -export const uploadMcpRuntimeImage = async (formData: FormData) => { - try { - const response = await fetchWithAuth(API_ENDPOINTS.mcp.uploadImage, { - method: "POST", - body: formData, - }); - const data = await parseJson>(response); - if (!response.ok || data.status !== "success") { - return { success: false, data: null, message: data.detail || data.message || "Failed to upload image" } as McpToolsApiResult; - } - return { success: true, data: data.data } as McpToolsApiResult<{ mcp_url?: string }>; - } catch (error) { - log.error("uploadMcpRuntimeImage failed", error); - return { success: false, data: null, message: "Failed to upload image" } as McpToolsApiResult; - } -}; +// Intentionally keep AddFromConfigApiResult type for backward compatibility in other modules. diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts index a49c6ac7a..6901688ff 100644 --- a/frontend/types/mcpTools.ts +++ b/frontend/types/mcpTools.ts @@ -1,15 +1,14 @@ -import type { UploadFile } from "antd/es/upload/interface"; import type { McpTool } from "@/types/agentConfig"; export enum McpTab { LOCAL = "local", - MARKET = "market", + MCP_REGISTRY = "mcp_registry", } -export enum McpServerType { +export enum McpTransportType { HTTP = "http", SSE = "sse", - CONTAINER = "container", + STDIO = "stdio", } export enum McpServiceStatus { @@ -31,106 +30,46 @@ export enum McpContainerStatus { export interface MarketMcpCard { name: string; - title: string; version: string; description: string; publishedAt: string; status: string; - websiteUrl: string; remotes: Array<{ type: string; url: string }>; + packages: Array<{ + registryType: string; + identifier: string; + version: string; + runtimeHint: string; + transport: { type: string; url: string }; + }>; serverJson: Record; - serverType: McpServerType; - serverUrl: string; -} - -export interface AddMcpLocalState { - newServiceName: string; - newServiceDesc: string; - newServerType: McpServerType; - newServiceUrl: string; - newServiceAuthorizationToken: string; - containerUploadFileList: UploadFile[]; - containerConfigJson: string; - containerPort: number | undefined; - containerServiceName: string; - newTagDrafts: string[]; - newTagInputValue: string; - addingService: boolean; -} - -export interface AddMcpMarketState { - marketSearchValue: string; - selectedMarketService: MarketMcpCard | null; - filteredMarketServices: MarketMcpCard[]; - marketLoading: boolean; - marketPage: number; - hasPrevMarketPage: boolean; - hasNextMarketPage: boolean; - marketVersion: string; - marketUpdatedSince: string; - marketIncludeDeleted: boolean; -} - -export interface AddMcpLocalActions { - onNewServiceNameChange: (value: string) => void; - onNewServiceDescChange: (value: string) => void; - onNewServerTypeChange: (value: McpServerType) => void; - onNewServiceUrlChange: (value: string) => void; - onNewServiceAuthorizationTokenChange: (value: string) => void; - onContainerUploadFileListChange: (fileList: UploadFile[]) => void; - onContainerConfigJsonChange: (value: string) => void; - onContainerPortChange: (value: number | undefined) => void; - onContainerServiceNameChange: (value: string) => void; - onAddNewTag: () => void; - onRemoveNewTag: (index: number) => void; - onNewTagInputChange: (value: string) => void; - onSaveAndAdd: () => void; } -export interface AddMcpMarketActions { - onMarketSearchChange: (value: string) => void; - onRefreshMarket: () => void; - onPrevMarketPage: () => void; - onNextMarketPage: () => void; - onMarketVersionChange: (value: string) => void; - onMarketUpdatedSinceChange: (value: string) => void; - onMarketIncludeDeletedChange: (value: boolean) => void; - onSelectMarketService: (service: MarketMcpCard | null) => void; - onQuickAddFromMarket: (service: MarketMcpCard) => void; -} - -export interface McpServiceDetailState { - selectedService: McpServiceItem | null; - draftService: McpServiceItem | null; - tagDrafts: string[]; - tagInputValue: string; - healthCheckLoading: boolean; - loadingTools: boolean; - toolsModalVisible: boolean; - currentServerTools: McpTool[]; -} - -export interface McpServiceDetailActions { - onDraftServiceChange: (service: McpServiceItem) => void; - onTagInputChange: (value: string) => void; - onAddDetailTag: () => void; - onRemoveTag: (index: number) => void; - onHealthCheck: () => void; - onViewTools: () => void; - onSaveUpdates: () => void; - onCloseToolsModal: () => void; - onRefreshTools: () => void; +export interface MarketQuickAddOption { + key: string; + sourceType: "remote" | "package"; + sourceLabel: string; + transportType: "http" | "sse" | "stdio"; + serverUrl?: string; + packageIdentifier?: string; + packageRegistryType?: string; + packageEnvTemplate?: Record; } export interface McpServiceItem { + mcpId: number; + containerId?: string; name: string; description: string; source: McpTab; status: McpServiceStatus; updatedAt: string; tags: string[]; - serverType: McpServerType; + transportType: McpTransportType; serverUrl: string; + version?: string | null; + mcpRegistryJson?: Record | null; + configJson?: Record | null; tools: string[]; healthStatus: McpHealthStatus; containerStatus?: McpContainerStatus; @@ -141,15 +80,17 @@ export interface AddMcpServicePayload { name: string; description: string; source: McpTab; - server_type: McpServerType; + transport_type: McpTransportType; server_url: string; tags: string[]; authorization_token?: string; container_config?: Record; + version?: string; + mcp_registry_json?: Record; } export interface UpdateMcpServicePayload { - current_name: string; + mcp_id: number; name: string; description: string; server_url: string; @@ -158,13 +99,12 @@ export interface UpdateMcpServicePayload { } export interface ToggleMcpServicePayload { - name: string; + mcp_id: number; enabled: boolean; } export interface HealthcheckMcpServicePayload { - name: string; - server_url: string; + mcp_id: number; } export interface AddMcpRuntimeServerPayload { From 1435eab2fe63283b5bf4e50d54a323297a6f697a Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Tue, 24 Mar 2026 23:59:21 +0800 Subject: [PATCH 04/59] =?UTF-8?q?Rewrite=20the=20code=20to=20rename=20?= =?UTF-8?q?=E2=80=9Cmarket=E2=80=9D=20to=20=E2=80=98registry=E2=80=99=20Re?= =?UTF-8?q?move=20the=20=E2=80=9Cmarket=5Fname=E2=80=9D=20field=20from=20t?= =?UTF-8?q?he=20mcp=5Frecord=5Ft=20SQL=20extension=20and=20correct=20the?= =?UTF-8?q?=20spelling=20error=20in=20the=20source=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构代码,将market命名转为registry 移除mcp_record_t扩展sql的market_name,修正拼写错误souce --- backend/apps/mcp_management_app.py | 22 +- backend/database/db_models.py | 5 +- backend/database/remote_mcp_db.py | 4 +- backend/services/mcp_management_service.py | 49 +--- ...ql => v1.8.2_0318_expand_mcp_record_t.sql} | 10 +- .../components/AddMcpServiceMarketSection.tsx | 166 -------------- .../components/AddMcpServiceModal.tsx | 68 +++--- .../AddMcpServiceRegistrySection.tsx | 166 ++++++++++++++ .../components/McpMarketCardList.tsx | 68 ------ ...{McpMarketCard.tsx => McpRegistryCard.tsx} | 42 ++-- .../components/McpRegistryCardList.tsx | 68 ++++++ ...ilModal.tsx => McpRegistryDetailModal.tsx} | 54 ++--- ...rketToolbar.tsx => McpRegistryToolbar.tsx} | 106 ++++----- .../mcp-tools/components/McpServiceCard.tsx | 2 +- .../components/McpServiceDetailModal.tsx | 6 +- frontend/const/mcpTools.ts | 2 +- ...AddMarket.ts => useMcpToolsAddRegistry.ts} | 214 +++++++++--------- frontend/lib/mcpTools.ts | 4 +- frontend/public/locales/en/common.json | 77 +++---- frontend/public/locales/zh/common.json | 77 +++---- frontend/services/api.ts | 2 +- frontend/services/mcpToolsService.ts | 22 +- frontend/types/mcpTools.ts | 6 +- 23 files changed, 605 insertions(+), 635 deletions(-) rename docker/sql/{v1.8.2_0318_expand_mcp_record.sql => v1.8.2_0318_expand_mcp_record_t.sql} (79%) delete mode 100644 frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx create mode 100644 frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx delete mode 100644 frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx rename frontend/app/[locale]/mcp-tools/components/{McpMarketCard.tsx => McpRegistryCard.tsx} (54%) create mode 100644 frontend/app/[locale]/mcp-tools/components/McpRegistryCardList.tsx rename frontend/app/[locale]/mcp-tools/components/{McpMarketDetailModal.tsx => McpRegistryDetailModal.tsx} (79%) rename frontend/app/[locale]/mcp-tools/components/{McpMarketToolbar.tsx => McpRegistryToolbar.tsx} (51%) rename frontend/hooks/mcpTools/{useMcpToolsAddMarket.ts => useMcpToolsAddRegistry.ts} (64%) diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py index b10445555..df31769ed 100644 --- a/backend/apps/mcp_management_app.py +++ b/backend/apps/mcp_management_app.py @@ -17,7 +17,7 @@ check_mcp_service_health_legacy, delete_mcp_service, delete_mcp_service_legacy, - list_market_mcp_services, + list_registry_mcp_services, list_mcp_service_tools_by_id, list_mcp_services, update_mcp_service, @@ -41,7 +41,7 @@ class AddMcpServiceRequest(BaseModel): authorization_token: Optional[str] = None container_config: Optional[dict[str, Any]] = None version: Optional[str] = None - mcp_registry_json: Optional[dict[str, Any]] = None + registry_json: Optional[dict[str, Any]] = None class AddContainerMcpServiceRequest(BaseModel): @@ -114,7 +114,7 @@ async def add_mcp_service_api( authorization_token = (payload.authorization_token or "").strip() container_config = payload.container_config version = (payload.version or "").strip() - mcp_registry_json = payload.mcp_registry_json + registry_json = payload.registry_json await add_mcp_service( tenant_id=tenant_id, @@ -128,7 +128,7 @@ async def add_mcp_service_api( authorization_token=authorization_token, container_config=container_config, version=version, - mcp_registry_json=mcp_registry_json, + registry_json=registry_json, ) return JSONResponse( status_code=HTTPStatus.OK, @@ -231,7 +231,7 @@ async def add_container_mcp_service_api( authorization_token=auth_token, container_config=container_config, version=None, - mcp_registry_json=None, + registry_json=None, enabled=True, container_id=container_info.get("container_id"), ) @@ -299,8 +299,8 @@ async def list_mcp_services_api( ) -@router.get("/market/list") -async def list_market_mcp_services_api( +@router.get("/registry/list") +async def list_registry_mcp_services_api( search: Optional[str] = None, include_deleted: bool = False, updated_since: Optional[str] = None, @@ -314,7 +314,7 @@ async def list_market_mcp_services_api( # Keep auth behavior consistent with other mcp-tools APIs. get_current_user_info(authorization, http_request) - market_data = await list_market_mcp_services( + data = await list_registry_mcp_services( search=(search or "").strip() or None, include_deleted=bool(include_deleted), updated_since=(updated_since or "").strip() or None, @@ -325,15 +325,15 @@ async def list_market_mcp_services_api( ) return JSONResponse( status_code=HTTPStatus.OK, - content={"status": "success", "data": market_data}, + content={"status": "success", "data": data}, ) except HTTPException: raise except Exception as exc: - logger.error(f"Failed to list market MCP services: {exc}") + logger.error(f"Failed to list MCP registry MCP services: {exc}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to list market MCP services", + detail="Failed to list MCP registry MCP services", ) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index d19efefa8..c81266bdc 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -334,10 +334,9 @@ class McpRecord(TableBase): doc="Authorization token for MCP server authentication (e.g., Bearer token)", default=None, ) - souce = Column(String(30), doc="Source type: local/mcp_registry") - market_name = Column(String(200), doc="Market identifier") + source = Column(String(30), doc="Source type: local/mcp_registry") version = Column(String(50), doc="MCP version") - mcp_registry_json = Column(JSONB, doc="Full MCP registry server.json snapshot") + registry_json = Column(JSONB, doc="Full MCP registry server.json snapshot") transport_type = Column(String(30), doc="Transport type: streamable-http/sse/stdio") config_json = Column(JSON, doc="MCP config data") enabled = Column(Boolean, default=True, doc="Enabled") diff --git a/backend/database/remote_mcp_db.py b/backend/database/remote_mcp_db.py index 84ee83840..ca94da83a 100644 --- a/backend/database/remote_mcp_db.py +++ b/backend/database/remote_mcp_db.py @@ -105,7 +105,7 @@ def update_mcp_record_manage_fields_by_id( server_url: str, description: str | None, tags: List[str] | None, - souce: str | None, + source: str | None, transport_type: str | None, authorization_token: str | None, config_json: Dict[str, Any] | None, @@ -121,7 +121,7 @@ def update_mcp_record_manage_fields_by_id( "mcp_server": server_url, "description": description, "tags": ",".join(tags or []), - "souce": souce, + "source": source, "transport_type": transport_type, "authorization_token": authorization_token, "config_json": config_json, diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py index 322d04151..1047ec31b 100644 --- a/backend/services/mcp_management_service.py +++ b/backend/services/mcp_management_service.py @@ -92,7 +92,7 @@ async def _start_container_by_id_for_record(record: Dict[str, Any]) -> Dict[str, return container_info -def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: +def _normalize_mcp_registry_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: server = entry.get("server") if isinstance(entry, dict) else None if not isinstance(server, dict): return None @@ -154,35 +154,7 @@ def _normalize_market_server(entry: Dict[str, Any]) -> Dict[str, Any] | None: "serverJson": server, } - -def _pick_latest_market_servers(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - grouped: Dict[str, Dict[str, Any]] = {} - for item in items: - key = str(item.get("name") or "").strip().lower() - if not key: - continue - existing = grouped.get(key) - if existing is None: - grouped[key] = item - continue - - existing_latest = bool(existing.get("isLatest")) - current_latest = bool(item.get("isLatest")) - if current_latest and not existing_latest: - grouped[key] = item - continue - if current_latest == existing_latest: - existing_time = _extract_str(existing.get("updatedAt")) or _extract_str(existing.get("publishedAt")) - current_time = _extract_str(item.get("updatedAt")) or _extract_str(item.get("publishedAt")) - if current_time > existing_time: - grouped[key] = item - - result = list(grouped.values()) - result.sort(key=lambda x: (_extract_str(x.get("updatedAt")) or _extract_str(x.get("publishedAt"))), reverse=True) - return result - - -async def list_market_mcp_services( +async def list_registry_mcp_services( *, search: str | None = None, include_deleted: bool = False, @@ -216,7 +188,7 @@ async def list_market_mcp_services( normalized: List[Dict[str, Any]] = [] if isinstance(raw_servers, list): for entry in raw_servers: - normalized_item = _normalize_market_server(entry) + normalized_item = _normalize_mcp_registry_server(entry) if normalized_item: normalized.append(normalized_item) @@ -242,12 +214,11 @@ async def add_mcp_service( authorization_token: str | None, container_config: Dict[str, Any] | None, version: str | None, - mcp_registry_json: Dict[str, Any] | None, + registry_json: Dict[str, Any] | None, enabled: bool = False, container_id: str | None = None, ) -> None: normalized_source = (source or "local").strip().lower() - normalized_source = "mcp_registry" if normalized_source in {"market", "registry"} else normalized_source if normalized_source not in {"local", "mcp_registry"}: raise ValueError(f"Invalid source: {source}") @@ -271,9 +242,9 @@ async def add_mcp_service( "status": status, "container_id": normalized_container_id, "authorization_token": authorization_token, - "souce": normalized_source, + "source": normalized_source, "version": version, - "mcp_registry_json": mcp_registry_json, + "registry_json": registry_json, "transport_type": normalized_transport_type, "enabled": enabled, "tags": ",".join(tags or []), @@ -305,11 +276,11 @@ def list_mcp_services(tenant_id: str) -> List[Dict[str, Any]]: logger.warning(f"Failed to load container runtime status: {exc}") for record in records: - souce = (record.get("souce") or record.get("source_type") or "").lower() + source = (record.get("source") or "").lower() transport_type = (record.get("transport_type") or "").lower() enabled = bool(record.get("enabled")) status = record.get("status") - registry_json = record.get("mcp_registry_json") if isinstance(record.get("mcp_registry_json"), dict) else None + registry_json = record.get("registry_json") if isinstance(record.get("registry_json"), dict) else None raw_config_json = record.get("config_json") if isinstance(record.get("config_json"), dict) else None container_id = _extract_str(record.get("container_id")) @@ -327,7 +298,7 @@ def list_mcp_services(tenant_id: str) -> List[Dict[str, Any]]: "containerId": container_id or None, "name": record.get("mcp_name"), "description": record.get("description") or record.get("category") or "", - "source": souce or "local", + "source": source or "local", "status": "enabled" if enabled else "disabled", "updatedAt": _format_time(record.get("update_time")), "tags": _split_tags(record.get("tags")), @@ -390,7 +361,7 @@ def update_mcp_service( name=new_name, description=description, server_url=server_url, - souce=(current_record.get("souce") or current_record.get("source_type") or "local"), + source=(current_record.get("source") or "local"), transport_type=(current_record.get("transport_type") or "streamable-http"), authorization_token=authorization_token, config_json=config_json, diff --git a/docker/sql/v1.8.2_0318_expand_mcp_record.sql b/docker/sql/v1.8.2_0318_expand_mcp_record_t.sql similarity index 79% rename from docker/sql/v1.8.2_0318_expand_mcp_record.sql rename to docker/sql/v1.8.2_0318_expand_mcp_record_t.sql index 6c852917e..9e7f98cc4 100644 --- a/docker/sql/v1.8.2_0318_expand_mcp_record.sql +++ b/docker/sql/v1.8.2_0318_expand_mcp_record_t.sql @@ -8,10 +8,9 @@ BEGIN; -- 1) Extend mcp_record_t with final column names (idempotent) ALTER TABLE IF EXISTS nexent.mcp_record_t - ADD COLUMN IF NOT EXISTS souce VARCHAR(30), - ADD COLUMN IF NOT EXISTS market_name VARCHAR(200), + ADD COLUMN IF NOT EXISTS source VARCHAR(30), ADD COLUMN IF NOT EXISTS version VARCHAR(50), - ADD COLUMN IF NOT EXISTS mcp_registry_json JSONB, + ADD COLUMN IF NOT EXISTS registry_json JSONB, ADD COLUMN IF NOT EXISTS transport_type VARCHAR(30), ADD COLUMN IF NOT EXISTS config_json JSON, ADD COLUMN IF NOT EXISTS enabled BOOLEAN DEFAULT TRUE, @@ -20,10 +19,9 @@ ALTER TABLE IF EXISTS nexent.mcp_record_t ADD COLUMN IF NOT EXISTS last_sync_time TIMESTAMP WITHOUT TIME ZONE; -- 2) Add comments for new columns -COMMENT ON COLUMN nexent.mcp_record_t.souce IS 'Source type: local/mcp_registry'; -COMMENT ON COLUMN nexent.mcp_record_t.market_name IS 'Market identifier'; +COMMENT ON COLUMN nexent.mcp_record_t.source IS 'Source type: local/mcp_registry'; COMMENT ON COLUMN nexent.mcp_record_t.version IS 'MCP version'; -COMMENT ON COLUMN nexent.mcp_record_t.mcp_registry_json IS 'Full MCP registry server.json snapshot'; +COMMENT ON COLUMN nexent.mcp_record_t.registry_json IS 'Full MCP registry server.json snapshot'; COMMENT ON COLUMN nexent.mcp_record_t.transport_type IS 'Transport type: streamable-http/sse/stdio'; COMMENT ON COLUMN nexent.mcp_record_t.config_json IS 'MCP config data'; COMMENT ON COLUMN nexent.mcp_record_t.enabled IS 'Enabled'; diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx deleted file mode 100644 index dd88f092d..000000000 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceMarketSection.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { Button, Modal, Radio } from "antd"; -import McpMarketToolbar from "./McpMarketToolbar"; -import McpMarketCardList from "./McpMarketCardList"; -import McpMarketDetailModal from "./McpMarketDetailModal"; -import type { MarketMcpCard, MarketQuickAddOption } from "@/types/mcpTools"; - -interface Props { - marketSearchValue: string; - selectedMarketService: MarketMcpCard | null; - filteredMarketServices: MarketMcpCard[]; - marketLoading: boolean; - marketPage: number; - hasPrevMarketPage: boolean; - hasNextMarketPage: boolean; - marketVersion: string; - marketUpdatedSince: string; - marketIncludeDeleted: boolean; - quickAddPickerVisible: boolean; - quickAddCandidateService: MarketMcpCard | null; - quickAddOptions: MarketQuickAddOption[]; - selectedQuickAddOptionKey: string; - quickAddSubmitting: boolean; - setMarketSearchValue: (value: string) => void; - setSelectedMarketService: (service: MarketMcpCard | null) => void; - setMarketVersion: (value: string) => void; - setMarketUpdatedSince: (value: string) => void; - setMarketIncludeDeleted: (value: boolean) => void; - setSelectedQuickAddOptionKey: (value: string) => void; - handleMarketPrevPage: () => void; - handleMarketNextPage: () => void; - handleQuickAddFromMarket: (service: MarketMcpCard) => void; - handleCloseQuickAddPicker: () => void; - handleConfirmQuickAddOption: () => Promise; - t: (key: string, params?: Record) => string; -} - -export default function AddMcpServiceMarketSection({ - marketSearchValue, - selectedMarketService, - filteredMarketServices, - marketLoading, - marketPage, - hasPrevMarketPage, - hasNextMarketPage, - marketVersion, - marketUpdatedSince, - marketIncludeDeleted, - quickAddPickerVisible, - quickAddCandidateService, - quickAddOptions, - selectedQuickAddOptionKey, - quickAddSubmitting, - setMarketSearchValue, - setSelectedMarketService, - setMarketVersion, - setMarketUpdatedSince, - setMarketIncludeDeleted, - setSelectedQuickAddOptionKey, - handleMarketPrevPage, - handleMarketNextPage, - handleQuickAddFromMarket, - handleCloseQuickAddPicker, - handleConfirmQuickAddOption, - t, -}: Props) { - return ( - <> -
    - - - -
    - - {selectedMarketService ? ( - setSelectedMarketService(null)} - onQuickAddFromMarket={handleQuickAddFromMarket} - /> - ) : null} - - -
    -

    - {t("mcpTools.market.quickAddPicker.description", { - name: quickAddCandidateService?.name || "-", - })} -

    - - setSelectedQuickAddOptionKey(String(event.target.value || ""))} - className="flex w-full flex-col gap-2" - > - {quickAddOptions.map((option) => { - const sourceLabel = - option.sourceType === "remote" - ? t("mcpTools.market.quickAddPicker.sourceRemote") - : t("mcpTools.market.quickAddPicker.sourcePackage"); - - return ( - -
    -

    {sourceLabel}

    -

    {option.sourceLabel}

    -
    -
    - ); - })} -
    - -
    - - -
    -
    -
    - - ); -} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx index 00270bf98..840192485 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next"; import { MCP_TAB } from "@/const/mcpTools"; import type { McpTab } from "@/types/mcpTools"; import { useMcpToolsAddLocal } from "@/hooks/mcpTools/useMcpToolsAddLocal"; -import { useMcpToolsAddMarket } from "@/hooks/mcpTools/useMcpToolsAddMarket"; +import { useMcpToolsAddRegistry } from "@/hooks/mcpTools/useMcpToolsAddRegistry"; import AddMcpServiceLocalSection from "./AddMcpServiceLocalSection"; -import AddMcpServiceMarketSection from "./AddMcpServiceMarketSection"; +import AddMcpServiceRegistrySection from "./AddMcpServiceRegistrySection"; interface AddMcpServiceModalProps { open: boolean; @@ -31,7 +31,7 @@ export default function AddMcpServiceModal({ onClose, }); - const market = useMcpToolsAddMarket({ + const registry = useMcpToolsAddRegistry({ open, addModalTab, t: (key) => String(t(key)), @@ -41,15 +41,15 @@ export default function AddMcpServiceModal({ }); const { reset: resetLocal } = local; - const { reset: resetMarket } = market; + const { reset: resetRegistry } = registry; useEffect(() => { if (!open) { setAddModalTab(MCP_TAB.LOCAL); resetLocal(); - resetMarket(); + resetRegistry(); } - }, [open, resetLocal, resetMarket]); + }, [open, resetLocal, resetRegistry]); if (!open) { return null; @@ -81,7 +81,7 @@ export default function AddMcpServiceModal({ onChange={(value) => setAddModalTab(value as McpTab)} options={[ { label: t("mcpTools.addModal.tabLocal"), value: MCP_TAB.LOCAL }, - { label: t("mcpTools.addModal.tabMarket"), value: MCP_TAB.MCP_REGISTRY }, + { label: t("mcpTools.addModal.tabRegistry"), value: MCP_TAB.MCP_REGISTRY }, ]} className="h-9 rounded-full border border-slate-200 bg-slate-100 p-[2px] text-sm [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-full [&_.ant-segmented-item-label]:px-4 [&_.ant-segmented-item-label]:leading-[30px] [&_.ant-segmented-thumb]:rounded-full [&_.ant-segmented-thumb]:bg-white [&_.ant-segmented-thumb]:shadow-sm [&_.ant-segmented-thumb]:top-[2px] [&_.ant-segmented-thumb]:bottom-[2px]" /> @@ -113,33 +113,33 @@ export default function AddMcpServiceModal({ t={(key, params) => String(t(key, params))} /> ) : ( - String(t(key, params))} /> )} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx new file mode 100644 index 000000000..9d7c027cf --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -0,0 +1,166 @@ +import { Button, Modal, Radio } from "antd"; +import McpRegistryToolbar from "./McpRegistryToolbar"; +import McpRegistryCardList from "./McpRegistryCardList"; +import McpRegistryDetailModal from "./McpRegistryDetailModal"; +import type { RegistryMcpCard, RegistryQuickAddOption } from "@/types/mcpTools"; + +interface Props { + registrySearchValue: string; + selectedRegistryService: RegistryMcpCard | null; + filteredRegistryServices: RegistryMcpCard[]; + registryLoading: boolean; + registryPage: number; + hasPrevRegistryPage: boolean; + hasNextRegistryPage: boolean; + registryVersion: string; + registryUpdatedSince: string; + registryIncludeDeleted: boolean; + quickAddPickerVisible: boolean; + quickAddCandidateService: RegistryMcpCard | null; + quickAddOptions: RegistryQuickAddOption[]; + selectedQuickAddOptionKey: string; + quickAddSubmitting: boolean; + setRegistrySearchValue: (value: string) => void; + setSelectedRegistryService: (service: RegistryMcpCard | null) => void; + setRegistryVersion: (value: string) => void; + setRegistryUpdatedSince: (value: string) => void; + setRegistryIncludeDeleted: (value: boolean) => void; + setSelectedQuickAddOptionKey: (value: string) => void; + handleRegistryPrevPage: () => void; + handleRegistryNextPage: () => void; + handleQuickAddFromRegistry: (service: RegistryMcpCard) => void; + handleCloseQuickAddPicker: () => void; + handleConfirmQuickAddOption: () => Promise; + t: (key: string, params?: Record) => string; +} + +export default function AddMcpServiceRegistrySection({ + registrySearchValue, + selectedRegistryService, + filteredRegistryServices, + registryLoading, + registryPage, + hasPrevRegistryPage, + hasNextRegistryPage, + registryVersion, + registryUpdatedSince, + registryIncludeDeleted, + quickAddPickerVisible, + quickAddCandidateService, + quickAddOptions, + selectedQuickAddOptionKey, + quickAddSubmitting, + setRegistrySearchValue, + setSelectedRegistryService, + setRegistryVersion, + setRegistryUpdatedSince, + setRegistryIncludeDeleted, + setSelectedQuickAddOptionKey, + handleRegistryPrevPage, + handleRegistryNextPage, + handleQuickAddFromRegistry, + handleCloseQuickAddPicker, + handleConfirmQuickAddOption, + t, +}: Props) { + return ( + <> +
    + + + +
    + + {selectedRegistryService ? ( + setSelectedRegistryService(null)} + onQuickAddFromRegistry={handleQuickAddFromRegistry} + /> + ) : null} + + +
    +

    + {t("mcpTools.registry.quickAddPicker.description", { + name: quickAddCandidateService?.name || "-", + })} +

    + + setSelectedQuickAddOptionKey(String(event.target.value || ""))} + className="flex w-full flex-col gap-2" + > + {quickAddOptions.map((option) => { + const sourceLabel = + option.sourceType === "remote" + ? t("mcpTools.registry.quickAddPicker.sourceRemote") + : t("mcpTools.registry.quickAddPicker.sourcePackage"); + + return ( + +
    +

    {sourceLabel}

    +

    {option.sourceLabel}

    +
    +
    + ); + })} +
    + +
    + + +
    +
    +
    + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx b/frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx deleted file mode 100644 index 4897116e1..000000000 --- a/frontend/app/[locale]/mcp-tools/components/McpMarketCardList.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button } from "antd"; -import type { MarketMcpCard } from "@/types/mcpTools"; -import McpMarketCard from "./McpMarketCard"; - -interface Props { - marketLoading: boolean; - services: MarketMcpCard[]; - hasPrevMarketPage: boolean; - hasNextMarketPage: boolean; - onPrevMarketPage: () => void; - onNextMarketPage: () => void; - onSelectMarketService: (service: MarketMcpCard) => void; - onQuickAddFromMarket: (service: MarketMcpCard) => void; - t: (key: string, params?: Record) => string; -} - -export default function McpMarketCardList({ - marketLoading, - services, - hasPrevMarketPage, - hasNextMarketPage, - onPrevMarketPage, - onNextMarketPage, - onSelectMarketService, - onQuickAddFromMarket, - t, -}: Props) { - if (marketLoading) { - return ( -
    - {t("mcpTools.market.loading")} -
    - ); - } - - if (services.length === 0) { - return ( -
    - {t("mcpTools.market.empty")} -
    - ); - } - - return ( -
    -
    - {services.map((service, index) => ( - - ))} -
    - -
    - - -
    -
    - ); -} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx similarity index 54% rename from frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx rename to frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx index 2da2260c1..b46395f9d 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpMarketCard.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx @@ -1,37 +1,37 @@ import { Button } from "antd"; -import { MARKET_SERVER_STATUS } from "@/const/mcpTools"; -import { formatMarketDate, formatMarketVersion } from "@/lib/mcpTools"; -import type { MarketMcpCard } from "@/types/mcpTools"; +import { MCP_REGISTRY_SERVER_STATUS } from "@/const/mcpTools"; +import { formatRegistryDate, formatRegistryVersion } from "@/lib/mcpTools"; +import type { RegistryMcpCard as RegistryMcpCard } from "@/types/mcpTools"; interface Props { - service: MarketMcpCard; + service: RegistryMcpCard; t: (key: string, params?: Record) => string; - onSelectMarketService: (service: MarketMcpCard) => void; - onQuickAddFromMarket: (service: MarketMcpCard) => void; + onSelectRegistryService: (service: RegistryMcpCard) => void; + onQuickAddFromRegistry: (service: RegistryMcpCard) => void; } -export default function McpMarketCard({ +export default function McpRegistryCard({ service, t, - onSelectMarketService, - onQuickAddFromMarket, + onSelectRegistryService: onSelectRegistryService, + onQuickAddFromRegistry: onQuickAddFromRegistry, }: Props) { const statusClassName = - service.status === MARKET_SERVER_STATUS.ACTIVE + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE ? "bg-emerald-100 text-emerald-700" - : service.status === MARKET_SERVER_STATUS.DEPRECATED + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED ? "bg-amber-100 text-amber-700" : "bg-slate-100 text-slate-600"; const statusTextKey = - service.status === MARKET_SERVER_STATUS.ACTIVE - ? "mcpTools.market.status.active" - : service.status === MARKET_SERVER_STATUS.DEPRECATED - ? "mcpTools.market.status.deprecated" - : "mcpTools.market.status.unknown"; + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + ? "mcpTools.registry.status.active" + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + ? "mcpTools.registry.status.deprecated" + : "mcpTools.registry.status.unknown"; return (
    onSelectMarketService(service)} + onClick={() => onSelectRegistryService(service)} className="group rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" >
    @@ -43,9 +43,9 @@ export default function McpMarketCard({
    - {formatMarketVersion(service.version)} + {formatRegistryVersion(service.version)} - {formatMarketDate(service.publishedAt)} + {formatRegistryDate(service.publishedAt)}

    {service.description}

    @@ -57,10 +57,10 @@ export default function McpMarketCard({ className="rounded-full" onClick={(event) => { event.stopPropagation(); - onQuickAddFromMarket(service); + onQuickAddFromRegistry(service); }} > - {t("mcpTools.market.quickAdd")} + {t("mcpTools.registry.quickAdd")}
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpRegistryCardList.tsx b/frontend/app/[locale]/mcp-tools/components/McpRegistryCardList.tsx new file mode 100644 index 000000000..89f721bd8 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpRegistryCardList.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import type { RegistryMcpCard } from "@/types/mcpTools"; +import McpRegistryCard from "./McpRegistryCard"; + +interface Props { + registryLoading: boolean; + services: RegistryMcpCard[]; + hasPrevRegistryPage: boolean; + hasNextRegistryPage: boolean; + onPrevRegistryPage: () => void; + onNextRegistryPage: () => void; + onSelectRegistryService: (service: RegistryMcpCard) => void; + onQuickAddFromRegistry: (service: RegistryMcpCard) => void; + t: (key: string, params?: Record) => string; +} + +export default function McpRegistryCardList({ + registryLoading, + services, + hasPrevRegistryPage, + hasNextRegistryPage, + onPrevRegistryPage, + onNextRegistryPage, + onSelectRegistryService, + onQuickAddFromRegistry, + t, +}: Props) { + if (registryLoading) { + return ( +
    + {t("mcpTools.registry.loading")} +
    + ); + } + + if (services.length === 0) { + return ( +
    + {t("mcpTools.registry.empty")} +
    + ); + } + + return ( +
    +
    + {services.map((service, index) => ( + + ))} +
    + +
    + + +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpRegistryDetailModal.tsx similarity index 79% rename from frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx rename to frontend/app/[locale]/mcp-tools/components/McpRegistryDetailModal.tsx index c76eb0d69..0b9f65985 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpMarketDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpRegistryDetailModal.tsx @@ -1,26 +1,26 @@ import { useState } from "react"; import { Button, Modal } from "antd"; -import { MARKET_SERVER_STATUS } from "@/const/mcpTools"; +import { MCP_REGISTRY_SERVER_STATUS } from "@/const/mcpTools"; import { extractRegistryLinks, - formatMarketDate, - formatMarketVersion, + formatRegistryDate, + formatRegistryVersion, toPrettyRegistryJson, } from "@/lib/mcpTools"; -import type { MarketMcpCard } from "@/types/mcpTools"; +import type { RegistryMcpCard } from "@/types/mcpTools"; interface Props { - service: MarketMcpCard; + service: RegistryMcpCard; t: (key: string, params?: Record) => string; onClose: () => void; - onQuickAddFromMarket: (service: MarketMcpCard) => void; + onQuickAddFromRegistry: (service: RegistryMcpCard) => void; } -export default function McpMarketDetailModal({ +export default function McpRegistryDetailModal({ service, t, onClose, - onQuickAddFromMarket, + onQuickAddFromRegistry: onQuickAddFromRegistry, }: Props) { const [showServerJsonModal, setShowServerJsonModal] = useState(false); const { websiteUrl, repositoryUrl } = extractRegistryLinks(service.serverJson); @@ -28,17 +28,17 @@ export default function McpMarketDetailModal({ const hasServerJson = Boolean(service.serverJson && Object.keys(service.serverJson).length > 0); const statusClassName = - service.status === MARKET_SERVER_STATUS.ACTIVE + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE ? "bg-emerald-100 text-emerald-700" - : service.status === MARKET_SERVER_STATUS.DEPRECATED + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED ? "bg-amber-100 text-amber-700" : "bg-slate-100 text-slate-600"; const statusTextKey = - service.status === MARKET_SERVER_STATUS.ACTIVE - ? "mcpTools.market.status.active" - : service.status === MARKET_SERVER_STATUS.DEPRECATED - ? "mcpTools.market.status.deprecated" - : "mcpTools.market.status.unknown"; + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + ? "mcpTools.registry.status.active" + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + ? "mcpTools.registry.status.deprecated" + : "mcpTools.registry.status.unknown"; return ( <> @@ -59,7 +59,7 @@ export default function McpMarketDetailModal({

    {service.name}

    -

    {formatMarketVersion(service.version)}

    +

    {formatRegistryVersion(service.version)}

    {t(statusTextKey)} @@ -70,13 +70,13 @@ export default function McpMarketDetailModal({

    {service.description}

    -

    {formatMarketDate(service.publishedAt)}

    +

    {formatRegistryDate(service.publishedAt)}

    {websiteUrl || repositoryUrl ? (
    {websiteUrl ? (
    - {t("mcpTools.market.website")} + {t("mcpTools.registry.website")} - {t("mcpTools.market.repository")} + {t("mcpTools.registry.repository")} 0 ? (
    -

    {t("mcpTools.market.remotes")}

    +

    {t("mcpTools.registry.remotes")}

    {service.remotes.map((remote, index) => (
    -

    {remote.type || t("mcpTools.market.remoteFallback")}

    +

    {remote.type || t("mcpTools.registry.remoteFallback")}

    {remote.url}

    ))} @@ -120,7 +120,7 @@ export default function McpMarketDetailModal({ {service.packages.length > 0 ? (
    -

    {t("mcpTools.market.packages")}

    +

    {t("mcpTools.registry.packages")}

    {service.packages.map((pkg, index) => (
    @@ -128,7 +128,7 @@ export default function McpMarketDetailModal({

    {pkg.registryType || "-"}{pkg.version ? `@${pkg.version}` : ""}

    {pkg.runtimeHint ?

    {pkg.runtimeHint}

    : null} {pkg.transport?.url ? ( -

    {pkg.transport.type || t("mcpTools.market.remoteFallback")}: {pkg.transport.url}

    +

    {pkg.transport.type || t("mcpTools.registry.remoteFallback")}: {pkg.transport.url}

    ) : null}
    ))} @@ -140,11 +140,11 @@ export default function McpMarketDetailModal({
    {hasServerJson ? ( ) : null} -
    @@ -158,7 +158,7 @@ export default function McpMarketDetailModal({ centered width={960} onCancel={() => setShowServerJsonModal(false)} - title={t("mcpTools.market.serverJsonTitle", { name: service.name })} + title={t("mcpTools.registry.serverJsonTitle", { name: service.name })} >
                 {serverJsonPretty}
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx b/frontend/app/[locale]/mcp-tools/components/McpRegistryToolbar.tsx
    similarity index 51%
    rename from frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx
    rename to frontend/app/[locale]/mcp-tools/components/McpRegistryToolbar.tsx
    index d4e5db6cc..97922c2be 100644
    --- a/frontend/app/[locale]/mcp-tools/components/McpMarketToolbar.tsx
    +++ b/frontend/app/[locale]/mcp-tools/components/McpRegistryToolbar.tsx
    @@ -4,84 +4,84 @@ import dayjs from "dayjs";
     import { VERSION_PATTERN } from "@/lib/mcpTools";
     
     interface Props {
    -  marketSearchValue: string;
    -  marketPage: number;
    +  registrySearchValue: string;
    +  registryPage: number;
       resultCount: number;
    -  marketVersion: string;
    -  marketUpdatedSince: string;
    -  marketIncludeDeleted: boolean;
    -  onMarketSearchChange: (value: string) => void;
    -  onMarketVersionChange: (value: string) => void;
    -  onMarketUpdatedSinceChange: (value: string) => void;
    -  onMarketIncludeDeletedChange: (value: boolean) => void;
    +  registryVersion: string;
    +  registryUpdatedSince: string;
    +  registryIncludeDeleted: boolean;
    +  onRegistrySearchChange: (value: string) => void;
    +  onRegistryVersionChange: (value: string) => void;
    +  onRegistryUpdatedSinceChange: (value: string) => void;
    +  onRegistryIncludeDeletedChange: (value: boolean) => void;
       t: (key: string, params?: Record) => string;
     }
     
    -export default function McpMarketToolbar({
    -  marketSearchValue,
    -  marketPage,
    +export default function McpRegistryToolbar({
    +  registrySearchValue,
    +  registryPage,
       resultCount,
    -  marketVersion,
    -  marketUpdatedSince,
    -  marketIncludeDeleted,
    -  onMarketSearchChange,
    -  onMarketVersionChange,
    -  onMarketUpdatedSinceChange,
    -  onMarketIncludeDeletedChange,
    +  registryVersion,
    +  registryUpdatedSince,
    +  registryIncludeDeleted,
    +  onRegistrySearchChange,
    +  onRegistryVersionChange,
    +  onRegistryUpdatedSinceChange,
    +  onRegistryIncludeDeletedChange,
       t,
     }: Props) {
    -  const [marketVersionMode, setMarketVersionMode] = useState<"all" | "latest" | "custom">("latest");
    +  const [registryVersionMode, setRegistryVersionMode] = useState<"all" | "latest" | "custom">("latest");
       const [customVersion, setCustomVersion] = useState("");
     
       const updatedSinceDateValue = useMemo(() => {
    -    if (!marketUpdatedSince) return null;
    -    const parsed = dayjs(marketUpdatedSince);
    +    if (!registryUpdatedSince) return null;
    +    const parsed = dayjs(registryUpdatedSince);
         return parsed.isValid() ? parsed : null;
    -  }, [marketUpdatedSince]);
    +  }, [registryUpdatedSince]);
     
       const customVersionError = customVersion.trim().length > 0 && !VERSION_PATTERN.test(customVersion.trim());
     
       useEffect(() => {
    -    const value = (marketVersion || "").trim();
    +    const value = (registryVersion || "").trim();
         if (!value) {
    -      setMarketVersionMode("all");
    +      setRegistryVersionMode("all");
           setCustomVersion("");
           return;
         }
         if (value.toLowerCase() === "latest") {
    -      setMarketVersionMode("latest");
    +      setRegistryVersionMode("latest");
           setCustomVersion("");
           return;
         }
    -    setMarketVersionMode("custom");
    +    setRegistryVersionMode("custom");
         setCustomVersion(value);
    -  }, [marketVersion]);
    +  }, [registryVersion]);
     
       const handleVersionModeChange = (mode: "all" | "latest" | "custom") => {
    -    setMarketVersionMode(mode);
    +    setRegistryVersionMode(mode);
         if (mode === "all") {
           setCustomVersion("");
    -      onMarketVersionChange("");
    +      onRegistryVersionChange("");
           return;
         }
         if (mode === "latest") {
           setCustomVersion("");
    -      onMarketVersionChange("latest");
    +      onRegistryVersionChange("latest");
           return;
         }
         setCustomVersion("");
    -    onMarketVersionChange("");
    +    onRegistryVersionChange("");
       };
     
       const handleCustomVersionChange = (value: string) => {
         setCustomVersion(value);
         const trimmed = value.trim();
         if (!trimmed) {
    -      onMarketVersionChange("");
    +      onRegistryVersionChange("");
           return;
         }
         if (VERSION_PATTERN.test(trimmed)) {
    -      onMarketVersionChange(trimmed);
    +      onRegistryVersionChange(trimmed);
         }
       };
     
    @@ -89,63 +89,63 @@ export default function McpMarketToolbar({
         
    onMarketSearchChange(event.target.value)} - placeholder={t("mcpTools.market.searchPlaceholder")} + value={registrySearchValue} + onChange={(event) => onRegistrySearchChange(event.target.value)} + placeholder={t("mcpTools.registry.searchPlaceholder")} size="large" className="w-full rounded-2xl" />
    - {t("mcpTools.market.pageResult", { page: marketPage, count: resultCount })} + {t("mcpTools.registry.pageResult", { page: registryPage, count: resultCount })}
    ) : null} diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx index 150f37bd2..fa15ef1ff 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx @@ -48,7 +48,7 @@ export default function McpServiceCard({
    - {service.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.mcp_registry")} + {service.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.registry")} {service.transportType === MCP_TRANSPORT_TYPE.HTTP diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx index 4a0c6884c..d7b4f32ec 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -187,7 +187,7 @@ export default function McpServiceDetailModal({
    {t("mcpTools.detail.source")} - {draftService.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.mcp_registry")} + {draftService.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.registry")}
    @@ -286,7 +286,7 @@ export default function McpServiceDetailModal({ autoInsertSpace={false} onClick={() => setShowServerJsonModal(true)} > - {t("mcpTools.market.viewServerJson")} + {t("mcpTools.registry.viewServerJson")} ) : null} {hasConfigJson ? ( @@ -386,7 +386,7 @@ export default function McpServiceDetailModal({ centered width={960} onCancel={() => setShowServerJsonModal(false)} - title={t("mcpTools.market.serverJsonTitle", { name: draftService.name })} + title={t("mcpTools.registry.serverJsonTitle", { name: draftService.name })} >
                 {registryJsonPretty}
    diff --git a/frontend/const/mcpTools.ts b/frontend/const/mcpTools.ts
    index 2c6fff737..87c63ce57 100644
    --- a/frontend/const/mcpTools.ts
    +++ b/frontend/const/mcpTools.ts
    @@ -19,4 +19,4 @@ export const MCP_CONTAINER_STATUS = {
     	STOPPED: McpContainerStatus.STOPPED,
     	UNKNOWN: McpContainerStatus.UNKNOWN,
     } as const;
    -export const MARKET_SERVER_STATUS = { ACTIVE: "active", DEPRECATED: "deprecated", UNKNOWN: "unknown" } as const;
    +export const MCP_REGISTRY_SERVER_STATUS = { ACTIVE: "active", DEPRECATED: "deprecated", UNKNOWN: "unknown" } as const;
    diff --git a/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts b/frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts
    similarity index 64%
    rename from frontend/hooks/mcpTools/useMcpToolsAddMarket.ts
    rename to frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts
    index 08a76cf7d..2066ecc08 100644
    --- a/frontend/hooks/mcpTools/useMcpToolsAddMarket.ts
    +++ b/frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts
    @@ -6,15 +6,15 @@ import { MCP_TRANSPORT_TYPE, MCP_TAB } from "@/const/mcpTools";
     import {
       addContainerMcpToolService,
       addMcpToolService,
    -  fetchMarketMcpCards,
    -  type MarketMcpCard,
    +  fetchRegistryMcpCards,
    +  type RegistryMcpCard,
     } from "@/services/mcpToolsService";
     import {
    -  type MarketQuickAddOption,
    +  type RegistryQuickAddOption,
       type McpTab,
     } from "@/types/mcpTools";
     
    -type UseMcpToolsAddMarketParams = {
    +type UseMcpToolsAddRegistryParams = {
       open: boolean;
       addModalTab: McpTab;
       t: (key: string) => string;
    @@ -66,7 +66,7 @@ const pickQuickAddPort = (): number => {
       return 5500 + seed;
     };
     
    -const extractPackageEnvTemplate = (service: MarketMcpCard, pkgIdentifier?: string): Record => {
    +const extractPackageEnvTemplate = (service: RegistryMcpCard, pkgIdentifier?: string): Record => {
       if (!pkgIdentifier) return {};
       const rawPackages = (service.serverJson as { packages?: unknown[] } | undefined)?.packages;
       if (!Array.isArray(rawPackages)) return {};
    @@ -88,8 +88,8 @@ const extractPackageEnvTemplate = (service: MarketMcpCard, pkgIdentifier?: strin
       }, {});
     };
     
    -const resolveQuickAddOptions = (service: MarketMcpCard): MarketQuickAddOption[] => {
    -  const options: MarketQuickAddOption[] = [];
    +const resolveQuickAddOptions = (service: RegistryMcpCard): RegistryQuickAddOption[] => {
    +  const options: RegistryQuickAddOption[] = [];
     
       (service.remotes || []).forEach((remote, index) => {
         const remoteTarget = resolveQuickAddTarget(remote.type, remote.url);
    @@ -137,39 +137,39 @@ const resolveQuickAddOptions = (service: MarketMcpCard): MarketQuickAddOption[]
       return options;
     };
     
    -export function useMcpToolsAddMarket({
    +export function useMcpToolsAddRegistry({
       open,
       addModalTab,
       t,
       message,
       onServiceAdded,
       onClose,
    -}: UseMcpToolsAddMarketParams) {
    -  const [marketSearchValue, setMarketSearchValue] = useState("");
    -  const [selectedMarketService, setSelectedMarketService] = useState(null);
    -  const [marketCurrentCursor, setMarketCurrentCursor] = useState(null);
    -  const [marketCursorHistory, setMarketCursorHistory] = useState([]);
    -  const [marketPage, setMarketPage] = useState(1);
    -  const [marketVersion, setMarketVersion] = useState("latest");
    -  const [marketUpdatedSince, setMarketUpdatedSince] = useState("");
    -  const [marketIncludeDeleted, setMarketIncludeDeleted] = useState(false);
    +}: UseMcpToolsAddRegistryParams) {
    +  const [registrySearchValue, setRegistrySearchValue] = useState("");
    +  const [selectedRegistryService, setSelectedRegistryService] = useState(null);
    +  const [registryCurrentCursor, setRegistryCurrentCursor] = useState(null);
    +  const [registryCursorHistory, setRegistryCursorHistory] = useState([]);
    +  const [registryPage, setRegistryPage] = useState(1);
    +  const [registryVersion, setRegistryVersion] = useState("latest");
    +  const [registryUpdatedSince, setRegistryUpdatedSince] = useState("");
    +  const [registryIncludeDeleted, setRegistryIncludeDeleted] = useState(false);
       const [quickAddPickerVisible, setQuickAddPickerVisible] = useState(false);
    -  const [quickAddCandidateService, setQuickAddCandidateService] = useState(null);
    -  const [quickAddOptions, setQuickAddOptions] = useState([]);
    +  const [quickAddCandidateService, setQuickAddCandidateService] = useState(null);
    +  const [quickAddOptions, setQuickAddOptions] = useState([]);
       const [selectedQuickAddOptionKey, setSelectedQuickAddOptionKey] = useState("");
       const [addingService, setAddingService] = useState(false);
     
       const addMutation = useMutation({ mutationFn: addMcpToolService });
     
       const reset = useCallback(() => {
    -    setMarketSearchValue("");
    -    setMarketCurrentCursor(null);
    -    setMarketCursorHistory([]);
    -    setMarketPage(1);
    -    setMarketVersion("latest");
    -    setMarketUpdatedSince("");
    -    setMarketIncludeDeleted(false);
    -    setSelectedMarketService(null);
    +    setRegistrySearchValue("");
    +    setRegistryCurrentCursor(null);
    +    setRegistryCursorHistory([]);
    +    setRegistryPage(1);
    +    setRegistryVersion("latest");
    +    setRegistryUpdatedSince("");
    +    setRegistryIncludeDeleted(false);
    +    setSelectedRegistryService(null);
         setQuickAddPickerVisible(false);
         setQuickAddCandidateService(null);
         setQuickAddOptions([]);
    @@ -177,37 +177,37 @@ export function useMcpToolsAddMarket({
         setAddingService(false);
       }, []);
     
    -  const loadMarketFirstPage = useCallback(() => {
    -    setMarketCurrentCursor(null);
    -    setMarketCursorHistory([]);
    -    setMarketPage(1);
    +  const loadRegistryFirstPage = useCallback(() => {
    +    setRegistryCurrentCursor(null);
    +    setRegistryCursorHistory([]);
    +    setRegistryPage(1);
       }, []);
     
       useEffect(() => {
         if (!(open && addModalTab === MCP_TAB.MCP_REGISTRY)) return;
         const timer = window.setTimeout(() => {
    -      loadMarketFirstPage();
    +      loadRegistryFirstPage();
         }, 350);
         return () => window.clearTimeout(timer);
       }, [
         open,
         addModalTab,
    -    marketSearchValue,
    -    marketVersion,
    -    marketUpdatedSince,
    -    marketIncludeDeleted,
    -    loadMarketFirstPage,
    +    registrySearchValue,
    +    registryVersion,
    +    registryUpdatedSince,
    +    registryIncludeDeleted,
    +    loadRegistryFirstPage,
       ]);
     
    -  const marketQuery = useQuery<{ items: MarketMcpCard[]; nextCursor: string | null }>({
    +  const registryQuery = useQuery<{ items: RegistryMcpCard[]; nextCursor: string | null }>({
         queryKey: [
           "mcp-tools",
           "market",
    -      marketSearchValue,
    -      marketCurrentCursor,
    -      marketVersion,
    -      marketUpdatedSince,
    -      marketIncludeDeleted,
    +      registrySearchValue,
    +      registryCurrentCursor,
    +      registryVersion,
    +      registryUpdatedSince,
    +      registryIncludeDeleted,
         ],
         enabled: open && addModalTab === MCP_TAB.MCP_REGISTRY,
         retry: false,
    @@ -215,56 +215,56 @@ export function useMcpToolsAddMarket({
         refetchOnWindowFocus: false,
         refetchOnMount: false,
         queryFn: async () => {
    -      const result = await fetchMarketMcpCards({
    -        search: marketSearchValue,
    -        cursor: marketCurrentCursor,
    -        version: marketVersion,
    -        updatedSince: marketUpdatedSince,
    -        includeDeleted: marketIncludeDeleted,
    +      const result = await fetchRegistryMcpCards({
    +        search: registrySearchValue,
    +        cursor: registryCurrentCursor,
    +        version: registryVersion,
    +        updatedSince: registryUpdatedSince,
    +        includeDeleted: registryIncludeDeleted,
           });
           return result.data;
         },
       });
     
    -  const marketServices = marketQuery.data?.items ?? [];
    -  const marketNextCursor = marketQuery.data?.nextCursor ?? null;
    +  const registryServices = registryQuery.data?.items ?? [];
    +  const registryNextCursor = registryQuery.data?.nextCursor ?? null;
     
       useEffect(() => {
    -    if (!(marketQuery.error instanceof Error)) return;
    -    log.error("[useMcpToolsAddMarket] Failed to load market MCP cards", {
    -      error: marketQuery.error,
    -      search: marketSearchValue,
    -      cursor: marketCurrentCursor,
    -      version: marketVersion,
    -      updatedSince: marketUpdatedSince,
    -      includeDeleted: marketIncludeDeleted,
    +    if (!(registryQuery.error instanceof Error)) return;
    +    log.error("[useMcpToolsAddRegistry] Failed to load registry MCP cards", {
    +      error: registryQuery.error,
    +      search: registrySearchValue,
    +      cursor: registryCurrentCursor,
    +      version: registryVersion,
    +      updatedSince: registryUpdatedSince,
    +      includeDeleted: registryIncludeDeleted,
         });
    -    message.error(t("mcpTools.market.loadFailed"));
    +    message.error(t("mcpTools.registry.loadFailed"));
       }, [
    -    marketQuery.error,
    -    marketSearchValue,
    -    marketCurrentCursor,
    -    marketVersion,
    -    marketUpdatedSince,
    -    marketIncludeDeleted,
    +    registryQuery.error,
    +    registrySearchValue,
    +    registryCurrentCursor,
    +    registryVersion,
    +    registryUpdatedSince,
    +    registryIncludeDeleted,
         message,
       ]);
     
    -  const handleMarketNextPage = useCallback(() => {
    -    if (!marketNextCursor || marketQuery.isFetching) return;
    -    const currentCursorSnapshot = marketCurrentCursor;
    -    setMarketCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]);
    -    setMarketCurrentCursor(marketNextCursor);
    -    setMarketPage((prev) => prev + 1);
    -  }, [marketCurrentCursor, marketNextCursor, marketQuery.isFetching]);
    -
    -  const handleMarketPrevPage = useCallback(() => {
    -    if (marketCursorHistory.length === 0 || marketQuery.isFetching) return;
    -    const previousCursor = marketCursorHistory[marketCursorHistory.length - 1] || null;
    -    setMarketCursorHistory((prev) => prev.slice(0, -1));
    -    setMarketCurrentCursor(previousCursor);
    -    setMarketPage((prev) => Math.max(1, prev - 1));
    -  }, [marketCursorHistory, marketQuery.isFetching]);
    +  const handleRegistryNextPage = useCallback(() => {
    +    if (!registryNextCursor || registryQuery.isFetching) return;
    +    const currentCursorSnapshot = registryCurrentCursor;
    +    setRegistryCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]);
    +    setRegistryCurrentCursor(registryNextCursor);
    +    setRegistryPage((prev) => prev + 1);
    +  }, [registryCurrentCursor, registryNextCursor, registryQuery.isFetching]);
    +
    +  const handleRegistryPrevPage = useCallback(() => {
    +    if (registryCursorHistory.length === 0 || registryQuery.isFetching) return;
    +    const previousCursor = registryCursorHistory[registryCursorHistory.length - 1] || null;
    +    setRegistryCursorHistory((prev) => prev.slice(0, -1));
    +    setRegistryCurrentCursor(previousCursor);
    +    setRegistryPage((prev) => Math.max(1, prev - 1));
    +  }, [registryCursorHistory, registryQuery.isFetching]);
     
       const handleCloseQuickAddPicker = useCallback(() => {
         setQuickAddPickerVisible(false);
    @@ -273,15 +273,15 @@ export function useMcpToolsAddMarket({
         setSelectedQuickAddOptionKey("");
       }, []);
     
    -  const handleQuickAddFromMarket = useCallback((service: MarketMcpCard) => {
    +  const handleQuickAddFromRegistry = useCallback((service: RegistryMcpCard) => {
         const quickAddOptionsForService = resolveQuickAddOptions(service);
         if (quickAddOptionsForService.length === 0) {
    -      log.warn("[useMcpToolsAddMarket] Quick add is unsupported for selected market service", {
    +      log.warn("[useMcpToolsAddRegistry] Quick add is unsupported for selected registry service", {
             serviceName: service.name,
             remotes: service.remotes,
             packages: service.packages,
           });
    -      message.warning(t("mcpTools.market.quickAddUnsupported"));
    +      message.warning(t("mcpTools.registry.quickAddUnsupported"));
           return;
         }
     
    @@ -297,7 +297,7 @@ export function useMcpToolsAddMarket({
     
         const selectedOption = quickAddOptions.find((option) => option.key === selectedQuickAddOptionKey);
         if (!selectedOption) {
    -      message.warning(t("mcpTools.market.quickAddUnsupported"));
    +      message.warning(t("mcpTools.registry.quickAddUnsupported"));
           return;
         }
     
    @@ -307,7 +307,7 @@ export function useMcpToolsAddMarket({
             const packageIdentifier = (selectedOption.packageIdentifier || "").trim();
             const command = inferStdioCommand(selectedOption.packageRegistryType);
             if (!packageIdentifier || !command) {
    -          message.warning(t("mcpTools.market.quickAddUnsupported"));
    +          message.warning(t("mcpTools.registry.quickAddUnsupported"));
               return;
             }
     
    @@ -337,16 +337,16 @@ export function useMcpToolsAddMarket({
               server_url: selectedOption.serverUrl || "",
               tags: [],
               version: quickAddCandidateService.version || undefined,
    -          mcp_registry_json: quickAddCandidateService.serverJson || undefined,
    +          registry_json: quickAddCandidateService.serverJson || undefined,
             });
           }
     
           await onServiceAdded();
    -      message.success(t("mcpTools.market.quickAddSuccess"));
    +      message.success(t("mcpTools.registry.quickAddSuccess"));
           handleCloseQuickAddPicker();
           onClose();
         } catch (error) {
    -      log.error("[useMcpToolsAddMarket] Failed to quick add market service", {
    +      log.error("[useMcpToolsAddRegistry] Failed to quick add registry service", {
             error,
             serviceName: quickAddCandidateService.name,
             remotes: quickAddCandidateService.remotes,
    @@ -370,30 +370,30 @@ export function useMcpToolsAddMarket({
       ]);
     
       return {
    -    marketSearchValue,
    -    selectedMarketService,
    -    filteredMarketServices: marketServices,
    -    marketLoading: marketQuery.isFetching,
    -    marketPage,
    -    hasPrevMarketPage: marketCursorHistory.length > 0,
    -    hasNextMarketPage: Boolean(marketNextCursor),
    -    marketVersion,
    -    marketUpdatedSince,
    -    marketIncludeDeleted,
    +    registrySearchValue,
    +    selectedRegistryService,
    +    filteredRegistryServices: registryServices,
    +    registryLoading: registryQuery.isFetching,
    +    registryPage,
    +    hasPrevRegistryPage: registryCursorHistory.length > 0,
    +    hasNextRegistryPage: Boolean(registryNextCursor),
    +    registryVersion,
    +    registryUpdatedSince,
    +    registryIncludeDeleted,
         quickAddPickerVisible,
         quickAddCandidateService,
         quickAddOptions,
         selectedQuickAddOptionKey,
         quickAddSubmitting: addingService,
    -    setMarketSearchValue,
    -    setSelectedMarketService,
    -    setMarketVersion,
    -    setMarketUpdatedSince,
    -    setMarketIncludeDeleted,
    +    setRegistrySearchValue,
    +    setSelectedRegistryService,
    +    setRegistryVersion,
    +    setRegistryUpdatedSince,
    +    setRegistryIncludeDeleted,
         setSelectedQuickAddOptionKey,
    -    handleMarketPrevPage,
    -    handleMarketNextPage,
    -    handleQuickAddFromMarket,
    +    handleRegistryPrevPage,
    +    handleRegistryNextPage,
    +    handleQuickAddFromRegistry,
         handleCloseQuickAddPicker,
         handleConfirmQuickAddOption,
         addingService,
    diff --git a/frontend/lib/mcpTools.ts b/frontend/lib/mcpTools.ts
    index a5f0e7769..75be3c4e9 100644
    --- a/frontend/lib/mcpTools.ts
    +++ b/frontend/lib/mcpTools.ts
    @@ -51,14 +51,14 @@ export const filterServiceCards = (services: McpServiceItem[], searchValue: stri
       });
     };
     
    -export const formatMarketDate = (value: string): string => {
    +export const formatRegistryDate = (value: string): string => {
       if (!value) return "-";
       const date = new Date(value);
       if (Number.isNaN(date.getTime())) return value;
       return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
     };
     
    -export const formatMarketVersion = (value: string): string => {
    +export const formatRegistryVersion = (value: string): string => {
       const version = (value || "").trim();
       if (!version) return "-";
       return /^v/i.test(version) ? version : `v${version}`;
    diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
    index 8c5cd4309..ef3d0dfbc 100644
    --- a/frontend/public/locales/en/common.json
    +++ b/frontend/public/locales/en/common.json
    @@ -1518,7 +1518,7 @@
       "mcpTools.status.enabled": "Enabled",
       "mcpTools.status.disabled": "Disabled",
       "mcpTools.source.local": "Local",
    -  "mcpTools.source.mcp_registry": "MCP Registry",
    +  "mcpTools.source.registry": "MCP Registry",
       "mcpTools.source.market": "Public Market",
       "mcpTools.serverType.http": "HTTP",
       "mcpTools.serverType.sse": "SSE",
    @@ -1549,6 +1549,7 @@
       "mcpTools.add.error.containerAddFailed": "Failed to add container config",
       "mcpTools.addModal.title": "Add MCP Service",
       "mcpTools.addModal.tabLocal": "Local",
    +  "mcpTools.addModal.tabRegistry": "MCP Registry",
       "mcpTools.addModal.tabMarket": "Public Market",
       "mcpTools.addModal.name": "Name",
       "mcpTools.addModal.description": "Description",
    @@ -1569,43 +1570,43 @@
       "mcpTools.addModal.removeTagAria": "Remove tag {{tag}}",
       "mcpTools.addModal.tagInputPlaceholder": "Press Enter after typing a tag",
       "mcpTools.addModal.saveAndAdd": "Save and Add",
    -  "mcpTools.market.loadFailed": "Failed to load public market list",
    -  "mcpTools.market.searchPlaceholder": "Search MCP services in public market",
    -  "mcpTools.market.pageResult": "Page {{page}} · {{count}} results",
    -  "mcpTools.market.versionFilter": "Version Filter",
    -  "mcpTools.market.versionAll": "All Versions",
    -  "mcpTools.market.versionLatest": "latest (most recent)",
    -  "mcpTools.market.versionCustom": "Custom Version",
    -  "mcpTools.market.updatedSince": "Updated Since (RFC3339)",
    -  "mcpTools.market.updatedSincePlaceholder": "Select updated time",
    -  "mcpTools.market.includeDeleted": "Include Deleted",
    -  "mcpTools.market.includeDeletedDesc": "Include deleted servers",
    -  "mcpTools.market.customVersion": "Custom Version",
    -  "mcpTools.market.customVersionPlaceholder": "e.g. 1.2.3",
    -  "mcpTools.market.customVersionError": "Numeric versions only, e.g. 1.2.3",
    -  "mcpTools.market.loading": "Loading public market MCP services...",
    -  "mcpTools.market.empty": "No matching public market MCP services found.",
    -  "mcpTools.market.quickAdd": "Quick Add",
    -  "mcpTools.market.quickAddPreview": "MCP service added from public market (frontend preview)",
    -  "mcpTools.market.quickAddSuccess": "MCP service added from public market",
    -  "mcpTools.market.quickAddUnsupported": "Quick add currently supports URL-based HTTP/SSE, or package-based stdio container services",
    -  "mcpTools.market.quickAddPicker.title": "Select Quick Add Target",
    -  "mcpTools.market.quickAddPicker.description": "Choose one address or package for quick add in {{name}}.",
    -  "mcpTools.market.quickAddPicker.sourceRemote": "Source: Remote",
    -  "mcpTools.market.quickAddPicker.sourcePackage": "Source: Package",
    -  "mcpTools.market.quickAddPicker.confirm": "Confirm Add",
    -  "mcpTools.market.prevPage": "Previous",
    -  "mcpTools.market.nextPage": "Next",
    -  "mcpTools.market.website": "Website:",
    -  "mcpTools.market.repository": "Repository:",
    -  "mcpTools.market.remotes": "Remotes",
    -  "mcpTools.market.packages": "Packages",
    -  "mcpTools.market.remoteFallback": "remote",
    -  "mcpTools.market.viewServerJson": "View full server.json",
    -  "mcpTools.market.serverJsonTitle": "{{name}} - server.json",
    -  "mcpTools.market.status.active": "active",
    -  "mcpTools.market.status.deprecated": "deprecated",
    -  "mcpTools.market.status.unknown": "unknown",
    +  "mcpTools.registry.loadFailed": "Failed to load public market list",
    +  "mcpTools.registry.searchPlaceholder": "Search MCP services in public market",
    +  "mcpTools.registry.pageResult": "Page {{page}} · {{count}} results",
    +  "mcpTools.registry.versionFilter": "Version Filter",
    +  "mcpTools.registry.versionAll": "All Versions",
    +  "mcpTools.registry.versionLatest": "latest (most recent)",
    +  "mcpTools.registry.versionCustom": "Custom Version",
    +  "mcpTools.registry.updatedSince": "Updated Since (RFC3339)",
    +  "mcpTools.registry.updatedSincePlaceholder": "Select updated time",
    +  "mcpTools.registry.includeDeleted": "Include Deleted",
    +  "mcpTools.registry.includeDeletedDesc": "Include deleted servers",
    +  "mcpTools.registry.customVersion": "Custom Version",
    +  "mcpTools.registry.customVersionPlaceholder": "e.g. 1.2.3",
    +  "mcpTools.registry.customVersionError": "Numeric versions only, e.g. 1.2.3",
    +  "mcpTools.registry.loading": "Loading public market MCP services...",
    +  "mcpTools.registry.empty": "No matching public market MCP services found.",
    +  "mcpTools.registry.quickAdd": "Quick Add",
    +  "mcpTools.registry.quickAddPreview": "MCP service added from public market (frontend preview)",
    +  "mcpTools.registry.quickAddSuccess": "MCP service added from public market",
    +  "mcpTools.registry.quickAddUnsupported": "Quick add currently supports URL-based HTTP/SSE, or package-based stdio container services",
    +  "mcpTools.registry.quickAddPicker.title": "Select Quick Add Target",
    +  "mcpTools.registry.quickAddPicker.description": "Choose one address or package for quick add in {{name}}.",
    +  "mcpTools.registry.quickAddPicker.sourceRemote": "Source: Remote",
    +  "mcpTools.registry.quickAddPicker.sourcePackage": "Source: Package",
    +  "mcpTools.registry.quickAddPicker.confirm": "Confirm Add",
    +  "mcpTools.registry.prevPage": "Previous",
    +  "mcpTools.registry.nextPage": "Next",
    +  "mcpTools.registry.website": "Website:",
    +  "mcpTools.registry.repository": "Repository:",
    +  "mcpTools.registry.remotes": "Remotes",
    +  "mcpTools.registry.packages": "Packages",
    +  "mcpTools.registry.remoteFallback": "remote",
    +  "mcpTools.registry.viewServerJson": "View full server.json",
    +  "mcpTools.registry.serverJsonTitle": "{{name}} - server.json",
    +  "mcpTools.registry.status.active": "active",
    +  "mcpTools.registry.status.deprecated": "deprecated",
    +  "mcpTools.registry.status.unknown": "unknown",
       "mcpTools.tools.loadFailed": "Failed to load tools",
       "mcpTools.detail.title": "MCP Service Details",
       "mcpTools.detail.name": "Name",
    diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json
    index 0101390aa..9181b527b 100644
    --- a/frontend/public/locales/zh/common.json
    +++ b/frontend/public/locales/zh/common.json
    @@ -1676,7 +1676,7 @@
       "mcpTools.status.enabled": "已启用",
       "mcpTools.status.disabled": "未启用",
       "mcpTools.source.local": "自定义",
    -  "mcpTools.source.mcp_registry": "MCP Registry",
    +  "mcpTools.source.registry": "外部市场",
       "mcpTools.source.market": "公共市场",
       "mcpTools.serverType.http": "HTTP",
       "mcpTools.serverType.sse": "SSE",
    @@ -1707,6 +1707,7 @@
       "mcpTools.add.error.containerAddFailed": "容器配置添加失败",
       "mcpTools.addModal.title": "添加 MCP 服务",
       "mcpTools.addModal.tabLocal": "自定义",
    +  "mcpTools.addModal.tabRegistry": "外部市场",
       "mcpTools.addModal.tabMarket": "公共市场",
       "mcpTools.addModal.name": "名称",
       "mcpTools.addModal.description": "描述",
    @@ -1727,43 +1728,43 @@
       "mcpTools.addModal.removeTagAria": "删除标签 {{tag}}",
       "mcpTools.addModal.tagInputPlaceholder": "输入标签后回车",
       "mcpTools.addModal.saveAndAdd": "保存并添加",
    -  "mcpTools.market.loadFailed": "获取公共市场列表失败",
    -  "mcpTools.market.searchPlaceholder": "搜索公共市场 MCP",
    -  "mcpTools.market.pageResult": "第 {{page}} 页 · {{count}} 个结果",
    -  "mcpTools.market.versionFilter": "版本筛选",
    -  "mcpTools.market.versionAll": "全部版本",
    -  "mcpTools.market.versionLatest": "最新版本",
    -  "mcpTools.market.versionCustom": "自定义版本",
    -  "mcpTools.market.updatedSince": "更新时间下限 (RFC3339)",
    -  "mcpTools.market.updatedSincePlaceholder": "选择更新时间",
    -  "mcpTools.market.includeDeleted": "包含已删除",
    -  "mcpTools.market.includeDeletedDesc": "包含已删除服务器",
    -  "mcpTools.market.customVersion": "自定义版本号",
    -  "mcpTools.market.customVersionPlaceholder": "例如 1.2.3",
    -  "mcpTools.market.customVersionError": "仅支持数字版本格式,例如 1.2.3",
    -  "mcpTools.market.loading": "正在加载公共市场 MCP...",
    -  "mcpTools.market.empty": "未找到匹配的公共市场 MCP。",
    -  "mcpTools.market.quickAdd": "快速添加",
    -  "mcpTools.market.quickAddPreview": "已从公共市场添加 MCP 服务(前端预览)",
    -  "mcpTools.market.quickAddSuccess": "已从公共市场添加 MCP 服务",
    -  "mcpTools.market.quickAddUnsupported": "当前快速添加仅支持 URL 形式的 HTTP/SSE,或 package 形式的 stdio 容器服务",
    -  "mcpTools.market.quickAddPicker.title": "选择快速添加目标",
    -  "mcpTools.market.quickAddPicker.description": "为 {{name}} 选择一个要快速添加的地址或安装包。",
    -  "mcpTools.market.quickAddPicker.sourceRemote": "来源: 远程地址",
    -  "mcpTools.market.quickAddPicker.sourcePackage": "来源: 安装包",
    -  "mcpTools.market.quickAddPicker.confirm": "确认添加",
    -  "mcpTools.market.prevPage": "上一页",
    -  "mcpTools.market.nextPage": "下一页",
    -  "mcpTools.market.website": "网站:",
    -  "mcpTools.market.repository": "仓库:",
    -  "mcpTools.market.remotes": "远程地址",
    -  "mcpTools.market.packages": "安装包",
    -  "mcpTools.market.remoteFallback": "远程",
    -  "mcpTools.market.viewServerJson": "查看完整 server.json",
    -  "mcpTools.market.serverJsonTitle": "{{name}} - server.json",
    -  "mcpTools.market.status.active": "活动",
    -  "mcpTools.market.status.deprecated": "弃用",
    -  "mcpTools.market.status.unknown": "未知",
    +  "mcpTools.registry.loadFailed": "获取公共市场列表失败",
    +  "mcpTools.registry.searchPlaceholder": "搜索公共市场 MCP",
    +  "mcpTools.registry.pageResult": "第 {{page}} 页 · {{count}} 个结果",
    +  "mcpTools.registry.versionFilter": "版本筛选",
    +  "mcpTools.registry.versionAll": "全部版本",
    +  "mcpTools.registry.versionLatest": "最新版本",
    +  "mcpTools.registry.versionCustom": "自定义版本",
    +  "mcpTools.registry.updatedSince": "更新时间下限 (RFC3339)",
    +  "mcpTools.registry.updatedSincePlaceholder": "选择更新时间",
    +  "mcpTools.registry.includeDeleted": "包含已删除",
    +  "mcpTools.registry.includeDeletedDesc": "包含已删除服务器",
    +  "mcpTools.registry.customVersion": "自定义版本号",
    +  "mcpTools.registry.customVersionPlaceholder": "例如 1.2.3",
    +  "mcpTools.registry.customVersionError": "仅支持数字版本格式,例如 1.2.3",
    +  "mcpTools.registry.loading": "正在加载公共市场 MCP...",
    +  "mcpTools.registry.empty": "未找到匹配的公共市场 MCP。",
    +  "mcpTools.registry.quickAdd": "快速添加",
    +  "mcpTools.registry.quickAddPreview": "已从公共市场添加 MCP 服务(前端预览)",
    +  "mcpTools.registry.quickAddSuccess": "已从公共市场添加 MCP 服务",
    +  "mcpTools.registry.quickAddUnsupported": "当前快速添加仅支持 URL 形式的 HTTP/SSE,或 package 形式的 stdio 容器服务",
    +  "mcpTools.registry.quickAddPicker.title": "选择快速添加目标",
    +  "mcpTools.registry.quickAddPicker.description": "为 {{name}} 选择一个要快速添加的地址或安装包。",
    +  "mcpTools.registry.quickAddPicker.sourceRemote": "来源: 远程地址",
    +  "mcpTools.registry.quickAddPicker.sourcePackage": "来源: 安装包",
    +  "mcpTools.registry.quickAddPicker.confirm": "确认添加",
    +  "mcpTools.registry.prevPage": "上一页",
    +  "mcpTools.registry.nextPage": "下一页",
    +  "mcpTools.registry.website": "网站:",
    +  "mcpTools.registry.repository": "仓库:",
    +  "mcpTools.registry.remotes": "远程地址",
    +  "mcpTools.registry.packages": "安装包",
    +  "mcpTools.registry.remoteFallback": "远程",
    +  "mcpTools.registry.viewServerJson": "查看完整 server.json",
    +  "mcpTools.registry.serverJsonTitle": "{{name}} - server.json",
    +  "mcpTools.registry.status.active": "活动",
    +  "mcpTools.registry.status.deprecated": "弃用",
    +  "mcpTools.registry.status.unknown": "未知",
       "mcpTools.tools.loadFailed": "获取工具列表失败",
       "mcpTools.detail.title": "MCP 服务详情",
       "mcpTools.detail.name": "名称",
    diff --git a/frontend/services/api.ts b/frontend/services/api.ts
    index 447ffb159..b2a733441 100644
    --- a/frontend/services/api.ts
    +++ b/frontend/services/api.ts
    @@ -232,7 +232,7 @@ export const API_ENDPOINTS = {
         enable: `${API_BASE_URL}/mcp-tools/enable`,
         disable: `${API_BASE_URL}/mcp-tools/disable`,
         healthcheck: `${API_BASE_URL}/mcp-tools/v2/healthcheck`,
    -    marketList: `${API_BASE_URL}/mcp-tools/market/list`,
    +    registryList: `${API_BASE_URL}/mcp-tools/registry/list`,
       },
       memory: {
         // ---------------- Memory configuration ----------------
    diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts
    index c45ad24d0..5e672f218 100644
    --- a/frontend/services/mcpToolsService.ts
    +++ b/frontend/services/mcpToolsService.ts
    @@ -6,7 +6,7 @@ import type {
       AddMcpRuntimeFromConfigPayload,
       AddMcpServicePayload,
       HealthcheckMcpServicePayload,
    -  MarketMcpCard,
    +  RegistryMcpCard,
       McpHealthStatus,
       McpServiceItem,
       McpTransportType,
    @@ -20,7 +20,7 @@ export type McpToolsApiResult = {
       data: T;
     };
     
    -export type { MarketMcpCard } from "@/types/mcpTools";
    +export type { RegistryMcpCard as RegistryMcpCard } from "@/types/mcpTools";
     
     type ApiEnvelope = {
       status: string;
    @@ -56,7 +56,7 @@ type HealthcheckPayload = {
       health_status: McpHealthStatus;
     };
     
    -export const fetchMarketMcpCards = async (params: {
    +export const fetchRegistryMcpCards = async (params: {
       search?: string;
       cursor?: string | null;
       version?: string;
    @@ -79,7 +79,7 @@ export const fetchMarketMcpCards = async (params: {
         query.set("cursor", params.cursor);
       }
     
    -  const result = await listMarketMcpTools(query);
    +  const result = await listRegistryMcpTools(query);
       const payload = result.data;
     
       return {
    @@ -88,7 +88,7 @@ export const fetchMarketMcpCards = async (params: {
           items: payload.items,
           nextCursor: payload.nextCursor ?? null,
         },
    -  } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>;
    +  } as McpToolsApiResult<{ items: RegistryMcpCard[]; nextCursor: string | null }>;
     };
     
     export const resolveContainerServerInfo = async (params: {
    @@ -179,16 +179,16 @@ export const listMcpTools = async () => {
       }
     };
     
    -export const listMarketMcpTools = async (query: URLSearchParams) => {
    +export const listRegistryMcpTools = async (query: URLSearchParams) => {
       try {
    -    const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.marketList}?${query.toString()}`);
    -    const data = await parseJson>(response);
    +    const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.registryList}?${query.toString()}`);
    +    const data = await parseJson>(response);
         if (data.status !== "success") {
    -      throw new Error("Failed to load market list");
    +      throw new Error("Failed to load registry mcp list");
         }
    -    return { success: true, data: data.data } as McpToolsApiResult<{ items: MarketMcpCard[]; nextCursor: string | null }>;
    +    return { success: true, data: data.data } as McpToolsApiResult<{ items: RegistryMcpCard[]; nextCursor: string | null }>;
       } catch (error) {
    -    log.error("listMarketMcpTools failed", error);
    +    log.error("listRegistryMcpTools failed", error);
         throw error;
       }
     };
    diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts
    index 6901688ff..b23bcb3cc 100644
    --- a/frontend/types/mcpTools.ts
    +++ b/frontend/types/mcpTools.ts
    @@ -28,7 +28,7 @@ export enum McpContainerStatus {
       UNKNOWN = "unknown",
     }
     
    -export interface MarketMcpCard {
    +export interface RegistryMcpCard {
       name: string;
       version: string;
       description: string;
    @@ -45,7 +45,7 @@ export interface MarketMcpCard {
       serverJson: Record;
     }
     
    -export interface MarketQuickAddOption {
    +export interface RegistryQuickAddOption {
       key: string;
       sourceType: "remote" | "package";
       sourceLabel: string;
    @@ -86,7 +86,7 @@ export interface AddMcpServicePayload {
       authorization_token?: string;
       container_config?: Record;
       version?: string;
    -  mcp_registry_json?: Record;
    +  registry_json?: Record;
     }
     
     export interface UpdateMcpServicePayload {
    
    From 552ffbd7f2b60e25b2f810561f0cae6178faba4e Mon Sep 17 00:00:00 2001
    From: HelloWorld 
    Date: Fri, 27 Mar 2026 16:58:47 +0800
    Subject: [PATCH 05/59] Add a community marketplace feature where users can
     upload their own MCPs to the community marketplace for other users to browse
     and discover.
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    添加社区市场功能,用户可以上传自己的MCP到社区市场供其它用户浏览发现。
    ---
     backend/apps/mcp_management_app.py            | 181 ++++++++++-
     backend/database/community_mcp_db.py          | 143 ++++++++
     backend/database/db_models.py                 |  27 ++
     backend/database/remote_mcp_db.py             |  20 ++
     backend/services/mcp_management_service.py    | 173 +++++++++-
     ...v1.8.2_0326_add_mcp_community_record_t.sql |  82 +++++
     .../AddMcpServiceCommunitySection.tsx         | 231 +++++++++++++
     .../components/AddMcpServiceModal.tsx         |  45 ++-
     .../mcp-tools/components/McpCommunityCard.tsx |  68 ++++
     .../components/McpCommunityCardList.tsx       |  68 ++++
     .../components/McpCommunityDetailModal.tsx    | 220 +++++++++++++
     .../components/McpCommunityToolbar.tsx        |  34 ++
     .../mcp-tools/components/McpServiceCard.tsx   |   6 +-
     .../components/McpServiceDetailModal.tsx      |   7 +
     .../components/MyCommunityMcpModal.tsx        | 229 +++++++++++++
     frontend/app/[locale]/mcp-tools/page.tsx      |  38 ++-
     frontend/const/mcpTools.ts                    |   2 +-
     .../hooks/mcpTools/useMcpToolsAddCommunity.ts | 305 ++++++++++++++++++
     frontend/hooks/mcpTools/useMcpToolsDetail.ts  |  19 ++
     frontend/public/locales/en/common.json        |  41 ++-
     frontend/public/locales/zh/common.json        |  41 ++-
     frontend/services/api.ts                      |   5 +
     frontend/services/mcpToolsService.ts          | 121 +++++++
     frontend/types/mcpTools.ts                    |  11 +
     24 files changed, 2094 insertions(+), 23 deletions(-)
     create mode 100644 backend/database/community_mcp_db.py
     create mode 100644 docker/sql/v1.8.2_0326_add_mcp_community_record_t.sql
     create mode 100644 frontend/app/[locale]/mcp-tools/components/AddMcpServiceCommunitySection.tsx
     create mode 100644 frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx
     create mode 100644 frontend/app/[locale]/mcp-tools/components/McpCommunityCardList.tsx
     create mode 100644 frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx
     create mode 100644 frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx
     create mode 100644 frontend/app/[locale]/mcp-tools/components/MyCommunityMcpModal.tsx
     create mode 100644 frontend/hooks/mcpTools/useMcpToolsAddCommunity.ts
    
    diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py
    index df31769ed..bca854476 100644
    --- a/backend/apps/mcp_management_app.py
    +++ b/backend/apps/mcp_management_app.py
    @@ -9,7 +9,7 @@
     from consts.const import NEXENT_MCP_DOCKER_IMAGE
     from consts.exceptions import MCPConnectionError, MCPContainerError
     from consts.model import MCPConfigRequest
    -from database.remote_mcp_db import check_mcp_name_exists
    +from database.remote_mcp_db import check_enabled_mcp_name_exists
     from services.mcp_container_service import MCPContainerManager
     from services.mcp_management_service import (
         add_mcp_service,
    @@ -17,11 +17,16 @@
         check_mcp_service_health_legacy,
         delete_mcp_service,
         delete_mcp_service_legacy,
    +    delete_community_mcp_service,
    +    list_community_mcp_services,
    +    list_my_community_mcp_services,
         list_registry_mcp_services,
         list_mcp_service_tools_by_id,
         list_mcp_services,
    +    publish_community_mcp_service,
         update_mcp_service,
         update_mcp_service_legacy,
    +    update_community_mcp_service,
         update_mcp_service_enabled,
         update_mcp_service_enabled_legacy,
     )
    @@ -35,7 +40,7 @@ class AddMcpServiceRequest(BaseModel):
         name: str = Field(min_length=1)
         server_url: str = Field(min_length=1)
         description: Optional[str] = None
    -    source: Literal["local", "mcp_registry", "market"] = "local"
    +    source: Literal["local", "mcp_registry", "community", "market"] = "local"
         transport_type: Literal["http", "sse", "stdio", "container"] = "http"
         tags: Optional[list[str]] = None
         authorization_token: Optional[str] = None
    @@ -47,8 +52,10 @@ class AddMcpServiceRequest(BaseModel):
     class AddContainerMcpServiceRequest(BaseModel):
         name: str = Field(min_length=1)
         description: Optional[str] = None
    +    source: Literal["local", "community", "market"] = "local"
         tags: Optional[list[str]] = None
         authorization_token: Optional[str] = None
    +    registry_json: Optional[dict[str, Any]] = None
         port: int = Field(..., ge=1, le=65535)
         mcp_config: MCPConfigRequest
     
    @@ -97,6 +104,26 @@ class ListMcpToolsByIdRequest(BaseModel):
         mcp_id: int
     
     
    +class CommunityListRequest(BaseModel):
    +    search: Optional[str] = None
    +    transport_type: Optional[Literal["http", "sse", "stdio"]] = None
    +    cursor: Optional[str] = None
    +    limit: int = Field(default=30, ge=1, le=100)
    +
    +
    +class CommunityPublishRequest(BaseModel):
    +    mcp_id: int = Field(gt=0)
    +
    +
    +class CommunityUpdateRequest(BaseModel):
    +    community_id: int = Field(gt=0)
    +    name: Optional[str] = Field(default=None, min_length=1)
    +    description: Optional[str] = None
    +    tags: Optional[list[str]] = None
    +    version: Optional[str] = None
    +    registry_json: Optional[dict[str, Any]] = None
    +
    +
     @router.post("/add")
     async def add_mcp_service_api(
         payload: AddMcpServiceRequest,
    @@ -160,10 +187,10 @@ async def add_container_mcp_service_api(
             user_id, tenant_id, _ = get_current_user_info(authorization, http_request)
     
             service_name = payload.name.strip()
    -        if check_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id):
    +        if check_enabled_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id):
                 raise HTTPException(
                     status_code=HTTPStatus.CONFLICT,
    -                detail="MCP name already exists",
    +                detail="Enabled MCP name already exists",
                 )
     
             servers = payload.mcp_config.mcpServers
    @@ -224,14 +251,14 @@ async def add_container_mcp_service_api(
                     user_id=user_id,
                     name=service_name,
                     description=payload.description,
    -                source="local",
    +                source=payload.source,
                     transport_type="stdio",
                     server_url=container_info["mcp_url"],
                     tags=payload.tags,
                     authorization_token=auth_token,
                     container_config=container_config,
                     version=None,
    -                registry_json=None,
    +                registry_json=payload.registry_json,
                     enabled=True,
                     container_id=container_info.get("container_id"),
                 )
    @@ -337,6 +364,148 @@ async def list_registry_mcp_services_api(
             )
     
     
    +@router.post("/community/list")
    +async def list_community_mcp_services_api(
    +    payload: CommunityListRequest,
    +    authorization: Optional[str] = Header(None),
    +    http_request: Request = None,
    +):
    +    try:
    +        get_current_user_info(authorization, http_request)
    +        data = await list_community_mcp_services(
    +            search=(payload.search or "").strip() or None,
    +            transport_type=(payload.transport_type or "").strip() or None,
    +            cursor=(payload.cursor or "").strip() or None,
    +            limit=payload.limit,
    +        )
    +        return JSONResponse(
    +            status_code=HTTPStatus.OK,
    +            content={"status": "success", "data": data},
    +        )
    +    except HTTPException:
    +        raise
    +    except Exception as exc:
    +        logger.error(f"Failed to list MCP community services: {exc}")
    +        raise HTTPException(
    +            status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
    +            detail="Failed to list MCP community services",
    +        )
    +
    +
    +@router.post("/community/publish")
    +async def publish_community_mcp_service_api(
    +    payload: CommunityPublishRequest,
    +    authorization: Optional[str] = Header(None),
    +    http_request: Request = None,
    +):
    +    try:
    +        user_id, tenant_id, _ = get_current_user_info(authorization, http_request)
    +        community_id = await publish_community_mcp_service(
    +            tenant_id=tenant_id,
    +            user_id=user_id,
    +            mcp_id=payload.mcp_id,
    +        )
    +        return JSONResponse(
    +            status_code=HTTPStatus.OK,
    +            content={"status": "success", "data": {"community_id": community_id}},
    +        )
    +    except ValueError as exc:
    +        raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc))
    +    except HTTPException:
    +        raise
    +    except Exception as exc:
    +        logger.error(f"Failed to publish MCP community service: {exc}")
    +        raise HTTPException(
    +            status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
    +            detail="Failed to publish MCP community service",
    +        )
    +
    +
    +@router.put("/community/update")
    +async def update_community_mcp_service_api(
    +    payload: CommunityUpdateRequest,
    +    authorization: Optional[str] = Header(None),
    +    http_request: Request = None,
    +):
    +    try:
    +        user_id, tenant_id, _ = get_current_user_info(authorization, http_request)
    +        await update_community_mcp_service(
    +            tenant_id=tenant_id,
    +            user_id=user_id,
    +            community_id=payload.community_id,
    +            name=(payload.name or "").strip() or None,
    +            description=payload.description,
    +            tags=payload.tags,
    +            version=(payload.version or "").strip() or None,
    +            registry_json=payload.registry_json,
    +        )
    +        return JSONResponse(
    +            status_code=HTTPStatus.OK,
    +            content={"status": "success"},
    +        )
    +    except ValueError as exc:
    +        raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc))
    +    except HTTPException:
    +        raise
    +    except Exception as exc:
    +        logger.error(f"Failed to update MCP community service: {exc}")
    +        raise HTTPException(
    +            status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
    +            detail="Failed to update MCP community service",
    +        )
    +
    +
    +@router.delete("/community/delete")
    +async def delete_community_mcp_service_api(
    +    community_id: int = Query(gt=0),
    +    authorization: Optional[str] = Header(None),
    +    http_request: Request = None,
    +):
    +    try:
    +        user_id, tenant_id, _ = get_current_user_info(authorization, http_request)
    +        await delete_community_mcp_service(
    +            tenant_id=tenant_id,
    +            user_id=user_id,
    +            community_id=community_id,
    +        )
    +        return JSONResponse(
    +            status_code=HTTPStatus.OK,
    +            content={"status": "success"},
    +        )
    +    except ValueError as exc:
    +        raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc))
    +    except HTTPException:
    +        raise
    +    except Exception as exc:
    +        logger.error(f"Failed to delete MCP community service: {exc}")
    +        raise HTTPException(
    +            status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
    +            detail="Failed to delete MCP community service",
    +        )
    +
    +
    +@router.get("/community/mine")
    +async def list_my_community_mcp_services_api(
    +    authorization: Optional[str] = Header(None),
    +    http_request: Request = None,
    +):
    +    try:
    +        _, tenant_id, _ = get_current_user_info(authorization, http_request)
    +        data = await list_my_community_mcp_services(tenant_id=tenant_id)
    +        return JSONResponse(
    +            status_code=HTTPStatus.OK,
    +            content={"status": "success", "data": data},
    +        )
    +    except HTTPException:
    +        raise
    +    except Exception as exc:
    +        logger.error(f"Failed to list my MCP community services: {exc}")
    +        raise HTTPException(
    +            status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
    +            detail="Failed to list my MCP community services",
    +        )
    +
    +
     @router.post("/tools")
     async def list_mcp_tools_api(
         payload: ListMcpToolsByIdRequest,
    diff --git a/backend/database/community_mcp_db.py b/backend/database/community_mcp_db.py
    new file mode 100644
    index 000000000..c1f0cf543
    --- /dev/null
    +++ b/backend/database/community_mcp_db.py
    @@ -0,0 +1,143 @@
    +import logging
    +from typing import Any, Dict, List
    +
    +from sqlalchemy import or_
    +
    +from database.client import as_dict, filter_property, get_db_session
    +from database.db_models import McpCommunityRecord
    +
    +logger = logging.getLogger("community_mcp_db")
    +
    +
    +def get_mcp_community_records(
    +    *,
    +    search: str | None = None,
    +    transport_type: str | None = None,
    +    cursor: str | None = None,
    +    limit: int = 30,
    +) -> Dict[str, Any]:
    +    with get_db_session() as session:
    +        query = session.query(McpCommunityRecord).filter(
    +            McpCommunityRecord.delete_flag != "Y"
    +        )
    +
    +        if transport_type:
    +            query = query.filter(McpCommunityRecord.transport_type == transport_type)
    +
    +        if search:
    +            keyword = f"%{search}%"
    +            query = query.filter(
    +                or_(
    +                    McpCommunityRecord.mcp_name.ilike(keyword),
    +                    McpCommunityRecord.description.ilike(keyword),
    +                    McpCommunityRecord.tags.ilike(keyword),
    +                )
    +            )
    +
    +        cursor_id: int | None = None
    +        if cursor:
    +            try:
    +                cursor_id = int(cursor)
    +            except ValueError:
    +                cursor_id = None
    +
    +        if cursor_id is not None:
    +            query = query.filter(McpCommunityRecord.community_id < cursor_id)
    +
    +        rows: List[McpCommunityRecord] = (
    +            query.order_by(McpCommunityRecord.community_id.desc())
    +            .limit(limit + 1)
    +            .all()
    +        )
    +
    +        has_next = len(rows) > limit
    +        page_rows = rows[:limit]
    +
    +        next_cursor = None
    +        if has_next and page_rows:
    +            next_cursor = str(page_rows[-1].community_id)
    +
    +        return {
    +            "count": len(page_rows),
    +            "nextCursor": next_cursor,
    +            "items": [as_dict(row) for row in page_rows],
    +        }
    +
    +
    +def create_mcp_community_record(mcp_data: Dict[str, Any], tenant_id: str, user_id: str) -> int:
    +    with get_db_session() as session:
    +        mcp_data.update({
    +            "tenant_id": tenant_id,
    +            "user_id": user_id,
    +            "created_by": user_id,
    +            "updated_by": user_id,
    +            "delete_flag": "N",
    +            "source": "community",
    +        })
    +        new_record = McpCommunityRecord(**filter_property(mcp_data, McpCommunityRecord))
    +        session.add(new_record)
    +        session.flush()
    +        return int(new_record.community_id)
    +
    +
    +def get_mcp_community_record_by_id_and_tenant(community_id: int, tenant_id: str) -> Dict[str, Any] | None:
    +    with get_db_session() as session:
    +        record = session.query(McpCommunityRecord).filter(
    +            McpCommunityRecord.community_id == community_id,
    +            McpCommunityRecord.tenant_id == tenant_id,
    +            McpCommunityRecord.delete_flag != "Y",
    +        ).first()
    +        return as_dict(record) if record else None
    +
    +
    +def update_mcp_community_record_by_id(
    +    *,
    +    community_id: int,
    +    tenant_id: str,
    +    user_id: str,
    +    name: str | None = None,
    +    description: str | None = None,
    +    tags: List[str] | None = None,
    +    version: str | None = None,
    +    registry_json: Dict[str, Any] | None = None,
    +    config_json: Dict[str, Any] | None = None,
    +) -> None:
    +    update_fields: Dict[str, Any] = {"updated_by": user_id}
    +
    +    if name is not None:
    +        update_fields["mcp_name"] = name
    +    if description is not None:
    +        update_fields["description"] = description
    +    if tags is not None:
    +        update_fields["tags"] = ",".join(tags)
    +    if version is not None:
    +        update_fields["version"] = version
    +    if registry_json is not None:
    +        update_fields["registry_json"] = registry_json
    +    if config_json is not None:
    +        update_fields["config_json"] = config_json
    +
    +    with get_db_session() as session:
    +        session.query(McpCommunityRecord).filter(
    +            McpCommunityRecord.community_id == community_id,
    +            McpCommunityRecord.tenant_id == tenant_id,
    +            McpCommunityRecord.delete_flag != "Y",
    +        ).update(update_fields)
    +
    +
    +def delete_mcp_community_record_by_id(*, community_id: int, tenant_id: str, user_id: str) -> None:
    +    with get_db_session() as session:
    +        session.query(McpCommunityRecord).filter(
    +            McpCommunityRecord.community_id == community_id,
    +            McpCommunityRecord.tenant_id == tenant_id,
    +            McpCommunityRecord.delete_flag != "Y",
    +        ).update({"delete_flag": "Y", "updated_by": user_id})
    +
    +
    +def list_mcp_community_records_by_tenant(tenant_id: str) -> List[Dict[str, Any]]:
    +    with get_db_session() as session:
    +        rows = session.query(McpCommunityRecord).filter(
    +            McpCommunityRecord.tenant_id == tenant_id,
    +            McpCommunityRecord.delete_flag != "Y",
    +        ).order_by(McpCommunityRecord.community_id.desc()).all()
    +        return [as_dict(row) for row in rows]
    diff --git a/backend/database/db_models.py b/backend/database/db_models.py
    index c81266bdc..a37a17b98 100644
    --- a/backend/database/db_models.py
    +++ b/backend/database/db_models.py
    @@ -345,6 +345,33 @@ class McpRecord(TableBase):
         last_sync_time = Column(TIMESTAMP(timezone=False), doc="Last sync time")
     
     
    +class McpCommunityRecord(TableBase):
    +    """Community MCP market records table."""
    +
    +    __tablename__ = "mcp_community_record_t"
    +    __table_args__ = {"schema": SCHEMA}
    +
    +    community_id = Column(
    +        Integer,
    +        Sequence("mcp_community_record_t_community_id_seq", schema=SCHEMA),
    +        primary_key=True,
    +        nullable=False,
    +        doc="Community record ID, unique primary key",
    +    )
    +    tenant_id = Column(String(100), doc="Publisher tenant ID")
    +    user_id = Column(String(100), doc="Publisher user ID")
    +    mcp_name = Column(String(100), doc="MCP name")
    +    mcp_server = Column(String(500), doc="MCP server URL")
    +    source = Column(String(30), doc="Source type, fixed to community")
    +    version = Column(String(50), doc="MCP version")
    +    registry_json = Column(JSONB, doc="Full MCP metadata JSON")
    +    transport_type = Column(String(30), doc="Transport type: http/sse/stdio")
    +    config_json = Column(JSON, doc="Public-shareable MCP configuration JSON")
    +    tags = Column(String(200), doc="Tags")
    +    description = Column(String(100), doc="Description")
    +    last_sync_time = Column(TIMESTAMP(timezone=False), doc="Last sync time")
    +
    +
     class McpServiceManage(TableBase):
         """
         MCP service management table
    diff --git a/backend/database/remote_mcp_db.py b/backend/database/remote_mcp_db.py
    index ca94da83a..b2f967596 100644
    --- a/backend/database/remote_mcp_db.py
    +++ b/backend/database/remote_mcp_db.py
    @@ -289,6 +289,26 @@ def check_mcp_name_exists(mcp_name: str, tenant_id: str) -> bool:
             return mcp_record is not None
     
     
    +def check_enabled_mcp_name_exists(mcp_name: str, tenant_id: str) -> bool:
    +    """
    +    Check if enabled MCP name already exists for a tenant.
    +
    +    Only enabled records participate in conflict checks for runtime container startup.
    +
    +    :param mcp_name: MCP name
    +    :param tenant_id: Tenant ID
    +    :return: True if enabled name exists, False otherwise
    +    """
    +    with get_db_session() as session:
    +        mcp_record = session.query(McpRecord).filter(
    +            McpRecord.mcp_name == mcp_name,
    +            McpRecord.tenant_id == tenant_id,
    +            McpRecord.delete_flag != 'Y',
    +            McpRecord.enabled.is_(True),
    +        ).first()
    +        return mcp_record is not None
    +
    +
     def get_mcp_record_by_id_and_tenant(mcp_id: int, tenant_id: str) -> Dict[str, Any] | None:
         """
         Get MCP record by ID and tenant ID
    diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py
    index 1047ec31b..d4b7d35a9 100644
    --- a/backend/services/mcp_management_service.py
    +++ b/backend/services/mcp_management_service.py
    @@ -24,6 +24,14 @@
         update_mcp_record_runtime_fields_by_id,
         update_mcp_record_status_by_id,
     )
    +from database.community_mcp_db import (
    +    create_mcp_community_record,
    +    delete_mcp_community_record_by_id,
    +    get_mcp_community_record_by_id_and_tenant,
    +    get_mcp_community_records,
    +    list_mcp_community_records_by_tenant,
    +    update_mcp_community_record_by_id,
    +)
     from services.mcp_container_service import MCPContainerManager
     from services.remote_mcp_service import mcp_server_health
     from services.tool_configuration_service import get_tool_from_remote_mcp_server
    @@ -154,6 +162,169 @@ def _normalize_mcp_registry_server(entry: Dict[str, Any]) -> Dict[str, Any] | No
             "serverJson": server,
         }
     
    +
    +def _normalize_community_remotes(record: Dict[str, Any], registry_json: Dict[str, Any]) -> List[Dict[str, str]]:
    +    remotes_out: List[Dict[str, str]] = []
    +    remotes = registry_json.get("remotes") if isinstance(registry_json, dict) else None
    +    if isinstance(remotes, list):
    +        for remote in remotes:
    +            if not isinstance(remote, dict):
    +                continue
    +            remote_url = _extract_str(remote.get("url"))
    +            remote_type = _extract_str(remote.get("type")).lower()
    +            if remote_url:
    +                remotes_out.append({"type": remote_type, "url": remote_url})
    +
    +    if remotes_out:
    +        return remotes_out
    +
    +    server_url = _extract_str(record.get("mcp_server"))
    +    if not server_url:
    +        return []
    +
    +    transport_type = _extract_str(record.get("transport_type")).lower()
    +    default_type = "streamable-http" if transport_type == "http" else transport_type or "streamable-http"
    +    return [{"type": default_type, "url": server_url}]
    +
    +
    +def _normalize_community_card(record: Dict[str, Any]) -> Dict[str, Any]:
    +    registry_json = record.get("registry_json") if isinstance(record.get("registry_json"), dict) else {}
    +    remotes_out = _normalize_community_remotes(record, registry_json)
    +    packages = registry_json.get("packages") if isinstance(registry_json.get("packages"), list) else []
    +    published_at = record.get("create_time")
    +    updated_at = record.get("update_time")
    +
    +    raw_transport_type = _extract_str(record.get("transport_type")).lower()
    +    normalized_transport_type = "stdio" if raw_transport_type in {"stdio", "container"} else "sse" if raw_transport_type == "sse" else "http"
    +    config_json = record.get("config_json") if isinstance(record.get("config_json"), dict) else None
    +
    +    return {
    +        "communityId": record.get("community_id"),
    +        "name": _extract_str(record.get("mcp_name")),
    +        "version": _extract_str(record.get("version")),
    +        "description": _extract_str(record.get("description")),
    +        "source": "community",
    +        "transportType": normalized_transport_type,
    +        "serverUrl": _extract_str(record.get("mcp_server")),
    +        "configJson": config_json,
    +        "mcpRegistryJson": registry_json if registry_json else None,
    +        "tags": _split_tags(record.get("tags")),
    +        "remotes": remotes_out,
    +        "packages": packages,
    +        "status": "active",
    +        "isLatest": True,
    +        "publishedAt": published_at.isoformat() if isinstance(published_at, datetime) else _extract_str(published_at),
    +        "updatedAt": updated_at.isoformat() if isinstance(updated_at, datetime) else _extract_str(updated_at),
    +        "serverJson": registry_json if registry_json else {},
    +    }
    +
    +
    +async def list_community_mcp_services(
    +    *,
    +    search: str | None = None,
    +    transport_type: str | None = None,
    +    cursor: str | None = None,
    +    limit: int = 30,
    +) -> Dict[str, Any]:
    +    db_result = get_mcp_community_records(
    +        search=(search or "").strip() or None,
    +        transport_type=(transport_type or "").strip().lower() or None,
    +        cursor=(cursor or "").strip() or None,
    +        limit=max(1, min(limit, 100)),
    +    )
    +
    +    items = [_normalize_community_card(record) for record in db_result.get("items", [])]
    +    return {
    +        "count": len(items),
    +        "nextCursor": db_result.get("nextCursor"),
    +        "items": items,
    +    }
    +
    +
    +def _normalize_transport_type(value: str | None) -> str:
    +    raw = (value or "").strip().lower()
    +    if raw in {"stdio", "container"}:
    +        return "stdio"
    +    if raw == "sse":
    +        return "sse"
    +    return "http"
    +
    +
    +async def publish_community_mcp_service(*, tenant_id: str, user_id: str, mcp_id: int) -> int:
    +    source_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id)
    +    if not source_record:
    +        raise ValueError("MCP record not found")
    +
    +    source_registry_json = source_record.get("registry_json") if isinstance(source_record.get("registry_json"), dict) else None
    +    source_config_json = source_record.get("config_json") if isinstance(source_record.get("config_json"), dict) else None
    +
    +    community_id = create_mcp_community_record(
    +        mcp_data={
    +            "mcp_name": _extract_str(source_record.get("mcp_name")),
    +            "mcp_server": _extract_str(source_record.get("mcp_server")),
    +            "version": _extract_str(source_record.get("version")),
    +            "registry_json": source_registry_json,
    +            "transport_type": _normalize_transport_type(source_record.get("transport_type")),
    +            "config_json": source_config_json,
    +            "tags": source_record.get("tags") or "",
    +            "description": _extract_str(source_record.get("description")),
    +            "last_sync_time": datetime.now(),
    +        },
    +        tenant_id=tenant_id,
    +        user_id=user_id,
    +    )
    +    return community_id
    +
    +
    +async def update_community_mcp_service(
    +    *,
    +    tenant_id: str,
    +    user_id: str,
    +    community_id: int,
    +    name: str | None,
    +    description: str | None,
    +    tags: List[str] | None,
    +    version: str | None,
    +    registry_json: Dict[str, Any] | None,
    +) -> None:
    +    current = get_mcp_community_record_by_id_and_tenant(community_id=community_id, tenant_id=tenant_id)
    +    if not current:
    +        raise ValueError("Community MCP record not found")
    +
    +    existing_config_json = current.get("config_json") if isinstance(current.get("config_json"), dict) else None
    +    next_registry_json = registry_json if isinstance(registry_json, dict) else current.get("registry_json")
    +    next_config_json = existing_config_json
    +    if isinstance(next_registry_json, dict) and isinstance(next_registry_json.get("configJson"), dict):
    +        next_config_json = next_registry_json.get("configJson")
    +
    +    update_mcp_community_record_by_id(
    +        community_id=community_id,
    +        tenant_id=tenant_id,
    +        user_id=user_id,
    +        name=name,
    +        description=description,
    +        tags=tags,
    +        version=version,
    +        registry_json=registry_json,
    +        config_json=next_config_json,
    +    )
    +
    +
    +async def delete_community_mcp_service(*, tenant_id: str, user_id: str, community_id: int) -> None:
    +    current = get_mcp_community_record_by_id_and_tenant(community_id=community_id, tenant_id=tenant_id)
    +    if not current:
    +        raise ValueError("Community MCP record not found")
    +    delete_mcp_community_record_by_id(community_id=community_id, tenant_id=tenant_id, user_id=user_id)
    +
    +
    +async def list_my_community_mcp_services(*, tenant_id: str) -> Dict[str, Any]:
    +    rows = list_mcp_community_records_by_tenant(tenant_id=tenant_id)
    +    items = [_normalize_community_card(row) for row in rows]
    +    return {
    +        "count": len(items),
    +        "items": items,
    +    }
    +
     async def list_registry_mcp_services(
         *,
         search: str | None = None,
    @@ -219,7 +390,7 @@ async def add_mcp_service(
         container_id: str | None = None,
     ) -> None:
         normalized_source = (source or "local").strip().lower()
    -    if normalized_source not in {"local", "mcp_registry"}:
    +    if normalized_source not in {"local", "mcp_registry", "community"}:
             raise ValueError(f"Invalid source: {source}")
     
         normalized_transport_type = (transport_type or "http").strip().lower()
    diff --git a/docker/sql/v1.8.2_0326_add_mcp_community_record_t.sql b/docker/sql/v1.8.2_0326_add_mcp_community_record_t.sql
    new file mode 100644
    index 000000000..f2131df1b
    --- /dev/null
    +++ b/docker/sql/v1.8.2_0326_add_mcp_community_record_t.sql
    @@ -0,0 +1,82 @@
    +-- Migration: Add mcp_community_record_t table
    +-- Date: 2026-03-26
    +-- Description: Community MCP market table aligned with public-shareable fields from mcp_record_t.
    +
    +SET search_path TO nexent;
    +
    +BEGIN;
    +
    +CREATE TABLE IF NOT EXISTS nexent.mcp_community_record_t (
    +    community_id SERIAL PRIMARY KEY NOT NULL,
    +    tenant_id VARCHAR(100),
    +    user_id VARCHAR(100),
    +    mcp_name VARCHAR(100) NOT NULL,
    +    mcp_server VARCHAR(500) NOT NULL,
    +    source VARCHAR(30) DEFAULT 'community',
    +    version VARCHAR(50),
    +    registry_json JSONB,
    +    transport_type VARCHAR(30),
    +    config_json JSON,
    +    tags VARCHAR(200),
    +    description VARCHAR(100),
    +    last_sync_time TIMESTAMP WITHOUT TIME ZONE,
    +    create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    +    update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    +    created_by VARCHAR(100),
    +    updated_by VARCHAR(100),
    +    delete_flag VARCHAR(1) DEFAULT 'N'
    +);
    +
    +ALTER TABLE nexent.mcp_community_record_t OWNER TO root;
    +
    +COMMENT ON TABLE nexent.mcp_community_record_t IS 'Community MCP market records, publishable from tenant MCP services';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.community_id IS 'Community record ID, unique primary key';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.tenant_id IS 'Publisher tenant ID';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.user_id IS 'Publisher user ID';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.mcp_name IS 'MCP name';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.mcp_server IS 'MCP server URL';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.source IS 'Source type, fixed to community for this table';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.version IS 'MCP version';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.registry_json IS 'Full MCP server metadata JSON for discovery and quick import';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.transport_type IS 'Transport type: http/sse/stdio';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.config_json IS 'Public-shareable MCP configuration JSON';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.tags IS 'Tags';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.description IS 'Description';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.last_sync_time IS 'Last sync time';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.create_time IS 'Creation time';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.update_time IS 'Update time';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.created_by IS 'Creator ID';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.updated_by IS 'Updater ID';
    +COMMENT ON COLUMN nexent.mcp_community_record_t.delete_flag IS 'Soft delete flag: Y/N';
    +
    +CREATE INDEX IF NOT EXISTS idx_mcp_community_tenant_delete
    +    ON nexent.mcp_community_record_t (tenant_id, delete_flag);
    +
    +CREATE INDEX IF NOT EXISTS idx_mcp_community_name_delete
    +    ON nexent.mcp_community_record_t (mcp_name, delete_flag);
    +
    +CREATE INDEX IF NOT EXISTS idx_mcp_community_transport_delete
    +    ON nexent.mcp_community_record_t (transport_type, delete_flag);
    +
    +CREATE INDEX IF NOT EXISTS idx_mcp_community_user_delete
    +    ON nexent.mcp_community_record_t (user_id, delete_flag);
    +
    +CREATE OR REPLACE FUNCTION update_mcp_community_record_update_time()
    +RETURNS TRIGGER AS $$
    +BEGIN
    +    NEW.update_time = CURRENT_TIMESTAMP;
    +    RETURN NEW;
    +END;
    +$$ LANGUAGE plpgsql;
    +
    +COMMENT ON FUNCTION update_mcp_community_record_update_time() IS 'Auto-update update_time for mcp_community_record_t';
    +
    +DROP TRIGGER IF EXISTS update_mcp_community_record_update_time_trigger ON nexent.mcp_community_record_t;
    +CREATE TRIGGER update_mcp_community_record_update_time_trigger
    +BEFORE UPDATE ON nexent.mcp_community_record_t
    +FOR EACH ROW
    +EXECUTE FUNCTION update_mcp_community_record_update_time();
    +
    +COMMENT ON TRIGGER update_mcp_community_record_update_time_trigger ON nexent.mcp_community_record_t IS 'Trigger to maintain update_time';
    +
    +COMMIT;
    diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceCommunitySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceCommunitySection.tsx
    new file mode 100644
    index 000000000..43cd935c6
    --- /dev/null
    +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceCommunitySection.tsx
    @@ -0,0 +1,231 @@
    +import { Input, InputNumber, Modal, Select, Tag } from "antd";
    +import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools";
    +import McpCommunityToolbar from "./McpCommunityToolbar";
    +import McpCommunityCardList from "./McpCommunityCardList";
    +import McpCommunityDetailModal from "./McpCommunityDetailModal";
    +import type { CommunityMcpCard, McpTransportType } from "@/types/mcpTools";
    +
    +interface Props {
    +  communitySearchValue: string;
    +  selectedCommunityService: CommunityMcpCard | null;
    +  filteredCommunityServices: CommunityMcpCard[];
    +  communityLoading: boolean;
    +  communityPage: number;
    +  hasPrevCommunityPage: boolean;
    +  hasNextCommunityPage: boolean;
    +  quickAddConfirmVisible: boolean;
    +  quickAddSourceService: CommunityMcpCard | null;
    +  quickAddDraft: {
    +    name: string;
    +    description: string;
    +    transportType: McpTransportType;
    +    serverUrl: string;
    +    authorizationToken: string;
    +    containerConfigJson: string;
    +    containerPort: number | undefined;
    +    tags: string[];
    +    tagInputValue: string;
    +  };
    +  setCommunitySearchValue: (value: string) => void;
    +  setSelectedCommunityService: (service: CommunityMcpCard | null) => void;
    +  updateQuickAddDraft: (next: {
    +    name?: string;
    +    description?: string;
    +    transportType?: McpTransportType;
    +    serverUrl?: string;
    +    authorizationToken?: string;
    +    containerConfigJson?: string;
    +    containerPort?: number | undefined;
    +    tagInputValue?: string;
    +  }) => void;
    +  addQuickAddTag: () => void;
    +  removeQuickAddTag: (index: number) => void;
    +  handleCommunityPrevPage: () => void;
    +  handleCommunityNextPage: () => void;
    +  handleQuickAddFromCommunity: (service: CommunityMcpCard) => void;
    +  handleCloseQuickAddConfirm: () => void;
    +  handleConfirmQuickAddFromCommunity: () => void;
    +  quickAddSubmitting: boolean;
    +  t: (key: string, params?: Record) => string;
    +}
    +
    +export default function AddMcpServiceCommunitySection({
    +  communitySearchValue,
    +  selectedCommunityService,
    +  filteredCommunityServices,
    +  communityLoading,
    +  communityPage,
    +  hasPrevCommunityPage,
    +  hasNextCommunityPage,
    +  quickAddConfirmVisible,
    +  quickAddSourceService,
    +  quickAddDraft,
    +  setCommunitySearchValue,
    +  setSelectedCommunityService,
    +  updateQuickAddDraft,
    +  addQuickAddTag,
    +  removeQuickAddTag,
    +  handleCommunityPrevPage,
    +  handleCommunityNextPage,
    +  handleQuickAddFromCommunity,
    +  handleCloseQuickAddConfirm,
    +  handleConfirmQuickAddFromCommunity,
    +  quickAddSubmitting,
    +  t,
    +}: Props) {
    +  return (
    +    <>
    +      
    + + + +
    + + {selectedCommunityService ? ( + setSelectedCommunityService(null)} + onQuickAddFromCommunity={handleQuickAddFromCommunity} + /> + ) : null} + + +
    + + + + + + +
    + ) : ( +
    + + + +
    + )} + +
    +

    {t("mcpTools.addModal.tags")}

    +
    + {quickAddDraft.tags.map((tag, index) => ( + + {tag} + + + ))} + updateQuickAddDraft({ tagInputValue: event.target.value })} + onPressEnter={addQuickAddTag} + onBlur={addQuickAddTag} + placeholder={t("mcpTools.addModal.tagInputPlaceholder")} + className="w-40 rounded-full" + /> +
    +
    +
    + + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx index 840192485..18dc27ebe 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -5,8 +5,10 @@ import { MCP_TAB } from "@/const/mcpTools"; import type { McpTab } from "@/types/mcpTools"; import { useMcpToolsAddLocal } from "@/hooks/mcpTools/useMcpToolsAddLocal"; import { useMcpToolsAddRegistry } from "@/hooks/mcpTools/useMcpToolsAddRegistry"; +import { useMcpToolsAddCommunity } from "@/hooks/mcpTools/useMcpToolsAddCommunity"; import AddMcpServiceLocalSection from "./AddMcpServiceLocalSection"; import AddMcpServiceRegistrySection from "./AddMcpServiceRegistrySection"; +import AddMcpServiceCommunitySection from "./AddMcpServiceCommunitySection"; interface AddMcpServiceModalProps { open: boolean; @@ -40,16 +42,27 @@ export default function AddMcpServiceModal({ onClose, }); + const community = useMcpToolsAddCommunity({ + open, + addModalTab, + t: (key) => String(t(key)), + message, + onServiceAdded, + onClose, + }); + const { reset: resetLocal } = local; const { reset: resetRegistry } = registry; + const { reset: resetCommunity } = community; useEffect(() => { if (!open) { setAddModalTab(MCP_TAB.LOCAL); resetLocal(); resetRegistry(); + resetCommunity(); } - }, [open, resetLocal, resetRegistry]); + }, [open, resetLocal, resetRegistry, resetCommunity]); if (!open) { return null; @@ -61,7 +74,7 @@ export default function AddMcpServiceModal({ footer={null} closable centered - width={addModalTab === MCP_TAB.MCP_REGISTRY ? 1200 : 900} + width={addModalTab === MCP_TAB.LOCAL ? 900 : 1200} onCancel={onClose} styles={{ mask: { background: "rgba(15,23,42,0.6)", backdropFilter: "blur(2px)" }, @@ -82,6 +95,7 @@ export default function AddMcpServiceModal({ options={[ { label: t("mcpTools.addModal.tabLocal"), value: MCP_TAB.LOCAL }, { label: t("mcpTools.addModal.tabRegistry"), value: MCP_TAB.MCP_REGISTRY }, + { label: t("mcpTools.addModal.tabCommunity"), value: MCP_TAB.COMMUNITY }, ]} className="h-9 rounded-full border border-slate-200 bg-slate-100 p-[2px] text-sm [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-full [&_.ant-segmented-item-label]:px-4 [&_.ant-segmented-item-label]:leading-[30px] [&_.ant-segmented-thumb]:rounded-full [&_.ant-segmented-thumb]:bg-white [&_.ant-segmented-thumb]:shadow-sm [&_.ant-segmented-thumb]:top-[2px] [&_.ant-segmented-thumb]:bottom-[2px]" /> @@ -112,7 +126,7 @@ export default function AddMcpServiceModal({ handleAddService={local.handleAddService} t={(key, params) => String(t(key, params))} /> - ) : ( + ) : addModalTab === MCP_TAB.MCP_REGISTRY ? ( String(t(key, params))} /> + ) : ( + String(t(key, params))} + /> )}
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx new file mode 100644 index 000000000..66b8c5fb4 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import { MCP_REGISTRY_SERVER_STATUS } from "@/const/mcpTools"; +import { formatRegistryDate, formatRegistryVersion } from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; + +interface Props { + service: CommunityMcpCard; + t: (key: string, params?: Record) => string; + onSelectCommunityService: (service: CommunityMcpCard) => void; + onQuickAddFromCommunity: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityCard({ + service, + t, + onSelectCommunityService, + onQuickAddFromCommunity, +}: Props) { + const statusClassName = + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + ? "bg-emerald-100 text-emerald-700" + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + ? "bg-amber-100 text-amber-700" + : "bg-slate-100 text-slate-600"; + const statusTextKey = + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + ? "mcpTools.community.status.active" + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + ? "mcpTools.community.status.deprecated" + : "mcpTools.community.status.unknown"; + + return ( +
    onSelectCommunityService(service)} + className="group rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" + > +
    +

    {service.name}

    + + {t(statusTextKey)} + +
    + +
    + + {formatRegistryVersion(service.version)} + + {formatRegistryDate(service.publishedAt)} +
    + +

    {service.description}

    + +
    + +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityCardList.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityCardList.tsx new file mode 100644 index 000000000..70751c660 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityCardList.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import McpCommunityCard from "./McpCommunityCard"; + +interface Props { + communityLoading: boolean; + services: CommunityMcpCard[]; + hasPrevCommunityPage: boolean; + hasNextCommunityPage: boolean; + onPrevCommunityPage: () => void; + onNextCommunityPage: () => void; + onSelectCommunityService: (service: CommunityMcpCard) => void; + onQuickAddFromCommunity: (service: CommunityMcpCard) => void; + t: (key: string, params?: Record) => string; +} + +export default function McpCommunityCardList({ + communityLoading, + services, + hasPrevCommunityPage, + hasNextCommunityPage, + onPrevCommunityPage, + onNextCommunityPage, + onSelectCommunityService, + onQuickAddFromCommunity, + t, +}: Props) { + if (communityLoading) { + return ( +
    + {t("mcpTools.community.loading")} +
    + ); + } + + if (services.length === 0) { + return ( +
    + {t("mcpTools.community.empty")} +
    + ); + } + + return ( +
    +
    + {services.map((service, index) => ( + + ))} +
    + +
    + + +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx new file mode 100644 index 000000000..38087d402 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx @@ -0,0 +1,220 @@ +import { useState } from "react"; +import { Button, Modal } from "antd"; +import { MCP_REGISTRY_SERVER_STATUS } from "@/const/mcpTools"; +import { + extractRegistryLinks, + formatRegistryDate, + formatRegistryVersion, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; + +interface Props { + service: CommunityMcpCard; + t: (key: string, params?: Record) => string; + onClose: () => void; + onQuickAddFromCommunity: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityDetailModal({ + service, + t, + onClose, + onQuickAddFromCommunity, +}: Props) { + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + const { websiteUrl, repositoryUrl } = extractRegistryLinks(service.serverJson); + const serverJsonPretty = toPrettyRegistryJson(service.serverJson); + const configJsonPretty = toPrettyRegistryJson(service.configJson); + const hasServerJson = Boolean(service.serverJson && Object.keys(service.serverJson).length > 0); + const hasConfigJson = Boolean(service.configJson && Object.keys(service.configJson).length > 0); + const serverTypeText = + service.transportType === "sse" + ? t("mcpTools.serverType.sse") + : service.transportType === "stdio" + ? t("mcpTools.serverType.stdio") + : t("mcpTools.serverType.http"); + const sourceText = t("mcpTools.source.community"); + + const statusClassName = + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + ? "bg-emerald-100 text-emerald-700" + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + ? "bg-amber-100 text-amber-700" + : "bg-slate-100 text-slate-600"; + const statusTextKey = + service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + ? "mcpTools.community.status.active" + : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + ? "mcpTools.community.status.deprecated" + : "mcpTools.community.status.unknown"; + + return ( + <> + +
    +
    +

    {t("mcpTools.detail.title")}

    +
    + +
    +
    +
    +

    {t("mcpTools.detail.name")}

    +

    + {service.name || "-"} +

    +
    +
    +

    {t("mcpTools.detail.description")}

    +

    + {service.description || "-"} +

    +
    +
    +

    {t("mcpTools.detail.serverUrl")}

    +

    + {service.serverUrl || "-"} +

    +
    +
    + +
    + + {(service.tags || []).length > 0 ? ( +
    +

    {t("mcpTools.detail.tags")}

    +
    + {(service.tags || []).map((tag) => ( + + {tag} + + ))} +
    +
    + ) : null} + +
    +
    + {t("mcpTools.detail.tools")} +
    + {hasServerJson ? ( + + ) : null} + {hasConfigJson ? ( + + ) : null} +
    +
    +
    +
    + +
    + +
    +
    + + + {showServerJsonModal && hasServerJson ? ( + setShowServerJsonModal(false)} + title={t("mcpTools.community.serverJsonTitle", { name: service.name })} + > +
    +            {serverJsonPretty}
    +          
    +
    + ) : null} + + {showConfigJsonModal && hasConfigJson ? ( + setShowConfigJsonModal(false)} + title={t("mcpTools.detail.configJsonTitle", { name: service.name })} + > +
    +            {configJsonPretty}
    +          
    +
    + ) : null} + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx new file mode 100644 index 000000000..4d8d65395 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx @@ -0,0 +1,34 @@ +import { Input } from "antd"; + +interface Props { + communitySearchValue: string; + communityPage: number; + resultCount: number; + onCommunitySearchChange: (value: string) => void; + t: (key: string, params?: Record) => string; +} + +export default function McpCommunityToolbar({ + communitySearchValue, + communityPage, + resultCount, + onCommunitySearchChange, + t, +}: Props) { + return ( +
    +
    + onCommunitySearchChange(event.target.value)} + placeholder={t("mcpTools.community.searchPlaceholder")} + size="large" + className="w-full rounded-2xl" + /> +
    + {t("mcpTools.community.pageResult", { page: communityPage, count: resultCount })} +
    +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx index fa15ef1ff..2137af493 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx @@ -48,7 +48,11 @@ export default function McpServiceCard({
    - {service.source === MCP_TAB.LOCAL ? t("mcpTools.source.local") : t("mcpTools.source.registry")} + {service.source === MCP_TAB.LOCAL + ? t("mcpTools.source.local") + : service.source === MCP_TAB.COMMUNITY + ? t("mcpTools.source.community") + : t("mcpTools.source.registry")} {service.transportType === MCP_TRANSPORT_TYPE.HTTP diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx index d7b4f32ec..b16525681 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -37,6 +37,8 @@ interface McpServiceDetailModalProps { closeToolsModal: () => void; handleRefreshTools: () => void; onDeleteConfirm: (serviceName: string) => void; + onPublishToCommunity: () => void; + publishLoading?: boolean; onToggleEnable: (service: McpServiceItem) => void; toggleLoading?: boolean; onClose: () => void; @@ -62,6 +64,8 @@ export default function McpServiceDetailModal({ closeToolsModal, handleRefreshTools, onDeleteConfirm, + onPublishToCommunity, + publishLoading = false, onToggleEnable, toggleLoading = false, onClose, @@ -353,6 +357,9 @@ export default function McpServiceDetailModal({ + + item.communityId && handleDelete(item.communityId)} + > + + +
    +
    + +

    {item.description || "-"}

    + +
    +
    {t("mcpTools.detail.serverType")}: {item.transportType}
    +
    {t("mcpTools.detail.serverUrl")}: {item.serverUrl || "-"}
    +
    + + {(item.tags || []).length > 0 ? ( +
    + {(item.tags || []).map((tag) => ( + + {tag} + + ))} +
    + ) : null} +
    + ))} +
    + )} +
    + + + setEditDraft(null)} + onOk={() => { + void saveEdit(); + }} + confirmLoading={saving} + title={t("mcpTools.community.mine.edit")} + okText={t("common.save")} + cancelText={t("common.cancel")} + > + {editDraft ? ( +
    + + + + +
    + ) : null} +
    + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index 1cc237cd1..cd93df42a 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -7,6 +7,7 @@ import { motion } from "framer-motion"; import log from "@/lib/logger"; import { useSetupFlow } from "@/hooks/useSetupFlow"; import AddMcpServiceModal from "./components/AddMcpServiceModal"; +import MyCommunityMcpModal from "./components/MyCommunityMcpModal"; import McpServiceCard from "./components/McpServiceCard"; import McpServiceDetailModal from "./components/McpServiceDetailModal"; import { useMcpToolsPage } from "../../../hooks/mcpTools/useMcpToolsPage"; @@ -16,6 +17,7 @@ export default function McpToolsPage() { const { t } = useTranslation("common"); const { pageVariants, pageTransition } = useSetupFlow(); const translate = useCallback((key: string) => String(t(key)), [t]); + const [showMyPublishedModal, setShowMyPublishedModal] = React.useState(false); const { searchValue, @@ -87,15 +89,25 @@ export default function McpToolsPage() {
    - +
    + + +
    @@ -153,6 +165,8 @@ export default function McpToolsPage() { closeToolsModal={detail.closeToolsModal} handleRefreshTools={detail.handleRefreshTools} onDeleteConfirm={(serviceName) => handleDeleteConfirm(detail.selectedService!.mcpId, serviceName)} + onPublishToCommunity={detail.handlePublishToCommunity} + publishLoading={detail.publishLoading} toggleLoading={togglingServiceId === detail.selectedService?.mcpId} onToggleEnable={(item) => { toggleServiceStatus(item).catch((error) => { @@ -172,6 +186,12 @@ export default function McpToolsPage() { onServiceAdded={loadServerList} onClose={() => setShowAddModal(false)} /> + + setShowMyPublishedModal(false)} + t={(key, params) => String(t(key, params))} + />
    diff --git a/frontend/const/mcpTools.ts b/frontend/const/mcpTools.ts index 87c63ce57..324cdc224 100644 --- a/frontend/const/mcpTools.ts +++ b/frontend/const/mcpTools.ts @@ -6,7 +6,7 @@ import { McpTab, } from "@/types/mcpTools"; -export const MCP_TAB = { LOCAL: McpTab.LOCAL, MCP_REGISTRY: McpTab.MCP_REGISTRY } as const; +export const MCP_TAB = { LOCAL: McpTab.LOCAL, MCP_REGISTRY: McpTab.MCP_REGISTRY, COMMUNITY: McpTab.COMMUNITY } as const; export const MCP_TRANSPORT_TYPE = { HTTP: McpTransportType.HTTP, SSE: McpTransportType.SSE, STDIO: McpTransportType.STDIO } as const; export const MCP_SERVICE_STATUS = { ENABLED: McpServiceStatus.ENABLED, DISABLED: McpServiceStatus.DISABLED } as const; export const MCP_HEALTH_STATUS = { diff --git a/frontend/hooks/mcpTools/useMcpToolsAddCommunity.ts b/frontend/hooks/mcpTools/useMcpToolsAddCommunity.ts new file mode 100644 index 000000000..39712d193 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpToolsAddCommunity.ts @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { MessageInstance } from "antd/es/message/interface"; +import log from "@/lib/logger"; +import { MCP_TRANSPORT_TYPE, MCP_TAB } from "@/const/mcpTools"; +import { + addContainerMcpToolService, + addMcpToolService, + fetchCommunityMcpCards, +} from "@/services/mcpToolsService"; +import { + type AddMcpRuntimeFromConfigPayload, + type CommunityMcpCard, + type McpTransportType, + type McpTab, +} from "@/types/mcpTools"; + +type UseMcpToolsAddCommunityParams = { + open: boolean; + addModalTab: McpTab; + t: (key: string) => string; + message: MessageInstance; + onServiceAdded: () => Promise; + onClose: () => void; +}; + +type CommunityQuickAddDraft = { + name: string; + description: string; + transportType: McpTransportType; + serverUrl: string; + authorizationToken: string; + containerConfigJson: string; + containerPort: number | undefined; + tags: string[]; + tagInputValue: string; + version?: string; + registryJson?: Record; +}; + +const INITIAL_DRAFT: CommunityQuickAddDraft = { + name: "", + description: "", + transportType: MCP_TRANSPORT_TYPE.HTTP, + serverUrl: "", + authorizationToken: "", + containerConfigJson: "", + containerPort: undefined, + tags: [], + tagInputValue: "", + version: undefined, + registryJson: undefined, +}; + +export function useMcpToolsAddCommunity({ + open, + addModalTab, + t, + message, + onServiceAdded, + onClose, +}: UseMcpToolsAddCommunityParams) { + const [communitySearchValue, setCommunitySearchValue] = useState(""); + const [selectedCommunityService, setSelectedCommunityService] = useState(null); + const [communityCurrentCursor, setCommunityCurrentCursor] = useState(null); + const [communityCursorHistory, setCommunityCursorHistory] = useState([]); + const [communityPage, setCommunityPage] = useState(1); + const [quickAddConfirmVisible, setQuickAddConfirmVisible] = useState(false); + const [quickAddSourceService, setQuickAddSourceService] = useState(null); + const [quickAddDraft, setQuickAddDraft] = useState(INITIAL_DRAFT); + const [addingService, setAddingService] = useState(false); + + const addMutation = useMutation({ mutationFn: addMcpToolService }); + + const reset = useCallback(() => { + setCommunitySearchValue(""); + setCommunityCurrentCursor(null); + setCommunityCursorHistory([]); + setCommunityPage(1); + setSelectedCommunityService(null); + setQuickAddConfirmVisible(false); + setQuickAddSourceService(null); + setQuickAddDraft(INITIAL_DRAFT); + setAddingService(false); + }, []); + + const loadCommunityFirstPage = useCallback(() => { + setCommunityCurrentCursor(null); + setCommunityCursorHistory([]); + setCommunityPage(1); + }, []); + + useEffect(() => { + if (!(open && addModalTab === MCP_TAB.COMMUNITY)) return; + const timer = window.setTimeout(() => { + loadCommunityFirstPage(); + }, 350); + return () => window.clearTimeout(timer); + }, [open, addModalTab, communitySearchValue, loadCommunityFirstPage]); + + const communityQuery = useQuery<{ items: CommunityMcpCard[]; nextCursor: string | null }>({ + queryKey: ["mcp-tools", "community", communitySearchValue, communityCurrentCursor], + enabled: open && addModalTab === MCP_TAB.COMMUNITY, + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchOnMount: false, + queryFn: async () => { + const result = await fetchCommunityMcpCards({ + search: communitySearchValue, + cursor: communityCurrentCursor, + }); + return result.data; + }, + }); + + const communityServices = communityQuery.data?.items ?? []; + const communityNextCursor = communityQuery.data?.nextCursor ?? null; + + useEffect(() => { + if (!(communityQuery.error instanceof Error)) return; + log.error("[useMcpToolsAddCommunity] Failed to load community MCP cards", { + error: communityQuery.error, + search: communitySearchValue, + cursor: communityCurrentCursor, + }); + message.error(t("mcpTools.community.loadFailed")); + }, [communityQuery.error, communitySearchValue, communityCurrentCursor, message, t]); + + const handleCommunityNextPage = useCallback(() => { + if (!communityNextCursor || communityQuery.isFetching) return; + const currentCursorSnapshot = communityCurrentCursor; + setCommunityCursorHistory((prev) => [...prev, currentCursorSnapshot ?? ""]); + setCommunityCurrentCursor(communityNextCursor); + setCommunityPage((prev) => prev + 1); + }, [communityCurrentCursor, communityNextCursor, communityQuery.isFetching]); + + const handleCommunityPrevPage = useCallback(() => { + if (communityCursorHistory.length === 0 || communityQuery.isFetching) return; + const previousCursor = communityCursorHistory[communityCursorHistory.length - 1] || null; + setCommunityCursorHistory((prev) => prev.slice(0, -1)); + setCommunityCurrentCursor(previousCursor); + setCommunityPage((prev) => Math.max(1, prev - 1)); + }, [communityCursorHistory, communityQuery.isFetching]); + + const handleQuickAddFromCommunity = useCallback((service: CommunityMcpCard) => { + const transportType = (service.transportType || MCP_TRANSPORT_TYPE.HTTP) as McpTransportType; + const nextConfig = service.configJson && typeof service.configJson === "object" + ? JSON.stringify(service.configJson, null, 2) + : ""; + + setQuickAddSourceService(service); + setQuickAddDraft({ + name: service.name || "", + description: service.description || "", + transportType, + serverUrl: service.serverUrl || "", + authorizationToken: "", + containerConfigJson: nextConfig, + containerPort: undefined, + tags: service.tags || [], + tagInputValue: "", + version: service.version || undefined, + registryJson: service.mcpRegistryJson || service.serverJson || undefined, + }); + setQuickAddConfirmVisible(true); + }, []); + + const updateQuickAddDraft = useCallback((next: Partial) => { + setQuickAddDraft((prev) => ({ ...prev, ...next })); + }, []); + + const addQuickAddTag = useCallback(() => { + const tag = quickAddDraft.tagInputValue.trim(); + if (!tag) return; + setQuickAddDraft((prev) => ({ + ...prev, + tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag], + tagInputValue: "", + })); + }, [quickAddDraft.tagInputValue]); + + const removeQuickAddTag = useCallback((index: number) => { + setQuickAddDraft((prev) => ({ + ...prev, + tags: prev.tags.filter((_, idx) => idx !== index), + })); + }, []); + + const handleCloseQuickAddConfirm = useCallback(() => { + if (addingService) return; + setQuickAddConfirmVisible(false); + setQuickAddSourceService(null); + setQuickAddDraft(INITIAL_DRAFT); + }, [addingService]); + + const handleConfirmQuickAddFromCommunity = useCallback(async () => { + const draft = quickAddDraft; + const serviceName = draft.name.trim(); + const transportType = draft.transportType; + const serverUrl = draft.serverUrl.trim(); + + if (!serviceName) { + message.error(t("mcpTools.add.validate.nameRequired")); + return; + } + + if ((transportType === MCP_TRANSPORT_TYPE.HTTP || transportType === MCP_TRANSPORT_TYPE.SSE) && !serverUrl) { + message.error(t("mcpTools.add.validate.httpUrlRequired")); + return; + } + + if (transportType === MCP_TRANSPORT_TYPE.STDIO) { + if (!draft.containerConfigJson.trim()) { + message.error(t("mcpTools.add.validate.containerConfigRequired")); + return; + } + if (!draft.containerPort) { + message.error(t("mcpTools.add.validate.containerRequired")); + return; + } + } + + setAddingService(true); + try { + if (transportType === MCP_TRANSPORT_TYPE.STDIO) { + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(draft.containerConfigJson); + } catch { + message.error(t("mcpTools.add.error.containerJsonInvalid")); + return; + } + + const mcpConfig = parsedConfig as AddMcpRuntimeFromConfigPayload; + if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== "object") { + message.error(t("mcpTools.add.validate.containerConfigRequired")); + return; + } + + await addContainerMcpToolService({ + name: serviceName, + description: draft.description.trim(), + source: "community", + tags: draft.tags, + authorization_token: draft.authorizationToken.trim() || undefined, + registry_json: draft.registryJson, + port: draft.containerPort as number, + mcp_config: mcpConfig, + }); + } else { + await addMutation.mutateAsync({ + name: serviceName, + description: draft.description.trim(), + source: MCP_TAB.COMMUNITY, + transport_type: transportType === MCP_TRANSPORT_TYPE.SSE ? MCP_TRANSPORT_TYPE.SSE : MCP_TRANSPORT_TYPE.HTTP, + server_url: serverUrl, + tags: draft.tags, + authorization_token: draft.authorizationToken.trim() || undefined, + version: draft.version || undefined, + registry_json: draft.registryJson, + }); + } + + await onServiceAdded(); + message.success(t("mcpTools.community.quickAddSuccess")); + onClose(); + } catch (error) { + log.error("[useMcpToolsAddCommunity] Failed to quick add community service", { + error, + serviceName, + transportType, + }); + message.error(t("mcpTools.add.failed")); + } finally { + setAddingService(false); + } + }, [addMutation, message, onClose, onServiceAdded, quickAddDraft, t]); + + return { + communitySearchValue, + selectedCommunityService, + filteredCommunityServices: communityServices, + communityLoading: communityQuery.isFetching, + communityPage, + hasPrevCommunityPage: communityCursorHistory.length > 0, + hasNextCommunityPage: Boolean(communityNextCursor), + quickAddSubmitting: addingService, + quickAddConfirmVisible, + quickAddSourceService, + quickAddDraft, + setCommunitySearchValue, + setSelectedCommunityService, + updateQuickAddDraft, + addQuickAddTag, + removeQuickAddTag, + handleCommunityPrevPage, + handleCommunityNextPage, + handleQuickAddFromCommunity, + handleCloseQuickAddConfirm, + handleConfirmQuickAddFromCommunity, + addingService, + reset, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpToolsDetail.ts b/frontend/hooks/mcpTools/useMcpToolsDetail.ts index b8abc9e2e..52ab2c1ec 100644 --- a/frontend/hooks/mcpTools/useMcpToolsDetail.ts +++ b/frontend/hooks/mcpTools/useMcpToolsDetail.ts @@ -6,6 +6,7 @@ import { MCP_HEALTH_STATUS } from "@/const/mcpTools"; import type { McpTool } from "@/types/agentConfig"; import { type McpServiceItem } from "@/types/mcpTools"; import { + publishCommunityMcpTool, deleteMcpToolService, healthcheckMcpToolService, listMcpRuntimeTools, @@ -86,6 +87,7 @@ export function useMcpToolsDetail({ const updateMutation = useMutation({ mutationFn: updateMcpToolService }); const deleteMutation = useMutation({ mutationFn: deleteMcpToolService }); const healthcheckMutation = useMutation({ mutationFn: healthcheckMcpToolService }); + const publishMutation = useMutation({ mutationFn: publishCommunityMcpTool }); const toolsQueryKey = ["mcp-tools", "runtime-tools", draftService?.mcpId]; @@ -227,6 +229,21 @@ export function useMcpToolsDetail({ } }; + const handlePublishToCommunity = async () => { + if (!selectedService) return; + try { + await publishMutation.mutateAsync(selectedService.mcpId); + message.success(t("mcpTools.community.publishSuccess")); + } catch (error) { + log.error("[useMcpToolsDetail] Failed to publish service to community", { + error, + serviceId: selectedService.mcpId, + serviceName: selectedService.name, + }); + message.error(t("mcpTools.community.publishFailed")); + } + }; + const addDetailTag = () => { const nextTag = tagInputValue.trim(); if (!nextTag) return; @@ -253,6 +270,8 @@ export function useMcpToolsDetail({ closeToolsModal: () => setToolsModalVisible(false), handleRefreshTools, onDeleteService, + handlePublishToCommunity, + publishLoading: publishMutation.isPending, closeDetail: () => onSelectedServiceChange(null), }; } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index ef3d0dfbc..ca995536f 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1496,6 +1496,7 @@ "mcpTools.page.searchPlaceholder": "Search by MCP service name, description, or tags", "mcpTools.page.resultCount": "{{count}} results", "mcpTools.page.addService": "Add MCP Service", + "mcpTools.page.myPublished": "My Published", "mcpTools.page.loading": "Loading MCP services...", "mcpTools.page.empty": "No MCP services yet. Add or import one first.", "mcpTools.list.loadFailed": "Failed to load MCP services", @@ -1519,6 +1520,7 @@ "mcpTools.status.disabled": "Disabled", "mcpTools.source.local": "Local", "mcpTools.source.registry": "MCP Registry", + "mcpTools.source.community": "Community Market", "mcpTools.source.market": "Public Market", "mcpTools.serverType.http": "HTTP", "mcpTools.serverType.sse": "SSE", @@ -1550,6 +1552,7 @@ "mcpTools.addModal.title": "Add MCP Service", "mcpTools.addModal.tabLocal": "Local", "mcpTools.addModal.tabRegistry": "MCP Registry", + "mcpTools.addModal.tabCommunity": "Community Market", "mcpTools.addModal.tabMarket": "Public Market", "mcpTools.addModal.name": "Name", "mcpTools.addModal.description": "Description", @@ -1577,7 +1580,7 @@ "mcpTools.registry.versionAll": "All Versions", "mcpTools.registry.versionLatest": "latest (most recent)", "mcpTools.registry.versionCustom": "Custom Version", - "mcpTools.registry.updatedSince": "Updated Since (RFC3339)", + "mcpTools.registry.updatedSince": "Updated Since ", "mcpTools.registry.updatedSincePlaceholder": "Select updated time", "mcpTools.registry.includeDeleted": "Include Deleted", "mcpTools.registry.includeDeletedDesc": "Include deleted servers", @@ -1607,6 +1610,42 @@ "mcpTools.registry.status.active": "active", "mcpTools.registry.status.deprecated": "deprecated", "mcpTools.registry.status.unknown": "unknown", + "mcpTools.community.loadFailed": "Failed to load community market list", + "mcpTools.community.searchPlaceholder": "Search MCP services in community market", + "mcpTools.community.pageResult": "Page {{page}} · {{count}} results", + "mcpTools.community.publishedAt": "Published At", + "mcpTools.community.loading": "Loading community market MCP services...", + "mcpTools.community.empty": "No matching community market MCP services found.", + "mcpTools.community.quickAdd": "Quick Add", + "mcpTools.community.publish": "Publish to Community", + "mcpTools.community.publishSuccess": "Published to community market", + "mcpTools.community.publishFailed": "Failed to publish to community market", + "mcpTools.community.quickAddSuccess": "MCP service added from community market", + "mcpTools.community.quickAddUnsupported": "Current community service configuration is incomplete and cannot be added quickly", + "mcpTools.community.quickAddConfirmTitle": "Confirm add community service: {{name}}", + "mcpTools.community.quickAddConfirm": "Confirm Add", + "mcpTools.community.quickAddPicker.title": "Select Quick Add Target", + "mcpTools.community.quickAddPicker.description": "Choose one address or package for quick add in {{name}}.", + "mcpTools.community.quickAddPicker.sourceRemote": "Source: Remote", + "mcpTools.community.quickAddPicker.sourcePackage": "Source: Package", + "mcpTools.community.quickAddPicker.confirm": "Confirm Add", + "mcpTools.community.prevPage": "Previous", + "mcpTools.community.nextPage": "Next", + "mcpTools.community.website": "Website:", + "mcpTools.community.repository": "Repository:", + "mcpTools.community.remotes": "Remotes", + "mcpTools.community.packages": "Packages", + "mcpTools.community.remoteFallback": "remote", + "mcpTools.community.viewServerJson": "View full server.json", + "mcpTools.community.serverJsonTitle": "{{name}} - server.json", + "mcpTools.community.status.active": "active", + "mcpTools.community.status.deprecated": "deprecated", + "mcpTools.community.status.unknown": "unknown", + "mcpTools.community.mine.title": "My Published", + "mcpTools.community.mine.empty": "No MCP has been published yet.", + "mcpTools.community.mine.edit": "Edit", + "mcpTools.community.mine.delete": "Delete", + "mcpTools.community.mine.tagsPlaceholder": "Separate tags with commas", "mcpTools.tools.loadFailed": "Failed to load tools", "mcpTools.detail.title": "MCP Service Details", "mcpTools.detail.name": "Name", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9181b527b..fd44745b5 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1654,6 +1654,7 @@ "mcpTools.page.searchPlaceholder": "搜索 MCP 服务名称、描述或标签", "mcpTools.page.resultCount": "{{count}} 个结果", "mcpTools.page.addService": "添加 MCP 服务", + "mcpTools.page.myPublished": "我的发布", "mcpTools.page.loading": "正在加载 MCP 服务列表...", "mcpTools.page.empty": "暂无 MCP 服务数据,请先添加或导入。", "mcpTools.list.loadFailed": "获取 MCP 服务列表失败", @@ -1677,6 +1678,7 @@ "mcpTools.status.disabled": "未启用", "mcpTools.source.local": "自定义", "mcpTools.source.registry": "外部市场", + "mcpTools.source.community": "社区市场", "mcpTools.source.market": "公共市场", "mcpTools.serverType.http": "HTTP", "mcpTools.serverType.sse": "SSE", @@ -1708,6 +1710,7 @@ "mcpTools.addModal.title": "添加 MCP 服务", "mcpTools.addModal.tabLocal": "自定义", "mcpTools.addModal.tabRegistry": "外部市场", + "mcpTools.addModal.tabCommunity": "社区市场", "mcpTools.addModal.tabMarket": "公共市场", "mcpTools.addModal.name": "名称", "mcpTools.addModal.description": "描述", @@ -1735,7 +1738,7 @@ "mcpTools.registry.versionAll": "全部版本", "mcpTools.registry.versionLatest": "最新版本", "mcpTools.registry.versionCustom": "自定义版本", - "mcpTools.registry.updatedSince": "更新时间下限 (RFC3339)", + "mcpTools.registry.updatedSince": "更新时间下限", "mcpTools.registry.updatedSincePlaceholder": "选择更新时间", "mcpTools.registry.includeDeleted": "包含已删除", "mcpTools.registry.includeDeletedDesc": "包含已删除服务器", @@ -1765,6 +1768,42 @@ "mcpTools.registry.status.active": "活动", "mcpTools.registry.status.deprecated": "弃用", "mcpTools.registry.status.unknown": "未知", + "mcpTools.community.loadFailed": "获取社区市场列表失败", + "mcpTools.community.searchPlaceholder": "搜索社区市场 MCP", + "mcpTools.community.pageResult": "第 {{page}} 页 · {{count}} 个结果", + "mcpTools.community.publishedAt": "发布时间", + "mcpTools.community.loading": "正在加载社区市场 MCP...", + "mcpTools.community.empty": "未找到匹配的社区市场 MCP。", + "mcpTools.community.quickAdd": "快速添加", + "mcpTools.community.publish": "发布到社区", + "mcpTools.community.publishSuccess": "已发布到社区市场", + "mcpTools.community.publishFailed": "发布到社区市场失败", + "mcpTools.community.quickAddSuccess": "已从社区市场添加 MCP 服务", + "mcpTools.community.quickAddUnsupported": "当前社区服务配置不完整,无法快速添加", + "mcpTools.community.quickAddConfirmTitle": "确认添加社区服务:{{name}}", + "mcpTools.community.quickAddConfirm": "确认添加", + "mcpTools.community.quickAddPicker.title": "选择快速添加目标", + "mcpTools.community.quickAddPicker.description": "为 {{name}} 选择一个要快速添加的地址或安装包。", + "mcpTools.community.quickAddPicker.sourceRemote": "来源: 远程地址", + "mcpTools.community.quickAddPicker.sourcePackage": "来源: 安装包", + "mcpTools.community.quickAddPicker.confirm": "确认添加", + "mcpTools.community.prevPage": "上一页", + "mcpTools.community.nextPage": "下一页", + "mcpTools.community.website": "网站:", + "mcpTools.community.repository": "仓库:", + "mcpTools.community.remotes": "远程地址", + "mcpTools.community.packages": "安装包", + "mcpTools.community.remoteFallback": "远程", + "mcpTools.community.viewServerJson": "查看完整 server.json", + "mcpTools.community.serverJsonTitle": "{{name}} - server.json", + "mcpTools.community.status.active": "活动", + "mcpTools.community.status.deprecated": "弃用", + "mcpTools.community.status.unknown": "未知", + "mcpTools.community.mine.title": "我的发布", + "mcpTools.community.mine.empty": "你还没有发布过 MCP。", + "mcpTools.community.mine.edit": "编辑", + "mcpTools.community.mine.delete": "删除", + "mcpTools.community.mine.tagsPlaceholder": "多个标签使用英文逗号分隔", "mcpTools.tools.loadFailed": "获取工具列表失败", "mcpTools.detail.title": "MCP 服务详情", "mcpTools.detail.name": "名称", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index b2a733441..50ac4f928 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -233,6 +233,11 @@ export const API_ENDPOINTS = { disable: `${API_BASE_URL}/mcp-tools/disable`, healthcheck: `${API_BASE_URL}/mcp-tools/v2/healthcheck`, registryList: `${API_BASE_URL}/mcp-tools/registry/list`, + communityList: `${API_BASE_URL}/mcp-tools/community/list`, + communityPublish: `${API_BASE_URL}/mcp-tools/community/publish`, + communityUpdate: `${API_BASE_URL}/mcp-tools/community/update`, + communityDelete: `${API_BASE_URL}/mcp-tools/community/delete`, + communityMine: `${API_BASE_URL}/mcp-tools/community/mine`, }, memory: { // ---------------- Memory configuration ---------------- diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts index 5e672f218..a6512c8dc 100644 --- a/frontend/services/mcpToolsService.ts +++ b/frontend/services/mcpToolsService.ts @@ -7,6 +7,7 @@ import type { AddMcpServicePayload, HealthcheckMcpServicePayload, RegistryMcpCard, + CommunityMcpCard, McpHealthStatus, McpServiceItem, McpTransportType, @@ -43,7 +44,9 @@ type AddContainerMcpToolPayload = { name: string; description: string; tags: string[]; + source?: "local" | "community" | "market"; authorization_token?: string; + registry_json?: Record; port: number; mcp_config: AddMcpRuntimeFromConfigPayload; }; @@ -91,6 +94,28 @@ export const fetchRegistryMcpCards = async (params: { } as McpToolsApiResult<{ items: RegistryMcpCard[]; nextCursor: string | null }>; }; +export const fetchCommunityMcpCards = async (params: { + search?: string; + cursor?: string | null; + transportType?: "http" | "sse" | "stdio"; + limit?: number; +}) => { + const result = await listCommunityMcpTools({ + search: params.search?.trim() || undefined, + cursor: params.cursor || undefined, + transport_type: params.transportType, + limit: params.limit ?? 30, + }); + + return { + success: true, + data: { + items: result.data.items, + nextCursor: result.data.nextCursor ?? null, + }, + } as McpToolsApiResult<{ items: CommunityMcpCard[]; nextCursor: string | null }>; +}; + export const resolveContainerServerInfo = async (params: { transportType: McpTransportType; serviceUrl: string; @@ -193,6 +218,102 @@ export const listRegistryMcpTools = async (query: URLSearchParams) => { } }; +export const listCommunityMcpTools = async (payload: { + search?: string; + transport_type?: "http" | "sse" | "stdio"; + cursor?: string; + limit?: number; +}) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.communityList, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to load community mcp list"); + } + return { success: true, data: data.data } as McpToolsApiResult<{ items: CommunityMcpCard[]; nextCursor: string | null }>; + } catch (error) { + log.error("listCommunityMcpTools failed", error); + throw error; + } +}; + +export const publishCommunityMcpTool = async (mcpId: number) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.communityPublish, { + method: "POST", + body: JSON.stringify({ mcp_id: mcpId }), + }); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to publish community mcp"); + } + return { success: true, data: data.data } as McpToolsApiResult<{ community_id: number }>; + } catch (error) { + log.error("publishCommunityMcpTool failed", error); + throw error; + } +}; + +export const listMyCommunityMcpTools = async () => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.communityMine); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to load my community mcp list"); + } + return { success: true, data: data.data } as McpToolsApiResult<{ count: number; items: CommunityMcpCard[] }>; + } catch (error) { + log.error("listMyCommunityMcpTools failed", error); + throw error; + } +}; + +export const updateCommunityMcpTool = async (payload: { + community_id: number; + name?: string; + description?: string; + tags?: string[]; + version?: string; + registry_json?: Record; +}) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.communityUpdate, { + method: "PUT", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + if (data.status !== "success") { + throw new Error("Failed to update community mcp"); + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("updateCommunityMcpTool failed", error); + throw error; + } +}; + +export const deleteCommunityMcpTool = async (communityId: number) => { + try { + const response = await fetchWithAuth( + `${API_ENDPOINTS.mcpTools.communityDelete}?community_id=${encodeURIComponent(String(communityId))}`, + { + method: "DELETE", + } + ); + const data = await parseJson(response); + if (data.status !== "success") { + throw new Error("Failed to delete community mcp"); + } + return { success: true, data: null } as McpToolsApiResult; + } catch (error) { + log.error("deleteCommunityMcpTool failed", error); + throw error; + } +}; + export const addMcpToolService = async (payload: AddMcpServicePayload) => { try { const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.add, { diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts index b23bcb3cc..843241869 100644 --- a/frontend/types/mcpTools.ts +++ b/frontend/types/mcpTools.ts @@ -3,6 +3,7 @@ import type { McpTool } from "@/types/agentConfig"; export enum McpTab { LOCAL = "local", MCP_REGISTRY = "mcp_registry", + COMMUNITY = "community", } export enum McpTransportType { @@ -56,6 +57,16 @@ export interface RegistryQuickAddOption { packageEnvTemplate?: Record; } +export interface CommunityMcpCard extends RegistryMcpCard { + communityId?: number; + source?: "community"; + transportType: "http" | "sse" | "stdio"; + serverUrl: string; + configJson?: Record | null; + mcpRegistryJson?: Record | null; + tags?: string[]; +} + export interface McpServiceItem { mcpId: number; containerId?: string; From 173f6f82844c53f1f80292fc9ee9bcf0a4df91c7 Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Mon, 30 Mar 2026 18:20:37 +0800 Subject: [PATCH 06/59] Support for displaying and filling in variables and request headers during quick addition in MCP Registry; Removal of the old MCP Tools interface and uniform migration to the new interface; Caching of the search bar in external marketplaces; Update to the logic of the tool list on the Agent page, synchronized with the MCP Tools page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部市场支持快速添加时的变量和请求头显示和填写; 去除mcp tools的旧接口,统一改为新接口; 外部市场搜索栏缓冲; 智能体页面工具列表逻辑更新,和mcp tools页面同步更新 --- backend/apps/mcp_management_app.py | 200 +-------- backend/services/mcp_management_service.py | 82 ++-- backend/services/remote_mcp_service.py | 8 +- .../services/tool_configuration_service.py | 4 +- .../agents/components/AgentConfigComp.tsx | 23 +- .../components/AddMcpServiceModal.tsx | 4 +- .../AddMcpServiceRegistrySection.tsx | 92 ++++- .../mcp-tools/components/McpCommunityCard.tsx | 4 +- .../components/McpCommunityDetailModal.tsx | 8 +- .../mcp-tools/components/McpRegistryCard.tsx | 19 +- .../components/McpRegistryDetailModal.tsx | 252 ++++++++++-- .../components/McpServiceDetailModal.tsx | 21 + .../components/MyCommunityMcpModal.tsx | 2 +- frontend/app/[locale]/mcp-tools/page.tsx | 4 + frontend/hooks/agent/useToolList.ts | 2 + .../hooks/mcpTools/useMcpToolsAddCommunity.ts | 2 +- .../hooks/mcpTools/useMcpToolsAddRegistry.ts | 378 ++++++++++++++++-- frontend/hooks/mcpTools/useMcpToolsDetail.ts | 64 ++- frontend/hooks/mcpTools/useMcpToolsToggle.ts | 4 + frontend/public/locales/en/common.json | 53 +++ frontend/public/locales/zh/common.json | 53 +++ frontend/services/api.ts | 6 +- frontend/services/mcpToolsService.ts | 12 +- frontend/types/mcpTools.ts | 112 +++++- 24 files changed, 1038 insertions(+), 371 deletions(-) diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py index bca854476..1f0f5f6f8 100644 --- a/backend/apps/mcp_management_app.py +++ b/backend/apps/mcp_management_app.py @@ -14,9 +14,7 @@ from services.mcp_management_service import ( add_mcp_service, check_mcp_service_health, - check_mcp_service_health_legacy, delete_mcp_service, - delete_mcp_service_legacy, delete_community_mcp_service, list_community_mcp_services, list_my_community_mcp_services, @@ -25,10 +23,8 @@ list_mcp_services, publish_community_mcp_service, update_mcp_service, - update_mcp_service_legacy, update_community_mcp_service, update_mcp_service_enabled, - update_mcp_service_enabled_legacy, ) from utils.auth_utils import get_current_user_info @@ -60,15 +56,6 @@ class AddContainerMcpServiceRequest(BaseModel): mcp_config: MCPConfigRequest -class UpdateMcpServiceRequest(BaseModel): - current_name: str = Field(min_length=1) - name: str = Field(min_length=1) - description: Optional[str] = None - server_url: str = Field(min_length=1) - tags: Optional[list[str]] = None - authorization_token: Optional[str] = None - - class UpdateMcpServiceByIdRequest(BaseModel): mcp_id: int name: str = Field(min_length=1) @@ -78,11 +65,6 @@ class UpdateMcpServiceByIdRequest(BaseModel): authorization_token: Optional[str] = None -class EnableMcpServiceRequest(BaseModel): - name: str = Field(min_length=1) - enabled: bool - - class EnableMcpServiceByIdRequest(BaseModel): mcp_id: int @@ -91,11 +73,6 @@ class DisableMcpServiceByIdRequest(BaseModel): mcp_id: int -class HealthcheckMcpServiceRequest(BaseModel): - name: str = Field(min_length=1) - server_url: str = Field(min_length=1) - - class HealthcheckMcpServiceByIdRequest(BaseModel): mcp_id: int @@ -243,7 +220,7 @@ async def add_container_mcp_service_api( started_container_id: Optional[str] = None started_container_id = container_info.get("container_id") - container_config = payload.mcp_config.model_dump() + container_config = payload.mcp_config.model_dump(exclude_none=True) try: await add_mcp_service( @@ -352,7 +329,7 @@ async def list_registry_mcp_services_api( ) return JSONResponse( status_code=HTTPStatus.OK, - content={"status": "success", "data": data}, + content=data, ) except HTTPException: raise @@ -544,45 +521,6 @@ async def list_mcp_tools_api( @router.put("/update") -async def update_mcp_service_api( - payload: UpdateMcpServiceRequest, - authorization: Optional[str] = Header(None), - http_request: Request = None, -): - try: - user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - current_name = payload.current_name - new_name = payload.name - description = payload.description - server_url = payload.server_url - tags = payload.tags - authorization_token = (payload.authorization_token or "").strip() - - update_mcp_service_legacy( - tenant_id=tenant_id, - user_id=user_id, - current_name=current_name, - new_name=new_name, - description=description, - server_url=server_url, - authorization_token=authorization_token, - tags=tags, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"status": "success"}, - ) - except HTTPException: - raise - except Exception as exc: - logger.error(f"Failed to update MCP service: {exc}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update MCP service", - ) - - -@router.put("/v2/update") async def update_mcp_service_by_id_api( payload: UpdateMcpServiceByIdRequest, authorization: Optional[str] = Header(None), @@ -614,77 +552,6 @@ async def update_mcp_service_by_id_api( ) -@router.post("/manage/enable") -async def update_mcp_service_enable_api( - payload: EnableMcpServiceRequest, - authorization: Optional[str] = Header(None), - http_request: Request = None, -): - try: - user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - name = payload.name - enabled = payload.enabled - - update_mcp_service_enabled_legacy( - tenant_id=tenant_id, - user_id=user_id, - name=name, - enabled=enabled, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"status": "success"}, - ) - except HTTPException: - raise - except Exception as exc: - logger.error(f"Failed to update MCP service status: {exc}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update MCP service status", - ) - - -@router.post("/v2/manage/enable") -async def update_mcp_service_enable_by_id_api( - payload: EnableMcpServiceByIdRequest, - authorization: Optional[str] = Header(None), - http_request: Request = None, -): - # Backward-compatible route. Prefer /enable or /disable. - try: - user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - await update_mcp_service_enabled( - tenant_id=tenant_id, - user_id=user_id, - mcp_id=payload.mcp_id, - enabled=True, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"status": "success"}, - ) - except ValueError as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(exc), - ) - except MCPConnectionError as exc: - logger.error(f"MCP connection failed while enabling service by id: {exc}") - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed", - ) - except HTTPException: - raise - except Exception as exc: - logger.error(f"Failed to enable MCP service by id: {exc}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update MCP service status", - ) - - @router.post("/enable") async def enable_mcp_service_by_id_api( payload: EnableMcpServiceByIdRequest, @@ -758,40 +625,6 @@ async def disable_mcp_service_by_id_api( @router.post("/healthcheck") -async def check_mcp_health_api( - payload: HealthcheckMcpServiceRequest, - authorization: Optional[str] = Header(None), - http_request: Request = None, -): - try: - user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - health_status = await check_mcp_service_health_legacy( - tenant_id=tenant_id, - user_id=user_id, - name=payload.name, - server_url=payload.server_url, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"status": "success", "data": {"health_status": health_status}}, - ) - except MCPConnectionError as exc: - logger.error(f"MCP connection failed: {exc}") - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed", - ) - except HTTPException: - raise - except Exception as exc: - logger.error(f"Failed to check MCP health: {exc}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to check MCP health", - ) - - -@router.post("/v2/healthcheck") async def check_mcp_health_by_id_api( payload: HealthcheckMcpServiceByIdRequest, authorization: Optional[str] = Header(None), @@ -812,7 +645,7 @@ async def check_mcp_health_by_id_api( logger.error(f"MCP connection failed: {exc}") raise HTTPException( status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed", + detail=str(exc) or "MCP connection failed", ) except HTTPException: raise @@ -825,33 +658,6 @@ async def check_mcp_health_by_id_api( @router.delete("/delete") -async def delete_mcp_service_api( - name: str = Query(min_length=1), - authorization: Optional[str] = Header(None), - http_request: Request = None, -): - try: - user_id, tenant_id, _ = get_current_user_info(authorization, http_request) - delete_mcp_service_legacy( - tenant_id=tenant_id, - user_id=user_id, - name=name, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"status": "success"}, - ) - except HTTPException: - raise - except Exception as exc: - logger.error(f"Failed to delete MCP service: {exc}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to delete MCP service", - ) - - -@router.delete("/v2/delete") async def delete_mcp_service_by_id_api( mcp_id: int = Query(gt=0), authorization: Optional[str] = Header(None), diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py index d4b7d35a9..c6ba1747e 100644 --- a/backend/services/mcp_management_service.py +++ b/backend/services/mcp_management_service.py @@ -109,58 +109,14 @@ def _normalize_mcp_registry_server(entry: Dict[str, Any]) -> Dict[str, Any] | No if not name: return None - version = _extract_str(server.get("version")) - description = _extract_str(server.get("description")) - - remotes_out: List[Dict[str, str]] = [] - packages_out: List[Dict[str, Any]] = [] - - remotes = server.get("remotes") - if isinstance(remotes, list): - for remote in remotes: - if not isinstance(remote, dict): - continue - remote_url = _extract_str(remote.get("url")) - remote_type = _extract_str(remote.get("type")).lower() - if remote_url: - remotes_out.append({"type": remote_type, "url": remote_url}) - - packages = server.get("packages") - if isinstance(packages, list): - for package in packages: - if not isinstance(package, dict): - continue - - transport_raw = package.get("transport") - transport = { - "type": _extract_str(transport_raw.get("type")) if isinstance(transport_raw, dict) else "", - "url": _extract_str(transport_raw.get("url")) if isinstance(transport_raw, dict) else "", - } - - packages_out.append({ - "registryType": _extract_str(package.get("registryType")), - "identifier": _extract_str(package.get("identifier")), - "version": _extract_str(package.get("version")), - "runtimeHint": _extract_str(package.get("runtimeHint")), - "transport": transport, - }) - - official_meta = {} - if isinstance(entry.get("_meta"), dict): - official_meta = entry.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {}) or {} - - return { - "name": name, - "version": version, - "description": description, - "remotes": remotes_out, - "packages": packages_out, - "status": _extract_str(official_meta.get("status")), - "isLatest": bool(official_meta.get("isLatest")), - "publishedAt": _extract_str(official_meta.get("publishedAt")), - "updatedAt": _extract_str(official_meta.get("updatedAt")), - "serverJson": server, - } + normalized_entry = dict(entry) + normalized_server = dict(server) + if not isinstance(normalized_server.get("remotes"), list): + normalized_server["remotes"] = [] + if not isinstance(normalized_server.get("packages"), list): + normalized_server["packages"] = [] + normalized_entry["server"] = normalized_server + return normalized_entry def _normalize_community_remotes(record: Dict[str, Any], registry_json: Dict[str, Any]) -> List[Dict[str, str]]: @@ -365,10 +321,10 @@ async def list_registry_mcp_services( metadata = payload.get("metadata") if isinstance(payload, dict) and isinstance(payload.get("metadata"), dict) else {} + # Keep response shape aligned with official MCP registry API. return { - "count": len(normalized), - "nextCursor": _extract_str(metadata.get("nextCursor")), - "items": normalized, + "servers": normalized, + "metadata": metadata, } @@ -741,9 +697,23 @@ async def check_mcp_service_health( remote_mcp_server=server_url, authorization_token=authorization_token, ) + except MCPConnectionError: + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=False, + ) + raise except Exception as exc: logger.error(f"MCP health check failed: {exc}") - status = False + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=False, + ) + raise MCPConnectionError(str(exc) or "MCP connection failed") update_mcp_record_status_by_id( mcp_id=mcp_id, diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index ab0f0b04f..f7122c259 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -1,6 +1,7 @@ import logging import os import tempfile +import asyncio from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport, SSETransport @@ -22,8 +23,6 @@ from services.mcp_container_service import MCPContainerManager logger = logging.getLogger("remote_mcp_service") - - async def mcp_server_health(remote_mcp_server: str, authorization_token: str | None = None) -> bool: try: # Select transport based on URL ending @@ -55,7 +54,10 @@ async def mcp_server_health(remote_mcp_server: str, authorization_token: str | N logger.error( f"Remote MCP server health check failed: {e}", exc_info=True) # Prevent library-level exits (e.g., SystemExit) from crashing the service - raise MCPConnectionError("MCP connection failed") + error_message = str(e).strip() or repr(e) + if isinstance(e, (asyncio.TimeoutError, TimeoutError)) or "timeout" in error_message.lower(): + raise MCPConnectionError("MCP_HEALTH_TIMEOUT") + raise MCPConnectionError(error_message) async def add_remote_mcp_server_list( diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 3e7b22d11..0382e90b2 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -234,8 +234,8 @@ async def get_all_mcp_tools(tenant_id: str) -> List[ToolInfo]: mcp_info = get_mcp_records_by_tenant(tenant_id=tenant_id) tools_info = [] for record in mcp_info: - # only update connected server - if record["status"]: + # Only scan MCP services that are explicitly enabled and currently healthy. + if bool(record.get("enabled")) and bool(record.get("status")): try: tools_info.extend(await get_tool_from_remote_mcp_server( mcp_server_name=record["mcp_name"], diff --git a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx index cb321f32c..be086fc64 100644 --- a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx @@ -26,6 +26,7 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { const [isMcpModalOpen, setIsMcpModalOpen] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); + const showLegacyMcpConfig = false; // Use tool list hook for data management const { groupedTools, invalidate } = useToolList(); @@ -118,16 +119,18 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { > {t("toolManagement.refresh.button.refresh")} - + {showLegacyMcpConfig ? ( + + ) : null} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx index 18dc27ebe..f736cf6b2 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -36,7 +36,7 @@ export default function AddMcpServiceModal({ const registry = useMcpToolsAddRegistry({ open, addModalTab, - t: (key) => String(t(key)), + t: (key, params) => String(t(key, params)), message, onServiceAdded, onClose, @@ -142,6 +142,7 @@ export default function AddMcpServiceModal({ quickAddCandidateService={registry.quickAddCandidateService} quickAddOptions={registry.quickAddOptions} selectedQuickAddOptionKey={registry.selectedQuickAddOptionKey} + quickAddVariableValues={registry.quickAddVariableValues} quickAddSubmitting={registry.quickAddSubmitting} setRegistrySearchValue={registry.setRegistrySearchValue} setSelectedRegistryService={registry.setSelectedRegistryService} @@ -149,6 +150,7 @@ export default function AddMcpServiceModal({ setRegistryUpdatedSince={registry.setRegistryUpdatedSince} setRegistryIncludeDeleted={registry.setRegistryIncludeDeleted} setSelectedQuickAddOptionKey={registry.setSelectedQuickAddOptionKey} + handleQuickAddVariableValueChange={registry.handleQuickAddVariableValueChange} handleRegistryPrevPage={registry.handleRegistryPrevPage} handleRegistryNextPage={registry.handleRegistryNextPage} handleQuickAddFromRegistry={registry.handleQuickAddFromRegistry} diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx index 9d7c027cf..deb36582c 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, Radio } from "antd"; +import { Button, Input, Modal, Radio } from "antd"; import McpRegistryToolbar from "./McpRegistryToolbar"; import McpRegistryCardList from "./McpRegistryCardList"; import McpRegistryDetailModal from "./McpRegistryDetailModal"; @@ -19,6 +19,7 @@ interface Props { quickAddCandidateService: RegistryMcpCard | null; quickAddOptions: RegistryQuickAddOption[]; selectedQuickAddOptionKey: string; + quickAddVariableValues: Record; quickAddSubmitting: boolean; setRegistrySearchValue: (value: string) => void; setSelectedRegistryService: (service: RegistryMcpCard | null) => void; @@ -26,6 +27,7 @@ interface Props { setRegistryUpdatedSince: (value: string) => void; setRegistryIncludeDeleted: (value: boolean) => void; setSelectedQuickAddOptionKey: (value: string) => void; + handleQuickAddVariableValueChange: (key: string, value: string) => void; handleRegistryPrevPage: () => void; handleRegistryNextPage: () => void; handleQuickAddFromRegistry: (service: RegistryMcpCard) => void; @@ -49,6 +51,7 @@ export default function AddMcpServiceRegistrySection({ quickAddCandidateService, quickAddOptions, selectedQuickAddOptionKey, + quickAddVariableValues, quickAddSubmitting, setRegistrySearchValue, setSelectedRegistryService, @@ -56,6 +59,7 @@ export default function AddMcpServiceRegistrySection({ setRegistryUpdatedSince, setRegistryIncludeDeleted, setSelectedQuickAddOptionKey, + handleQuickAddVariableValueChange, handleRegistryPrevPage, handleRegistryNextPage, handleQuickAddFromRegistry, @@ -63,6 +67,86 @@ export default function AddMcpServiceRegistrySection({ handleConfirmQuickAddOption, t, }: Props) { + const selectedQuickAddOption = quickAddOptions.find((option) => option.key === selectedQuickAddOptionKey) || null; + + const renderVariableInputs = ( + titleKey: string, + fields: Array<{ + key: string; + formKey?: string; + label?: string; + description?: string; + format?: string; + default?: string; + placeholder?: string; + isRequired?: boolean; + }> + ) => { + if (!fields.length) return null; + + return ( +
    +

    {t(titleKey)}

    + {fields.map((field) => ( + + ))} +
    + ); + }; + + const renderRuntimeArgumentInputs = () => { + const args = selectedQuickAddOption?.packageRuntimeArguments || []; + if (!args.length) return null; + + return ( +
    +

    {t("mcpTools.registry.quickAddPicker.runtimeArgumentsTitle")}

    + {args.map((arg) => ( + + ))} +
    + ); + }; + return ( <>
    @@ -143,6 +227,12 @@ export default function AddMcpServiceRegistrySection({ })} + {renderVariableInputs("mcpTools.registry.quickAddPicker.variablesTitle", selectedQuickAddOption?.remoteVariables || [])} + {renderVariableInputs("mcpTools.registry.quickAddPicker.packageTransportVariablesTitle", selectedQuickAddOption?.packageTransportVariables || [])} + {renderVariableInputs("mcpTools.registry.quickAddPicker.packageTransportHeadersTitle", selectedQuickAddOption?.packageTransportHeaders || [])} + {renderVariableInputs("mcpTools.registry.quickAddPicker.packageEnvironmentVariablesTitle", selectedQuickAddOption?.packageEnvironmentVariables || [])} + {renderRuntimeArgumentInputs()} +
    {t("mcpTools.community.publishedAt")} - {formatRegistryDate(service.publishedAt)} + {formatRegistryDate(service.publishedAt || "")}
    {websiteUrl ? (
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx index b46395f9d..d0cfb7383 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpRegistryCard.tsx @@ -16,16 +16,19 @@ export default function McpRegistryCard({ onSelectRegistryService: onSelectRegistryService, onQuickAddFromRegistry: onQuickAddFromRegistry, }: Props) { + const server = service.server; + const officialMeta = ((service._meta as Record | undefined)?.["io.modelcontextprotocol.registry/official"] || {}) as Record; + const officialStatus = String(officialMeta.status || "").toLowerCase(); const statusClassName = - service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + officialStatus === MCP_REGISTRY_SERVER_STATUS.ACTIVE ? "bg-emerald-100 text-emerald-700" - : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + : officialStatus === MCP_REGISTRY_SERVER_STATUS.DEPRECATED ? "bg-amber-100 text-amber-700" : "bg-slate-100 text-slate-600"; const statusTextKey = - service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE + officialStatus === MCP_REGISTRY_SERVER_STATUS.ACTIVE ? "mcpTools.registry.status.active" - : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED + : officialStatus === MCP_REGISTRY_SERVER_STATUS.DEPRECATED ? "mcpTools.registry.status.deprecated" : "mcpTools.registry.status.unknown"; @@ -35,7 +38,7 @@ export default function McpRegistryCard({ className="group rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg" >
    -

    {service.name}

    +

    {server.name}

    {t(statusTextKey)} @@ -43,12 +46,12 @@ export default function McpRegistryCard({
    - {formatRegistryVersion(service.version)} + {formatRegistryVersion(server.version || "")} - {formatRegistryDate(service.publishedAt)} + {formatRegistryDate(String(officialMeta.publishedAt || ""))}
    -

    {service.description}

    +

    {server.description || ""}

    + + ))} + setEditDraft({ ...editDraft, tagInputValue: event.target.value })} + onPressEnter={addDraftTag} + onBlur={addDraftTag} + placeholder={t("mcpTools.addModal.tagInputPlaceholder")} + className="w-40 rounded-full" + /> +
    ) : null} diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index 9464c971c..1efb66229 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -26,6 +26,9 @@ export default function McpToolsPage() { setSourceFilter, transportTypeFilter, setTransportTypeFilter, + tagFilter, + setTagFilter, + tagStats, loadingServices, selectedService, setSelectedService, @@ -91,7 +94,7 @@ export default function McpToolsPage() { {t("mcpTools.page.resultCount", { count: filteredServices.length })}
    -
    +
    ({ + value: item.tag, + label: `${item.tag} (${item.count})`, + })), + ]} + />
    diff --git a/frontend/hooks/mcpTools/useMcpToolsPage.ts b/frontend/hooks/mcpTools/useMcpToolsPage.ts index f6fcf42d5..e2364922a 100644 --- a/frontend/hooks/mcpTools/useMcpToolsPage.ts +++ b/frontend/hooks/mcpTools/useMcpToolsPage.ts @@ -5,7 +5,7 @@ import log from "@/lib/logger"; import { filterServiceCards } from "@/lib/mcpTools"; import type { McpTool } from "@/types/agentConfig"; import type { McpServiceItem } from "@/types/mcpTools"; -import { listMcpTools } from "@/services/mcpToolsService"; +import { fetchMcpTagStats, listMcpTools } from "@/services/mcpToolsService"; import { useMcpToolsDetail } from "./useMcpToolsDetail"; import { useMcpToolsToggle } from "./useMcpToolsToggle"; @@ -23,18 +23,31 @@ export function useMcpToolsPage({ t, message }: UseMcpToolsPageParams) { const [searchValue, setSearchValue] = useState(""); const [sourceFilter, setSourceFilter] = useState("all"); const [transportTypeFilter, setTransportTypeFilter] = useState("all"); + const [tagFilter, setTagFilter] = useState("all"); const [services, setServices] = useState([]); const [selectedService, setSelectedService] = useState(null); const [showAddModal, setShowAddModal] = useState(false); const listQuery = useQuery({ - queryKey: ["mcp-tools", "list"], + queryKey: ["mcp-tools", "list", tagFilter], retry: false, staleTime: 30_000, refetchOnWindowFocus: false, refetchOnMount: false, queryFn: async () => { - const result = await listMcpTools(); + const result = await listMcpTools({ tag: tagFilter === "all" ? undefined : tagFilter }); + return result.data; + }, + }); + + const tagStatsQuery = useQuery({ + queryKey: ["mcp-tools", "tag-stats"], + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchOnMount: false, + queryFn: async () => { + const result = await fetchMcpTagStats(); return result.data; }, }); @@ -50,6 +63,11 @@ export function useMcpToolsPage({ t, message }: UseMcpToolsPageParams) { message.error(t("mcpTools.list.loadFailed")); }, [listQuery.error, message, t]); + useEffect(() => { + if (!(tagStatsQuery.error instanceof Error)) return; + log.error("[useMcpToolsPage] Failed to load MCP tag stats", { error: tagStatsQuery.error }); + }, [tagStatsQuery.error]); + const loadServerList = useCallback(async () => { const result = await listQuery.refetch(); if (result.error || !result.data) { @@ -122,6 +140,9 @@ export function useMcpToolsPage({ t, message }: UseMcpToolsPageParams) { setSourceFilter, transportTypeFilter, setTransportTypeFilter, + tagFilter, + setTagFilter, + tagStats: tagStatsQuery.data || [], services, loadingServices: listQuery.isFetching && services.length === 0, selectedService, diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 20374b9a4..7245cc7ef 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1581,6 +1581,7 @@ "mcpTools.page.resultCount": "{{count}} results", "mcpTools.page.sourceFilter.all": "All Sources", "mcpTools.page.transportFilter.all": "All Transports", + "mcpTools.page.tagFilter.all": "All Tags", "mcpTools.page.addService": "Add MCP Service", "mcpTools.page.myPublished": "My Published", "mcpTools.page.loading": "Loading MCP services...", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index b794567e3..185e98c76 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1739,6 +1739,7 @@ "mcpTools.page.resultCount": "{{count}} 个结果", "mcpTools.page.sourceFilter.all": "全部来源", "mcpTools.page.transportFilter.all": "全部协议", + "mcpTools.page.tagFilter.all": "全部标签", "mcpTools.page.addService": "添加 MCP 服务", "mcpTools.page.myPublished": "我的发布", "mcpTools.page.loading": "正在加载 MCP 服务列表...", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index c59834dc9..fa4ebe99c 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -225,6 +225,7 @@ export const API_ENDPOINTS = { }, mcpTools: { list: `${API_BASE_URL}/mcp-tools/list`, + tagsStats: `${API_BASE_URL}/mcp-tools/tags/stats`, tools: `${API_BASE_URL}/mcp-tools/tools`, add: `${API_BASE_URL}/mcp-tools/add`, addContainer: `${API_BASE_URL}/mcp-tools/container/add`, diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts index 805741ff8..4f92233a8 100644 --- a/frontend/services/mcpToolsService.ts +++ b/frontend/services/mcpToolsService.ts @@ -8,6 +8,7 @@ import type { HealthcheckMcpServicePayload, RegistryMcpCard, CommunityMcpCard, + McpTagStat, McpHealthStatus, McpServiceItem, McpTransportType, @@ -183,9 +184,14 @@ export const addContainerMcpToolService = async (payload: AddContainerMcpToolPay } }; -export const listMcpTools = async () => { +export const listMcpTools = async (params?: { tag?: string }) => { try { - const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.list); + const query = new URLSearchParams(); + if (params?.tag?.trim()) { + query.set("tag", params.tag.trim()); + } + const url = query.size > 0 ? `${API_ENDPOINTS.mcpTools.list}?${query.toString()}` : API_ENDPOINTS.mcpTools.list; + const response = await fetchWithAuth(url); const data = await parseJson>(response); if (data.status !== "success") { throw new Error("Failed to load MCP services"); @@ -197,6 +203,20 @@ export const listMcpTools = async () => { } }; +export const fetchMcpTagStats = async () => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.tagsStats); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to load MCP tag stats"); + } + return { success: true, data: data.data } as McpToolsApiResult; + } catch (error) { + log.error("fetchMcpTagStats failed", error); + throw error; + } +}; + export const listRegistryMcpTools = async (query: URLSearchParams) => { try { const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.registryList}?${query.toString()}`); @@ -219,6 +239,7 @@ export const listRegistryMcpTools = async (query: URLSearchParams) => { export const listCommunityMcpTools = async (payload: { search?: string; + tag?: string; transport_type?: "http" | "sse" | "stdio"; cursor?: string; limit?: number; diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts index 33680c64b..6a8c0a64c 100644 --- a/frontend/types/mcpTools.ts +++ b/frontend/types/mcpTools.ts @@ -173,6 +173,11 @@ export interface McpServiceItem { authorizationToken?: string; } +export interface McpTagStat { + tag: string; + count: number; +} + export interface AddMcpServicePayload { name: string; description: string; From 9c1783cd868c234c28fb35bb93c0a18c67240d7c Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Thu, 2 Apr 2026 14:46:27 +0800 Subject: [PATCH 10/59] =?UTF-8?q?Add=20restrictions=20on=20request=20heade?= =?UTF-8?q?rs;=20only=20Bearer=20tokens=20in=20the=20Authorization=20heade?= =?UTF-8?q?r=20are=20allowed.=E5=A2=9E=E5=8A=A0=E8=AF=B7=E6=B1=82=E5=A4=B4?= =?UTF-8?q?=E5=A1=AB=E5=86=99=E9=99=90=E5=88=B6=EF=BC=8C=E5=8F=AA=E5=85=81?= =?UTF-8?q?=E8=AE=B8Authorization=E7=9A=84Bearer=20Token=E5=A1=AB=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ql => v2.0.1_0318_expand_mcp_record_t.sql} | 0 ...2.0.1_0326_add_mcp_community_record_t.sql} | 0 .../AddMcpServiceRegistrySection.tsx | 1 + .../hooks/mcpTools/useMcpToolsAddRegistry.ts | 66 +++++++++++++++++-- frontend/public/locales/en/common.json | 2 + frontend/public/locales/zh/common.json | 2 + frontend/types/mcpTools.ts | 2 + 7 files changed, 68 insertions(+), 5 deletions(-) rename docker/sql/{v1.8.2_0318_expand_mcp_record_t.sql => v2.0.1_0318_expand_mcp_record_t.sql} (100%) rename docker/sql/{v1.8.2_0326_add_mcp_community_record_t.sql => v2.0.1_0326_add_mcp_community_record_t.sql} (100%) diff --git a/docker/sql/v1.8.2_0318_expand_mcp_record_t.sql b/docker/sql/v2.0.1_0318_expand_mcp_record_t.sql similarity index 100% rename from docker/sql/v1.8.2_0318_expand_mcp_record_t.sql rename to docker/sql/v2.0.1_0318_expand_mcp_record_t.sql diff --git a/docker/sql/v1.8.2_0326_add_mcp_community_record_t.sql b/docker/sql/v2.0.1_0326_add_mcp_community_record_t.sql similarity index 100% rename from docker/sql/v1.8.2_0326_add_mcp_community_record_t.sql rename to docker/sql/v2.0.1_0326_add_mcp_community_record_t.sql diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx index deb36582c..9dc3d1c36 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -228,6 +228,7 @@ export default function AddMcpServiceRegistrySection({ {renderVariableInputs("mcpTools.registry.quickAddPicker.variablesTitle", selectedQuickAddOption?.remoteVariables || [])} + {renderVariableInputs("mcpTools.registry.quickAddPicker.remoteHeadersTitle", selectedQuickAddOption?.remoteHeaders || [])} {renderVariableInputs("mcpTools.registry.quickAddPicker.packageTransportVariablesTitle", selectedQuickAddOption?.packageTransportVariables || [])} {renderVariableInputs("mcpTools.registry.quickAddPicker.packageTransportHeadersTitle", selectedQuickAddOption?.packageTransportHeaders || [])} {renderVariableInputs("mcpTools.registry.quickAddPicker.packageEnvironmentVariablesTitle", selectedQuickAddOption?.packageEnvironmentVariables || [])} diff --git a/frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts b/frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts index 786e5e830..85534a626 100644 --- a/frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts +++ b/frontend/hooks/mcpTools/useMcpToolsAddRegistry.ts @@ -186,9 +186,9 @@ const extractRuntimeArguments = (runtimeArguments: unknown, formPrefix: string): }); }; -const extractRemoteVariables = (service: RegistryMcpCard, remoteType?: string, remoteUrl?: string): RegistryRemoteVariable[] => { +const findMatchedRemote = (service: RegistryMcpCard, remoteType?: string, remoteUrl?: string): Record | null => { const rawRemotes = service.server?.remotes; - if (!Array.isArray(rawRemotes)) return []; + if (!Array.isArray(rawRemotes)) return null; const matchedRemote = rawRemotes.find((entry) => { if (!entry || typeof entry !== "object") return false; @@ -196,7 +196,14 @@ const extractRemoteVariables = (service: RegistryMcpCard, remoteType?: string, r const candidateType = typeof candidate.type === "string" ? candidate.type.toLowerCase() : ""; const candidateUrl = typeof candidate.url === "string" ? candidate.url : ""; return candidateType === String(remoteType || "").toLowerCase() && candidateUrl === String(remoteUrl || ""); - }) as { variables?: Record } | undefined; + }) as Record | undefined; + + return matchedRemote || null; +}; + + +const extractRemoteVariables = (service: RegistryMcpCard, remoteType?: string, remoteUrl?: string): RegistryRemoteVariable[] => { + const matchedRemote = findMatchedRemote(service, remoteType, remoteUrl) as { variables?: Record } | null; if (!matchedRemote || !matchedRemote.variables || typeof matchedRemote.variables !== "object") { return []; @@ -205,6 +212,13 @@ const extractRemoteVariables = (service: RegistryMcpCard, remoteType?: string, r return extractVariableMapInputs(matchedRemote.variables, "remote-var"); }; + +const extractRemoteHeaders = (service: RegistryMcpCard, remoteType?: string, remoteUrl?: string): RegistryRemoteVariable[] => { + const matchedRemote = findMatchedRemote(service, remoteType, remoteUrl); + if (!matchedRemote) return []; + return extractKeyValueInputs(matchedRemote.headers, "remote-header", "header"); +}; + const buildInitialVariableValues = (option: RegistryQuickAddOption | null): Record => { if (!option) { return {}; @@ -212,6 +226,7 @@ const buildInitialVariableValues = (option: RegistryQuickAddOption | null): Reco const fields: RegistryRemoteVariable[] = [ ...(option.remoteVariables || []), + ...(option.remoteHeaders || []), ...(option.packageEnvironmentVariables || []), ...(option.packageTransportHeaders || []), ...(option.packageTransportVariables || []), @@ -246,6 +261,25 @@ const getFieldValueByFormKey = (values: Record, formKey?: string const isFieldRequired = (field: { isRequired?: boolean }) => Boolean(field.isRequired); +const normalizeHeaderKey = (value: string | undefined): string => String(value || "").trim().toLowerCase(); + +const isAuthorizationHeader = (field: RegistryRemoteVariable): boolean => { + const key = normalizeHeaderKey(field.key); + const label = normalizeHeaderKey(field.label); + return key === "authorization" || label === "authorization"; +}; + +const pickSupportedAuthorizationHeaders = (headers: RegistryRemoteVariable[] | undefined): RegistryRemoteVariable[] => { + return (headers || []).filter(isAuthorizationHeader); +}; + +const collectUnsupportedRequiredHeaderNames = (headers: RegistryRemoteVariable[] | undefined): string[] => { + return (headers || []) + .filter((header) => isFieldRequired(header) && !isAuthorizationHeader(header)) + .map((header) => (header.label || header.key || "header").trim()) + .filter((name, index, arr) => Boolean(name) && arr.indexOf(name) === index); +}; + const buildResolvedRuntimeArgs = (option: RegistryQuickAddOption, values: Record): string[] => { const runtimeArgs = option.packageRuntimeArguments || []; if (runtimeArgs.length === 0) { @@ -290,6 +324,9 @@ const resolveQuickAddOptions = (service: RegistryMcpCard): RegistryQuickAddOptio if (!remoteTarget) return; const remoteVariables = extractRemoteVariables(service, remote.type, remote.url); + const allRemoteHeaders = extractRemoteHeaders(service, remote.type, remote.url); + const remoteHeaders = pickSupportedAuthorizationHeaders(allRemoteHeaders); + const unsupportedRequiredHeaders = collectUnsupportedRequiredHeaderNames(allRemoteHeaders); options.push({ key: `remote-${index}`, @@ -299,6 +336,8 @@ const resolveQuickAddOptions = (service: RegistryMcpCard): RegistryQuickAddOptio serverUrl: remoteTarget.serverUrl, serverUrlTemplate: remote.url, remoteVariables, + remoteHeaders, + unsupportedRequiredHeaders, }); }); @@ -312,7 +351,9 @@ const resolveQuickAddOptions = (service: RegistryMcpCard): RegistryQuickAddOptio const transportUrl = toStringOrUndefined(packageTransport?.url) || ""; const packageTarget = resolveQuickAddTarget(transportType, transportUrl); - const packageTransportHeaders = extractKeyValueInputs(packageTransport?.headers, `pkg-transport-header:${index}`, "header"); + const allPackageTransportHeaders = extractKeyValueInputs(packageTransport?.headers, `pkg-transport-header:${index}`, "header"); + const packageTransportHeaders = pickSupportedAuthorizationHeaders(allPackageTransportHeaders); + const unsupportedRequiredHeaders = collectUnsupportedRequiredHeaderNames(allPackageTransportHeaders); const packageTransportVariables = extractVariableMapInputs(packageTransport?.variables, `pkg-transport-var:${index}`); const packageEnvironmentVariables = extractKeyValueInputs(rawPackage?.environmentVariables, `pkg-env:${index}`, "env"); const packageRuntimeArguments = extractRuntimeArguments(rawPackage?.runtimeArguments, `pkg-runtime-arg:${index}`); @@ -330,6 +371,7 @@ const resolveQuickAddOptions = (service: RegistryMcpCard): RegistryQuickAddOptio packageRuntimeHint, packageEnvironmentVariables, packageTransportHeaders, + unsupportedRequiredHeaders, packageTransportVariables, packageRuntimeArguments, }); @@ -346,6 +388,7 @@ const resolveQuickAddOptions = (service: RegistryMcpCard): RegistryQuickAddOptio packageRuntimeHint, packageEnvironmentVariables, packageTransportHeaders, + unsupportedRequiredHeaders, packageTransportVariables, packageRuntimeArguments, packageIdentifier, @@ -539,6 +582,15 @@ export function useMcpToolsAddRegistry({ return; } + if ((selectedOption.unsupportedRequiredHeaders || []).length > 0) { + message.warning( + t("mcpTools.registry.quickAddPicker.unsupportedRequiredHeaders", { + headers: (selectedOption.unsupportedRequiredHeaders || []).join(", "), + }) + ); + return; + } + setAddingService(true); try { if (selectedOption.transportType === "stdio") { @@ -596,6 +648,7 @@ export function useMcpToolsAddRegistry({ } else { const requiredFields = [ ...(selectedOption.remoteVariables || []), + ...(selectedOption.remoteHeaders || []), ...(selectedOption.packageTransportVariables || []), ...(selectedOption.packageTransportHeaders || []), ]; @@ -636,7 +689,10 @@ export function useMcpToolsAddRegistry({ transport_type: selectedOption.transportType === "sse" ? MCP_TRANSPORT_TYPE.SSE : MCP_TRANSPORT_TYPE.HTTP, server_url: resolvedUrl, tags: [], - authorization_token: resolveAuthorizationFromHeaders(selectedOption.packageTransportHeaders, quickAddVariableValues), + authorization_token: resolveAuthorizationFromHeaders( + [...(selectedOption.remoteHeaders || []), ...(selectedOption.packageTransportHeaders || [])], + quickAddVariableValues + ), version: quickAddCandidateService.server?.version || undefined, registry_json: quickAddCandidateService.server, }); diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 7245cc7ef..6f9915d75 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1691,6 +1691,7 @@ "mcpTools.registry.quickAddPicker.sourcePackage": "Source: Package", "mcpTools.registry.quickAddPicker.confirm": "Confirm Add", "mcpTools.registry.quickAddPicker.variablesTitle": "Variables", + "mcpTools.registry.quickAddPicker.remoteHeadersTitle": "Remote Headers", "mcpTools.registry.quickAddPicker.packageTransportVariablesTitle": "Package Transport Variables", "mcpTools.registry.quickAddPicker.packageTransportHeadersTitle": "Package Transport Headers", "mcpTools.registry.quickAddPicker.packageEnvironmentVariablesTitle": "Package Environment Variables", @@ -1701,6 +1702,7 @@ "mcpTools.registry.quickAddPicker.variableFormat": "Format", "mcpTools.registry.quickAddPicker.variableDefault": "Default", "mcpTools.registry.quickAddPicker.variableRequiredMissing": "Variable {{key}} is required", + "mcpTools.registry.quickAddPicker.unsupportedRequiredHeaders": "This quick add is not supported yet because required headers other than Authorization exist: {{headers}}", "mcpTools.registry.quickAddPicker.variableUnresolved": "URL template still has unresolved variables. Please fill them first", "mcpTools.registry.prevPage": "Previous", "mcpTools.registry.nextPage": "Next", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 185e98c76..182141c7e 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1849,6 +1849,7 @@ "mcpTools.registry.quickAddPicker.sourcePackage": "来源: 安装包", "mcpTools.registry.quickAddPicker.confirm": "确认添加", "mcpTools.registry.quickAddPicker.variablesTitle": "变量", + "mcpTools.registry.quickAddPicker.remoteHeadersTitle": "远程请求头", "mcpTools.registry.quickAddPicker.packageTransportVariablesTitle": "Package 传输变量", "mcpTools.registry.quickAddPicker.packageTransportHeadersTitle": "Package 传输请求头", "mcpTools.registry.quickAddPicker.packageEnvironmentVariablesTitle": "Package 环境变量", @@ -1859,6 +1860,7 @@ "mcpTools.registry.quickAddPicker.variableFormat": "格式", "mcpTools.registry.quickAddPicker.variableDefault": "默认值", "mcpTools.registry.quickAddPicker.variableRequiredMissing": "变量 {{key}} 为必填,请先填写", + "mcpTools.registry.quickAddPicker.unsupportedRequiredHeaders": "该服务包含 Authorization 之外的必填请求头,暂不支持快速添加:{{headers}}", "mcpTools.registry.quickAddPicker.variableUnresolved": "URL 模板中仍存在未替换变量,请检查并填写", "mcpTools.registry.prevPage": "上一页", "mcpTools.registry.nextPage": "下一页", diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts index 6a8c0a64c..9e47614f1 100644 --- a/frontend/types/mcpTools.ts +++ b/frontend/types/mcpTools.ts @@ -123,6 +123,8 @@ export interface RegistryQuickAddOption { serverUrl?: string; serverUrlTemplate?: string; remoteVariables?: RegistryRemoteVariable[]; + remoteHeaders?: RegistryRemoteVariable[]; + unsupportedRequiredHeaders?: string[]; packageIndex?: number; packageRuntimeHint?: string; packageEnvironmentVariables?: RegistryRemoteVariable[]; From 8c126768bb5d4764c52dd2cae38ff5a3ee3a5594 Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Thu, 2 Apr 2026 17:12:44 +0800 Subject: [PATCH 11/59] =?UTF-8?q?Supports=20displaying=20descriptions=20in?= =?UTF-8?q?=20Markdown=20format,=20supports=20expanding=20and=20collapsing?= =?UTF-8?q?=20descriptions,=20and=20supports=20descriptions=20of=20unlimit?= =?UTF-8?q?ed=20length.=20=E6=94=AF=E6=8C=81=E6=8F=8F=E8=BF=B0markdown?= =?UTF-8?q?=E5=BD=A2=E5=BC=8F=E5=B1=95=E7=A4=BA=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E5=B1=95=E5=BC=80=E5=92=8C=E6=94=B6=E8=B5=B7?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=97=A0=E9=99=90=E9=95=BF=E7=9A=84?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database/db_models.py | 4 +- .../sql/v2.0.1_0318_expand_mcp_record_t.sql | 2 +- ...v2.0.1_0326_add_mcp_community_record_t.sql | 2 +- .../components/AddMcpServiceLocalSection.tsx | 34 +++++- .../components/McpCommunityDetailModal.tsx | 13 +- .../components/McpDescriptionField.tsx | 115 ++++++++++++++++++ .../components/McpServiceDetailModal.tsx | 24 ++-- .../components/MyCommunityMcpModal.tsx | 19 ++- frontend/public/locales/en/common.json | 7 ++ frontend/public/locales/zh/common.json | 7 ++ 10 files changed, 199 insertions(+), 28 deletions(-) create mode 100644 frontend/app/[locale]/mcp-tools/components/McpDescriptionField.tsx diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 5c8be10fb..4cfa41527 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -341,7 +341,7 @@ class McpRecord(TableBase): config_json = Column(JSON, doc="MCP config data") enabled = Column(Boolean, default=True, doc="Enabled") tags = Column(ARRAY(Text), doc="Tags") - description = Column(String(100), doc="Description") + description = Column(Text, doc="Description") last_sync_time = Column(TIMESTAMP(timezone=False), doc="Last sync time") @@ -368,7 +368,7 @@ class McpCommunityRecord(TableBase): transport_type = Column(String(30), doc="Transport type: http/sse/stdio") config_json = Column(JSON, doc="Public-shareable MCP configuration JSON") tags = Column(ARRAY(Text), doc="Tags") - description = Column(String(100), doc="Description") + description = Column(Text, doc="Description") last_sync_time = Column(TIMESTAMP(timezone=False), doc="Last sync time") diff --git a/docker/sql/v2.0.1_0318_expand_mcp_record_t.sql b/docker/sql/v2.0.1_0318_expand_mcp_record_t.sql index 965ff5a65..794eca4fb 100644 --- a/docker/sql/v2.0.1_0318_expand_mcp_record_t.sql +++ b/docker/sql/v2.0.1_0318_expand_mcp_record_t.sql @@ -15,7 +15,7 @@ ALTER TABLE IF EXISTS nexent.mcp_record_t ADD COLUMN IF NOT EXISTS config_json JSON, ADD COLUMN IF NOT EXISTS enabled BOOLEAN DEFAULT TRUE, ADD COLUMN IF NOT EXISTS tags TEXT[], - ADD COLUMN IF NOT EXISTS description VARCHAR(100), + ADD COLUMN IF NOT EXISTS description TEXT, ADD COLUMN IF NOT EXISTS last_sync_time TIMESTAMP WITHOUT TIME ZONE; -- 2) Add comments for new columns diff --git a/docker/sql/v2.0.1_0326_add_mcp_community_record_t.sql b/docker/sql/v2.0.1_0326_add_mcp_community_record_t.sql index 50f9f97fb..202f32b74 100644 --- a/docker/sql/v2.0.1_0326_add_mcp_community_record_t.sql +++ b/docker/sql/v2.0.1_0326_add_mcp_community_record_t.sql @@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS nexent.mcp_community_record_t ( transport_type VARCHAR(30), config_json JSON, tags TEXT[], - description VARCHAR(100), + description TEXT, last_sync_time TIMESTAMP WITHOUT TIME ZONE, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx index ea8a84401..afd3863ba 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx @@ -1,6 +1,8 @@ +import { useMemo, useState } from "react"; import { Button, Input, InputNumber, Select, Tag } from "antd"; import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; import type { McpTransportType } from "@/types/mcpTools"; +import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; interface Props { newServiceName: string; @@ -51,6 +53,13 @@ export default function AddMcpServiceLocalSection({ handleAddService, t, }: Props) { + const [descriptionExpanded, setDescriptionExpanded] = useState(false); + + const canToggleDescription = useMemo(() => { + const text = String(newServiceDesc || ""); + return text.length > 280 || text.split("\n").length > 8; + }, [newServiceDesc]); + return ( <>
    @@ -65,11 +74,34 @@ export default function AddMcpServiceLocalSection({
    -

    {t("mcpTools.detail.description")}

    -

    - {service.description || "-"} -

    + String(t(key, params as any))} + toggleMinChars={160} + toggleMinLines={5} + />

    {t("mcpTools.detail.serverUrl")}

    diff --git a/frontend/app/[locale]/mcp-tools/components/McpDescriptionField.tsx b/frontend/app/[locale]/mcp-tools/components/McpDescriptionField.tsx new file mode 100644 index 000000000..3a6ce68f9 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpDescriptionField.tsx @@ -0,0 +1,115 @@ +import { useMemo, useState } from "react"; +import { Button, Input } from "antd"; +import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; + +type Props = { + label: string; + value?: string; + t: (key: string, params?: Record) => string; + readOnly?: boolean; + onChange?: (value: string) => void; + minRows?: number; + maxRows?: number; + toggleMinChars?: number; + toggleMinLines?: number; + wrapperClassName?: string; +}; + +export default function McpDescriptionField({ + label, + value, + t, + readOnly = false, + onChange, + minRows = 10, + maxRows = 24, + toggleMinChars = 160, + toggleMinLines = 5, + wrapperClassName = "text-sm text-slate-500", +}: Props) { + const [expanded, setExpanded] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const editable = !readOnly && typeof onChange === "function"; + const descriptionText = useMemo(() => { + const text = String(value || "").trim(); + return text || "-"; + }, [value]); + + const canToggle = useMemo(() => { + if (descriptionText === "-") return false; + const lineCount = descriptionText.split("\n").length; + return descriptionText.length > toggleMinChars || lineCount > toggleMinLines; + }, [descriptionText, toggleMinChars, toggleMinLines]); + + return ( +
    +
    + {label} +
    + {editable && isEditing ? ( + + ) : null} + {canToggle ? ( + + ) : null} +
    +
    + + {editable && isEditing ? ( + <> + onChange?.(event.target.value)} + autoSize={{ minRows, maxRows }} + className="mt-2 w-full rounded-2xl" + placeholder={t("mcpTools.community.descriptionMarkdownPlaceholder")} + /> +

    {t("mcpTools.community.descriptionMarkdownHint")}

    + + ) : ( +
    setIsEditing(true) : undefined} + onKeyDown={ + editable + ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setIsEditing(true); + } + } + : undefined + } + > +
    + +
    + {editable ?

    {t("mcpTools.detail.descriptionClickToEdit")}

    : null} +
    + )} +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx index 8e259c1b8..72b9186ae 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -14,6 +14,7 @@ import { type McpServiceItem, } from "@/types/mcpTools"; import { extractRegistryLinks, toPrettyRegistryJson } from "@/lib/mcpTools"; +import McpDescriptionField from "./McpDescriptionField"; import McpServiceDetailToolListModal from "./McpServiceDetailToolListModal"; import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; @@ -152,19 +153,16 @@ export default function McpServiceDetailModal({ className="mt-2 w-full rounded-2xl" /> - + setDraftService({ ...draftService, description: value })} + t={(key, params) => String(t(key, params as any))} + minRows={10} + maxRows={24} + toggleMinChars={160} + toggleMinLines={5} + /> - +
    + ); diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx index d35448e03..d971d2fc6 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react"; -import { Button, Input, Select, Tag } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { Button, Form, Input, Select, Tag } from "antd"; import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; import type { McpTransportType } from "@/types/mcpTools"; import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; @@ -62,8 +62,39 @@ export default function AddMcpServiceLocalSection({ containerPortAvailable, t, }: Props) { + const [form] = Form.useForm(); const [descriptionExpanded, setDescriptionExpanded] = useState(false); + const isHttpUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } + }; + + useEffect(() => { + form.setFieldsValue({ + newServiceName, + newServiceDesc, + newTransportType, + newServiceUrl, + newServiceAuthorizationToken, + containerConfigJson, + containerPort, + }); + }, [ + containerConfigJson, + containerPort, + form, + newServiceAuthorizationToken, + newServiceDesc, + newServiceName, + newServiceUrl, + newTransportType, + ]); + const canToggleDescription = useMemo(() => { const text = String(newServiceDesc || ""); return text.length > 280 || text.split("\n").length > 8; @@ -71,25 +102,46 @@ export default function AddMcpServiceLocalSection({ return ( <> -
    - + -
    ) : (
    - + - + { + if (value === undefined || value === null || value === "") { + throw new Error(t("mcpTools.add.validate.containerRequired")); + } + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(t("mcpTools.add.validate.containerPortRange")); + } + }, + }, + ]} + > +
    + { + setContainerPort(value); + form.setFieldValue("containerPort", value); + }} + handleSuggestContainerPort={handleSuggestContainerPort} + t={t} + /> +
    +
    )} @@ -200,10 +332,22 @@ export default function AddMcpServiceLocalSection({ /> - +
    -
    diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx index 821a9fe93..c5f9a307b 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -1,4 +1,5 @@ -import { Button, Input, Modal, Radio } from "antd"; +import { useEffect } from "react"; +import { Button, Form, Input, Modal, Radio } from "antd"; import McpRegistryToolbar from "./McpRegistryToolbar"; import McpRegistryCardList from "./McpRegistryCardList"; import McpRegistryDetailModal from "./McpRegistryDetailModal"; @@ -80,8 +81,18 @@ export default function AddMcpServiceRegistrySection({ containerPortAvailable, t, }: Props) { + const [form] = Form.useForm(); const selectedQuickAddOption = quickAddOptions.find((option) => option.key === selectedQuickAddOptionKey) || null; + useEffect(() => { + if (!quickAddPickerVisible) return; + form.setFieldsValue({ + selectedQuickAddOptionKey, + quickAddContainerPort, + ...quickAddVariableValues, + }); + }, [form, quickAddContainerPort, quickAddPickerVisible, quickAddVariableValues, selectedQuickAddOptionKey]); + const renderVariableInputs = ( titleKey: string, fields: Array<{ @@ -107,12 +118,26 @@ export default function AddMcpServiceRegistrySection({ {field.isRequired ? * : null} {field.description ?

    {field.description}

    : null} - handleQuickAddVariableValueChange(field.formKey || "", event.target.value)} - className="mt-2 w-full rounded-xl" - placeholder={field.placeholder || field.default || field.format || t("mcpTools.registry.quickAddPicker.variablePlaceholder")} - /> + + { + handleQuickAddVariableValueChange(field.formKey || "", event.target.value); + form.setFieldValue(field.formKey, event.target.value); + }} + className="mt-2 w-full rounded-xl" + placeholder={field.placeholder || field.default || field.format || t("mcpTools.registry.quickAddPicker.variablePlaceholder")} + /> +
    {field.format ? ( {t("mcpTools.registry.quickAddPicker.variableFormat")}: {field.format} @@ -144,12 +169,26 @@ export default function AddMcpServiceRegistrySection({ {arg.type === "named" ? t("mcpTools.registry.quickAddPicker.runtimeNamed") : t("mcpTools.registry.quickAddPicker.runtimePositional")}

    {arg.description ?

    {arg.description}

    : null} - handleQuickAddVariableValueChange(arg.formKey, event.target.value)} - className="mt-2 w-full rounded-xl" - placeholder={arg.default || arg.format || t("mcpTools.registry.quickAddPicker.variablePlaceholder")} - /> + + { + handleQuickAddVariableValueChange(arg.formKey, event.target.value); + form.setFieldValue(arg.formKey, event.target.value); + }} + className="mt-2 w-full rounded-xl" + placeholder={arg.default || arg.format || t("mcpTools.registry.quickAddPicker.variablePlaceholder")} + /> +
    {arg.format ? {t("mcpTools.registry.quickAddPicker.variableFormat")}: {arg.format} : null} {arg.default ? {t("mcpTools.registry.quickAddPicker.variableDefault")}: {arg.default} : null} @@ -207,18 +246,27 @@ export default function AddMcpServiceRegistrySection({ centered destroyOnHidden > -
    +

    {t("mcpTools.registry.quickAddPicker.description", { name: quickAddCandidateService?.name || "-", })}

    - setSelectedQuickAddOptionKey(String(event.target.value || ""))} - className="flex w-full flex-col gap-2" + + { + const nextValue = String(event.target.value || ""); + setSelectedQuickAddOptionKey(nextValue); + form.setFieldValue("selectedQuickAddOptionKey", nextValue); + }} + className="flex w-full flex-col gap-2" + > {quickAddOptions.map((option) => { const sourceLabel = option.sourceType === "remote" @@ -238,19 +286,43 @@ export default function AddMcpServiceRegistrySection({ ); })} - + + {selectedQuickAddOption?.transportType === "stdio" ? (
    - + { + if (value === undefined || value === null || value === "") { + throw new Error(t("mcpTools.add.validate.containerRequired")); + } + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(t("mcpTools.add.validate.containerPortRange")); + } + }, + }, + ]} + > +
    + { + setQuickAddContainerPort(value); + form.setFieldValue("quickAddContainerPort", value); + }} + handleSuggestContainerPort={handleSuggestContainerPort} + t={t} + /> +
    +
    ) : null} @@ -270,14 +342,19 @@ export default function AddMcpServiceRegistrySection({ className="rounded-full" loading={quickAddSubmitting} disabled={!selectedQuickAddOptionKey} - onClick={() => { - void handleConfirmQuickAddOption(); + onClick={async () => { + try { + await form.validateFields(); + await handleConfirmQuickAddOption(); + } catch { + return; + } }} > {t("mcpTools.registry.quickAddPicker.confirm")}
    -
    + ); diff --git a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx index 3668221ce..852c8c5ab 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -1,6 +1,6 @@ -import { Modal, Input, Button, Tag } from "antd"; +import { Modal, Input, Button, Form, Tag } from "antd"; import { useTranslation } from "react-i18next"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { MCP_CONTAINER_STATUS, MCP_HEALTH_STATUS, @@ -79,6 +79,7 @@ export default function McpServiceDetailModal({ onToggleEnable, onClose, }: McpServiceDetailModalProps) { + const [form] = Form.useForm(); const { t } = useTranslation("common"); const [logsModalOpen, setLogsModalOpen] = useState(false); @@ -93,6 +94,25 @@ export default function McpServiceDetailModal({ const registryJsonPretty = toPrettyRegistryJson(draftService?.mcpRegistryJson); const configJsonPretty = toPrettyRegistryJson(draftService?.configJson); + const isHttpUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } + }; + + useEffect(() => { + if (!open || !draftService) return; + form.setFieldsValue({ + name: draftService.name, + description: draftService.description, + serverUrl: draftService.serverUrl, + authorizationToken: draftService.authorizationToken ?? "", + }); + }, [draftService, form, open]); + const getHealthStatusLabel = (status: McpHealthStatus) => { if (status === MCP_HEALTH_STATUS.HEALTHY) { return t("mcpTools.health.healthy"); @@ -139,60 +159,105 @@ export default function McpServiceDetailModal({
    -
    - - + + setDraftService({ ...draftService, description: value })} - t={(key, params) => String(t(key, params as any))} - minRows={1} - maxRows={24} - toggleMinChars={160} - toggleMinLines={1} - /> - + + { + const text = String(value || "").trim(); + if (!text) { + throw new Error(t("mcpTools.add.validate.httpUrlRequired")); + } + if (text.length > 500) { + throw new Error(t("mcpTools.add.validate.httpUrlMaxLength")); + } + if (!isHttpUrl(text)) { + throw new Error(t("mcpTools.add.validate.httpUrlFormat")); + } + }, + }, + ]} + > + onChange={(event) => { setDraftService({ ...draftService, serverUrl: event.target.value, - }) - } + }); + form.setFieldValue("serverUrl", event.target.value); + }} className="mt-2 w-full rounded-2xl" /> - + {draftService.transportType === MCP_TRANSPORT_TYPE.HTTP || draftService.transportType === MCP_TRANSPORT_TYPE.SSE ? ( - + ) : null} -
    +
    @@ -365,7 +430,17 @@ export default function McpServiceDetailModal({ > {t("common.delete")} -
    diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 52736309a..44953524e 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1720,6 +1720,9 @@ "mcpTools.registry.quickAddPicker.variableRequiredMissing": "Variable {{key}} is required", "mcpTools.registry.quickAddPicker.unsupportedRequiredHeaders": "This quick add is not supported yet because required headers other than Authorization exist: {{headers}}", "mcpTools.registry.quickAddPicker.variableUnresolved": "URL template still has unresolved variables. Please fill them first", + "mcpTools.registry.market.more": "More MCP Markets", + "mcpTools.registry.market.modelscope": "ModelScope MCP Plaza", + "mcpTools.registry.market.mcpso": "MCP.so", "mcpTools.registry.prevPage": "Previous", "mcpTools.registry.nextPage": "Next", "mcpTools.registry.website": "Website:", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 61337f59b..1d996d8d2 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1878,6 +1878,9 @@ "mcpTools.registry.quickAddPicker.variableRequiredMissing": "变量 {{key}} 为必填,请先填写", "mcpTools.registry.quickAddPicker.unsupportedRequiredHeaders": "该服务包含 Authorization 之外的必填请求头,暂不支持快速添加:{{headers}}", "mcpTools.registry.quickAddPicker.variableUnresolved": "URL 模板中仍存在未替换变量,请检查并填写", + "mcpTools.registry.market.more": "更多MCP市场", + "mcpTools.registry.market.modelscope": "魔搭 MCP 广场", + "mcpTools.registry.market.mcpso": "MCP.so", "mcpTools.registry.prevPage": "上一页", "mcpTools.registry.nextPage": "下一页", "mcpTools.registry.website": "网站:", From f9c46b434507365a2371d4e1d1a0482adfa391a5 Mon Sep 17 00:00:00 2001 From: HelloWorld Date: Fri, 10 Apr 2026 16:21:11 +0800 Subject: [PATCH 19/59] =?UTF-8?q?Optimized=20the=20frontend's=20handling?= =?UTF-8?q?=20of=20OCI=20unsupported=20display.=20Slightly=20optimized=20t?= =?UTF-8?q?he=20backend=20code=20for=20OCI=20unsupported=20cases.=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF=E5=AF=B9oci=E7=9A=84?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81=E6=98=BE=E7=A4=BA=E3=80=82=20?= =?UTF-8?q?=E7=A8=8D=E5=BE=AE=E4=BC=98=E5=8C=96=E4=BA=86=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=AF=B9oci=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/mcp_management_service.py | 42 +++---- .../AddMcpServiceRegistrySection.tsx | 117 +++++++++++------- .../hooks/mcpTools/useMcpToolsAddRegistry.ts | 23 +++- frontend/types/mcpTools.ts | 5 +- 4 files changed, 114 insertions(+), 73 deletions(-) diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py index a4bf5550f..8d9f2d2e7 100644 --- a/backend/services/mcp_management_service.py +++ b/backend/services/mcp_management_service.py @@ -366,6 +366,8 @@ async def add_container_mcp_service( command = config.command if not command: raise McpValidationError("command is required") + if command.strip().lower() == "docker": + raise McpValidationError("Docker command is not supported") env_vars = dict(config.env or {}) auth_token = authorization_token @@ -388,20 +390,19 @@ async def add_container_mcp_service( ] container_manager = MCPContainerManager() - container_info = await container_manager.start_mcp_container( - service_name=service_name, - tenant_id=tenant_id, - user_id=user_id, - env_vars=env_vars, - host_port=port, - image=config.image or NEXENT_MCP_DOCKER_IMAGE, - full_command=full_command, - ) - started_container_id: str | None = container_info.get("container_id") + try: + container_info = await container_manager.start_mcp_container( + service_name=service_name, + tenant_id=tenant_id, + user_id=user_id, + env_vars=env_vars, + host_port=port, + image=NEXENT_MCP_DOCKER_IMAGE, + full_command=full_command, + ) - container_config = mcp_config.model_dump(exclude_none=True) + container_config = mcp_config.model_dump(exclude_none=True) - try: await add_mcp_service( tenant_id=tenant_id, user_id=user_id, @@ -419,13 +420,8 @@ async def add_container_mcp_service( container_id=container_info.get("container_id"), container_port=container_info.get("host_port"), ) - except MCPConnectionError: - if started_container_id: - try: - cleanup_manager = MCPContainerManager() - await cleanup_manager.stop_mcp_container(started_container_id) - except Exception as cleanup_exc: - logger.warning(f"Failed to cleanup container {started_container_id}: {cleanup_exc}") + except Exception as exc: + logger.warning(f"Failed to start container MCP service, status: {exc}") raise return { @@ -639,7 +635,9 @@ async def update_mcp_service_enabled( next_container_port = container_info.get("host_port") or next_container_port health_ok = False - for attempt in range(10): + MCP_CONTAINER_HEALTH_CHECK_ATTEMPTS = 4 + MCP_CONTAINER_HEALTH_CHECK_DELAY_SECONDS = 0.5 + for attempt in range(MCP_CONTAINER_HEALTH_CHECK_ATTEMPTS): try: health_ok = await mcp_server_health( remote_mcp_server=next_server_url, @@ -649,8 +647,8 @@ async def update_mcp_service_enabled( health_ok = False if health_ok: break - if attempt < 9: - await asyncio.sleep(1) + if attempt < MCP_CONTAINER_HEALTH_CHECK_ATTEMPTS - 1: + await asyncio.sleep(MCP_CONTAINER_HEALTH_CHECK_DELAY_SECONDS) if not health_ok: await _stop_container_without_remove_if_exists(next_container_id) update_mcp_record_runtime_fields_by_id( diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx index c5f9a307b..c09ab2ffd 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -1,10 +1,10 @@ import { useEffect } from "react"; -import { Button, Form, Input, Modal, Radio } from "antd"; +import { Alert, Button, Form, Input, Modal, Radio } from "antd"; import McpRegistryToolbar from "./McpRegistryToolbar"; import McpRegistryCardList from "./McpRegistryCardList"; import McpRegistryDetailModal from "./McpRegistryDetailModal"; import ContainerPortField from "./ContainerPortField"; -import type { RegistryMcpCard, RegistryQuickAddOption } from "@/types/mcpTools"; +import type { RegistryMcpCard, RegistryQuickAddOption, RegistryPackageArgumentInput } from "@/types/mcpTools"; interface Props { registrySearchValue: string; @@ -83,6 +83,9 @@ export default function AddMcpServiceRegistrySection({ }: Props) { const [form] = Form.useForm(); const selectedQuickAddOption = quickAddOptions.find((option) => option.key === selectedQuickAddOptionKey) || null; + const selectedQuickAddOptionIsUnsupportedOci = + selectedQuickAddOption?.sourceType === "package" && + (selectedQuickAddOption.packageRegistryType || "").trim().toLowerCase() === "oci"; useEffect(() => { if (!quickAddPickerVisible) return; @@ -152,13 +155,12 @@ export default function AddMcpServiceRegistrySection({ ); }; - const renderRuntimeArgumentInputs = () => { - const args = selectedQuickAddOption?.packageRuntimeArguments || []; + const renderArgumentInputs = (args: RegistryPackageArgumentInput[], title: string) => { if (!args.length) return null; return (
    -

    {t("mcpTools.registry.quickAddPicker.runtimeArgumentsTitle")}

    +

    {title}

    {args.map((arg) => (
    - - ); -} diff --git a/frontend/app/[locale]/mcp-tools/components/MyCommunityMcpModal.tsx b/frontend/app/[locale]/mcp-tools/components/MyCommunityMcpModal.tsx index 863c870b1..efafe1270 100644 --- a/frontend/app/[locale]/mcp-tools/components/MyCommunityMcpModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/MyCommunityMcpModal.tsx @@ -1,142 +1,69 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect } from "react"; import { Button, Empty, Form, Input, Modal, Popconfirm, Spin, Tag } from "antd"; +import { useTranslation } from "react-i18next"; import type { CommunityMcpCard } from "@/types/mcpTools"; -import { - deleteCommunityMcpTool, - listMyCommunityMcpTools, - updateCommunityMcpTool, -} from "@/services/mcpToolsService"; import { formatRegistryVersion } from "@/lib/mcpTools"; -import McpDescriptionField from "./McpDescriptionField"; +import { useMyCommunityMcp } from "@/hooks/mcpTools/useMyCommunityMcp"; -type Props = { +interface MyCommunityMcpModalProps { open: boolean; onClose: () => void; - t: (key: string, params?: Record) => string; -}; - -type Draft = { - communityId: number; - name: string; - description: string; - version: string; - tags: string[]; - tagInputValue: string; -}; +} -export default function MyCommunityMcpModal({ open, onClose, t }: Props) { +export default function MyCommunityMcpModal({ + open, + onClose, +}: MyCommunityMcpModalProps) { + const { t } = useTranslation("common"); const [editForm] = Form.useForm(); - const [loading, setLoading] = useState(false); - const [items, setItems] = useState([]); - const [search, setSearch] = useState(""); - const [editDraft, setEditDraft] = useState(null); - const [saving, setSaving] = useState(false); - const [deletingId, setDeletingId] = useState(null); - - const loadMine = async () => { - setLoading(true); - try { - const result = await listMyCommunityMcpTools(); - setItems(result.data.items || []); - } catch { - setItems([]); - } finally { - setLoading(false); - } - }; + const { + loading, + filteredItems, + search, + setSearch, + editDraft, + startEditing, + cancelEditing, + updateDraft, + addDraftTag, + removeDraftTag, + saveEdit, + saving, + remove, + deletingId, + } = useMyCommunityMcp(open); useEffect(() => { - if (!open) return; - void loadMine(); - }, [open]); - - const filteredItems = useMemo(() => { - const keyword = search.trim().toLowerCase(); - if (!keyword) return items; - return items.filter((item) => { - const tags = (item.tags || []).join(",").toLowerCase(); - return ( - (item.name || "").toLowerCase().includes(keyword) || - (item.description || "").toLowerCase().includes(keyword) || - tags.includes(keyword) - ); - }); - }, [items, search]); - - const openEdit = (item: CommunityMcpCard) => { - if (!item.communityId) return; - const nextDraft = { - communityId: item.communityId, - name: item.name || "", - description: item.description || "", - version: item.version || "", - tags: item.tags || [], - tagInputValue: "", - }; - setEditDraft(nextDraft); - editForm.setFieldsValue({ - name: nextDraft.name, - description: nextDraft.description, - version: nextDraft.version, - }); - }; - - const addDraftTag = () => { - if (!editDraft) return; - const nextTag = editDraft.tagInputValue.trim(); - if (!nextTag) return; - if (editDraft.tags.includes(nextTag)) { - setEditDraft({ ...editDraft, tagInputValue: "" }); + if (!editDraft) { + editForm.resetFields(); return; } - setEditDraft({ - ...editDraft, - tags: [...editDraft.tags, nextTag], - tagInputValue: "", - }); - }; - - const removeDraftTag = (index: number) => { - if (!editDraft) return; - setEditDraft({ - ...editDraft, - tags: editDraft.tags.filter((_, idx) => idx !== index), + editForm.setFieldsValue({ + name: editDraft.name, + description: editDraft.description, + version: editDraft.version, }); - }; + }, [editDraft, editForm]); - const saveEdit = async () => { - if (!editDraft) return; - setSaving(true); + const handleSave = async () => { try { await editForm.validateFields(); - await updateCommunityMcpTool({ - community_id: editDraft.communityId, - name: editDraft.name.trim(), - description: editDraft.description.trim(), - version: editDraft.version.trim(), - tags: editDraft.tags, - }); - setEditDraft(null); - editForm.resetFields(); - await loadMine(); - } finally { - setSaving(false); - } - }; - - const handleDelete = async (communityId: number) => { - setDeletingId(communityId); - try { - await deleteCommunityMcpTool(communityId); - await loadMine(); - } finally { - setDeletingId(null); + } catch { + return; } + await saveEdit(); }; return ( <> - +
    {filteredItems.map((item) => ( -
    -
    -
    -

    {item.name}

    -

    {formatRegistryVersion(item.version || "")}

    -
    -
    - - item.communityId && handleDelete(item.communityId)} - > - - -
    -
    - -

    {item.description || "-"}

    - -
    -
    {t("mcpTools.detail.serverType")}: {item.transportType}
    -
    {t("mcpTools.detail.serverUrl")}: {item.serverUrl || "-"}
    -
    - - {(item.tags || []).length > 0 ? ( -
    - {(item.tags || []).map((tag) => ( - - {tag} - - ))} -
    - ) : null} -
    + startEditing(item)} + onDelete={() => item.communityId && remove(item.communityId)} + /> ))}
    )} @@ -210,32 +97,40 @@ export default function MyCommunityMcpModal({ open, onClose, t }: Props) { { - setEditDraft(null); - editForm.resetFields(); - }} - onOk={() => { - void saveEdit(); - }} + onCancel={cancelEditing} + onOk={handleSave} confirmLoading={saving} title={t("mcpTools.community.mine.edit")} okText={t("common.save")} cancelText={t("common.cancel")} > {editDraft ? ( -
    + setEditDraft({ ...editDraft, name: event.target.value })} + onChange={(event) => updateDraft({ name: event.target.value })} className="mt-1 rounded-xl" /> @@ -243,21 +138,23 @@ export default function MyCommunityMcpModal({ open, onClose, t }: Props) { - { - setEditDraft({ ...editDraft, description: value }); - editForm.setFieldValue("description", value); + onChange={(event) => { + updateDraft({ description: event.target.value }); + editForm.setFieldValue("description", event.target.value); }} - t={(key, params) => String(t(key, params as any))} - minRows={1} - maxRows={24} - toggleMinChars={160} - toggleMinLines={1} - wrapperClassName="text-xs text-slate-500" + autoSize={{ minRows: 1, maxRows: 24 }} + className="mt-1 rounded-xl" + placeholder={t("mcpTools.detail.description")} /> @@ -269,22 +166,24 @@ export default function MyCommunityMcpModal({ open, onClose, t }: Props) { { validator: async (_rule, value) => { const text = String(value || "").trim(); - if (!text) { - return; - } - if (text.length > 100) { - throw new Error(t("mcpTools.community.mine.versionMaxLength")); - } - if (!/^\d+(?:\.\d+){0,2}$/.test(text)) { - throw new Error(t("mcpTools.community.mine.versionFormat")); - } + if (!text) return; + if (text.length > 100) + throw new Error( + t("mcpTools.community.mine.versionMaxLength") + ); + if (!/^\d+(?:\.\d+){0,2}$/.test(text)) + throw new Error( + t("mcpTools.community.mine.versionFormat") + ); }, }, ]} > setEditDraft({ ...editDraft, version: event.target.value })} + onChange={(event) => + updateDraft({ version: event.target.value }) + } className="mt-1 rounded-xl" /> @@ -293,8 +192,13 @@ export default function MyCommunityMcpModal({ open, onClose, t }: Props) { {t("mcpTools.detail.tags")}
    {editDraft.tags.map((tag, index) => ( - - {tag} + + + {tag} + + + + +
    + + +

    + {item.description || "-"} +

    + +
    +
    + {t("mcpTools.detail.serverType")}: {item.transportType} +
    +
    + {t("mcpTools.detail.serverUrl")}: {item.serverUrl || "-"} +
    +
    + + {(item.tags || []).length > 0 ? ( +
    + {(item.tags || []).map((tag) => ( + + {tag} + + ))} +
    + ) : null} + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index c381ae5b0..24098e36e 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -1,62 +1,37 @@ "use client"; -import React, { useCallback } from "react"; -import { App, Button, Input, Select } from "antd"; +import { useState } from "react"; +import { Button, Input, Select } from "antd"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import log from "@/lib/logger"; import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { useMcpServicesList } from "@/hooks/mcpTools/useMcpServicesList"; +import { useMcpServiceToggle } from "@/hooks/mcpTools/useMcpServiceToggle"; +import type { McpServiceItem } from "@/types/mcpTools"; import AddMcpServiceModal from "./components/AddMcpServiceModal"; import MyCommunityMcpModal from "./components/MyCommunityMcpModal"; import McpServiceCard from "./components/McpServiceCard"; import McpServiceDetailModal from "./components/McpServiceDetailModal"; -import { useMcpToolsPage } from "../../../hooks/mcpTools/useMcpToolsPage"; export default function McpToolsPage() { - const { message, modal } = App.useApp(); const { t } = useTranslation("common"); const { pageVariants, pageTransition } = useSetupFlow(); - const translate = useCallback((key: string) => String(t(key)), [t]); - const [showMyPublishedModal, setShowMyPublishedModal] = React.useState(false); - const { - searchValue, - setSearchValue, - sourceFilter, - setSourceFilter, - transportTypeFilter, - setTransportTypeFilter, - tagFilter, - setTagFilter, - tagStats, - loadingServices, - selectedService, - setSelectedService, - showAddModal, - setShowAddModal, - loadServerList, - filteredServices, - toggleServiceStatus, - isServiceToggling, - detail, - } = useMcpToolsPage({ - t: translate, - message, - }); + const [showAddModal, setShowAddModal] = useState(false); + const [showMyPublishedModal, setShowMyPublishedModal] = useState(false); + const [selected, setSelected] = useState(null); - const handleDeleteConfirm = (mcpId: number, serviceName: string) => { - modal.confirm({ - title: t("mcpTools.delete.confirmTitle"), - content: ( -
    -

    {serviceName}

    -

    {t("mcpTools.delete.confirmDesc")}

    -
    - ), - okText: t("mcpTools.delete.confirmOk"), - cancelText: t("mcpTools.delete.confirmCancel"), - okButtonProps: { danger: true }, - onOk: () => detail.onDeleteService(mcpId, serviceName), + const list = useMcpServicesList(); + const toggle = useMcpServiceToggle(); + + const handleToggle = (service: McpServiceItem) => { + toggle.toggle(service).catch((error) => { + log.error("[McpToolsPage] Failed to toggle service status", { + error, + serviceName: service.name, + serverUrl: service.serverUrl, + }); }); }; @@ -72,8 +47,12 @@ export default function McpToolsPage() { >
    -

    {t("mcpTools.page.title")}

    -

    {t("mcpTools.page.subtitle")}

    +

    + {t("mcpTools.page.title")} +

    +

    + {t("mcpTools.page.subtitle")} +

    @@ -84,14 +63,18 @@ export default function McpToolsPage() {
    setSearchValue(event.target.value)} + value={list.filters.search} + onChange={(event) => + list.updateFilter("search", event.target.value) + } placeholder={String(t("mcpTools.page.searchPlaceholder"))} size="large" className="w-full h-10 rounded-2xl" />
    - {t("mcpTools.page.resultCount", { count: filteredServices.length })} + {t("mcpTools.page.resultCount", { + count: list.filteredServices.length, + })}
    @@ -121,8 +104,8 @@ export default function McpToolsPage() {
    list.updateFilter("transport", value)} className="w-full" options={[ { value: "all", label: t("mcpTools.page.transportFilter.all") }, { value: "http", label: t("mcpTools.serverType.http") }, { value: "sse", label: t("mcpTools.serverType.sse") }, - { value: "container", label: t("mcpTools.serverType.container") }, + { + value: "container", + label: t("mcpTools.serverType.container"), + }, ]} /> { - const text = String(value || "").trim(); - if (!text) - throw new Error( - t("mcpTools.add.validate.containerConfigRequired") - ); - try { - JSON.parse(text); - } catch { - throw new Error( - t("mcpTools.add.error.containerJsonInvalid") - ); - } - }, - }, - ]} + rules={rules.containerConfig} > { - if (value === undefined || value === null || value === "") { - throw new Error( - t("mcpTools.add.validate.containerRequired") - ); - } - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error( - t("mcpTools.add.validate.containerPortRange") - ); - } - }, - }, - ]} + rules={rules.containerPort} >
    )} -
    -

    - {t("mcpTools.addModal.tags")} -

    -
    - {draft.tags.map((tag, index) => ( - - - {tag} - - - - ))} - setTagInput(event.target.value)} - onPressEnter={addTag} - onBlur={addTag} - placeholder={t("mcpTools.addModal.tagInputPlaceholder")} - className="w-40 rounded-full" - /> -
    -
    + ); diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx index b9f8ba910..d95456d7d 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceLocalSection.tsx @@ -1,41 +1,30 @@ -import { useMemo, useState } from "react"; -import { Button, Form, Input, Select, Tag } from "antd"; +import { useState } from "react"; +import { Button, Form, Input, Select } from "antd"; import { useTranslation } from "react-i18next"; -import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; +import { INITIAL_LOCAL_ADD_DRAFT, MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; import type { LocalAddMcpDraft, McpTransportType } from "@/types/mcpTools"; -import { isHttpUrl } from "@/lib/mcpTools"; -import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; import { useMcpAddLocal } from "@/hooks/mcpTools/useMcpAddLocal"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; import ContainerPortField from "./ContainerPortField"; +import TagEditor from "./shared/TagEditor"; interface AddMcpServiceLocalSectionProps { active: boolean; onAdded: () => void; } -const INITIAL_DRAFT: LocalAddMcpDraft = { - name: "", - description: "", - transportType: MCP_TRANSPORT_TYPE.HTTP, - serverUrl: "", - authorizationToken: "", - containerConfigJson: "", - containerPort: undefined, - tags: [], -}; - export default function AddMcpServiceLocalSection({ active, onAdded, }: AddMcpServiceLocalSectionProps) { const { t } = useTranslation("common"); + const rules = useMcpFormRules(); const [form] = Form.useForm(); - const [draft, setDraft] = useState(INITIAL_DRAFT); + const [draft, setDraft] = useState(INITIAL_LOCAL_ADD_DRAFT); const [tagInput, setTagInput] = useState(""); - const [descriptionExpanded, setDescriptionExpanded] = useState(false); const { submit, submitting } = useMcpAddLocal({ onSuccess: () => { - setDraft(INITIAL_DRAFT); + setDraft(INITIAL_LOCAL_ADD_DRAFT); setTagInput(""); form.resetFields(); onAdded(); @@ -46,29 +35,33 @@ export default function AddMcpServiceLocalSection({ setDraft((prev) => ({ ...prev, ...patch })); }; + // Syncs external `draft` into AntD Form state so validation sees the value. + const bindField = (key: K) => ({ + value: draft[key], + onChange: (eventOrValue: unknown) => { + const next = + eventOrValue && + typeof eventOrValue === "object" && + "target" in (eventOrValue as Record) + ? (eventOrValue as { target: { value: LocalAddMcpDraft[K] } }).target + .value + : (eventOrValue as LocalAddMcpDraft[K]); + patchDraft({ [key]: next } as Partial); + form.setFieldValue(key as string, next); + }, + }); + const addTag = () => { const next = tagInput.trim(); - if (!next) { - setTagInput(""); - return; - } - if (draft.tags.includes(next)) { - setTagInput(""); - return; - } - patchDraft({ tags: [...draft.tags, next] }); setTagInput(""); + if (!next || draft.tags.includes(next)) return; + patchDraft({ tags: [...draft.tags, next] }); }; const removeTag = (index: number) => { patchDraft({ tags: draft.tags.filter((_, i) => i !== index) }); }; - const canToggleDescription = useMemo(() => { - const text = draft.description || ""; - return text.length > 280 || text.split("\n").length > 8; - }, [draft.description]); - const handleSubmit = async () => { try { await form.validateFields(); @@ -80,6 +73,10 @@ export default function AddMcpServiceLocalSection({ if (!active) return null; + const isHttpLike = + draft.transportType === MCP_TRANSPORT_TYPE.HTTP || + draft.transportType === MCP_TRANSPORT_TYPE.SSE; + return ( <>
    - { - patchDraft({ name: event.target.value }); - form.setFieldValue("name", event.target.value); - }} - className="mt-2 w-full rounded-2xl" - /> + { - patchDraft({ description: event.target.value }); - form.setFieldValue("description", event.target.value); - }} + {...bindField("description")} autoSize={{ minRows: 1, maxRows: 20 }} className="mt-2 w-full rounded-2xl" /> @@ -143,12 +112,7 @@ export default function AddMcpServiceLocalSection({ name="transportType" initialValue={draft.transportType} className="mb-0 text-sm text-slate-500" - rules={[ - { - required: true, - message: t("mcpTools.add.validate.transportTypeRequired"), - }, - ]} + rules={rules.transportType} > { - patchDraft({ serverUrl: event.target.value }); - form.setFieldValue("serverUrl", event.target.value); - }} + {...bindField("serverUrl")} className="mt-2 w-full rounded-2xl" placeholder={t("mcpTools.addModal.serverUrl")} /> @@ -213,22 +156,10 @@ export default function AddMcpServiceLocalSection({ label={t("mcpTools.addModal.bearerTokenOptional")} name="authorizationToken" className="mb-0 text-sm text-slate-500" - rules={[ - { - type: "string", - max: 500, - message: t( - "mcpTools.add.validate.authorizationTokenMaxLength" - ), - }, - ]} + rules={rules.authToken} > { - patchDraft({ authorizationToken: event.target.value }); - form.setFieldValue("authorizationToken", event.target.value); - }} + {...bindField("authorizationToken")} className="mt-2 w-full rounded-2xl" placeholder={t("mcpTools.addModal.bearerTokenPlaceholder")} /> @@ -240,31 +171,10 @@ export default function AddMcpServiceLocalSection({ label={t("mcpTools.addModal.containerConfig")} name="containerConfigJson" className="mb-0 text-sm text-slate-500" - rules={[ - { - validator: async (_rule, value) => { - const text = String(value || "").trim(); - if (!text) - throw new Error( - t("mcpTools.add.validate.containerConfigRequired") - ); - try { - JSON.parse(text); - } catch { - throw new Error( - t("mcpTools.add.error.containerJsonInvalid") - ); - } - }, - }, - ]} + rules={rules.containerConfig} > { - patchDraft({ containerConfigJson: event.target.value }); - form.setFieldValue("containerConfigJson", event.target.value); - }} + {...bindField("containerConfigJson")} rows={5} placeholder={t("mcpTools.addModal.containerConfigPlaceholder")} className="mt-2" @@ -274,23 +184,7 @@ export default function AddMcpServiceLocalSection({ { - if (value === undefined || value === null || value === "") { - throw new Error( - t("mcpTools.add.validate.containerRequired") - ); - } - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error( - t("mcpTools.add.validate.containerPortRange") - ); - } - }, - }, - ]} + rules={rules.containerPort} >
    )} -
    -

    - {t("mcpTools.addModal.tags")} -

    -
    - {draft.tags.map((tag, index) => ( - - - {tag} - - - - ))} - setTagInput(event.target.value)} - onPressEnter={addTag} - onBlur={addTag} - placeholder={t("mcpTools.addModal.tagInputPlaceholder")} - className="w-40 rounded-full" - /> -
    -
    +
    diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx index 47c41e8a0..5a6e7d211 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -6,6 +6,7 @@ import type { RegistryPackageArgumentInput, RegistryRemoteVariable, } from "@/types/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; import { useMcpRegistryBrowser } from "@/hooks/mcpTools/useMcpRegistryBrowser"; import { useMcpRegistryQuickAdd } from "@/hooks/mcpTools/useMcpRegistryQuickAdd"; import McpRegistryToolbar from "./McpRegistryToolbar"; @@ -22,7 +23,6 @@ export default function AddMcpServiceRegistrySection({ active, onAdded, }: AddMcpServiceRegistrySectionProps) { - const { t } = useTranslation("common"); const [selected, setSelected] = useState(null); const browser = useMcpRegistryBrowser(active); const quickAdd = useMcpRegistryQuickAdd({ onSuccess: onAdded }); @@ -69,18 +69,19 @@ export default function AddMcpServiceRegistrySection({ /> ) : null} - + ); } interface QuickAddPickerModalProps { controller: ReturnType; - t: (key: string, params?: Record) => string; } -function QuickAddPickerModal({ controller, t }: QuickAddPickerModalProps) { +function QuickAddPickerModal({ controller }: QuickAddPickerModalProps) { + const { t } = useTranslation("common"); const [form] = Form.useForm(); + const rules = useMcpFormRules(); const { visible, candidate, @@ -134,25 +135,10 @@ function QuickAddPickerModal({ controller, t }: QuickAddPickerModalProps) { { - if ( - value === undefined || - value === null || - value === "" - ) { - throw new Error( - t("mcpTools.add.validate.containerRequired") - ); - } - const port = Number(value); - if ( - !Number.isInteger(port) || - port < 1 || - port > 65535 - ) { - throw new Error( - t("mcpTools.add.validate.containerPortRange") - ); - } - }, - }, - ]} + rules={rules.containerPort} >
    setContainerPort(value === null ? undefined : value) } - min={1} - max={65535} + min={MCP_PORT_RANGE.MIN} + max={MCP_PORT_RANGE.MAX} controls={false} className="w-full" placeholder={t("mcpTools.addModal.containerPortPlaceholder")} diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx index f84bc104d..0d069c02c 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityCard.tsx @@ -1,11 +1,12 @@ import { Button } from "antd"; import { useTranslation } from "react-i18next"; import { - MCP_TRANSPORT_TYPE, - MCP_REGISTRY_SERVER_STATUS, -} from "@/const/mcpTools"; -import { formatRegistryDate, formatRegistryVersion } from "@/lib/mcpTools"; + formatRegistryDate, + formatRegistryVersion, + getTransportLabelKey, +} from "@/lib/mcpTools"; import type { CommunityMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "./shared/RegistryStatusBadge"; interface McpCommunityCardProps { service: CommunityMcpCard; @@ -19,24 +20,7 @@ export default function McpCommunityCard({ onQuickAdd, }: McpCommunityCardProps) { const { t } = useTranslation("common"); - const statusClassName = - service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE - ? "bg-emerald-100 text-emerald-700" - : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED - ? "bg-amber-100 text-amber-700" - : "bg-slate-100 text-slate-600"; - const statusTextKey = - service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE - ? "mcpTools.community.status.active" - : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED - ? "mcpTools.community.status.deprecated" - : "mcpTools.community.status.unknown"; - const transportLabel = - service.transportType === MCP_TRANSPORT_TYPE.HTTP - ? t("mcpTools.serverType.http") - : service.transportType === MCP_TRANSPORT_TYPE.SSE - ? t("mcpTools.serverType.sse") - : t("mcpTools.serverType.container"); + const transportLabel = t(getTransportLabelKey(service.transportType)); return (
    {service.name} - - {t(statusTextKey)} - +
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx index 15f22b654..563891221 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityDetailModal.tsx @@ -1,14 +1,15 @@ import { useState } from "react"; import { Button, Modal } from "antd"; import { useTranslation } from "react-i18next"; -import { MCP_REGISTRY_SERVER_STATUS } from "@/const/mcpTools"; import { extractRegistryLinks, formatRegistryDate, formatRegistryVersion, + getTransportLabelKey, toPrettyRegistryJson, } from "@/lib/mcpTools"; import type { CommunityMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "./shared/RegistryStatusBadge"; interface McpCommunityDetailModalProps { service: CommunityMcpCard; @@ -39,27 +40,9 @@ export default function McpCommunityDetailModal({ const hasConfigJson = Boolean( service.configJson && Object.keys(service.configJson).length > 0 ); - const serverTypeText = - service.transportType === "sse" - ? t("mcpTools.serverType.sse") - : service.transportType === "container" - ? t("mcpTools.serverType.container") - : t("mcpTools.serverType.http"); + const serverTypeText = t(getTransportLabelKey(service.transportType)); const sourceText = t("mcpTools.source.community"); - const statusClassName = - service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE - ? "bg-emerald-100 text-emerald-700" - : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED - ? "bg-amber-100 text-amber-700" - : "bg-slate-100 text-slate-600"; - const statusTextKey = - service.status === MCP_REGISTRY_SERVER_STATUS.ACTIVE - ? "mcpTools.community.status.active" - : service.status === MCP_REGISTRY_SERVER_STATUS.DEPRECATED - ? "mcpTools.community.status.deprecated" - : "mcpTools.community.status.unknown"; - return ( <> {t("mcpTools.detail.status")} - - {t(statusTextKey)} - +
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx b/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx index 1f02620bc..9082cdaf7 100644 --- a/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx +++ b/frontend/app/[locale]/mcp-tools/components/McpCommunityToolbar.tsx @@ -1,17 +1,18 @@ import { Input, Select } from "antd"; import { useTranslation } from "react-i18next"; -import type { McpTagStat } from "@/types/mcpTools"; -import type { CommunityTransportFilter } from "@/hooks/mcpTools/useMcpCommunityBrowser"; +import { MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; +import type { McpTagStat, McpTransportFilter } from "@/types/mcpTools"; +import { FILTER_ALL } from "@/types/mcpTools"; interface McpCommunityToolbarProps { search: string; - transport: CommunityTransportFilter; + transport: McpTransportFilter; tag: string; tagStats: McpTagStat[]; page: number; resultCount: number; onSearchChange: (value: string) => void; - onTransportChange: (value: CommunityTransportFilter) => void; + onTransportChange: (value: McpTransportFilter) => void; onTagChange: (value: string) => void; } @@ -44,10 +45,22 @@ export default function McpCommunityToolbar({ onChange={onTransportChange} className="w-full" options={[ - { value: "all", label: t("mcpTools.page.transportFilter.all") }, - { value: "http", label: t("mcpTools.serverType.http") }, - { value: "sse", label: t("mcpTools.serverType.sse") }, - { value: "container", label: t("mcpTools.serverType.container") }, + { + value: FILTER_ALL, + label: t("mcpTools.page.transportFilter.all"), + }, + { + value: MCP_TRANSPORT_TYPE.HTTP, + label: t("mcpTools.serverType.http"), + }, + { + value: MCP_TRANSPORT_TYPE.SSE, + label: t("mcpTools.serverType.sse"), + }, + { + value: MCP_TRANSPORT_TYPE.CONTAINER, + label: t("mcpTools.serverType.container"), + }, ]} /> { - if (source === MCP_TAB.LOCAL) return "mcpTools.source.local"; - if (source === MCP_TAB.COMMUNITY) return "mcpTools.source.community"; - return "mcpTools.source.registry"; -}; - -const transportLabelKey = (transportType: McpServiceItem["transportType"]) => { - if (transportType === MCP_TRANSPORT_TYPE.HTTP) - return "mcpTools.serverType.http"; - if (transportType === MCP_TRANSPORT_TYPE.SSE) - return "mcpTools.serverType.sse"; - return "mcpTools.serverType.container"; -}; - export default function McpServiceCard({ service, onSelect, @@ -72,10 +55,10 @@ export default function McpServiceCard({
    - {t(sourceLabelKey(service.source))} + {t(getSourceLabelKey(service.source))} - {t(transportLabelKey(service.transportType))} + {t(getTransportLabelKey(service.transportType))} {service.tags.map((tag) => ( boolean; } -const resolveHealthStatusLabel = ( - status: McpHealthStatus, - t: (key: string) => string -): string => { - if (status === MCP_HEALTH_STATUS.HEALTHY) return t("mcpTools.health.healthy"); - if (status === MCP_HEALTH_STATUS.UNHEALTHY) - return t("mcpTools.health.unhealthy"); - return t("mcpTools.health.unchecked"); -}; - -const resolveContainerStatusLabel = ( - status: McpContainerStatus | undefined, - t: (key: string) => string -): string => { - if (status === MCP_CONTAINER_STATUS.RUNNING) - return t("mcpTools.containerStatus.running"); - if (status === MCP_CONTAINER_STATUS.STOPPED) - return t("mcpTools.containerStatus.stopped"); - return t("mcpTools.containerStatus.unknown"); -}; - -const resolveSourceLabel = ( - source: McpServiceItem["source"], - t: (key: string) => string -): string => { - if (source === MCP_TAB.LOCAL) return t("mcpTools.source.local"); - if (source === MCP_TAB.COMMUNITY) return t("mcpTools.source.community"); - return t("mcpTools.source.registry"); -}; - -const resolveTransportLabel = ( - transportType: McpServiceItem["transportType"], - t: (key: string) => string -): string => { - if (transportType === MCP_TRANSPORT_TYPE.HTTP) - return t("mcpTools.serverType.http"); - if (transportType === MCP_TRANSPORT_TYPE.SSE) - return t("mcpTools.serverType.sse"); - return t("mcpTools.serverType.container"); -}; - export default function McpServiceDetailModal({ selectedService, onClose, @@ -78,7 +32,7 @@ export default function McpServiceDetailModal({ }: McpServiceDetailModalProps) { const { modal } = App.useApp(); const { t } = useTranslation("common"); - const translate = (key: string) => String(t(key)); + const rules = useMcpFormRules(); const [form] = Form.useForm(); const [logsOpen, setLogsOpen] = useState(false); const [showServerJson, setShowServerJson] = useState(false); @@ -174,18 +128,7 @@ export default function McpServiceDetailModal({ label={t("mcpTools.detail.name")} name="name" className="mb-0 text-sm text-slate-500" - rules={[ - { - required: true, - whitespace: true, - message: t("mcpTools.add.validate.nameRequired"), - }, - { - type: "string", - max: 100, - message: t("mcpTools.add.validate.nameMaxLength"), - }, - ]} + rules={rules.name} > { - detail.setDraft({ ...draft, description: event.target.value }); + detail.setDraft({ + ...draft, + description: event.target.value, + }); form.setFieldValue("description", event.target.value); }} autoSize={{ minRows: 1, maxRows: 24 }} @@ -224,25 +164,7 @@ export default function McpServiceDetailModal({ label={t("mcpTools.detail.serverUrl")} name="serverUrl" className="mb-0 text-sm text-slate-500" - rules={[ - { - validator: async (_rule, value) => { - const text = String(value || "").trim(); - if (!text) - throw new Error( - t("mcpTools.add.validate.httpUrlRequired") - ); - if (text.length > 500) - throw new Error( - t("mcpTools.add.validate.httpUrlMaxLength") - ); - if (!isHttpUrl(text)) - throw new Error( - t("mcpTools.add.validate.httpUrlFormat") - ); - }, - }, - ]} + rules={rules.httpUrl} > {draft.version ? (
    - {resolveHealthStatusLabel(draft.healthStatus, translate)} + {t(getHealthStatusKey(draft.healthStatus))}
    @@ -406,40 +317,16 @@ export default function McpServiceDetailModal({
    -
    -

    - {t("mcpTools.detail.tags")} -

    -
    - {draft.tags.map((tag, index) => ( - - - {tag} - - - - ))} - detail.setTagInput(event.target.value)} - onPressEnter={detail.addTag} - onBlur={detail.addTag} - placeholder={t("mcpTools.detail.tagInputPlaceholder")} - className="w-40 rounded-full" - /> -
    -
    +
    diff --git a/frontend/app/[locale]/mcp-tools/components/McpServicesFilterBar.tsx b/frontend/app/[locale]/mcp-tools/components/McpServicesFilterBar.tsx new file mode 100644 index 000000000..56968411d --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpServicesFilterBar.tsx @@ -0,0 +1,90 @@ +import { Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { MCP_TAB, MCP_TRANSPORT_TYPE } from "@/const/mcpTools"; +import type { + McpSourceFilter, + McpTagStat, + McpTransportFilter, +} from "@/types/mcpTools"; +import { FILTER_ALL } from "@/types/mcpTools"; + +interface McpServicesFilterBarProps { + source: McpSourceFilter; + transport: McpTransportFilter; + tag: string; + tagStats: McpTagStat[]; + onSourceChange: (value: McpSourceFilter) => void; + onTransportChange: (value: McpTransportFilter) => void; + onTagChange: (value: string) => void; +} + +/** + * Three-column filter bar shown at the top of the MCP services listing page. + * Kept as a standalone component so the page file only wires state and the + * option lists (which are largely static) stay out of the page body. + */ +export default function McpServicesFilterBar({ + source, + transport, + tag, + tagStats, + onSourceChange, + onTransportChange, + onTagChange, +}: McpServicesFilterBarProps) { + const { t } = useTranslation("common"); + + return ( +
    + + { - const text = String(value || "").trim(); - if (!text) return; - if (text.length > 100) - throw new Error( - t("mcpTools.community.mine.versionMaxLength") - ); - if (!/^\d+(?:\.\d+){0,2}$/.test(text)) - throw new Error( - t("mcpTools.community.mine.versionFormat") - ); - }, - }, - ]} + rules={rules.version} > -
    - {t("mcpTools.detail.tags")} -
    - {editDraft.tags.map((tag, index) => ( - - - {tag} - - - - ))} - - updateDraft({ tagInput: event.target.value }) - } - onPressEnter={addDraftTag} - onBlur={addDraftTag} - placeholder={t("mcpTools.addModal.tagInputPlaceholder")} - className="w-40 rounded-full" - /> -
    -
    + updateDraft({ tagInput: value })} + onAddTag={addDraftTag} + onRemoveTag={removeDraftTag} + removeAriaKey="mcpTools.detail.removeTagAria" + /> ) : null} @@ -285,7 +231,8 @@ function MyCommunityItem({
    - {t("mcpTools.detail.serverType")}: {item.transportType} + {t("mcpTools.detail.serverType")}:{" "} + {t(getTransportLabelKey(item.transportType))}
    {t("mcpTools.detail.serverUrl")}: {item.serverUrl || "-"} diff --git a/frontend/app/[locale]/mcp-tools/components/shared/RegistryStatusBadge.tsx b/frontend/app/[locale]/mcp-tools/components/shared/RegistryStatusBadge.tsx new file mode 100644 index 000000000..91e795cc2 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/shared/RegistryStatusBadge.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; +import { getRegistryStatusBadge } from "@/lib/mcpTools"; + +interface RegistryStatusBadgeProps { + status: string | undefined; + /** + * Picks between `mcpTools.registry.status.*` and `mcpTools.community.status.*` + * translation keys. Defaults to `registry`. + */ + variant?: "registry" | "community"; + /** Extra classes, e.g. to tweak padding on small cards. */ + className?: string; +} + +/** + * Small colour-coded status pill shared by registry & community cards and + * their detail modals. Centralising it means every surface renders the same + * colours and translation keys. + */ +export default function RegistryStatusBadge({ + status, + variant = "registry", + className = "", +}: RegistryStatusBadgeProps) { + const { t } = useTranslation("common"); + const badge = getRegistryStatusBadge(status, variant); + return ( + + {t(badge.textKey)} + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/shared/TagEditor.tsx b/frontend/app/[locale]/mcp-tools/components/shared/TagEditor.tsx new file mode 100644 index 000000000..1f5724397 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/shared/TagEditor.tsx @@ -0,0 +1,70 @@ +import { Input, Tag } from "antd"; +import { useTranslation } from "react-i18next"; + +interface TagEditorProps { + /** Optional heading shown above the tag list. */ + title?: string; + tags: string[]; + tagInput: string; + onTagInputChange: (value: string) => void; + onAddTag: () => void; + onRemoveTag: (index: number) => void; + /** + * i18n key used for the remove-button `aria-label`. Defaults to the key used + * by the add-service flows; the detail flow overrides this so the label + * matches the surrounding copy. + */ + removeAriaKey?: string; + placeholderKey?: string; +} + +/** + * Reusable tag editor: renders the current tag chips (each with a little + * remove cross) plus an inline input that commits on Enter/blur. Every MCP + * form that accepts tags uses this component so they all behave identically. + */ +export default function TagEditor({ + title, + tags, + tagInput, + onTagInputChange, + onAddTag, + onRemoveTag, + removeAriaKey = "mcpTools.addModal.removeTagAria", + placeholderKey = "mcpTools.addModal.tagInputPlaceholder", +}: TagEditorProps) { + const { t } = useTranslation("common"); + return ( +
    + {title ? ( +

    + {title} +

    + ) : null} +
    + {tags.map((tag, index) => ( + + {tag} + + + ))} + onTagInputChange(event.target.value)} + onPressEnter={onAddTag} + onBlur={onAddTag} + placeholder={t(placeholderKey)} + className="w-40 rounded-full" + /> +
    +
    + ); +} diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index 24098e36e..b63ce6929 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Button, Input, Select } from "antd"; +import { Button, Input } from "antd"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import log from "@/lib/logger"; @@ -13,6 +13,7 @@ import AddMcpServiceModal from "./components/AddMcpServiceModal"; import MyCommunityMcpModal from "./components/MyCommunityMcpModal"; import McpServiceCard from "./components/McpServiceCard"; import McpServiceDetailModal from "./components/McpServiceDetailModal"; +import McpServicesFilterBar from "./components/McpServicesFilterBar"; export default function McpToolsPage() { const { t } = useTranslation("common"); @@ -101,48 +102,15 @@ export default function McpToolsPage() {
    -
    - list.updateFilter("transport", value)} - className="w-full" - options={[ - { value: "all", label: t("mcpTools.page.transportFilter.all") }, - { value: "http", label: t("mcpTools.serverType.http") }, - { value: "sse", label: t("mcpTools.serverType.sse") }, - { - value: "container", - label: t("mcpTools.serverType.container"), - }, - ]} - /> - + @@ -148,7 +145,7 @@ export default function AddMcpServiceLocalSection({ > @@ -160,13 +157,13 @@ export default function AddMcpServiceLocalSection({ >
    ) : ( -
    +
    addTag(tag || "")} onRemoveTag={removeTag} />
    -
    diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx index cfedf4760..4457eb23b 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceModal.tsx @@ -25,27 +25,37 @@ export default function AddMcpServiceModal({ if (!open) return null; + /** Fixed body height + inner scroll: avoids size jump on tab/transport change and prevents overflow. */ + const bodyFrame = "min(95vh, 900px)"; + return ( -
    -
    +
    +

    {t("mcpTools.addModal.title")}

    -
    +
    setTab(value as McpTab)} @@ -60,22 +70,24 @@ export default function AddMcpServiceModal({ value: MCP_TAB.COMMUNITY, }, ]} - className="h-9 rounded-full border border-slate-200 bg-slate-100 p-[2px] text-sm [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-full [&_.ant-segmented-item-label]:px-4 [&_.ant-segmented-item-label]:leading-[30px] [&_.ant-segmented-thumb]:rounded-full [&_.ant-segmented-thumb]:bg-white [&_.ant-segmented-thumb]:shadow-sm [&_.ant-segmented-thumb]:top-[2px] [&_.ant-segmented-thumb]:bottom-[2px]" + className="h-9 rounded-md border border-slate-200 bg-slate-100 p-[2px] text-sm [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-md [&_.ant-segmented-item-label]:px-4 [&_.ant-segmented-item-label]:leading-[30px] [&_.ant-segmented-thumb]:rounded-md [&_.ant-segmented-thumb]:bg-white [&_.ant-segmented-thumb]:shadow-sm [&_.ant-segmented-thumb]:top-[2px] [&_.ant-segmented-thumb]:bottom-[2px]" />
    - - - +
    + + + +
    ); diff --git a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx index 5a6e7d211..b2cf5d357 100644 --- a/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx +++ b/frontend/app/[locale]/mcp-tools/components/AddMcpServiceRegistrySection.tsx @@ -116,7 +116,7 @@ function QuickAddPickerModal({ controller }: QuickAddPickerModalProps) { ) => { if (!fields.length) return null; return ( -
    +

    {t(titleKey)}

    {fields.map((field) => (