diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index 0cfc962ea..aab098984 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -16,6 +16,7 @@ from apps.model_managment_app import router as model_manager_router from apps.oauth_app import router as oauth_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.skill_app import router as skill_router from apps.tenant_config_app import router as tenant_config_router @@ -64,6 +65,7 @@ app.include_router(prompt_router) app.include_router(skill_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..cfb0c292a --- /dev/null +++ b/backend/apps/mcp_management_app.py @@ -0,0 +1,302 @@ +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from fastapi.responses import JSONResponse +from http import HTTPStatus + +from consts.exceptions import ( + MCPConnectionError, + McpNotFoundError, + McpValidationError, + UnauthorizedError, +) +from consts.model import ( + RegistryListQuery, + CommunityListRequest, + CommunityPublishRequest, + CommunityUpdateRequest, +) +from services.mcp_management_service import ( + list_community_mcp_services, + list_community_mcp_tag_stats, + list_my_community_mcp_services, + list_registry_mcp_services, + publish_community_mcp_service, + update_community_mcp_service, + delete_community_mcp_service, +) +from utils.auth_utils import get_current_user_info + +router = APIRouter(prefix="/mcp-tools") +logger = logging.getLogger("mcp_management_app") + + +# --------------------------------------------------------------------------- +# Registry Endpoints (MCP Registry - external service) +# --------------------------------------------------------------------------- + +@router.get("/registry/list") +async def list_registry_mcp_services_api( + query: RegistryListQuery = Depends(), + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + """ + List MCP services from the official MCP Registry. + """ + try: + get_current_user_info(authorization, http_request) + + data = await list_registry_mcp_services( + search=query.search, + include_deleted=query.include_deleted, + updated_since=query.updated_since, + version=query.version, + cursor=query.cursor, + limit=query.limit, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content=data, + ) + except UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc), + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to list MCP registry services: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to list MCP registry services" + ) + + +# --------------------------------------------------------------------------- +# Community Endpoints +# --------------------------------------------------------------------------- + +@router.get("/community/list") +async def list_community_mcp_services_api( + query: CommunityListRequest = Depends(), + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + """ + List public community MCP services. + """ + try: + get_current_user_info(authorization, http_request) + data = await list_community_mcp_services( + search=query.search, + tag=query.tag, + transport_type=query.transport_type, + cursor=query.cursor, + limit=query.limit, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": data}, + ) + except UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc), + ) + 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.get("/community/tags/stats") +async def list_community_mcp_tag_stats_api( + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + """ + Get community MCP tag statistics. + """ + try: + get_current_user_info(authorization, http_request) + stats = list_community_mcp_tag_stats() + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": stats}, + ) + except UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc), + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to list community MCP tag stats: {exc}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to list community MCP tag stats" + ) + + +@router.post("/community/publish") +async def publish_community_mcp_service_api( + payload: CommunityPublishRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + """ + Publish a local MCP service to the community. + """ + 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, + name=payload.name, + description=payload.description, + version=payload.version, + tags=payload.tags, + mcp_server=payload.mcp_server, + config_json=payload.config_json, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": {"community_id": community_id}}, + ) + except McpNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except McpValidationError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + except UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + 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, +): + """ + Update a community MCP service. + """ + 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, + description=payload.description, + tags=payload.tags, + version=payload.version, + registry_json=payload.registry_json, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success"}, + ) + except McpNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except McpValidationError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + except UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + 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, +): + """ + Delete a community MCP service. + """ + 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 McpNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + 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, +): + """ + List MCP services published by the current user to the community. + """ + 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 UnauthorizedError as exc: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc), + ) + 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" + ) diff --git a/backend/apps/remote_mcp_app.py b/backend/apps/remote_mcp_app.py index 0dd6127fd..7fa1b902b 100644 --- a/backend/apps/remote_mcp_app.py +++ b/backend/apps/remote_mcp_app.py @@ -6,12 +6,27 @@ from fastapi.responses import JSONResponse, StreamingResponse from http import HTTPStatus -from consts.const import NEXENT_MCP_DOCKER_IMAGE, ENABLE_UPLOAD_IMAGE -from consts.exceptions import MCPConnectionError, MCPNameIllegal, MCPContainerError -from consts.model import MCPConfigRequest, MCPUpdateRequest +from consts.const import ENABLE_UPLOAD_IMAGE +from consts.exceptions import ( + MCPConnectionError, + MCPNameIllegal, + MCPContainerError, + McpNotFoundError, + McpValidationError, + McpNameConflictError, + McpPortConflictError, +) +from consts.model import ( + MCPConfigRequest, + AddMcpServiceRequest, + AddContainerMcpServiceRequest, + UpdateMcpServiceRequest, + EnableMcpServiceRequest, + DisableMcpServiceRequest, + HealthcheckMcpServiceRequest, + ListMcpServicesQuery, +) from services.remote_mcp_service import ( - add_remote_mcp_server_list, - delete_remote_mcp_server_list, get_remote_mcp_server_list, check_mcp_health_and_update_db, delete_mcp_by_container_id, @@ -19,8 +34,16 @@ update_remote_mcp_server_list, attach_mcp_container_permissions, get_mcp_record_by_id, + list_mcp_service_tools_by_id, + add_mcp_service, + add_container_mcp_service, + update_mcp_service, + update_mcp_service_enabled, + delete_mcp_service, + check_mcp_service_health, + check_container_port_conflict, + suggest_container_port, ) -from database.remote_mcp_db import check_mcp_name_exists from services.tool_configuration_service import get_tool_from_remote_mcp_server from services.mcp_container_service import MCPContainerManager from utils.auth_utils import get_current_user_info @@ -29,454 +52,385 @@ logger = logging.getLogger("remote_mcp_app") -@router.post("/tools") -async def get_tools_from_remote_mcp( - service_name: str, - mcp_url: str, +# --------------------------------------------------------------------------- +# Tools Endpoint +# --------------------------------------------------------------------------- + +@router.get("/tools") +async def get_tools_from_mcp( + mcp_id: int = Query(..., description="MCP service ID"), authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Used to list tool information from the remote MCP server """ + """ + Get tools from MCP server by MCP ID. + """ try: - _, tenant_id, _ = get_current_user_info( - authorization, http_request) - tools_info = await get_tool_from_remote_mcp_server( - mcp_server_name=service_name, - remote_mcp_server=mcp_url, - tenant_id=tenant_id + _, tenant_id, _ = get_current_user_info(authorization, http_request) + + tools_info = await list_mcp_service_tools_by_id( + tenant_id=tenant_id, + mcp_id=mcp_id, ) + return JSONResponse( status_code=HTTPStatus.OK, content={ - "tools": [tool.__dict__ for tool in tools_info], "status": "success"} + "tools": [t.model_dump() if hasattr(t, 'model_dump') else t for t in tools_info], + "status": "success" + } ) + except McpNotFoundError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except MCPConnectionError as e: - logger.error(f"Failed to get tools from remote MCP server: {e}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed") + logger.error(f"Failed to get tools from MCP server: {e}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed" + ) except Exception as e: - logger.error(f"get tools from remote MCP server failed, error: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to get tools from remote MCP server.") + logger.error(f"get tools from MCP server failed, error: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to get tools from MCP server." + ) +# --------------------------------------------------------------------------- +# Add Endpoints +# --------------------------------------------------------------------------- + @router.post("/add") -async def add_remote_proxies( - mcp_url: str, - service_name: str, - authorization_token: Optional[str] = Query( - None, description="Authorization token for MCP server authentication (e.g., Bearer token)"), - tenant_id: Optional[str] = Query( - None, description="Tenant ID for filtering (uses auth if not provided)"), +async def add_mcp_service_endpoint( + payload: AddMcpServiceRequest, authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Used to add a remote MCP server """ + """ + Add an MCP service. + Supports both remote MCP (URL-based) and local MCP (record-based). + """ try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id - effective_tenant_id = tenant_id or auth_tenant_id - await add_remote_mcp_server_list(tenant_id=effective_tenant_id, - user_id=user_id, - remote_mcp_server=mcp_url, - remote_mcp_server_name=service_name, - container_id=None, - authorization_token=authorization_token) + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + + await add_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=payload.name, + description=payload.description, + source=payload.source.value if hasattr(payload.source, 'value') else payload.source, + server_url=payload.server_url, + tags=payload.tags, + authorization_token=payload.authorization_token, + container_config=payload.container_config, + registry_json=payload.registry_json, + enabled=payload.enabled if payload.enabled is not None else False, + ) + return JSONResponse( status_code=HTTPStatus.OK, - content={"message": "Successfully added remote MCP proxy", - "status": "success"} + content={"message": "Successfully added MCP service", "status": "success"} ) except MCPNameIllegal as e: - logger.error(f"Failed to add remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.CONFLICT, - detail="MCP name already exists") + logger.error(f"Failed to add MCP service: {e}") + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="MCP name already exists") except MCPConnectionError as e: - logger.error(f"Failed to add remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed") + logger.error(f"Failed to add MCP service: {e}") + raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="MCP connection failed") + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: - logger.error(f"Failed to add remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to add remote MCP proxy") + logger.error(f"Failed to add MCP service: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to add MCP service" + ) -@router.delete("") -async def delete_remote_proxies( - service_name: str, - mcp_url: str, - tenant_id: Optional[str] = Query( - None, description="Tenant ID for filtering (uses auth if not provided)"), +@router.post("/add-from-config") +async def add_container_mcp_service_endpoint( + payload: AddContainerMcpServiceRequest, authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Used to delete a remote MCP server """ + """ + Add a container-based MCP service with full configuration. + Endpoint path is kept as /add-from-config for backward compatibility. + """ try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id - effective_tenant_id = tenant_id or auth_tenant_id - await delete_remote_mcp_server_list(tenant_id=effective_tenant_id, - user_id=user_id, - remote_mcp_server=mcp_url, - remote_mcp_server_name=service_name) + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + + container_info = await add_container_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=payload.name, + description=payload.description, + source=payload.source.value if hasattr(payload.source, 'value') else payload.source, + tags=payload.tags, + authorization_token=payload.authorization_token, + registry_json=payload.registry_json, + port=payload.port, + mcp_config=payload.mcp_config, + ) + return JSONResponse( status_code=HTTPStatus.OK, - content={"message": "Successfully deleted remote MCP proxy", - "status": "success"} + content={ + "status": "success", + "data": { + "service_name": container_info.get("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 McpNameConflictError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except McpPortConflictError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except MCPContainerError as e: + logger.error(f"Failed to start MCP container service: {e}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="Docker service unavailable" + ) + except MCPConnectionError as e: + logger.error(f"MCP connection failed when adding container service: {e}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed" ) except Exception as e: - logger.error(f"Failed to delete remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to delete remote MCP proxy") + logger.error(f"Failed to add container MCP service: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to add container MCP service" + ) + +# --------------------------------------------------------------------------- +# Update Endpoint +# --------------------------------------------------------------------------- @router.put("/update") -async def update_remote_proxy( - update_data: MCPUpdateRequest, +async def update_mcp_service_endpoint( + payload: UpdateMcpServiceRequest, tenant_id: Optional[str] = Query( None, description="Tenant ID for filtering (uses auth if not provided)"), authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Used to update an existing remote MCP server """ + """Update an existing MCP service by ID.""" try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) effective_tenant_id = tenant_id or auth_tenant_id - await update_remote_mcp_server_list( - update_data=update_data, + + update_mcp_service( tenant_id=effective_tenant_id, - user_id=user_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, + tags=payload.tags, ) + return JSONResponse( status_code=HTTPStatus.OK, - content={"message": "Successfully updated remote MCP proxy", - "status": "success"} + content={"message": "Successfully updated MCP service", "status": "success"} ) - except MCPNameIllegal as e: - logger.error(f"Failed to update remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.CONFLICT, - detail=str(e)) - except MCPConnectionError as e: - logger.error(f"Failed to update remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail=str(e)) + + except McpNotFoundError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: - logger.error(f"Failed to update remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update remote MCP proxy") + logger.error(f"Failed to update MCP service: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service" + ) -@router.get("/list") -async def get_remote_proxies( +# --------------------------------------------------------------------------- +# Delete Endpoints +# --------------------------------------------------------------------------- + +@router.delete("/{mcp_id}") +async def delete_mcp_by_id( + mcp_id: int, tenant_id: Optional[str] = Query( None, description="Tenant ID for filtering (uses auth if not provided)"), authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Used to get the list of remote MCP servers """ + """Delete MCP service by ID.""" try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) effective_tenant_id = tenant_id or auth_tenant_id - remote_mcp_server_list = await get_remote_mcp_server_list( + + await delete_mcp_service( tenant_id=effective_tenant_id, user_id=user_id, - is_need_auth=False + mcp_id=mcp_id ) + return JSONResponse( status_code=HTTPStatus.OK, - content={"remote_mcp_server_list": remote_mcp_server_list, - "enable_upload_image": ENABLE_UPLOAD_IMAGE, - "status": "success"} + content={"message": "Successfully deleted MCP service", "status": "success"} ) + except McpNotFoundError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except Exception as e: - logger.error(f"Failed to get remote MCP proxy: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to get remote MCP proxy") + logger.error(f"Failed to delete MCP service: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete MCP service" + ) -@router.get("/record/{mcp_id}") -async def get_mcp_record( - mcp_id: int, +@router.delete("/container/{container_id}") +async def stop_mcp_container( + container_id: str, tenant_id: Optional[str] = Query( None, description="Tenant ID for filtering (uses auth if not provided)"), authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Get single MCP record by ID """ + """Stop and remove MCP container.""" try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) effective_tenant_id = tenant_id or auth_tenant_id - mcp_record = await get_mcp_record_by_id( - mcp_id=mcp_id, - tenant_id=effective_tenant_id - ) - - if not mcp_record: + try: + container_manager = MCPContainerManager() + except MCPContainerError as e: + logger.error(f"Failed to initialize container manager: {e}") raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="MCP record not found" + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="Docker service unavailable" ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={ - "mcp_name": mcp_record.get("mcp_name"), - "mcp_server": mcp_record.get("mcp_server"), - "authorization_token": mcp_record.get("authorization_token"), - "status": "success" - } - ) + success = await container_manager.stop_mcp_container(container_id) + + if success: + await delete_mcp_by_container_id( + tenant_id=effective_tenant_id, + user_id=user_id, + container_id=container_id, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Container and MCP service stopped successfully", + "status": "success", + }, + ) + else: + return JSONResponse( + status_code=HTTPStatus.NOT_FOUND, + content={"message": "Container not found", "status": "error"}, + ) except HTTPException: raise except Exception as e: - logger.error(f"Failed to get MCP record: {e}") + logger.error(f"Failed to stop container: {e}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to get MCP record" + detail=f"Failed to stop container: {str(e)}" ) -@router.get("/healthcheck") -async def check_mcp_health( - mcp_url: str, - service_name: str, - tenant_id: Optional[str] = Query( - None, description="Tenant ID for filtering (uses auth if not provided)"), - authorization: Optional[str] = Header(None), - http_request: Request = None -): - """ Used to check the health of the MCP server, the front end can call it, - and automatically update the database status """ - try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id - effective_tenant_id = tenant_id or auth_tenant_id - await check_mcp_health_and_update_db(mcp_url, service_name, effective_tenant_id, user_id) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"status": "success"} - ) - except MCPConnectionError as e: - logger.error(f"MCP connection failed: {e}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed") - except Exception as e: - logger.error(f"Failed to check the health of the MCP server: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to check the health of the MCP server") +# --------------------------------------------------------------------------- +# List Endpoints +# --------------------------------------------------------------------------- - -@router.post("/add-from-config") -async def add_mcp_from_config( - mcp_config: MCPConfigRequest, +@router.get("/list") +async def get_mcp_list( tenant_id: Optional[str] = Query( None, description="Tenant ID for filtering (uses auth if not provided)"), authorization: Optional[str] = Header(None), http_request: Request = None ): """ - Add MCP server by starting a container with command+args config. - Similar to Cursor's MCP server configuration format. - - Example request: - { - "mcpServers": { - "12306-mcp": { - "command": "npx", - "args": ["-y", "12306-mcp"], - "env": {"NODE_ENV": "production"} - } - } - } + Get list of MCP services. + Returns remote MCP list with full details including container_id, description, + enabled, source, update_time, tags, container_port, registry_json, config_json, + container_status, and authorization_token. """ try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) effective_tenant_id = tenant_id or auth_tenant_id - # Initialize container manager - try: - container_manager = MCPContainerManager() - except MCPContainerError as e: - logger.error(f"Failed to initialize container manager: {e}") - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="Docker service unavailable. Please ensure Docker socket is mounted." - ) - - results = [] - errors = [] - - for service_name, config in mcp_config.mcpServers.items(): - try: - command = config.command - args = config.args or [] - env_vars = config.env or {} - port = config.port - - if not command: - errors.append(f"{service_name}: command is required") - continue - - if port is None: - errors.append(f"{service_name}: port is required") - continue - - # Check if MCP service name already exists before starting container - if check_mcp_name_exists(mcp_name=service_name, tenant_id=effective_tenant_id): - errors.append(f"{service_name}: MCP name already exists") - continue - - # Build full command to run inside nexent/nexent-mcp image - full_command = [ - "python", - "-m", - "mcp_proxy", - "--host", - "0.0.0.0", - "--port", - str(port), - "--transport", - "streamablehttp", - "--", - command, - *args, - ] - - # Start container - container_info = await container_manager.start_mcp_container( - service_name=service_name, - tenant_id=effective_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, - ) - - # Register to remote MCP server list - await add_remote_mcp_server_list( - tenant_id=effective_tenant_id, - user_id=user_id, - remote_mcp_server=container_info["mcp_url"], - remote_mcp_server_name=service_name, - container_id=container_info["container_id"], - ) - - results.append({ - "service_name": service_name, - "status": "success", - "mcp_url": container_info["mcp_url"], - "container_id": container_info["container_id"], - "container_name": container_info.get("container_name"), - "host_port": container_info.get("host_port") - }) - - except MCPContainerError as e: - logger.error( - f"Failed to start MCP container {service_name}: {e}") - error_str = str(e) - # Check if error is related to image not found - if "not found" in error_str.lower() or "404" in error_str: - errors.append( - f"{service_name}: Image not found - MCP service startup image is missing") - else: - errors.append(f"{service_name}: {error_str}") - except Exception as e: - logger.error( - f"Unexpected error adding MCP {service_name}: {e}") - errors.append(f"{service_name}: {str(e)}") - - if errors and not results: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"All MCP servers failed: {errors}" - ) + remote_mcp_list = await get_remote_mcp_server_list( + tenant_id=effective_tenant_id, + user_id=user_id, + is_need_auth=True + ) return JSONResponse( status_code=HTTPStatus.OK, content={ - "message": "MCP servers processed", - "results": results, - "errors": errors if errors else None, + "remote_mcp_server_list": remote_mcp_list, + "enable_upload_image": ENABLE_UPLOAD_IMAGE, "status": "success" } ) - - except HTTPException: - raise except Exception as e: - logger.error(f"Failed to add MCP from config: {e}") + logger.error(f"Failed to get MCP list: {e}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to add MCP servers: {str(e)}" + detail="Failed to get MCP list" ) -@router.delete("/container/{container_id}") -async def stop_mcp_container( - container_id: str, +@router.get("/record/{mcp_id}") +async def get_mcp_record( + mcp_id: int, tenant_id: Optional[str] = Query( None, description="Tenant ID for filtering (uses auth if not provided)"), authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Stop and remove MCP container """ + """Get single MCP record by ID.""" try: - user_id, auth_tenant_id, _ = get_current_user_info( - authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) effective_tenant_id = tenant_id or auth_tenant_id - try: - container_manager = MCPContainerManager() - except MCPContainerError as e: - logger.error(f"Failed to initialize container manager: {e}") - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="Docker service unavailable" - ) - - success = await container_manager.stop_mcp_container(container_id) + mcp_record = await get_mcp_record_by_id( + mcp_id=mcp_id, + tenant_id=effective_tenant_id + ) - if success: - # Soft delete the corresponding MCP record (if any) by container ID - await delete_mcp_by_container_id( - tenant_id=effective_tenant_id, - user_id=user_id, - container_id=container_id, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={ - "message": "Container and MCP service stopped successfully", - "status": "success", - }, - ) - else: - return JSONResponse( + if not mcp_record: + raise HTTPException( status_code=HTTPStatus.NOT_FOUND, - content={"message": "Container not found", "status": "error"}, + detail="MCP record not found" ) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "mcp_name": mcp_record.get("mcp_name"), + "mcp_server": mcp_record.get("mcp_server"), + "authorization_token": mcp_record.get("authorization_token"), + "status": "success" + } + ) except HTTPException: raise except Exception as e: - logger.error(f"Failed to stop container: {e}") + logger.error(f"Failed to get MCP record: {e}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to stop container: {str(e)}" + detail="Failed to get MCP record" ) @@ -487,11 +441,10 @@ async def list_mcp_containers( authorization: Optional[str] = Header(None), http_request: Request = None ): - """ List all MCP containers for the current tenant """ + """List all MCP containers for the current tenant.""" try: user_id, auth_tenant_id, _ = get_current_user_info( authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id try: @@ -539,11 +492,10 @@ async def get_container_logs( authorization: Optional[str] = Header(None), http_request: Request = None ): - """ Get logs from MCP container via SSE stream """ + """Get logs from MCP container via SSE stream.""" try: user_id, auth_tenant_id, _ = get_current_user_info( authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id try: @@ -556,12 +508,11 @@ async def get_container_logs( ) async def generate_log_stream(): - """Generate SSE stream of container logs""" + """Generate SSE stream of container logs.""" try: async for log_line in container_manager.stream_container_logs( container_id, tail=tail, follow=follow ): - # Format as SSE: data: {json}\n\n payload = json.dumps( {"logs": log_line, "status": "success"}, ensure_ascii=False @@ -597,7 +548,185 @@ async def generate_log_stream(): ) -# Conditionally add upload-image route based on ENABLE_UPLOAD_IMAGE setting +@router.get("/healthcheck") +async def check_mcp_health( + mcp_id: int = Query(..., description="MCP service ID"), + authorization: Optional[str] = Header(None), + http_request: Request = None +): + """Check MCP service health by ID.""" + 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, + mcp_id=mcp_id, + ) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": {"health_status": health_status}} + ) + except McpNotFoundError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except MCPConnectionError as e: + logger.error(f"MCP connection failed: {e}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail=str(e) or "MCP connection failed" + ) + except Exception as e: + logger.error(f"Failed to check MCP health: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check MCP health" + ) + + +# --------------------------------------------------------------------------- +# Port Management Endpoints +# --------------------------------------------------------------------------- + +@router.get("/port/check") +async def check_mcp_port( + port: int = Query(..., ge=1, le=65535), + authorization: Optional[str] = Header(None), + http_request: Request = None +): + """Check if a port is available for MCP container.""" + try: + get_current_user_info(authorization, http_request) + available = check_container_port_conflict(port=port) + no_cache_headers = { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": {"available": available}}, + headers=no_cache_headers + ) + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Failed to check MCP port: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check MCP port" + ) + + +@router.get("/port/suggest") +async def suggest_mcp_port( + authorization: Optional[str] = Header(None), + http_request: Request = None +): + """Suggest an available port for MCP container.""" + try: + get_current_user_info(authorization, http_request) + port = suggest_container_port() + return JSONResponse( + status_code=HTTPStatus.OK, + content={"status": "success", "data": {"port": port}} + ) + except McpPortConflictError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except Exception as e: + logger.error(f"Failed to suggest MCP port: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to suggest MCP port" + ) + + +# --------------------------------------------------------------------------- +# Enable/Disable Endpoints +# --------------------------------------------------------------------------- + +@router.post("/enable") +async def enable_mcp_service( + payload: EnableMcpServiceRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None +): + """Enable an MCP service by ID.""" + 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 McpNotFoundError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except McpNameConflictError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except McpPortConflictError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except MCPConnectionError as e: + logger.error(f"MCP connection failed while enabling service: {e}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail="MCP connection failed" + ) + except Exception as e: + logger.error(f"Failed to enable MCP service: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service status" + ) + + +@router.post("/disable") +async def disable_mcp_service( + payload: DisableMcpServiceRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None +): + """Disable an MCP service by ID.""" + 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 McpNotFoundError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except McpValidationError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Failed to disable MCP service: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update MCP service status" + ) + + +# --------------------------------------------------------------------------- +# Image Upload Endpoint +# --------------------------------------------------------------------------- + if ENABLE_UPLOAD_IMAGE: @router.post("/upload-image") async def upload_mcp_image( @@ -621,13 +750,10 @@ async def upload_mcp_image( try: user_id, auth_tenant_id, _ = get_current_user_info( authorization, http_request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id - # Read file content content = await file.read() - # Call service layer to handle the business logic result = await upload_and_start_mcp_image( tenant_id=effective_tenant_id, user_id=user_id, diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 9481ebab2..52dd8d50a 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -118,6 +118,26 @@ class MCPNameIllegal(Exception): pass +class McpNotFoundError(Exception): + """Raised when MCP resource is not found.""" + pass + + +class McpValidationError(Exception): + """Raised when MCP payload or runtime data is invalid.""" + pass + + +class McpNameConflictError(Exception): + """Raised when MCP name conflicts with an existing enabled service.""" + pass + + +class McpPortConflictError(Exception): + """Raised when an MCP container port conflicts with an existing service or runtime port.""" + pass + + class NoInviteCodeException(Exception): """Raised when invite code is not found.""" diff --git a/backend/consts/model.py b/backend/consts/model.py index 7cea3fdb5..ce5463158 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Optional, Any, List, Dict -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, EmailStr, field_validator from nexent.core.agents.agent_model import ToolConfig @@ -954,3 +954,190 @@ class SkillCreateInteractiveRequest(BaseModel): existing_skill: Optional[Dict[str, Any]] = None complexity: Optional[str] = "simple" language: Optional[str] = "zh" + + +# --------------------------------------------------------------------------- +# MCP Management Data Models +# --------------------------------------------------------------------------- + +class MCPSourceType(str, Enum): + """MCP source type enumeration""" + LOCAL = "local" + MCP_REGISTRY = "mcp_registry" + COMMUNITY = "community" + + +class AddMcpServiceRequest(BaseModel): + """Request model for adding an MCP service""" + name: str = Field(..., min_length=1, description="MCP service name") + server_url: str = Field(..., min_length=1, description="MCP server URL") + description: Optional[str] = Field(None, description="MCP service description") + source: MCPSourceType = Field(default=MCPSourceType.LOCAL, description="MCP source type") + tags: List[str] = Field(default_factory=list, description="MCP tags") + authorization_token: Optional[str] = Field(None, description="Authorization token for MCP server") + container_config: Optional[Dict[str, Any]] = Field(None, description="Container configuration") + registry_json: Optional[Dict[str, Any]] = Field(None, description="Registry metadata JSON") + enabled: Optional[bool] = Field(default=False, description="Whether the MCP is enabled after creation") + + @field_validator("name", "server_url", "description", "authorization_token", mode="before") + @classmethod + def _strip_text(cls, value: Any): + if isinstance(value, str): + return value.strip() + return value + + +class AddContainerMcpServiceRequest(BaseModel): + """Request model for adding a container-based MCP service""" + name: str = Field(..., min_length=1, description="MCP service name") + description: Optional[str] = Field(None, description="MCP service description") + source: MCPSourceType = Field(default=MCPSourceType.LOCAL, description="MCP source type") + tags: List[str] = Field(default_factory=list, description="MCP tags") + authorization_token: Optional[str] = Field(None, description="Authorization token for MCP server") + registry_json: Optional[Dict[str, Any]] = Field(None, description="Registry metadata JSON") + port: int = Field(..., ge=1, le=65535, description="Host port for the container") + mcp_config: MCPConfigRequest = Field(..., description="MCP server configuration") + + @field_validator("name", "description", "authorization_token", mode="before") + @classmethod + def _strip_text(cls, value: Any): + if isinstance(value, str): + return value.strip() + return value + + +class UpdateMcpServiceRequest(BaseModel): + """Request model for updating an MCP service""" + mcp_id: int = Field(..., gt=0, description="MCP record ID") + name: str = Field(..., min_length=1, description="New MCP service name") + description: Optional[str] = Field(None, description="MCP service description") + server_url: str = Field(..., min_length=1, description="New MCP server URL") + tags: List[str] = Field(default_factory=list, description="MCP tags") + authorization_token: Optional[str] = Field(None, description="Authorization token for MCP server") + + @field_validator("name", "server_url", "description", "authorization_token", mode="before") + @classmethod + def _strip_text(cls, value: Any): + if isinstance(value, str): + return value.strip() + return value + + +class EnableMcpServiceRequest(BaseModel): + """Request model for enabling an MCP service""" + mcp_id: int = Field(..., gt=0, description="MCP record ID to enable") + + +class DisableMcpServiceRequest(BaseModel): + """Request model for disabling an MCP service""" + mcp_id: int = Field(..., gt=0, description="MCP record ID to disable") + + +class HealthcheckMcpServiceRequest(BaseModel): + """Request model for checking MCP service health""" + mcp_id: int = Field(..., gt=0, description="MCP record ID to health check") + + +class ListMcpToolsRequest(BaseModel): + """Request model for listing MCP service tools""" + mcp_id: int = Field(..., gt=0, description="MCP record ID") + + +class PortConflictCheckRequest(BaseModel): + """Request model for checking port availability""" + port: int = Field(..., ge=1, le=65535, description="Port number to check") + + +class ListMcpServicesQuery(BaseModel): + """Query parameters for listing MCP services""" + tag: Optional[str] = Field(None, description="Filter by tag") + + @field_validator("tag", mode="before") + @classmethod + def _strip_tag(cls, value: Any): + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +class RegistryListQuery(BaseModel): + """Query parameters for listing MCP registry services""" + search: Optional[str] = Field(None, description="Search keyword") + include_deleted: bool = Field(default=False, description="Include deleted records") + updated_since: Optional[str] = Field(None, description="Filter by update time") + version: Optional[str] = Field(None, description="Filter by version") + cursor: Optional[str] = Field(None, description="Pagination cursor") + limit: int = Field(default=30, ge=1, le=100, description="Items per page") + + @field_validator("search", "updated_since", "version", "cursor", mode="before") + @classmethod + def _strip_text(cls, value: Any): + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +class CommunityListRequest(BaseModel): + """Request model for listing community MCP services""" + search: Optional[str] = Field(None, description="Search keyword") + tag: Optional[str] = Field(None, description="Filter by tag") + transport_type: Optional[str] = Field(None,description="Filter by transport: url or container") + cursor: Optional[str] = Field(None, description="Pagination cursor") + limit: int = Field(default=30, ge=1, le=100, description="Items per page") + + @field_validator("search", "tag", "cursor", "transport_type", mode="before") + @classmethod + def _strip_text(cls, value: Any): + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +class CommunityPublishRequest(BaseModel): + """Publish a local MCP to the community; optional fields override the snapshot.""" + + mcp_id: int = Field(..., gt=0, description="MCP record ID to publish") + name: Optional[str] = Field(None, description="Community display name override") + description: Optional[str] = Field(None, description="Description override") + version: Optional[str] = Field(None, description="Version override") + tags: Optional[List[str]] = Field(None, description="Tags override") + mcp_server: Optional[str] = Field(None, max_length=500, description="Remote MCP server URL override (URL / HTTP / SSE transports)") + config_json: Optional[Dict[str, Any]] = Field(None, description="Container MCP configuration JSON override") + + @field_validator("name", "description", "version", "mcp_server", mode="before") + @classmethod + def _strip_publish_optional_text(cls, value: Any): + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +class CommunityUpdateRequest(BaseModel): + """Request model for updating community MCP service""" + community_id: int = Field(..., gt=0, description="Community record ID") + name: Optional[str] = Field(default=None, min_length=1, description="New MCP service name") + description: Optional[str] = Field(None, description="MCP service description") + tags: List[str] = Field(default_factory=list, description="MCP tags") + version: Optional[str] = Field(None, description="MCP version") + registry_json: Optional[Dict[str, Any]] = Field(None, description="Registry metadata JSON") + config_json: Optional[Dict[str, Any]] = Field( + None, + description="Container MCP configuration JSON (omit to leave unchanged)", + ) + + @field_validator("name", "description", "version", mode="before") + @classmethod + def _strip_text(cls, value: Any): + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +class DeleteMcpServiceRequest(BaseModel): + """Request model for deleting an MCP service""" + mcp_id: int = Field(..., gt=0, description="MCP record ID to delete") diff --git a/backend/database/community_mcp_db.py b/backend/database/community_mcp_db.py new file mode 100644 index 000000000..92b78a4ed --- /dev/null +++ b/backend/database/community_mcp_db.py @@ -0,0 +1,181 @@ +import logging +from typing import Any, Dict, List + +from sqlalchemy import func, 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, + tag: 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 tag: + query = query.filter(McpCommunityRecord.tags.any(tag)) + + if search: + keyword = f"%{search}%" + query = query.filter( + or_( + McpCommunityRecord.mcp_name.ilike(keyword), + McpCommunityRecord.description.ilike(keyword), + func.array_to_string(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 get_mcp_community_tag_stats() -> List[Dict[str, Any]]: + with get_db_session() as session: + rows = ( + session.query( + func.unnest(McpCommunityRecord.tags).label("tag"), + func.count(McpCommunityRecord.community_id).label("count"), + ) + .filter( + McpCommunityRecord.delete_flag != "Y", + ) + .group_by("tag") + .order_by(func.count(McpCommunityRecord.community_id).desc(), "tag") + .all() + ) + return [{"tag": str(row.tag), "count": int(row.count)} for row in rows if row.tag] + + +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"] = 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] + +def get_mcp_community_tag_stats_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: + with get_db_session() as session: + rows = ( + session.query( + func.unnest(McpCommunityRecord.tags).label("tag"), + func.count(McpCommunityRecord.community_id).label("count"), + ) + .filter( + McpCommunityRecord.tenant_id == tenant_id, + McpCommunityRecord.delete_flag != "Y", + ) + .group_by("tag") + .order_by(func.count(McpCommunityRecord.community_id).desc(), "tag") + .all() + ) + return [{"tag": str(row.tag), "count": int(row.count)} for row in rows if row.tag] diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 947c0a812..b4090f784 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -1,5 +1,5 @@ from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, ForeignKeyConstraint, Integer, JSON, Numeric, PrimaryKeyConstraint, Sequence, String, Text, TIMESTAMP, UniqueConstraint, Index, Float -from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import DeclarativeBase from sqlalchemy.sql import func @@ -415,12 +415,47 @@ class McpRecord(TableBase): String(200), doc="Docker container ID for MCP service, None for non-containerized MCP", ) + container_port = Column( + Integer, + doc="Host port bound for containerized MCP service", + ) authorization_token = Column( String(500), doc="Authorization token for MCP server authentication (e.g., Bearer token)", default=None, ) + source = Column(String(30), doc="Source type: local/mcp_registry/community") + registry_json = Column(JSONB, doc="Full MCP registry server.json snapshot") + config_json = Column(JSON, doc="MCP config data") + enabled = Column(Boolean, default=True, doc="Enabled") + tags = Column(ARRAY(Text), doc="Tags") + description = Column(Text, doc="Description") + + +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/container") + config_json = Column(JSON, doc="Public-shareable MCP configuration JSON") + tags = Column(ARRAY(Text), doc="Tags") + description = Column(Text, doc="Description") class UserTenant(TableBase): """ diff --git a/backend/database/remote_mcp_db.py b/backend/database/remote_mcp_db.py index d535f9fba..39275de7b 100644 --- a/backend/database/remote_mcp_db.py +++ b/backend/database/remote_mcp_db.py @@ -15,16 +15,31 @@ def create_mcp_record(mcp_data: Dict[str, Any], tenant_id: str, user_id: str): :param tenant_id: Tenant ID :param user_id: User ID :return: Created MCP record + + Note: Only fields defined in the McpRecord model are inserted. + Fields like 'transport_type' and 'version' are not part of McpRecord + and will be ignored. """ + # Filter to only include fields that exist in the model + # McpRecord fields: mcp_id, tenant_id, user_id, mcp_name, mcp_server, status, + # container_id, container_port, authorization_token, source, registry_json, + # config_json, enabled, tags, description, create_time, update_time, created_by, updated_by, delete_flag + allowed_fields = { + 'mcp_name', 'mcp_server', 'status', 'container_id', 'container_port', + 'authorization_token', 'source', 'registry_json', 'config_json', + 'enabled', 'tags', 'description' + } + + filtered_data = {k: v for k, v in mcp_data.items() if k in allowed_fields and v is not None} + filtered_data.update({ + "tenant_id": tenant_id, + "user_id": user_id, + "created_by": user_id, + "updated_by": user_id, + "delete_flag": "N" + }) 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" - }) - new_mcp = McpRecord(**filter_property(mcp_data, McpRecord)) + new_mcp = McpRecord(**filtered_data) session.add(new_mcp) @@ -80,7 +95,7 @@ def update_mcp_status_by_name_and_url(mcp_name: str, mcp_server: str, tenant_id: ).update({"status": status, "updated_by": user_id}) -def get_mcp_records_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: +def get_mcp_records_by_tenant(tenant_id: str, tag: str | None = None) -> List[Dict[str, Any]]: """ Get all MCP records for a tenant @@ -88,14 +103,137 @@ def get_mcp_records_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: :return: List of MCP records """ with get_db_session() as session: - mcp_records = session.query(McpRecord).filter( + query = session.query(McpRecord).filter( McpRecord.tenant_id == tenant_id, McpRecord.delete_flag != 'Y' - ).order_by(McpRecord.create_time.desc()).all() + ) + + if tag: + query = query.filter(McpRecord.tags.any(tag)) + + mcp_records = query.order_by(McpRecord.create_time.desc()).all() return [as_dict(record) for record in mcp_records] +def get_mcp_records_by_container_port(container_port: int) -> List[Dict[str, Any]]: + """ + Get enabled MCP records that already use the given container port. + + The lookup is global. + """ + with get_db_session() as session: + query = session.query(McpRecord).filter( + McpRecord.container_port == container_port, + McpRecord.delete_flag != 'Y' + ) + + records = query.order_by(McpRecord.create_time.desc()).all() + return [as_dict(record) for record in 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, + source: 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": tags or [], + "source": source, + "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_container_fields_by_id( + *, + mcp_id: int, + tenant_id: str, + user_id: str, + container_id: str | None, + container_port: int | 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, + "container_port": container_port, + "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 @@ -187,6 +325,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 new file mode 100644 index 000000000..a62de250a --- /dev/null +++ b/backend/services/mcp_management_service.py @@ -0,0 +1,334 @@ +import logging +from datetime import datetime +from typing import Any, Dict, List +from urllib.parse import urlencode + +import aiohttp + +from consts.exceptions import ( + MCPConnectionError, + McpNotFoundError, + McpValidationError, +) +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, + get_mcp_community_tag_stats, + list_mcp_community_records_by_tenant, + update_mcp_community_record_by_id, +) +from database.remote_mcp_db import get_mcp_record_by_id_and_tenant + +logger = logging.getLogger("mcp_management_service") + +MCP_REGISTRY_BASE_URL = "https://registry.modelcontextprotocol.io/v0.1/servers" + + +# --------------------------------------------------------------------------- +# Community MCP Service Functions +# --------------------------------------------------------------------------- + +async def list_community_mcp_services( + *, + search: str | None = None, + tag: str | None = None, + transport_type: str | None = None, + cursor: str | None = None, + limit: int = 30, +) -> Dict[str, Any]: + """List public community MCP services. + + Args: + search: Search keyword + tag: Filter by tag + transport_type: Filter by transport (url or container) + cursor: Pagination cursor + limit: Items per page + + Returns: + Dictionary with count, nextCursor, and items + """ + db_result = get_mcp_community_records( + search=search, + tag=tag, + transport_type=transport_type, + cursor=cursor, + limit=limit, + ) + + raw_items = db_result.get("items", []) + items = [] + for item in raw_items: + items.append({ + "communityId": item.get("community_id"), + "name": item.get("mcp_name"), + "version": item.get("version"), + "description": item.get("description"), + "status": "active", + "createdAt": item.get("create_time"), + "updatedAt": item.get("update_time"), + "source": "community", + "transportType": item.get("transport_type"), + "serverUrl": item.get("mcp_server"), + "configJson": item.get("config_json") if isinstance(item.get("config_json"), dict) else None, + "registryJson": item.get("registry_json") if isinstance(item.get("registry_json"), dict) else None, + "tags": item.get("tags") or [], + }) + return { + "count": len(items), + "nextCursor": db_result.get("nextCursor"), + "items": items, + } + + +def list_community_mcp_tag_stats() -> List[Dict[str, Any]]: + """Get community MCP tag statistics. + + Args: + tenant_id: Tenant ID + + Returns: + List of tag statistics + """ + return get_mcp_community_tag_stats() + + +async def publish_community_mcp_service( + *, + tenant_id: str, + user_id: str, + mcp_id: int, + name: str | None = None, + description: str | None = None, + version: str | None = None, + tags: List[str] | None = None, + mcp_server: str | None = None, + config_json: Dict[str, Any] | None = None, +) -> int: + """Publish a local MCP service to the community. + + Optional ``name`` / ``description`` / ``version`` / ``tags`` / ``mcp_server`` / + ``config_json`` override the values copied from the local MCP row when creating + the community record. Omit an optional field (``None``) to keep the local MCP + value for that field. + + Args: + tenant_id: Tenant ID + user_id: User ID + mcp_id: MCP record ID to publish + name: Optional community display name override + description: Optional description override + version: Optional version override + tags: Optional tags override + mcp_server: Optional remote MCP URL override + config_json: Optional container config override + + Returns: + Community record ID + + Raises: + McpNotFoundError: If MCP record is not found + """ + source_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not source_record: + raise McpNotFoundError("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 + + final_name = name if name is not None else source_record.get("mcp_name") + final_description = description if description is not None else source_record.get("description") + final_version = version if version is not None else source_record.get("version") + final_tags = tags if tags is not None else source_record.get("tags") + final_mcp_server = ( + mcp_server if mcp_server is not None else source_record.get("mcp_server") + ) + final_config_json = ( + config_json if isinstance(config_json, dict) else source_config_json + ) + + # Remote MCP table may omit transport_type; community list still needs it for filters. + community_transport_type = "container" if final_config_json is not None else "url" + + community_id = create_mcp_community_record( + mcp_data={ + "mcp_name": final_name, + "mcp_server": final_mcp_server, + "version": final_version, + "registry_json": source_registry_json, + "transport_type": source_record.get("transport_type") or community_transport_type, + "config_json": final_config_json, + "tags": final_tags, + "description": final_description, + }, + 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: + """Update a community MCP service. + + Args: + tenant_id: Tenant ID + user_id: User ID + community_id: Community record ID + name: New MCP service name + description: MCP service description + tags: MCP tags + version: MCP version + registry_json: Registry metadata JSON + + Raises: + McpNotFoundError: If community MCP record is not found + """ + current = get_mcp_community_record_by_id_and_tenant(community_id=community_id, tenant_id=tenant_id) + if not current: + raise McpNotFoundError("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(existing_config_json, dict) else None + + 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=next_registry_json, + config_json=next_config_json, + ) + + +async def delete_community_mcp_service( + *, + tenant_id: str, + user_id: str, + community_id: int, +) -> None: + """Delete a community MCP service. + + Args: + tenant_id: Tenant ID + user_id: User ID + community_id: Community record ID + + Raises: + McpNotFoundError: If community MCP record is not found + """ + current = get_mcp_community_record_by_id_and_tenant(community_id=community_id, tenant_id=tenant_id) + if not current: + raise McpNotFoundError("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]: + """List MCP services published by the current user to the community. + + Args: + tenant_id: Tenant ID + + Returns: + Dictionary with count and items + """ + rows = list_mcp_community_records_by_tenant(tenant_id=tenant_id) + items = [] + for row in rows: + items.append({ + "communityId": row.get("community_id"), + "name": row.get("mcp_name"), + "version": row.get("version"), + "description": row.get("description"), + "status": "active", + "createdAt": row.get("create_time"), + "updatedAt": row.get("update_time"), + "source": "community", + "transportType": row.get("transport_type"), + "serverUrl": row.get("mcp_server"), + "configJson": row.get("config_json") if isinstance(row.get("config_json"), dict) else None, + "registryJson": row.get("registry_json") if isinstance(row.get("registry_json"), dict) else None, + "tags": row.get("tags") or [], + }) + return { + "count": len(items), + "items": items, + } + + +# --------------------------------------------------------------------------- +# Registry Functions +# --------------------------------------------------------------------------- + +async def list_registry_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]: + """List MCP services from the official MCP Registry. + + Args: + search: Search keyword + include_deleted: Include deleted records + updated_since: Filter by update time + version: Filter by version + cursor: Pagination cursor + limit: Items per page + + Returns: + Dictionary with servers and metadata + """ + 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 [] + metadata = payload.get("metadata") if isinstance(payload, dict) and isinstance(payload.get("metadata"), dict) else {} + + return { + "servers": raw_servers if isinstance(raw_servers, list) else [], + "metadata": metadata, + } diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index ab0f0b04f..a33c74f0e 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -1,62 +1,162 @@ import logging import os import tempfile +import asyncio +import socket +import random from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport, SSETransport -from consts.const import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ -from consts.exceptions import MCPConnectionError, MCPNameIllegal +from consts.const import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ, NEXENT_MCP_DOCKER_IMAGE +from consts.exceptions import ( + MCPConnectionError, + MCPNameIllegal, + MCPContainerError, + McpNotFoundError, + McpValidationError, + McpNameConflictError, + McpPortConflictError, +) +from consts.model import MCPConfigRequest from database.remote_mcp_db import ( create_mcp_record, - delete_mcp_record_by_name_and_url, delete_mcp_record_by_container_id, get_mcp_records_by_tenant, check_mcp_name_exists, + check_enabled_mcp_name_exists, update_mcp_status_by_name_and_url, update_mcp_record_by_name_and_url, + update_mcp_record_manage_fields_by_id, + update_mcp_record_enabled_by_id, + update_mcp_record_container_fields_by_id, + update_mcp_record_status_by_id, + delete_mcp_record_by_id, get_mcp_authorization_token_by_name_and_url, get_mcp_record_by_id_and_tenant, ) from database.user_tenant_db import get_user_tenant_by_user_id from services.mcp_container_service import MCPContainerManager +from services.tool_configuration_service import get_tool_from_remote_mcp_server logger = logging.getLogger("remote_mcp_service") +# --------------------------------------------------------------------------- +# Health Check +# --------------------------------------------------------------------------- + async def mcp_server_health(remote_mcp_server: str, authorization_token: str | None = None) -> bool: + """Check if an MCP server is healthy and reachable.""" try: - # Select transport based on URL ending url_stripped = remote_mcp_server.strip() headers = {"Authorization": authorization_token} if authorization_token else {} if url_stripped.endswith("/sse"): - transport = SSETransport( - url=url_stripped, - headers=headers - ) + transport = SSETransport(url=url_stripped, headers=headers) elif url_stripped.endswith("/mcp"): - transport = StreamableHttpTransport( - url=url_stripped, - headers=headers - ) + transport = StreamableHttpTransport(url=url_stripped, headers=headers) else: - # Default to StreamableHttpTransport for unrecognized formats - transport = StreamableHttpTransport( - url=url_stripped, - headers=headers - ) + transport = StreamableHttpTransport(url=url_stripped, headers=headers) client = Client(transport=transport) async with client: connected = client.is_connected() return connected except BaseException as e: - 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") + logger.error(f"Remote MCP server health check failed: {e}", exc_info=True) + 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) + + +# --------------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------------- + +def _is_container_record(record: dict | None) -> bool: + """Check if the MCP record is container-based. + + A record is considered container-based if it has: + - container_id (Docker container ID) + - config_json (container configuration) + """ + if not record: + return False + return record.get("container_id") is not None or record.get("config_json") is not None + + +# --------------------------------------------------------------------------- +# Port Management Functions +# --------------------------------------------------------------------------- + +def check_container_port_conflict_records(port: int) -> bool: + """Check if there are enabled MCP records that already use the given container port.""" + from database.remote_mcp_db import get_mcp_records_by_container_port + return not get_mcp_records_by_container_port(container_port=port) + + +def check_runtime_host_port_available(port: int) -> bool: + """Return True when the host port is not occupied by a listener.""" + probe_targets = [(socket.AF_INET, "127.0.0.1")] + if socket.has_ipv6: + probe_targets.append((socket.AF_INET6, "::1")) + + try: + host_infos = socket.getaddrinfo("host.docker.internal", port, socket.AF_UNSPEC, socket.SOCK_STREAM) + for family, _, _, _, sockaddr in host_infos: + probe_targets.append((family, sockaddr[0])) + except OSError: + pass + + for family, host in probe_targets: + try: + with socket.socket(family, socket.SOCK_STREAM) as probe_socket: + probe_socket.settimeout(0.2) + connect_result = probe_socket.connect_ex((host, port) if family == socket.AF_INET else (host, port, 0, 0)) + if connect_result == 0: + logger.info(f"Host port {port} is already in use on {host}") + return False + except OSError: + continue + + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as bind_probe: + if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + bind_probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + bind_probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) + bind_probe.bind(("0.0.0.0", port)) + bind_probe.listen(1) + return True + except OSError as exc: + logger.info(f"Host port {port} is already in use: {exc}") + return False + + +def check_container_port_conflict(*, port: int) -> bool: + """Check if a port is available for MCP container.""" + no_conflict_records = check_container_port_conflict_records(port=port) + runtime_available = check_runtime_host_port_available(port) + return no_conflict_records and runtime_available + + +def suggest_container_port() -> int: + """Suggest an available port for MCP container.""" + min_port = 2000 + max_port = 50000 + count = 0 + while count < 1000: + port = random.randint(min_port, max_port) + if check_container_port_conflict(port=port): + return port + count += 1 + raise McpPortConflictError("No available port found") +# --------------------------------------------------------------------------- +# Add Functions +# --------------------------------------------------------------------------- async def add_remote_mcp_server_list( tenant_id: str, @@ -66,18 +166,27 @@ async def add_remote_mcp_server_list( container_id: str | None = None, authorization_token: str | None = None, ): + """Add a remote MCP server to the list. - # check if MCP name already exists + Args: + tenant_id: Tenant ID + user_id: User ID + remote_mcp_server: MCP server URL + remote_mcp_server_name: MCP service name + container_id: Docker container ID (optional) + authorization_token: Authorization token (optional) + + Raises: + MCPNameIllegal: If MCP name already exists + MCPConnectionError: If MCP server is not reachable + """ if check_mcp_name_exists(mcp_name=remote_mcp_server_name, tenant_id=tenant_id): - logger.error( - f"MCP name already exists, tenant_id: {tenant_id}, remote_mcp_server_name: {remote_mcp_server_name}") + logger.error(f"MCP name already exists: {remote_mcp_server_name}") raise MCPNameIllegal("MCP name already exists") - # check if the address is available if not await mcp_server_health(remote_mcp_server=remote_mcp_server, authorization_token=authorization_token): raise MCPConnectionError("MCP connection failed") - # update the PG database record insert_mcp_data = { "mcp_name": remote_mcp_server_name, "mcp_server": remote_mcp_server, @@ -85,28 +194,194 @@ async def add_remote_mcp_server_list( "container_id": container_id, "authorization_token": authorization_token, } - create_mcp_record(mcp_data=insert_mcp_data, - tenant_id=tenant_id, user_id=user_id) + create_mcp_record(mcp_data=insert_mcp_data, tenant_id=tenant_id, user_id=user_id) + + +async def add_mcp_service( + *, + tenant_id: str, + user_id: str, + name: str, + description: str | None, + source: str, + server_url: str, + tags: list | None, + authorization_token: str | None, + container_config: dict | None, + registry_json: dict | None, + enabled: bool = False, + container_id: str | None = None, + container_port: int | None = None, +) -> None: + """Add an MCP service record. + + Args: + tenant_id: Tenant ID + user_id: User ID + name: MCP service name + description: MCP service description + source: Source type (local/mcp_registry/community) + server_url: MCP server URL + tags: MCP tags + authorization_token: Authorization token for MCP server + container_config: Container configuration + registry_json: Registry metadata JSON + enabled: Whether the MCP is enabled + container_id: Docker container ID + container_port: Container port + """ + status: bool | None = None + normalized_container_id = container_id if isinstance(container_id, str) and container_id else None + is_container = container_id is not None or container_config is not None + config_json = container_config if is_container and isinstance(container_config, dict) else None + if enabled: + if check_mcp_name_exists(mcp_name=name, tenant_id=tenant_id): + logger.error(f"MCP name already exists: {name}") + raise MCPNameIllegal("MCP name already exists") -async def delete_remote_mcp_server_list(tenant_id: str, - user_id: str, - remote_mcp_server: str, - remote_mcp_server_name: str): - # delete the record in the PG database - delete_mcp_record_by_name_and_url(mcp_name=remote_mcp_server_name, - mcp_server=remote_mcp_server, - tenant_id=tenant_id, - user_id=user_id) + if not await mcp_server_health(remote_mcp_server=server_url, authorization_token=authorization_token): + raise MCPConnectionError("MCP connection failed") + status = True -async def update_remote_mcp_server_list( - update_data, + create_mcp_record( + mcp_data={ + "mcp_name": name, + "mcp_server": server_url, + "status": status, + "container_id": normalized_container_id, + "container_port": container_port, + "authorization_token": authorization_token, + "source": source, + "registry_json": registry_json, + "enabled": enabled, + "tags": tags, + "description": description, + "config_json": config_json, + }, + tenant_id=tenant_id, + user_id=user_id, + ) + + +async def add_container_mcp_service( + *, tenant_id: str, user_id: str, -): + name: str, + description: str | None, + source: str, + tags: list | None, + authorization_token: str | None, + registry_json: dict | None, + port: int, + mcp_config: MCPConfigRequest, +) -> dict: + """Add a container-based MCP service. + + Args: + tenant_id: Tenant ID + user_id: User ID + name: MCP service name + description: MCP service description + source: Source type + tags: MCP tags + authorization_token: Authorization token + registry_json: Registry metadata JSON + port: Host port for the container + mcp_config: MCP server configuration + + Returns: + Container information dictionary """ - Update an existing remote MCP server record. + service_name = name + if check_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id): + raise McpNameConflictError("Enabled MCP name already exists") + + if not check_container_port_conflict(port=port): + raise McpPortConflictError(f"Port {port} is already in use") + + servers = mcp_config.mcpServers + if len(servers) != 1: + raise McpValidationError("Exactly one mcpServers entry is required") + + _, config = next(iter(servers.items())) + 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 + 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() + 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, + ) + logger.info(f"Started MCP container with info: {container_info}") + + container_config = mcp_config.model_dump(exclude_none=True) + + await add_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=service_name, + description=description, + source=source, + server_url=container_info.get("mcp_url"), + tags=tags, + authorization_token=auth_token, + container_config=container_config, + registry_json=registry_json, + enabled=True, + container_id=container_info.get("container_id"), + container_port=container_info.get("host_port"), + ) + except Exception as exc: + logger.warning(f"Failed to start container MCP service: {exc}") + raise + + return { + "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"), + } + + +# --------------------------------------------------------------------------- +# Update Functions +# --------------------------------------------------------------------------- + +async def update_remote_mcp_server_list(update_data, tenant_id: str, user_id: str) -> None: + """Update an existing remote MCP server record. Args: update_data: MCPUpdateRequest containing current and new values @@ -114,26 +389,18 @@ async def update_remote_mcp_server_list( user_id: User ID Raises: - MCPNameIllegal: If the new MCP name already exists (and is different from current) + MCPNameIllegal: If the new MCP name already exists MCPConnectionError: If the new MCP server URL is not accessible """ - # Check if the current record exists by verifying the name exists for this tenant if not check_mcp_name_exists(mcp_name=update_data.current_service_name, tenant_id=tenant_id): - logger.error( - f"MCP name does not exist, tenant_id: {tenant_id}, current_mcp_server_name: {update_data.current_service_name}") raise MCPNameIllegal("MCP name does not exist") - # If the new name is different from the current name, check if it already exists if update_data.new_service_name != update_data.current_service_name: if check_mcp_name_exists(mcp_name=update_data.new_service_name, tenant_id=tenant_id): - logger.error( - f"New MCP name already exists, tenant_id: {tenant_id}, new_mcp_server_name: {update_data.new_service_name}") raise MCPNameIllegal("New MCP name already exists") - # User authorization token authorization_token = update_data.new_authorization_token - # Check if the new server URL is accessible try: status = await mcp_server_health( remote_mcp_server=update_data.new_mcp_url, @@ -143,11 +410,8 @@ async def update_remote_mcp_server_list( status = False if not status: - logger.error( - f"New MCP server health check failed: {update_data.new_mcp_url}") raise MCPConnectionError("New MCP server connection failed") - # Update the database record update_mcp_record_by_name_and_url( update_data=update_data, tenant_id=tenant_id, @@ -156,7 +420,303 @@ async def update_remote_mcp_server_list( ) -async def get_remote_mcp_server_list(tenant_id: str, user_id: str | None = None, is_need_auth: bool = True) -> list[dict]: +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 | None, +) -> None: + """Update an MCP service record by ID. + + Args: + tenant_id: Tenant ID + user_id: User ID + mcp_id: MCP record ID + new_name: New MCP service name + description: MCP service description + server_url: New MCP server URL + authorization_token: Authorization token + tags: MCP tags + + Raises: + McpNotFoundError: If MCP record is not found + """ + current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not current_record: + raise McpNotFoundError("MCP record not found") + + is_container = _is_container_record(current_record) + config_json = None + if is_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, + source=(current_record.get("source") or "local"), + authorization_token=authorization_token, + config_json=config_json, + tags=tags, + ) + + +async def update_mcp_service_enabled( + *, + tenant_id: str, + user_id: str, + mcp_id: int, + enabled: bool, +) -> None: + """Enable or disable an MCP service. + + Args: + tenant_id: Tenant ID + user_id: User ID + mcp_id: MCP record ID + enabled: True to enable, False to disable + + Raises: + McpNotFoundError: If MCP record is not found + McpNameConflictError: If an enabled service with the same name exists + McpPortConflictError: If the container port is not available + MCPConnectionError: If MCP connection fails + """ + current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not current_record: + raise McpNotFoundError("MCP record not found") + + if enabled: + current_name = current_record.get("mcp_name") + if current_name: + records = get_mcp_records_by_tenant(tenant_id=tenant_id) + for record in records: + if int(record.get("mcp_id") or 0) == mcp_id: + continue + record_name = record.get("mcp_name") + is_enabled = bool(record.get("enabled")) + if is_enabled and record_name == current_name: + raise McpNameConflictError("An enabled service already uses this name") + + authorization_token = current_record.get("authorization_token") + + if _is_container_record(current_record): + if enabled: + port = current_record.get("container_port") + if port is None: + raise McpValidationError("Container port is missing, cannot rebuild container") + if not check_runtime_host_port_available(port): + raise McpPortConflictError(f"Port {port} is already in use") + + config_json = current_record.get("config_json") + if not isinstance(config_json, dict): + raise McpValidationError("Container configuration is missing, cannot rebuild container") + + try: + mcp_config = MCPConfigRequest(**config_json) + except Exception as exc: + raise McpValidationError(f"Invalid container configuration: {exc}") + + servers = mcp_config.mcpServers + if not servers or len(servers) != 1: + raise McpValidationError("Exactly one mcpServers entry is required") + _, config = next(iter(servers.items())) + command = config.command + if not command: + raise McpValidationError("command is required") + + env_vars = dict(config.env or {}) + if authorization_token: + env_vars["authorization_token"] = authorization_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=current_record.get("mcp_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, + ) + + next_server_url = container_info.get("mcp_url") + next_container_id = container_info.get("container_id") + next_container_port = container_info.get("host_port") or port + + health_ok = False + MCP_CONTAINER_HEALTH_CHECK_ATTEMPTS = 10 + 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, + authorization_token=authorization_token, + ) + except MCPConnectionError: + health_ok = False + if health_ok: + break + if attempt < MCP_CONTAINER_HEALTH_CHECK_ATTEMPTS - 1: + await asyncio.sleep(MCP_CONTAINER_HEALTH_CHECK_DELAY_SECONDS) + + if not health_ok: + if next_container_id: + try: + await MCPContainerManager().stop_mcp_container(next_container_id) + except Exception as exc: + logger.warning(f"Failed to stop unhealthy container {next_container_id}: {exc}") + update_mcp_record_container_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + container_id=None, + container_port=port, + mcp_server=next_server_url, + status=False, + ) + raise MCPConnectionError("MCP connection failed") + + update_mcp_record_container_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + container_id=next_container_id, + container_port=next_container_port, + mcp_server=next_server_url, + status=True, + ) + else: + current_container_id = current_record.get("container_id") + if current_container_id: + try: + manager = MCPContainerManager() + await manager.stop_mcp_container(current_container_id) + except Exception as exc: + logger.warning(f"Failed to stop container {current_container_id}: {exc}") + update_mcp_record_container_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + container_id=None, + container_port=current_record.get("container_port"), + mcp_server=current_record.get("mcp_server"), + status=None, + ) + elif enabled: + server_url = 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, + ) + + +# --------------------------------------------------------------------------- +# Delete Functions +# --------------------------------------------------------------------------- + +async def delete_mcp_service( + *, + tenant_id: str, + user_id: str, + mcp_id: int, +) -> None: + """Delete an MCP service by ID. + + Args: + tenant_id: Tenant ID + user_id: User ID + mcp_id: MCP record ID + + Raises: + McpNotFoundError: If MCP record is not found + """ + current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not current_record: + raise McpNotFoundError("MCP record not found") + container_id = current_record.get("container_id") + if container_id: + try: + manager = MCPContainerManager() + await manager.stop_mcp_container(container_id=container_id) + except Exception as exc: + logger.warning(f"Failed to stop container: {exc}, but continue to delete MCP record") + + delete_mcp_record_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + ) + + +async def delete_mcp_by_container_id(tenant_id: str, user_id: str, container_id: str) -> None: + """Soft delete MCP record associated with a specific container ID.""" + delete_mcp_record_by_container_id( + container_id=container_id, + tenant_id=tenant_id, + user_id=user_id, + ) + + +# --------------------------------------------------------------------------- +# List Functions +# --------------------------------------------------------------------------- + +async def get_remote_mcp_server_list( + tenant_id: str, + user_id: str | None = None, + is_need_auth: bool = True, +) -> list[dict]: + """Get list of remote MCP servers with full details. + + Args: + tenant_id: Tenant ID + user_id: User ID for permission checking + is_need_auth: Whether to include authorization tokens + + Returns: + List of MCP server records with all fields including container_id, description, + enabled, source, update_time, tags, container_port, registry_json, config_json, + container_status, and authorization_token + """ mcp_records = get_mcp_records_by_tenant(tenant_id=tenant_id) mcp_records_list = [] can_edit_all = False @@ -165,20 +725,56 @@ async def get_remote_mcp_server_list(tenant_id: str, user_id: str | None = None, user_role = str(user_tenant_record.get("user_role") or "").upper() can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + container_status_map = {} + try: + manager = MCPContainerManager() + for container in manager.list_mcp_containers(tenant_id=tenant_id): + container_id = container.get("container_id") + status = container.get("status") + 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 mcp_records: created_by = record.get("created_by") or record.get("user_id") if user_id is None: permission = PERMISSION_READ else: - permission = PERMISSION_EDIT if can_edit_all or str( - created_by) == str(user_id) else PERMISSION_READ + permission = PERMISSION_EDIT if can_edit_all or str(created_by) == str(user_id) else PERMISSION_READ + + config_json = record.get("config_json") + container_id = record.get("container_id") + + is_container = container_id is not None or config_json is not None + + container_status = None + if is_container: + if container_id: + container_status = container_status_map.get(container_id, "stopped") + else: + container_status = "stopped" record_dict = { "remote_mcp_server_name": record["mcp_name"], "remote_mcp_server": record["mcp_server"], - "status": record["status"], + "status": record.get("status"), "permission": permission, "mcp_id": record.get("mcp_id"), + "container_id": container_id, + "description": record.get("description"), + "enabled": record.get("enabled"), + "source": record.get("source"), + "update_time": record.get("update_time"), + "tags": record.get("tags") or [], + "container_port": record.get("container_port"), + "registry_json": record.get("registry_json"), + "config_json": record.get("config_json"), + "container_status": container_status, } if is_need_auth: record_dict["authorization_token"] = record.get("authorization_token") @@ -192,13 +788,15 @@ def attach_mcp_container_permissions( tenant_id: str, user_id: str | None = None, ) -> list[dict]: - """ - Attach permission (EDIT/READ) to each MCP container entry. + """Attach permission (EDIT/READ) to each MCP container entry. + + Args: + containers: List of container records + tenant_id: Tenant ID + user_id: User ID for permission checking - Rules: - - If user's role is in CAN_EDIT_ALL_USER_ROLES => EDIT for all containers - - Otherwise => EDIT only if the container is associated with an MCP record created by this user - - If association cannot be determined => default to READ + Returns: + List of containers with permission field added """ if not containers: return [] @@ -208,19 +806,17 @@ def attach_mcp_container_permissions( user_role = str(user_tenant_record.get("user_role") or "").upper() can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES - created_by_by_container_id: dict[str, str] = {} + created_by_by_container_id = {} try: for record in get_mcp_records_by_tenant(tenant_id=tenant_id) or []: cid = record.get("container_id") if not cid: continue - created_by_by_container_id[str(cid)] = str( - record.get("created_by") or record.get("user_id") or "" - ) + created_by_by_container_id[str(cid)] = str(record.get("created_by") or record.get("user_id") or "") except Exception as e: logger.warning(f"Failed to load MCP records for permission mapping: {e}") - enriched: list[dict] = [] + enriched = [] for container in containers: container_id = str(container.get("container_id") or "") created_by = created_by_by_container_id.get(container_id, "") @@ -228,24 +824,56 @@ def attach_mcp_container_permissions( if user_id is None: permission = PERMISSION_READ else: - permission = PERMISSION_EDIT if can_edit_all or ( - created_by and str(created_by) == str(user_id) - ) else PERMISSION_READ + permission = PERMISSION_EDIT if can_edit_all or (created_by and str(created_by) == str(user_id)) else PERMISSION_READ enriched.append({**container, "permission": permission}) return enriched -async def check_mcp_health_and_update_db(mcp_url, service_name, tenant_id, user_id): - # Get authorization token from database +async def get_mcp_record_by_id(mcp_id: int, tenant_id: str) -> dict | None: + """Get MCP record by ID. + + Args: + mcp_id: MCP record ID + tenant_id: Tenant ID + + Returns: + Dictionary containing mcp_name, mcp_server, and authorization_token, or None if not found + """ + mcp_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not mcp_record: + return None + + return { + "mcp_name": mcp_record.get("mcp_name"), + "mcp_server": mcp_record.get("mcp_server"), + "authorization_token": mcp_record.get("authorization_token"), + } + + +# --------------------------------------------------------------------------- +# Health Check Functions +# --------------------------------------------------------------------------- + +async def check_mcp_health_and_update_db(mcp_url, service_name, tenant_id, user_id) -> None: + """Check MCP health and update database status. + + Args: + mcp_url: MCP server URL + service_name: MCP service name + tenant_id: Tenant ID + user_id: User ID + + Raises: + MCPConnectionError: If MCP connection fails + """ authorization_token = get_mcp_authorization_token_by_name_and_url( mcp_name=service_name, mcp_server=mcp_url, tenant_id=tenant_id ) - # check the health of the MCP server try: status = await mcp_server_health( remote_mcp_server=mcp_url, @@ -253,53 +881,125 @@ async def check_mcp_health_and_update_db(mcp_url, service_name, tenant_id, user_ ) except BaseException: status = False - # update the status of the MCP server in the database + update_mcp_status_by_name_and_url( mcp_name=service_name, mcp_server=mcp_url, tenant_id=tenant_id, user_id=user_id, - status=status) + status=status + ) if not status: raise MCPConnectionError("MCP connection failed") -async def delete_mcp_by_container_id(tenant_id: str, user_id: str, container_id: str): - """ - Soft delete MCP record associated with a specific container ID. +async def check_mcp_service_health( + *, + tenant_id: str, + user_id: str, + mcp_id: int, +) -> str: + """Check MCP service health by ID. + + Args: + tenant_id: Tenant ID + user_id: User ID + mcp_id: MCP record ID - This is used when stopping a containerized MCP so that the MCP record and - its container are removed together. + Returns: + "healthy" if MCP is reachable + + Raises: + McpNotFoundError: If MCP record is not found + McpValidationError: If MCP server URL is empty + MCPConnectionError: If MCP connection fails """ - delete_mcp_record_by_container_id( - container_id=container_id, + record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not record: + raise McpNotFoundError("MCP record not found") + + server_url = record.get("mcp_server") + if not server_url: + raise McpValidationError("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 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}") + 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, tenant_id=tenant_id, user_id=user_id, + status=status, ) + if not status: + raise MCPConnectionError("MCP connection failed") -async def get_mcp_record_by_id(mcp_id: int, tenant_id: str) -> dict | None: - """ - Get MCP record by ID + return "healthy" + + +# --------------------------------------------------------------------------- +# Tool Functions +# --------------------------------------------------------------------------- + +async def list_mcp_service_tools_by_id(*, tenant_id: str, mcp_id: int) -> list[dict]: + """Get tools from an MCP service by ID. Args: - mcp_id: MCP record ID tenant_id: Tenant ID + mcp_id: MCP record ID Returns: - Dictionary containing mcp_name, mcp_server, and authorization_token, or None if not found + List of tool dictionaries + + Raises: + McpNotFoundError: If MCP record is not found + McpValidationError: If MCP record is missing connection fields + MCPConnectionError: If MCP connection fails """ - mcp_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) - if not mcp_record: - return None + record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) + if not record: + raise McpNotFoundError("MCP record not found") - return { - "mcp_name": mcp_record.get("mcp_name"), - "mcp_server": mcp_record.get("mcp_server"), - "authorization_token": mcp_record.get("authorization_token"), - } + service_name = record.get("mcp_name") + server_url = record.get("mcp_server") + if not service_name or not server_url: + raise McpValidationError("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] +# --------------------------------------------------------------------------- +# Image Upload Functions +# --------------------------------------------------------------------------- + async def upload_and_start_mcp_image( tenant_id: str, user_id: str, @@ -308,69 +1008,56 @@ async def upload_and_start_mcp_image( port: int, service_name: str | None = None, env_vars: str | None = None, -): - """ - Upload MCP Docker image and start container. +) -> dict: + """Upload MCP Docker image and start container. Args: - tenant_id: Tenant ID for isolation - user_id: User ID for isolation + tenant_id: Tenant ID + user_id: User ID file_content: Raw file content bytes filename: Original filename port: Host port to expose the MCP server on - service_name: Optional name for the MCP service (auto-generated if not provided) + service_name: Optional name for the MCP service env_vars: Optional environment variables as JSON string Returns: - Dictionary with service details including mcp_url, container_id, etc. + Dictionary with service details Raises: MCPContainerError: If container operations fail MCPNameIllegal: If service name already exists ValueError: If file validation fails """ - # Validate file type if not filename.lower().endswith('.tar'): raise ValueError("Only .tar files are allowed") - # Validate file size (limit to 1GB) file_size = len(file_content) - if file_size > 1024 * 1024 * 1024: # 1GB limit + if file_size > 1024 * 1024 * 1024: raise ValueError("File size exceeds 1GB limit") - # Parse environment variables parsed_env_vars = None if env_vars: + import json try: - import json parsed_env_vars = json.loads(env_vars) if not isinstance(parsed_env_vars, dict): raise ValueError("Environment variables must be a JSON object") except (json.JSONDecodeError, ValueError) as e: raise ValueError(f"Invalid environment variables format: {str(e)}") - # Generate service name if not provided final_service_name = service_name if not final_service_name: - # Remove .tar extension from filename final_service_name = os.path.splitext(filename)[0] - # Check if MCP service name already exists if check_mcp_name_exists(mcp_name=final_service_name, tenant_id=tenant_id): raise MCPNameIllegal("MCP service name already exists") - # Save file to temporary location (delete=False, manual cleanup) with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as temp_file: temp_file.write(file_content) temp_file_path = temp_file.name try: - # Initialize container manager container_manager = MCPContainerManager() - - # Start container from uploaded image - # Note: uploaded image should be a complete MCP server implementation - # that can be started directly without additional commands (uses image's CMD/ENTRYPOINT) container_info = await container_manager.start_mcp_container_from_tar( tar_file_path=temp_file_path, service_name=final_service_name, @@ -378,22 +1065,18 @@ async def upload_and_start_mcp_image( user_id=user_id, env_vars=parsed_env_vars, host_port=port, - full_command=None, # Uploaded image should contain the MCP server + full_command=None, ) finally: - # Manual cleanup of temporary file try: os.unlink(temp_file_path) except Exception as e: - logger.warning( - f"Failed to clean up temporary file {temp_file_path}: {e}") + logger.warning(f"Failed to clean up temporary file {temp_file_path}: {e}") - # Extract authorization_token from env_vars for database registration authorization_token = None if parsed_env_vars: authorization_token = parsed_env_vars.get("authorization_token") - # Register to remote MCP server list await add_remote_mcp_server_list( tenant_id=tenant_id, user_id=user_id, diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 88edfba17..17ab4aa92 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -263,8 +263,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/docker/sql/v2.0.2_0411_add_mcp_community_record_t.sql b/docker/sql/v2.0.2_0411_add_mcp_community_record_t.sql new file mode 100644 index 000000000..5e7a38e4a --- /dev/null +++ b/docker/sql/v2.0.2_0411_add_mcp_community_record_t.sql @@ -0,0 +1,83 @@ +-- 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 TEXT[], + description TEXT, + 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/container'; +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.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 INDEX IF NOT EXISTS idx_mcp_community_tags_gin + ON nexent.mcp_community_record_t USING GIN (tags); + +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/docker/sql/v2.0.2_0411_expand_mcp_record_t.sql b/docker/sql/v2.0.2_0411_expand_mcp_record_t.sql new file mode 100644 index 000000000..58abefa0f --- /dev/null +++ b/docker/sql/v2.0.2_0411_expand_mcp_record_t.sql @@ -0,0 +1,41 @@ +-- 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 source VARCHAR(30), + ADD COLUMN IF NOT EXISTS registry_json JSONB, + 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 TEXT, + ADD COLUMN IF NOT EXISTS container_port INTEGER; + +-- 2) Add comments for new columns +COMMENT ON COLUMN nexent.mcp_record_t.source IS 'Source type: local/mcp_registry'; +COMMENT ON COLUMN nexent.mcp_record_t.registry_json IS 'Full MCP registry server.json snapshot'; +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.container_port IS 'Host port bound for containerized MCP service'; + +-- 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); + +CREATE INDEX IF NOT EXISTS idx_mcp_record_t_tags_gin + ON nexent.mcp_record_t USING GIN (tags); + +COMMIT; diff --git a/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx index fc14a89af..aed71b517 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx @@ -16,6 +16,7 @@ import { App, Upload, Tabs, + Tag, } from "antd"; import { Trash, @@ -104,6 +105,7 @@ export default function McpConfigModal({ const [containerPort, setContainerPort] = useState( undefined ); + const [containerServiceName, setContainerServiceName] = useState(""); const [logsModalVisible, setLogsModalVisible] = useState(false); const [currentContainerId, setCurrentContainerId] = useState(""); @@ -306,8 +308,7 @@ export default function McpConfigModal({ setUpdatingServer(true); const result = await handleUpdateServer( - editingServer.service_name, - editingServer.mcp_url, + editingServer.mcp_id, name.trim(), url.trim(), authorizationToken @@ -347,12 +348,13 @@ export default function McpConfigModal({ } setAddingContainer(true); - const result = await handleAddContainer(config, containerPort); + const result = await handleAddContainer(config, containerPort, containerServiceName.trim() || undefined); if (!result.success) { message.error(result.messageKey ? t(result.messageKey) : (result.message || t("mcpConfig.message.addContainerFailed"))); } else { setContainerConfigJson(""); setContainerPort(undefined); + setContainerServiceName(""); message.success(result.messageKey ? t(result.messageKey) : t("mcpService.message.addContainerSuccess")); } setAddingContainer(false); @@ -561,9 +563,28 @@ export default function McpConfigModal({ title: t("mcpConfig.serverList.column.url"), dataIndex: "mcp_url", key: "mcp_url", - width: "40%", + width: "30%", ellipsis: true, }, + { + title: t("mcpConfig.serverList.column.enabled"), + key: "enabled", + width: "10%", + render: (_: any, record: any) => { + const isEnabled = record.enabled; + return isEnabled ? ( + + {t("mcpConfig.serverList.enabled.yes")} + + ) : ( + + + {t("mcpConfig.serverList.enabled.no")} + + + ); + }, + }, { title: t("mcpConfig.serverList.column.action"), key: "action", @@ -831,7 +852,7 @@ export default function McpConfigModal({ children: ( - +
+ {t("mcpConfig.addContainer.serviceName")}: + + setContainerServiceName(e.target.value)} + style={{ width: 150 }} + maxLength={20} + disabled={actionsLocked} + /> + {t("mcpConfig.addContainer.port")}: @@ -1226,7 +1260,6 @@ export default function McpConfigModal({ size="small" pagination={false} locale={{ emptyText: t("mcpConfig.serverList.empty") }} - scroll={{ y: 300 }} style={{ width: "100%" }} />
@@ -1253,7 +1286,6 @@ export default function McpConfigModal({ size="small" pagination={false} locale={{ emptyText: t("mcpConfig.containerList.empty") }} - scroll={{ y: 300 }} style={{ width: "100%" }} /> @@ -1277,7 +1309,6 @@ export default function McpConfigModal({ size="small" pagination={false} locale={{ emptyText: t("mcpConfig.openapiService.list.empty") }} - scroll={{ y: 300 }} style={{ width: "100%" }} /> 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..47ad2bf2e --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceCard.tsx @@ -0,0 +1,71 @@ +import { Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { MCP_GRID_CARD_OUTER, MCP_GRID_CARD_OUTER_STYLE } from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { getSourceLabelKey, getTransportLabelKey } from "@/lib/mcpTools"; +import StatusBadge from "./shared/StatusBadge"; +import TransportIcon from "./shared/TransportIcon"; + +interface McpServiceCardProps { + service: McpServiceItem; + onSelect: (service: McpServiceItem) => void; +} + +export default function McpServiceCard({ + service, + onSelect, +}: McpServiceCardProps) { + const { t } = useTranslation("common"); + const transportLabel = t(getTransportLabelKey(service.transportType)); + const sourceLabel = t(getSourceLabelKey(service.source)); + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+ +
+
+

+ {service.name} +

+ +
+
+ {sourceLabel} + · + {transportLabel} +
+
+
+ +
+

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

+
+ + {service.tags.length > 0 ? ( +
+ {service.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : 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..ebbf241d4 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpServiceDetailModal.tsx @@ -0,0 +1,466 @@ +import { useEffect, useState } from "react"; +import { App, Modal, Input, Button, Form } from "antd"; +import { useTranslation } from "react-i18next"; +import { + McpHealthStatus, + McpServiceStatus, + McpTransportType, + MCP_TOOLS_MODAL_WRAP_CLASS, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { + extractRegistryLinks, + getContainerStatusKey, + getHealthStatusKey, + getSourceLabelKey, + getTransportLabelKey, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useMcpServiceDetail } from "@/hooks/mcpTools/useMcpServiceDetail"; +import { useMcpServiceToggle } from "@/hooks/mcpTools/useMcpServiceToggle"; +import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; +import McpToolListModal from "@/components/mcp/McpToolListModal"; +import TagEditor from "./shared/TagEditor"; +import JsonPreviewModal from "./shared/JsonPreviewModal"; +import PublishConfirmModal from "./PublishConfirmModal"; +import StatusBadge from "./shared/StatusBadge"; + +interface McpServiceDetailModalProps { + selectedService: McpServiceItem | null; + onClose: () => void; + onToggled?: (mcpId: number, next: McpServiceStatus) => void; +} + +export default function McpServiceDetailModal({ + selectedService, + onClose, + onToggled: onStatusChanged, +}: McpServiceDetailModalProps) { + const { modal } = App.useApp(); + const { t } = useTranslation("common"); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const [logsOpen, setLogsOpen] = useState(false); + const [showServerJson, setShowServerJson] = useState(false); + const [showConfigJson, setShowConfigJson] = useState(false); + const [publishConfirmOpen, setPublishConfirmOpen] = useState(false); + + const detail = useMcpServiceDetail({ selectedService, onClose }); + const { draft } = detail; + const toggle = useMcpServiceToggle(); + + useEffect(() => { + if (!draft) return; + form.setFieldsValue({ + name: draft.name, + description: draft.description, + serverUrl: draft.serverUrl, + authorizationToken: draft.authorizationToken ?? "", + }); + }, [draft, form]); + + if (!selectedService || !draft) { + return null; + } + + const toolsRefreshing = toggle.isRefreshing(selectedService.mcpId); + const toggleLoading = toggle.isToggling(selectedService.mcpId); + const toggleBusy = toggleLoading || toolsRefreshing; + + const hasRegistryJson = Boolean(draft.registryJson); + const hasConfigJson = Boolean(draft.configJson); + const { websiteUrl, repositoryUrl } = extractRegistryLinks( + draft.registryJson + ); + const isHttpLike = + draft.transportType !== McpTransportType.CONTAINER; + + const handleSave = async () => { + try { + await form.validateFields(); + } catch { + return; + } + await detail.save(); + }; + + const handleDeleteClick = () => { + modal.confirm({ + title: t("mcpTools.delete.confirmTitle"), + content: ( +
+

+ {selectedService.name} +

+

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

+
+ ), + okText: t("mcpTools.delete.confirmOk"), + cancelText: t("mcpTools.delete.confirmCancel"), + okButtonProps: { danger: true }, + onOk: () => detail.remove(), + }); + }; + + return ( + <> + +
+
+

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

+
+ +
+
+
+
+ + { + detail.setDraft({ ...draft, name: event.target.value }); + form.setFieldValue("name", event.target.value); + }} + className="mt-2 w-full rounded-md" + /> + + + + { + detail.setDraft({ + ...draft, + description: event.target.value, + }); + form.setFieldValue("description", event.target.value); + }} + autoSize={{ minRows: 1, maxRows: 24 }} + className="mt-2 w-full rounded-md" + /> + + + + { + detail.setDraft({ + ...draft, + serverUrl: event.target.value, + }); + form.setFieldValue("serverUrl", event.target.value); + }} + className="mt-2 w-full rounded-md" + /> + + + {isHttpLike ? ( + + { + detail.setDraft({ + ...draft, + authorizationToken: event.target.value, + }); + form.setFieldValue( + "authorizationToken", + event.target.value + ); + }} + className="mt-2 w-full rounded-md" + placeholder={t("mcpTools.detail.bearerTokenPlaceholder")} + /> + + ) : null} +
+
+ +
+
+ + + {websiteUrl ? ( + + ) : null} + {repositoryUrl ? ( + + ) : null} +
+ + {t("mcpTools.detail.status")} + + +
+
+ + {t("mcpTools.detail.health")} + +
+ + + {t(getHealthStatusKey(draft.healthStatus))} + + +
+
+ {draft.transportType === McpTransportType.CONTAINER ? ( + + ) : null} +
+
+ +
+ + {t("mcpTools.detail.tools")} + +
+ {draft.containerId ? ( + + ) : null} + {hasRegistryJson ? ( + + ) : null} + {hasConfigJson ? ( + + ) : null} + +
+
+ +
+ detail.addTag(tag || "")} + onRemoveTag={detail.removeTag} + removeAriaKey="mcpTools.detail.removeTagAria" + placeholderKey="mcpTools.detail.tagInputPlaceholder" + /> +
+
+
+ +
+ + + + +
+
+
+ + + + setShowServerJson(false)} + /> + + setShowConfigJson(false)} + /> + + {draft.containerId ? ( + setLogsOpen(false)} + containerId={draft.containerId} + /> + ) : null} + + setPublishConfirmOpen(false)} + onConfirm={async (override) => { + const ok = await detail.publish(override); + if (ok) setPublishConfirmOpen(false); + }} + /> + + ); +} + +type StatusLampVariant = "success" | "neutral" | "danger"; + +/** Green / grey / red dot for run-state and health at a glance. */ +function StatusLamp({ variant }: { variant: StatusLampVariant }) { + const cls = + variant === "success" + ? "bg-emerald-500 shadow-[0_0_0_1px_rgba(16,185,129,0.35),0_0_8px_rgba(16,185,129,0.25)]" + : variant === "danger" + ? "bg-rose-500 shadow-[0_0_0_1px_rgba(244,63,94,0.35),0_0_8px_rgba(244,63,94,0.2)]" + : "bg-slate-300"; + return ( + + ); +} + +function healthLampVariant( + health: McpServiceItem["healthStatus"] +): StatusLampVariant { + if (health === McpHealthStatus.HEALTHY) return "success"; + if (health === McpHealthStatus.UNHEALTHY) return "danger"; + return "neutral"; +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function DetailLink({ label, href }: { label: string; href: string }) { + return ( +
+ {label} + + {href} + +
+ ); +} 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..d2146c5d1 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/McpServicesFilterBar.tsx @@ -0,0 +1,89 @@ +import { Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { FILTER_ALL, McpSource, McpTransportType } from "@/const/mcpTools"; +import type { + McpSourceFilter, + McpTagStat, + McpTransportFilter, +} from "@/types/mcpTools"; + +interface McpServicesFilterBarProps { + /** When omitted, the source filter is not shown (e.g. published tab). */ + source?: McpSourceFilter; + onSourceChange?: (value: McpSourceFilter) => void; + transport: McpTransportFilter; + tag: string; + tagStats: McpTagStat[]; + onTransportChange: (value: McpTransportFilter) => void; + onTagChange: (value: string) => void; +} + +/** + * Compact 3-pill filter bar designed to sit inline with the search input on + * desktop. Each select is fixed-width so the whole row stays balanced + * regardless of locale length. + */ +export default function McpServicesFilterBar({ + source, + onSourceChange, + transport, + tag, + tagStats, + onTransportChange, + onTagChange, +}: McpServicesFilterBarProps) { + const { t } = useTranslation("common"); + const showSource = source !== undefined && onSourceChange !== undefined; + + return ( +
+ {showSource ? ( + + { + patch({ name: event.target.value }); + form.setFieldValue("name", event.target.value); + }} + className="rounded-md" + /> + + + + { + patch({ description: event.target.value }); + form.setFieldValue("description", event.target.value); + }} + autoSize={{ minRows: 2, maxRows: 12 }} + className="rounded-md" + /> + + + + { + patch({ version: event.target.value }); + form.setFieldValue("version", event.target.value); + }} + placeholder="1.0.0" + className="rounded-md" + /> + + + {source?.transportType !== McpTransportType.CONTAINER ? ( + + { + patch({ serverUrl: event.target.value }); + form.setFieldValue("serverUrl", event.target.value); + }} + className="rounded-md" + /> + + ) : null} + + {source?.transportType === McpTransportType.CONTAINER ? ( + + { + patch({ containerConfigJson: event.target.value }); + form.setFieldValue("containerConfigJson", event.target.value); + }} + rows={6} + className="mt-2 rounded-md font-mono text-sm" + placeholder={t("mcpTools.addModal.containerConfigPlaceholder")} + /> + + ) : null} + + { + const next = (tag || "").trim(); + if (!next || draft.tags.includes(next)) return; + patch({ tags: [...draft.tags, next] }); + }} + onRemoveTag={(index) => + patch({ tags: draft.tags.filter((_, i) => i !== index) }) + } + removeAriaKey="mcpTools.detail.removeTagAria" + /> + + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/PublishedServiceCard.tsx b/frontend/app/[locale]/mcp-tools/components/PublishedServiceCard.tsx new file mode 100644 index 000000000..6d72fb4a8 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/PublishedServiceCard.tsx @@ -0,0 +1,76 @@ +import { Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_GRID_CARD_OUTER, + MCP_GRID_CARD_OUTER_STYLE, +} from "@/const/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { getTransportLabelKey } from "@/lib/mcpTools"; +import TransportIcon from "./shared/TransportIcon"; + +interface PublishedServiceCardProps { + service: CommunityMcpCard; + onSelect: (service: CommunityMcpCard) => void; +} + +export default function PublishedServiceCard({ + service, + onSelect, +}: PublishedServiceCardProps) { + const { t } = useTranslation("common"); + const version = (service.version || "").trim(); + const tags = service.tags || []; + const transportLabel = t(getTransportLabelKey(service.transportType)); + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+ +
+
+

+ {service.name} +

+ {version ? ( + + v{version} + + ) : null} +
+
+ {transportLabel} +
+
+
+ +
+

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

+
+ + {tags.length > 0 ? ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/PublishedServiceDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/PublishedServiceDetailModal.tsx new file mode 100644 index 000000000..abb205da7 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/PublishedServiceDetailModal.tsx @@ -0,0 +1,355 @@ +import { useEffect, useState } from "react"; +import { App, Button, Form, Input, Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_TOOLS_MODAL_WRAP_CLASS, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { usePublishedServiceDetailEdit } from "@/hooks/mcpTools/usePublishedServiceDetailEdit"; +import { + extractRegistryLinks, + formatRegistryDate, + getTransportLabelKey, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import RegistryStatusBadge from "./shared/StatusBadge"; +import JsonPreviewModal from "./shared/JsonPreviewModal"; +import TagEditor from "./shared/TagEditor"; + +const sectionCard = + "rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm"; + +interface PublishedServiceDetailModalProps { + open: boolean; + service: CommunityMcpCard | null; + onClose: () => void; +} + +/** + * Editable detail for the "my published" tab. Read-only block mirrors + * {@link McpCommunityDetailModal} (URL, type, status, times, links, JSON); + * name / description / version / tags stay editable and persist via the + * parent draft + save. + */ +export default function PublishedServiceDetailModal({ + open, + service, + onClose, +}: PublishedServiceDetailModalProps) { + const { t } = useTranslation("common"); + const { modal } = App.useApp(); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const edit = usePublishedServiceDetailEdit(service, open); + const { draft, saving, deleting, updateDraft, addDraftTag, removeDraftTag } = + edit; + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + + const { websiteUrl, repositoryUrl } = extractRegistryLinks( + (service?.registryJson || undefined) as Record | undefined + ); + const serverJsonPretty = toPrettyRegistryJson( + (service?.registryJson || undefined) as Record | undefined + ); + const configJsonPretty = toPrettyRegistryJson( + (service?.configJson || undefined) as Record | undefined + ); + const hasServerJson = Boolean( + service?.registryJson && Object.keys(service.registryJson).length > 0 + ); + const hasConfigJson = Boolean( + service?.configJson && Object.keys(service.configJson).length > 0 + ); + + const serverTypeText = service + ? t(getTransportLabelKey(service.transportType)) + : ""; + + useEffect(() => { + if (!open) { + setShowServerJsonModal(false); + setShowConfigJsonModal(false); + } + }, [open]); + + useEffect(() => { + if (!open || !draft) return; + form.setFieldsValue({ + name: draft.name, + description: draft.description, + version: draft.version, + }); + }, [open, draft, form]); + + const handleSave = async () => { + try { + await form.validateFields(); + } catch { + return; + } + const ok = await edit.save(); + if (ok) onClose(); + }; + + const handleDelete = () => { + if (!service?.communityId) return; + modal.confirm({ + title: t("mcpTools.delete.confirmTitle"), + content: ( +
+

{service.name}

+

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

+
+ ), + okText: t("mcpTools.delete.confirmOk"), + cancelText: t("mcpTools.delete.confirmCancel"), + okButtonProps: { danger: true }, + onOk: async () => { + if (typeof service.communityId !== "number") return; + const ok = await edit.remove(service.communityId); + if (ok) onClose(); + }, + }); + }; + + if (!service) return null; + + return ( + <> + +
+
+

+ {t("mcpTools.published.detailTitle")} +

+
+ +
+
+
+
+ + { + updateDraft({ name: event.target.value }); + form.setFieldValue("name", event.target.value); + }} + className="mt-2 rounded-md" + /> + + + + { + updateDraft({ description: event.target.value }); + form.setFieldValue("description", event.target.value); + }} + autoSize={{ minRows: 1, maxRows: 16 }} + className="mt-2 rounded-md" + /> + +
+
+ +
+
+ {!service.configJson ? ( +
+

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

+

+ {service.serverUrl} +

+
+ ) : null} + +
+
+ + {t("mcpTools.detail.serverType")} + + + {serverTypeText} + +
+
+ + {t("mcpTools.detail.status")} + + +
+
+ + {t("mcpTools.detail.createdAt")} + + + {formatRegistryDate(service.createdAt)} + +
+ {service.updatedAt ? ( +
+ + {t("mcpTools.detail.updatedAt")} + + + {formatRegistryDate(service.updatedAt)} + +
+ ) : null} + {websiteUrl ? ( +
+ + {t("mcpTools.detail.website")} + + + {websiteUrl} + +
+ ) : null} + {repositoryUrl ? ( +
+ + {t("mcpTools.detail.repository")} + + + {repositoryUrl} + +
+ ) : null} +
+ +
+ + {t("mcpTools.detail.tools")} + +
+ {hasServerJson ? ( + + ) : null} + {hasConfigJson ? ( + + ) : null} +
+
+
+
+ +
+ + { + updateDraft({ version: event.target.value }); + form.setFieldValue("version", event.target.value); + }} + placeholder="1.0.0" + className="mt-2 rounded-md" + /> + +
+ addDraftTag((tag || "").trim())} + onRemoveTag={removeDraftTag} + removeAriaKey="mcpTools.detail.removeTagAria" + /> +
+
+
+
+ +
+ + +
+
+
+ + setShowServerJsonModal(false)} + /> + + setShowConfigJsonModal(false)} + /> + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-tools/components/add/AddMcpServiceModal.tsx new file mode 100644 index 000000000..69ae5c8bf --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/AddMcpServiceModal.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import { Modal, Segmented } from "antd"; +import { useTranslation } from "react-i18next"; +import { + McpSource, + MCP_ADD_SERVICE_MODAL_WIDTH_LOCAL, + MCP_ADD_SERVICE_MODAL_WIDTH_MARKETS, +} from "@/const/mcpTools"; +import AddMcpServiceLocalSection from "./local/AddMcpServiceLocalSection"; +import AddMcpServiceRegistrySection from "./registry/AddMcpServiceRegistrySection"; +import AddMcpServiceCommunitySection from "./community/AddMcpServiceCommunitySection"; + +interface AddMcpServiceModalProps { + open: boolean; + onClose: () => void; +} + +export default function AddMcpServiceModal({ + open, + onClose, +}: AddMcpServiceModalProps) { + const { t } = useTranslation("common"); + const [tab, setTab] = useState(McpSource.LOCAL); + + useEffect(() => { + if (!open) setTab(McpSource.LOCAL); + }, [open]); + + if (!open) return null; + + /** Fixed body height + inner scroll: avoids size jump on tab/transport change and prevents overflow. */ + const bodyFrame = "min(90vh, 700px)"; + + const modalWidth = + tab === McpSource.LOCAL + ? MCP_ADD_SERVICE_MODAL_WIDTH_LOCAL + : MCP_ADD_SERVICE_MODAL_WIDTH_MARKETS; + + return ( + +
+
+

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

+
+ +
+ setTab(value as McpSource)} + options={[ + { label: t("mcpTools.addModal.tabLocal"), value: McpSource.LOCAL }, + { + label: t("mcpTools.addModal.tabRegistry"), + value: McpSource.REGISTRY, + }, + { + label: t("mcpTools.addModal.tabCommunity"), + value: McpSource.COMMUNITY, + }, + ]} + 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/add/community/AddMcpServiceCommunitySection.tsx b/frontend/app/[locale]/mcp-tools/components/add/community/AddMcpServiceCommunitySection.tsx new file mode 100644 index 000000000..28bcee638 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/community/AddMcpServiceCommunitySection.tsx @@ -0,0 +1,291 @@ +import { useEffect, useState } from "react"; +import { Form, Input, Modal, Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { McpTransportType } from "@/const/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useMcpCommunityBrowser } from "@/hooks/mcpTools/useMcpCommunityBrowser"; +import { useMcpCommunityQuickAdd } from "@/hooks/mcpTools/useMcpCommunityQuickAdd"; +import McpCommunityToolbar from "./McpCommunityToolbar"; +import McpCommunityCardList from "./McpCommunityCardList"; +import McpCommunityDetailModal from "./McpCommunityDetailModal"; +import ContainerPortField from "../../shared/ContainerPortField"; +import TagEditor from "../../shared/TagEditor"; + +interface AddMcpServiceCommunitySectionProps { + active: boolean; + onAdded: () => void; +} + +export default function AddMcpServiceCommunitySection({ + active, + onAdded, +}: AddMcpServiceCommunitySectionProps) { + const [selected, setSelected] = useState(null); + const browser = useMcpCommunityBrowser(active); + const quickAdd = useMcpCommunityQuickAdd({ onSuccess: onAdded }); + + if (!active) return null; + + return ( + <> +
+ browser.updateFilter("search", value)} + onTransportChange={(value) => + browser.updateFilter("transport", value) + } + onTagChange={(value) => browser.updateFilter("tag", value)} + /> + + +
+ + {selected ? ( + setSelected(null)} + onQuickAdd={quickAdd.open} + /> + ) : null} + + {quickAdd.visible ? ( + + ) : null} + + ); +} + +interface CommunityQuickAddModalProps { + controller: ReturnType; +} + +function CommunityQuickAddModal({ controller }: CommunityQuickAddModalProps) { + const { t } = useTranslation("common"); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const { visible, source, draft, submitting } = controller; + + useEffect(() => { + if (!visible || !draft) return; + form.setFieldsValue({ + name: draft.name, + description: draft.description, + transportType: draft.transportType, + serverUrl: draft.serverUrl, + authorizationToken: draft.authorizationToken, + containerConfigJson: draft.containerConfigJson, + containerPort: draft.containerPort, + }); + }, [visible, draft, form]); + + if (!draft) { + return ( + + ); + } + + const addTag = (tag: string) => { + const next = (tag || "").trim(); + if (!next || draft.tags.includes(next)) return; + controller.updateDraft({ tags: [...draft.tags, next] }); + }; + + const removeTag = (index: number) => { + controller.updateDraft({ tags: draft.tags.filter((_, i) => i !== index) }); + }; + + const handleOk = async () => { + try { + await form.validateFields(); + } catch { + return; + } + await controller.confirm(); + }; + + return ( + +
+ + { + controller.updateDraft({ name: event.target.value }); + form.setFieldValue("name", event.target.value); + }} + className="mt-2 w-full rounded-md" + /> + + + + { + controller.updateDraft({ description: event.target.value }); + form.setFieldValue("description", event.target.value); + }} + autoSize={{ minRows: 1, maxRows: 24 }} + className="mt-2 w-full rounded-md" + /> + + + + { + controller.updateDraft({ serverUrl: event.target.value }); + form.setFieldValue("serverUrl", event.target.value); + }} + className="mt-2 w-full rounded-md" + /> + + + { + controller.updateDraft({ + authorizationToken: event.target.value, + }); + form.setFieldValue("authorizationToken", event.target.value); + }} + className="mt-2 w-full rounded-md" + placeholder={t("mcpTools.addModal.bearerTokenPlaceholder")} + /> + +
+ ) : ( +
+ + { + controller.updateDraft({ + containerConfigJson: event.target.value, + }); + form.setFieldValue("containerConfigJson", event.target.value); + }} + rows={6} + className="mt-2" + placeholder={t("mcpTools.addModal.containerConfigPlaceholder")} + /> + + + +
+ { + controller.updateDraft({ containerPort: value }); + form.setFieldValue("containerPort", value); + }} + /> +
+
+
+ )} + + addTag(tag || "")} + onRemoveTag={removeTag} + /> + + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityCard.tsx b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityCard.tsx new file mode 100644 index 000000000..af841c478 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityCard.tsx @@ -0,0 +1,87 @@ +import { Button, Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_GRID_CARD_OUTER, + MCP_GRID_CARD_OUTER_STYLE, +} from "@/const/mcpTools"; +import { + formatRegistryDate, + formatRegistryVersion, + getTransportLabelKey, +} from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "../../shared/StatusBadge"; + +interface McpCommunityCardProps { + service: CommunityMcpCard; + onSelect: (service: CommunityMcpCard) => void; + onQuickAdd: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityCard({ + service, + onSelect, + onQuickAdd, +}: McpCommunityCardProps) { + const { t } = useTranslation("common"); + const transportLabel = t(getTransportLabelKey(service.transportType)); + const tags = service.tags || []; + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+

+ {service.name} +

+ +
+ +
+ + {formatRegistryVersion(service.version || "")} + + + {formatRegistryDate(service.createdAt || "")} + +
+ +
+

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

+
+ +
+ {transportLabel} + {tags.map((tag) => ( + + {tag} + + ))} +
+ +
+ +
+
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityCardList.tsx b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityCardList.tsx new file mode 100644 index 000000000..94206b038 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityCardList.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import McpCommunityCard from "./McpCommunityCard"; + +interface McpCommunityCardListProps { + loading: boolean; + services: CommunityMcpCard[]; + hasPrevPage: boolean; + hasNextPage: boolean; + onPrevPage: () => void; + onNextPage: () => void; + onSelect: (service: CommunityMcpCard) => void; + onQuickAdd: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityCardList({ + loading, + services, + hasPrevPage, + hasNextPage, + onPrevPage, + onNextPage, + onSelect, + onQuickAdd, +}: McpCommunityCardListProps) { + const { t } = useTranslation("common"); + + if (loading) { + 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/add/community/McpCommunityDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityDetailModal.tsx new file mode 100644 index 000000000..9a9111ba8 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityDetailModal.tsx @@ -0,0 +1,258 @@ +import { useState } from "react"; +import { Button, Modal, Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_TOOLS_MODAL_WRAP_CLASS, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import { + extractRegistryLinks, + formatRegistryDate, + formatRegistryVersion, + getTransportLabelKey, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "../../shared/StatusBadge"; +import JsonPreviewModal from "../../shared/JsonPreviewModal"; + +const sectionCard = + "rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm"; + +interface McpCommunityDetailModalProps { + service: CommunityMcpCard; + onClose: () => void; + onQuickAdd: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityDetailModal({ + service, + onClose, + onQuickAdd, +}: McpCommunityDetailModalProps) { + const { t } = useTranslation("common"); + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + const { websiteUrl, repositoryUrl } = extractRegistryLinks( + service.registryJson as Record + ); + const serverJsonPretty = toPrettyRegistryJson( + service.registryJson as Record + ); + const configJsonPretty = toPrettyRegistryJson( + (service.configJson || undefined) as Record | undefined + ); + const hasServerJson = Boolean( + service.registryJson && Object.keys(service.registryJson).length > 0 + ); + const hasConfigJson = Boolean( + service.configJson && Object.keys(service.configJson).length > 0 + ); + const serverTypeText = t(getTransportLabelKey(service.transportType)); + const sourceText = t("mcpTools.source.community"); + + return ( + <> + +
+
+

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

+
+ +
+
+
+
+

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

+

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

+
+
+

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

+

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

+
+
+

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

+

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

+
+
+
+ +
+
+
+ + {t("mcpTools.detail.source")} + + {sourceText} +
+
+ + {t("mcpTools.detail.serverType")} + + + {serverTypeText} + +
+ {service.version ? ( +
+ + {t("mcpTools.detail.version")} + + + {formatRegistryVersion(service.version)} + +
+ ) : null} +
+ + {t("mcpTools.detail.status")} + + +
+
+ + {t("mcpTools.community.publishedAt")} + + + {formatRegistryDate(service.createdAt)} + +
+ {service.updatedAt ? ( +
+ + {t("mcpTools.detail.updatedAt")} + + + {formatRegistryDate(service.updatedAt)} + +
+ ) : null} + {websiteUrl ? ( +
+ + {t("mcpTools.detail.website")} + + + {websiteUrl} + +
+ ) : null} + {repositoryUrl ? ( +
+ + {t("mcpTools.detail.repository")} + + + {repositoryUrl} + +
+ ) : null} +
+
+ + {(service.tags || []).length > 0 ? ( +
+

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

+
+ {(service.tags || []).map((tag) => ( + + {tag} + + ))} +
+
+ ) : null} + + +
+ + {t("mcpTools.detail.tools")} + +
+ {hasServerJson ? ( + + ) : null} + {hasConfigJson ? ( + + ) : null} +
+
+
+ +
+ +
+
+
+ + setShowServerJsonModal(false)} + /> + + setShowConfigJsonModal(false)} + /> + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityToolbar.tsx b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityToolbar.tsx new file mode 100644 index 000000000..761963a81 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/community/McpCommunityToolbar.tsx @@ -0,0 +1,87 @@ +import { Input, Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { FILTER_ALL, McpTransportType } from "@/const/mcpTools"; +import type { McpTagStat, McpTransportFilter } from "@/types/mcpTools"; + +interface McpCommunityToolbarProps { + search: string; + transport: McpTransportFilter; + tag: string; + tagStats: McpTagStat[]; + page: number; + resultCount: number; + onSearchChange: (value: string) => void; + onTransportChange: (value: McpTransportFilter) => void; + onTagChange: (value: string) => void; +} + +/** + * Community-browser toolbar. Search input takes ~2/3 of the row, the two + * filter selects share the remaining space and stay narrow on desktop. + */ +export default function McpCommunityToolbar({ + search, + transport, + tag, + tagStats, + page, + resultCount, + onSearchChange, + onTransportChange, + onTagChange, +}: McpCommunityToolbarProps) { + const { t } = useTranslation("common"); + + return ( +
+
+ onSearchChange(event.target.value)} + placeholder={t("mcpTools.community.searchPlaceholder")} + size="large" + allowClear + className="w-full rounded-md lg:basis-2/3" + /> +
+ ({ + value: item.tag, + label: `${item.tag} (${item.count})`, + })), + ]} + /> +
+
+ + {t("mcpTools.community.pageResult", { page, count: resultCount })} + +
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/local/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-tools/components/add/local/AddMcpServiceLocalSection.tsx new file mode 100644 index 000000000..01521bfa2 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/local/AddMcpServiceLocalSection.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { Button, Form, Input, Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_ADD_SERVICE_LOCAL_SECTION_WIDTH_PX, + McpTransportType, +} from "@/const/mcpTools"; +import type { LocalAddMcpDraft } from "@/types/mcpTools"; +import { useMcpAddLocal } from "@/hooks/mcpTools/useMcpAddLocal"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import ContainerPortField from "../../shared/ContainerPortField"; +import TagEditor from "../../shared/TagEditor"; + +const createInitialDraft = (): LocalAddMcpDraft => ({ + name: "", + description: "", + transportType: McpTransportType.URL, + serverUrl: "", + authorizationToken: "", + containerConfigJson: "", + containerPort: undefined, + tags: [], +}); + +interface AddMcpServiceLocalSectionProps { + active: boolean; + onAdded: () => void; +} + +export default function AddMcpServiceLocalSection({ + active, + onAdded, +}: AddMcpServiceLocalSectionProps) { + const { t } = useTranslation("common"); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const [draft, setDraft] = useState(() => createInitialDraft()); + const { submit, submitting } = useMcpAddLocal({ + onSuccess: () => { + setDraft(createInitialDraft()); + form.resetFields(); + onAdded(); + }, + }); + + const patchDraft = (patch: Partial) => { + 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 = (tag: string) => { + const next = (tag || "").trim(); + 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 handleSubmit = async () => { + try { + await form.validateFields(); + } catch { + return; + } + await submit(draft); + }; + + if (!active) return null; + + const isHttpLike = draft.transportType !== McpTransportType.CONTAINER; + + return ( +
+
+ + + + + + + + + + + + + + +
+ ) : ( +
+ + + + + +
+ { + patchDraft({ containerPort: value }); + form.setFieldValue("containerPort", value); + }} + /> +
+
+
+ )} + + addTag(tag || "")} + onRemoveTag={removeTag} + /> + + +
+ +
+ + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/registry/AddMcpServiceRegistrySection.tsx b/frontend/app/[locale]/mcp-tools/components/add/registry/AddMcpServiceRegistrySection.tsx new file mode 100644 index 000000000..72a082d3b --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/registry/AddMcpServiceRegistrySection.tsx @@ -0,0 +1,382 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, Form, Input, Modal, Radio } from "antd"; +import { useTranslation } from "react-i18next"; +import type { + RegistryMcpCard, + 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"; +import McpRegistryCardList from "./McpRegistryCardList"; +import McpRegistryDetailModal from "./McpRegistryDetailModal"; +import ContainerPortField from "../../shared/ContainerPortField"; +import { McpTransportType } from "@/const/mcpTools"; + +interface AddMcpServiceRegistrySectionProps { + active: boolean; + onAdded: () => void; +} + +export default function AddMcpServiceRegistrySection({ + active, + onAdded, +}: AddMcpServiceRegistrySectionProps) { + const [selected, setSelected] = useState(null); + const browser = useMcpRegistryBrowser(active); + const quickAdd = useMcpRegistryQuickAdd({ onSuccess: onAdded }); + + if (!active) return null; + + return ( + <> +
+ browser.updateFilter("search", value)} + onVersionChange={(value) => browser.updateFilter("version", value)} + onUpdatedSinceChange={(value) => + browser.updateFilter("updatedSince", value) + } + onIncludeDeletedChange={(value) => + browser.updateFilter("includeDeleted", value) + } + /> + + +
+ + {selected ? ( + setSelected(null)} + onQuickAdd={quickAdd.open} + /> + ) : null} + + + + ); +} + +interface QuickAddPickerModalProps { + controller: ReturnType; +} + +function QuickAddPickerModal({ controller }: QuickAddPickerModalProps) { + const { t } = useTranslation("common"); + const [form] = Form.useForm(); + const rules = useMcpFormRules(); + const { + visible, + candidate, + options, + selectedOption, + selectedKey, + values, + containerPort, + submitting, + } = controller; + const unsupportedOci = + selectedOption?.sourceType === "package" && + (selectedOption.packageRegistryType || "").trim().toLowerCase() === "oci"; + + useEffect(() => { + if (!visible) return; + form.setFieldsValue({ selectedKey, containerPort, ...values }); + }, [visible, form, selectedKey, containerPort, values]); + + const handleConfirm = async () => { + try { + await form.validateFields(); + } catch { + return; + } + await controller.confirm(); + }; + + const renderVariableInputs = ( + titleKey: string, + fields: RegistryRemoteVariable[] = [] + ) => { + if (!fields.length) return null; + return ( +
+

{t(titleKey)}

+ {fields.map((field) => ( + + ))} +
+ ); + }; + + const renderArgumentInputs = ( + args: RegistryPackageArgumentInput[] = [], + title: string + ) => { + if (!args.length) return null; + return ( +
+

{title}

+ {args.map((arg) => ( + + ))} +
+ ); + }; + + return ( + +
+

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

+ + + { + const next = String(event.target.value || ""); + controller.chooseOption(next); + form.setFieldValue("selectedKey", next); + }} + className="flex w-full flex-col gap-2" + > + {options.map((option) => { + const sourceLabel = + option.sourceType === "remote" + ? t("mcpTools.registry.quickAddPicker.sourceRemote") + : t("mcpTools.registry.quickAddPicker.sourcePackage"); + return ( + +
+

{sourceLabel}

+

+ {option.sourceLabel} +

+
+
+ ); + })} +
+
+ + {unsupportedOci ? ( + + ) : ( + <> + {selectedOption?.transportType === McpTransportType.CONTAINER ? ( +
+ +
+ { + controller.setContainerPort(value); + form.setFieldValue("containerPort", value); + }} + /> +
+
+
+ ) : null} + + {renderVariableInputs( + "mcpTools.registry.quickAddPicker.variablesTitle", + selectedOption?.remoteVariables + )} + {renderVariableInputs( + "mcpTools.registry.quickAddPicker.remoteHeadersTitle", + selectedOption?.remoteHeaders + )} + {renderVariableInputs( + "mcpTools.registry.quickAddPicker.packageTransportVariablesTitle", + selectedOption?.packageTransportVariables + )} + {renderVariableInputs( + "mcpTools.registry.quickAddPicker.packageTransportHeadersTitle", + selectedOption?.packageTransportHeaders + )} + {renderVariableInputs( + "mcpTools.registry.quickAddPicker.packageEnvironmentVariablesTitle", + selectedOption?.packageEnvironmentVariables + )} + {renderArgumentInputs( + selectedOption?.packageRuntimeArguments, + t("mcpTools.registry.quickAddPicker.runtimeArgumentsTitle") + )} + {renderArgumentInputs( + selectedOption?.packageArguments, + t("mcpTools.registry.packageField.packageArguments") + )} + + )} + +
+ + +
+ +
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryCard.tsx b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryCard.tsx new file mode 100644 index 000000000..926f75599 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryCard.tsx @@ -0,0 +1,81 @@ +import { Button, Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_GRID_CARD_OUTER, + MCP_GRID_CARD_OUTER_STYLE, +} from "@/const/mcpTools"; +import { formatRegistryDate, formatRegistryVersion } from "@/lib/mcpTools"; +import type { RegistryMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "../../shared/StatusBadge"; + +interface McpRegistryCardProps { + service: RegistryMcpCard; + onSelect: (service: RegistryMcpCard) => void; + onQuickAdd: (service: RegistryMcpCard) => void; +} + +export default function McpRegistryCard({ + service, + onSelect, + onQuickAdd, +}: McpRegistryCardProps) { + const { t } = useTranslation("common"); + const server = service.server; + const officialMeta = (( + service._meta as Record | undefined + )?.["io.modelcontextprotocol.registry/official"] || {}) as Record< + string, + unknown + >; + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+

+ {server.name} +

+ +
+ +
+ + {formatRegistryVersion(server.version || "")} + + + {formatRegistryDate(String(officialMeta.publishedAt || ""))} + +
+ +
+

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

+
+ +
+ +
+
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryCardList.tsx b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryCardList.tsx new file mode 100644 index 000000000..3af10f813 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryCardList.tsx @@ -0,0 +1,68 @@ +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; +import type { RegistryMcpCard } from "@/types/mcpTools"; +import McpRegistryCard from "./McpRegistryCard"; + +interface McpRegistryCardListProps { + loading: boolean; + services: RegistryMcpCard[]; + hasPrevPage: boolean; + hasNextPage: boolean; + onPrevPage: () => void; + onNextPage: () => void; + onSelect: (service: RegistryMcpCard) => void; + onQuickAdd: (service: RegistryMcpCard) => void; +} + +export default function McpRegistryCardList({ + loading, + services, + hasPrevPage, + hasNextPage, + onPrevPage, + onNextPage, + onSelect, + onQuickAdd, +}: McpRegistryCardListProps) { + const { t } = useTranslation("common"); + + if (loading) { + 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/add/registry/McpRegistryDetailModal.tsx b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryDetailModal.tsx new file mode 100644 index 000000000..5a6b3d469 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryDetailModal.tsx @@ -0,0 +1,642 @@ +import { useState } from "react"; +import { Button, Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import { + extractRegistryLinks, + formatRegistryDate, + formatRegistryVersion, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import type { RegistryMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "../../shared/StatusBadge"; +import { + MCP_TOOLS_MODAL_WRAP_CLASS, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import JsonPreviewModal from "../../shared/JsonPreviewModal"; + +interface McpRegistryDetailModalProps { + service: RegistryMcpCard; + onClose: () => void; + onQuickAdd: (service: RegistryMcpCard) => void; +} + +export default function McpRegistryDetailModal({ + service, + onClose, + onQuickAdd, +}: McpRegistryDetailModalProps) { + const { t } = useTranslation("common"); + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const server = service.server; + const officialMeta = (( + service._meta as Record | undefined + )?.["io.modelcontextprotocol.registry/official"] || {}) as Record< + string, + unknown + >; + const { websiteUrl, repositoryUrl } = extractRegistryLinks(server); + const serverJsonPretty = toPrettyRegistryJson(server); + const hasServerJson = Boolean(server && Object.keys(server).length > 0); + + const displayRemotes = Array.isArray(server.remotes) ? server.remotes : []; + const displayPackages = Array.isArray(server.packages) + ? server.packages.filter( + (pkg): pkg is Record => + Boolean(pkg) && typeof pkg === "object" + ) + : []; + + const normalizeHeaderItems = (headers: unknown[]) => { + return headers.filter( + (header): header is Record => + Boolean(header) && typeof header === "object" + ); + }; + + const hasRenderableValue = (value: unknown) => { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") + return Object.keys(value as Record).length > 0; + return true; + }; + + const getHeaderFieldLabel = (key: string) => { + const knownKeyMap: Record = { + name: "mcpTools.registry.headerField.name", + key: "mcpTools.registry.headerField.name", + url: "mcpTools.registry.headerField.url", + description: "mcpTools.registry.headerField.description", + isRequired: "mcpTools.registry.headerField.isRequired", + isSecret: "mcpTools.registry.headerField.isSecret", + isRepeated: "mcpTools.registry.headerField.isRepeated", + format: "mcpTools.registry.headerField.format", + valueHint: "mcpTools.registry.headerField.valueHint", + value: "mcpTools.registry.headerField.value", + default: "mcpTools.registry.headerField.default", + placeholder: "mcpTools.registry.headerField.placeholder", + choices: "mcpTools.registry.headerField.choices", + variables: "mcpTools.registry.headerField.variables", + type: "mcpTools.registry.headerField.type", + }; + const translationKey = knownKeyMap[key]; + return translationKey ? t(translationKey) : key; + }; + + const getVariableFieldLabel = (key: string) => { + const knownKeyMap: Record = { + name: "mcpTools.registry.variableField.name", + key: "mcpTools.registry.variableField.name", + url: "mcpTools.registry.variableField.url", + description: "mcpTools.registry.variableField.description", + format: "mcpTools.registry.variableField.format", + valueHint: "mcpTools.registry.variableField.valueHint", + value: "mcpTools.registry.variableField.value", + default: "mcpTools.registry.variableField.default", + placeholder: "mcpTools.registry.variableField.placeholder", + choices: "mcpTools.registry.variableField.choices", + variables: "mcpTools.registry.variableField.variables", + type: "mcpTools.registry.variableField.type", + isRequired: "mcpTools.registry.variableField.isRequired", + isSecret: "mcpTools.registry.variableField.isSecret", + isRepeated: "mcpTools.registry.variableField.isRepeated", + }; + const translationKey = knownKeyMap[key]; + return translationKey ? t(translationKey) : key; + }; + + const getPackageFieldLabel = (key: string) => { + const knownKeyMap: Record = { + registryType: "mcpTools.registry.packageField.registryType", + identifier: "mcpTools.registry.packageField.identifier", + version: "mcpTools.registry.packageField.version", + runtimeHint: "mcpTools.registry.packageField.runtimeHint", + registryBaseUrl: "mcpTools.registry.packageField.registryBaseUrl", + fileSha256: "mcpTools.registry.packageField.fileSha256", + environmentVariables: + "mcpTools.registry.packageField.environmentVariables", + runtimeArguments: "mcpTools.registry.packageField.runtimeArguments", + packageArguments: "mcpTools.registry.packageField.packageArguments", + transport: "mcpTools.registry.packageField.transport", + }; + const translationKey = knownKeyMap[key]; + return translationKey ? t(translationKey) : key; + }; + + const formatHeaderFieldValue = (value: unknown) => { + if (typeof value === "boolean") { + return value ? t("common.yes") : t("common.no"); + } + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return ""; + }; + + const normalizeRecordItems = (items: unknown) => { + if (!Array.isArray(items)) return [] as Record[]; + return items.filter( + (item): item is Record => + Boolean(item) && typeof item === "object" + ); + }; + + const renderFieldRows = ( + record: Record, + labelResolver: (key: string) => string, + keyPath: string, + excludedKeys: string[] = [] + ) => { + const excluded = new Set(excludedKeys); + const entries = Object.entries(record).filter( + ([key, value]) => !excluded.has(key) && hasRenderableValue(value) + ); + if (entries.length === 0) { + return

-

; + } + return ( +
+ {entries.map(([fieldKey, fieldValue]) => ( +
+ + {labelResolver(fieldKey)}: + {" "} + {renderStructuredValue(fieldValue, `${keyPath}-${fieldKey}`)} +
+ ))} +
+ ); + }; + + const renderConfigCards = ( + title: string, + items: Record[], + labelResolver: (key: string) => string, + keyPath: string, + titleResolver?: (item: Record, index: number) => string, + excludedKeys: string[] = [] + ) => { + if (!items.length) return null; + return ( +
+

{title}

+ {items.map((item, index) => { + const itemTitle = titleResolver + ? titleResolver(item, index) + : t("mcpTools.registry.variableFallback", { index: index + 1 }); + return ( +
+

+ {itemTitle} +

+ {renderFieldRows( + item, + labelResolver, + `${keyPath}-${index}`, + excludedKeys + )} +
+ ); + })} +
+ ); + }; + + const renderStructuredValue = ( + value: unknown, + keyPath: string + ): React.ReactNode => { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return {formatHeaderFieldValue(value)}; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return -; + } + return ( +
+ {value.map((item, index) => ( +
+
+ #{index + 1} +
+ {renderStructuredValue(item, `${keyPath}-${index}`)} +
+ ))} +
+ ); + } + + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).filter( + ([, nested]) => hasRenderableValue(nested) + ); + if (entries.length === 0) { + return -; + } + return ( +
+ {entries.map(([nestedKey, nestedValue]) => ( +
+ {nestedKey}:{" "} + {renderStructuredValue(nestedValue, `${keyPath}-${nestedKey}`)} +
+ ))} +
+ ); + } + + return -; + }; + + const resolveRemoteHeaders = (remote: Record) => { + const headers = Array.isArray(remote.headers) ? remote.headers : []; + return normalizeHeaderItems(headers as unknown[]); + }; + + const resolveRemoteVariables = (remote: Record) => { + const variables = remote.variables; + if (!variables || typeof variables !== "object") { + return [] as Array<{ key: string; config: Record }>; + } + + return Object.entries(variables) + .filter(([, value]) => Boolean(value) && typeof value === "object") + .map(([key, value]) => ({ + key, + config: value as Record, + })); + }; + + return ( + <> + +
+
+
+
+

+ {server.name} +

+

+ {formatRegistryVersion(server.version || "")} +

+
+ +
+
+ +
+

{server.description || ""}

+ +

+ {formatRegistryDate(String(officialMeta.publishedAt || ""))} +

+ + {websiteUrl || repositoryUrl ? ( +
+ {websiteUrl ? ( +
+ + {t("mcpTools.registry.website")} + + + {websiteUrl} + +
+ ) : null} + + {repositoryUrl ? ( +
+ + {t("mcpTools.registry.repository")} + + + {repositoryUrl} + +
+ ) : null} +
+ ) : null} + + {displayRemotes.length > 0 ? ( +
+

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

+
+ {displayRemotes.map((remote, index) => { + const remoteRecord = remote as Record; + const remoteHeaders = resolveRemoteHeaders(remoteRecord); + const remoteVariables = + resolveRemoteVariables(remoteRecord); + const remoteType = String(remoteRecord.type || ""); + const remoteUrl = String(remoteRecord.url || ""); + + return ( +
+

+ {remoteType || t("mcpTools.registry.remoteFallback")} +

+

{remoteUrl}

+ {remoteHeaders.length > 0 ? ( +
+

+ {t("mcpTools.registry.remoteHeaders")} +

+ {remoteHeaders.map((header, headerIndex) => ( +
+

+ {typeof header.name === "string" && + header.name.trim() + ? header.name + : t("mcpTools.registry.headerFallback", { + index: headerIndex + 1, + })} +

+
+ {Object.entries(header) + .filter( + ([key, value]) => + key !== "name" && + hasRenderableValue(value) + ) + .map(([key, value]) => ( +
+ + {getHeaderFieldLabel(key)}: + {" "} + {renderStructuredValue( + value, + `${server.name}-${remoteUrl}-${headerIndex}-${key}` + )} +
+ ))} +
+
+ ))} +
+ ) : null} + {remoteVariables.length > 0 ? ( +
+

+ {t("mcpTools.registry.remoteVariables")} +

+ {remoteVariables.map((variable, variableIndex) => ( +
+

+ {variable.key} +

+
+ {Object.entries(variable.config) + .filter(([, value]) => + hasRenderableValue(value) + ) + .map(([fieldKey, fieldValue]) => ( +
+ + {getVariableFieldLabel(fieldKey)}: + {" "} + {renderStructuredValue( + fieldValue, + `${server.name}-${remoteUrl}-${variable.key}-${fieldKey}` + )} +
+ ))} +
+
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} + + {displayPackages.length > 0 ? ( +
+

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

+
+ {displayPackages.map((pkg, index) => ( +
+

+ {String(pkg.identifier || "-")} +

+
+ {Object.entries(pkg) + .filter( + ([fieldKey, value]) => + ![ + "transport", + "runtimeArguments", + "packageArguments", + "environmentVariables", + ].includes(fieldKey) && hasRenderableValue(value) + ) + .map(([fieldKey, fieldValue]) => ( +
+ + {getPackageFieldLabel(fieldKey)}: + {" "} + {renderStructuredValue( + fieldValue, + `${server.name}-${String(pkg.identifier || index)}-${fieldKey}` + )} +
+ ))} +
+ + {pkg.transport && typeof pkg.transport === "object" ? ( +
+

+ {t("mcpTools.registry.packageField.transport")} +

+
+ {renderFieldRows( + pkg.transport as Record, + getVariableFieldLabel, + `${server.name}-${String(pkg.identifier || index)}-transport`, + ["headers", "variables"] + )} +
+ {renderConfigCards( + t("mcpTools.registry.remoteHeaders"), + normalizeRecordItems( + (pkg.transport as Record).headers + ), + getHeaderFieldLabel, + `${server.name}-${String(pkg.identifier || index)}-transport-headers`, + (item, headerIndex) => + typeof item.name === "string" && item.name.trim() + ? item.name + : t("mcpTools.registry.headerFallback", { + index: headerIndex + 1, + }), + ["name"] + )} + {renderConfigCards( + t("mcpTools.registry.remoteVariables"), + Object.entries( + ((pkg.transport as Record) + .variables as Record) || {} + ) + .filter( + ([, value]) => + Boolean(value) && typeof value === "object" + ) + .map(([key, value]) => ({ + key, + ...(value as Record), + })), + getVariableFieldLabel, + `${server.name}-${String(pkg.identifier || index)}-transport-variables`, + (item, variableIndex) => + typeof item.key === "string" && item.key.trim() + ? item.key + : t("mcpTools.registry.variableFallback", { + index: variableIndex + 1, + }), + ["key"] + )} +
+ ) : null} + + {renderConfigCards( + t("mcpTools.registry.packageField.runtimeArguments"), + normalizeRecordItems(pkg.runtimeArguments), + getVariableFieldLabel, + `${server.name}-${String(pkg.identifier || index)}-runtime-arguments`, + (item, argIndex) => + typeof item.name === "string" && item.name.trim() + ? item.name + : t("mcpTools.registry.variableFallback", { + index: argIndex + 1, + }) + )} + + {renderConfigCards( + t("mcpTools.registry.packageField.packageArguments"), + normalizeRecordItems(pkg.packageArguments), + getVariableFieldLabel, + `${server.name}-${String(pkg.identifier || index)}-package-arguments`, + (item, argIndex) => + typeof item.name === "string" && item.name.trim() + ? item.name + : t("mcpTools.registry.variableFallback", { + index: argIndex + 1, + }) + )} + + {(() => { + const env = pkg.environmentVariables; + const envItems = Array.isArray(env) + ? normalizeRecordItems(env) + : env && typeof env === "object" + ? Object.entries( + env as Record + ).map(([key, value]) => ({ key, value })) + : []; + + return renderConfigCards( + t( + "mcpTools.registry.packageField.environmentVariables" + ), + envItems, + getVariableFieldLabel, + `${server.name}-${String(pkg.identifier || index)}-environment-variables`, + (item, envIndex) => + typeof item.name === "string" && item.name.trim() + ? item.name + : typeof item.key === "string" && item.key.trim() + ? item.key + : t("mcpTools.registry.variableFallback", { + index: envIndex + 1, + }), + ["name", "key"] + ); + })()} +
+ ))} +
+
+ ) : null} +
+ +
+ {hasServerJson ? ( + + ) : null} + +
+
+
+ + setShowServerJsonModal(false)} + /> + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryToolbar.tsx b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryToolbar.tsx new file mode 100644 index 000000000..3c0afde92 --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/add/registry/McpRegistryToolbar.tsx @@ -0,0 +1,157 @@ +import { useEffect, useMemo, useState } from "react"; +import { DatePicker, Dropdown, Input, Select, Switch } from "antd"; +import type { MenuProps } from "antd"; +import dayjs from "dayjs"; +import { useTranslation } from "react-i18next"; +import { McpVersionFilterMode } from "@/const/mcpTools"; + +interface McpRegistryToolbarProps { + search: string; + version: string; + updatedSince: string; + includeDeleted: boolean; + page: number; + resultCount: number; + onSearchChange: (value: string) => void; + onVersionChange: (value: string) => void; + onUpdatedSinceChange: (value: string) => void; + onIncludeDeletedChange: (value: boolean) => void; +} + +/** + * Two-line toolbar for the registry browser: + * row 1 — search input + 3 compact filters + * row 2 — paginated result count + "more markets" dropdown + */ +export default function McpRegistryToolbar({ + search, + version, + updatedSince, + includeDeleted, + page, + resultCount, + onSearchChange, + onVersionChange, + onUpdatedSinceChange, + onIncludeDeletedChange, +}: McpRegistryToolbarProps) { + const { t } = useTranslation("common"); + const [versionMode, setVersionMode] = useState( + McpVersionFilterMode.LATEST + ); + + const marketMenuItems: MenuProps["items"] = [ + { + key: "modelscope", + label: ( + + {t("mcpTools.registry.market.modelscope")} + + ), + }, + { + key: "mcp-so", + label: ( + + {t("mcpTools.registry.market.mcpso")} + + ), + }, + ]; + + const updatedSinceDateValue = useMemo(() => { + if (!updatedSince) return null; + const parsed = dayjs(updatedSince); + return parsed.isValid() ? parsed : null; + }, [updatedSince]); + + useEffect(() => { + const value = (version || "").trim().toLowerCase(); + if (!value) setVersionMode(McpVersionFilterMode.ALL); + else if (value === "latest") setVersionMode(McpVersionFilterMode.LATEST); + else setVersionMode(McpVersionFilterMode.LATEST); + }, [version]); + + const handleVersionModeChange = (mode: McpVersionFilterMode) => { + setVersionMode(mode); + onVersionChange(mode === McpVersionFilterMode.LATEST ? "latest" : ""); + }; + + return ( +
+
+ onSearchChange(event.target.value)} + placeholder={t("mcpTools.registry.searchPlaceholder")} + size="large" + allowClear + className="w-full rounded-md lg:flex-1" + /> +
+ setValue(event.target.value)} + onPressEnter={commit} + onBlur={commit} + placeholder={t(placeholderKey)} + className="w-32" + /> + ) : ( + setEditing(true)} + className="m-0 cursor-pointer border-dashed bg-transparent" + > + {t("common.add")} + + )} +
+
+ ); +} diff --git a/frontend/app/[locale]/mcp-tools/components/shared/TransportIcon.tsx b/frontend/app/[locale]/mcp-tools/components/shared/TransportIcon.tsx new file mode 100644 index 000000000..587a96b5c --- /dev/null +++ b/frontend/app/[locale]/mcp-tools/components/shared/TransportIcon.tsx @@ -0,0 +1,55 @@ +import { ContainerOutlined, LinkOutlined } from "@ant-design/icons"; +import { McpTransportType } from "@/const/mcpTools"; + +interface TransportVisual { + Icon: typeof LinkOutlined; + className: string; +} + +/** + * Visual mapping for transport-type icons rendered on MCP cards. + * Only URL and CONTAINER are mapped explicitly; legacy HTTP/SSE values + * fall back to the URL visual. + */ +const TRANSPORT_VISUALS: Record = { + [McpTransportType.URL]: { + Icon: LinkOutlined, + className: "bg-sky-50 text-sky-600", + }, + [McpTransportType.CONTAINER]: { + Icon: ContainerOutlined, + className: "bg-violet-50 text-violet-600", + }, +}; + +const DEFAULT_VISUAL: TransportVisual = { + Icon: LinkOutlined, + className: "bg-sky-50 text-sky-600", +}; + +interface TransportIconProps { + transportType: string; + label?: string; + className?: string; +} + +export default function TransportIcon({ + transportType, + label, + className, +}: TransportIconProps) { + const visual = TRANSPORT_VISUALS[transportType] || DEFAULT_VISUAL; + const Icon = visual.Icon; + + return ( + + + + ); +} diff --git a/frontend/app/[locale]/mcp-tools/page.tsx b/frontend/app/[locale]/mcp-tools/page.tsx index 12691f8f2..ac4da426b 100644 --- a/frontend/app/[locale]/mcp-tools/page.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -1,103 +1,335 @@ "use client"; -import React from "react"; -import { motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { InboxOutlined, CloudUploadOutlined } from "@ant-design/icons"; +import { Button, ConfigProvider, Empty, Input, Segmented, Spin } from "antd"; import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; import { Puzzle } from "lucide-react"; +import { useMcpServicesList } from "@/hooks/mcpTools/useMcpServicesList"; +import { useMyCommunityMcp } from "@/hooks/mcpTools/useMyCommunityMcp"; +import type { CommunityMcpCard, McpServiceItem } from "@/types/mcpTools"; +import { + McpServiceStatus, + McpToolsServicesTab, +} from "@/const/mcpTools"; +import AddMcpServiceModal from "./components/add/AddMcpServiceModal"; +import McpServiceCard from "./components/McpServiceCard"; +import McpServiceDetailModal from "./components/McpServiceDetailModal"; +import McpServicesFilterBar from "./components/McpServicesFilterBar"; +import PublishedServiceCard from "./components/PublishedServiceCard"; +import PublishedServiceDetailModal from "./components/PublishedServiceDetailModal"; -import { useSetupFlow } from "@/hooks/useSetupFlow"; +/** Scoped Ant Design theme for MCP tools (primary buttons, etc.). Segmented uses default styling. */ +const mcpToolsTheme = { + token: { colorPrimary: "#059669", colorInfo: "#0d9488" }, +}; -/** - * McpToolsContent - MCP tools management coming soon page - * This will allow admins to manage MCP servers and tools - */ -export default function McpToolsContent({}) { +export default function McpToolsPage() { const { t } = useTranslation("common"); - - // Use custom hook for common setup flow logic const { pageVariants, pageTransition } = useSetupFlow(); + const [tab, setTab] = useState(McpToolsServicesTab.IMPORTED); + const [showAddModal, setShowAddModal] = useState(false); + const [selectedImported, setSelectedImported] = + useState(null); + const [selectedPublished, setSelectedPublished] = + useState(null); + + const list = useMcpServicesList(); + const myPublished = useMyCommunityMcp(tab === McpToolsServicesTab.PUBLISHED); + + const handleToggled = async (mcpId: number) => { + const result = await list.refetch(); + const updated = result.data?.find((s) => s.mcpId === mcpId); + if (updated && detailMcpIdRef.current === mcpId) { + setSelectedImported(updated); + } + }; + + const detailMcpIdRef = useRef(null); + const openDetail = (service: McpServiceItem) => { + detailMcpIdRef.current = service.mcpId; + setSelectedImported(service); + }; + const closeDetail = () => { + detailMcpIdRef.current = null; + setSelectedImported(null); + }; + + const handleSelectPublished = (item: CommunityMcpCard) => { + setSelectedPublished(item); + }; + + const closePublished = () => { + setSelectedPublished(null); + }; + + const resultCount = + tab === McpToolsServicesTab.IMPORTED + ? list.filteredServices.length + : myPublished.filteredItems.length; + return ( - <> -
+ +
+ {/* + Own scroll + scrollbar-gutter on this page only: avoids layout shift when + tabs change height, without changing global ClientLayout. + */} +
-
- {/* Icon */} +
+ {/* Title + add service (same row on sm+) */} - +
+
+ +
+
+

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

+

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

+
+
+
- {/* Title */} - - {t("mcpTools.comingSoon.title")} - + {/* Tab switch + result count (same row) */} +
+ setTab(value as McpToolsServicesTab)} + options={[ + { + value: McpToolsServicesTab.IMPORTED, + label: ( + + + {t("mcpTools.page.tab.imported")} + + ), + }, + { + value: McpToolsServicesTab.PUBLISHED, + label: ( + + + {t("mcpTools.page.tab.published")} + + ), + }, + ]} + className="h-9 w-full max-w-xs rounded-md border border-slate-200 bg-slate-100 p-[2px] text-sm shadow-sm sm:w-auto [&_.ant-segmented-group]:h-full [&_.ant-segmented-item]:rounded-md [&_.ant-segmented-item-label]:flex [&_.ant-segmented-item-label]:items-center [&_.ant-segmented-item-label]:px-3 [&_.ant-segmented-item-label]:text-sm [&_.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]" + /> + + {t("mcpTools.page.resultCount", { count: resultCount })} + +
- {/* Description */} - - {t("mcpTools.comingSoon.description")} - + {tab === McpToolsServicesTab.IMPORTED ? ( + + ) : ( + + )} - {/* Feature list */} - -
  • - - - {t("mcpTools.comingSoon.feature1")} - -
  • -
  • - - - {t("mcpTools.comingSoon.feature2")} - -
  • -
  • - - - {t("mcpTools.comingSoon.feature3")} - -
  • -
    - - {/* Coming soon badge */} - - {t("mcpTools.comingSoon.badge")} - + {selectedImported ? ( + + ) : null} + + + + setShowAddModal(false)} + />
    +
    + + ); +} + +type ServicesListController = ReturnType; + +function ImportedView({ + list, + onSelect, +}: { + list: ServicesListController; + onSelect: (service: McpServiceItem) => void; +}) { + const { t } = useTranslation("common"); + + return ( + <> + list.updateFilter("search", value)} + searchPlaceholder={String(t("mcpTools.page.searchPlaceholder"))} + filters={ + list.updateFilter("source", value)} + onTransportChange={(value) => list.updateFilter("transport", value)} + onTagChange={(value) => list.updateFilter("tag", value)} + /> + } + /> + + {list.loading ? ( + {t("mcpTools.page.loading")} + ) : list.filteredServices.length === 0 ? ( + {t("mcpTools.page.empty")} + ) : ( + + {list.filteredServices.map((service) => ( + + ))} + + )} ); } + +function PublishedView({ + myPublished, + onSelect, +}: { + myPublished: ReturnType; + onSelect: (item: CommunityMcpCard) => void; +}) { + const { t } = useTranslation("common"); + + return ( + <> + myPublished.updateFilter("search", value)} + searchPlaceholder={String(t("mcpTools.community.searchPlaceholder"))} + filters={ + + myPublished.updateFilter("transport", value) + } + onTagChange={(value) => myPublished.updateFilter("tag", value)} + /> + } + /> + + {myPublished.loading ? ( + + + + ) : myPublished.filteredItems.length === 0 ? ( + + + + ) : ( + + {myPublished.filteredItems.map((item) => ( + + ))} + + )} + + ); +} + +function SearchAndFilterRow({ + searchValue, + onSearchChange, + searchPlaceholder, + filters, +}: { + searchValue: string; + onSearchChange: (value: string) => void; + searchPlaceholder: string; + filters: React.ReactNode; +}) { + return ( +
    + onSearchChange(event.target.value)} + placeholder={searchPlaceholder} + size="middle" + allowClear + className="w-full rounded-md lg:flex-1" + /> + {filters ? ( +
    {filters}
    + ) : null} +
    + ); +} + +function ResponsiveCardGrid({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function PlaceholderBox({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} diff --git a/frontend/app/[locale]/tenant-resources/components/resources/McpList.tsx b/frontend/app/[locale]/tenant-resources/components/resources/McpList.tsx index 3c65a5ed8..d44765924 100644 --- a/frontend/app/[locale]/tenant-resources/components/resources/McpList.tsx +++ b/frontend/app/[locale]/tenant-resources/components/resources/McpList.tsx @@ -98,6 +98,7 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { const [addingContainer, setAddingContainer] = useState(false); const [containerConfigJson, setContainerConfigJson] = useState(""); const [containerPort, setContainerPort] = useState(undefined); + const [containerServiceName, setContainerServiceName] = useState(""); const [logsModalVisible, setLogsModalVisible] = useState(false); const [currentContainerId, setCurrentContainerId] = useState(""); @@ -265,8 +266,7 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { setUpdatingServer(true); const result = await handleUpdateServer( - editingServer.service_name, - editingServer.mcp_url, + editingServer.mcp_id, name.trim(), url.trim(), authorizationToken @@ -304,10 +304,11 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { } setAddingContainer(true); - const result = await handleAddContainer(config, containerPort); + const result = await handleAddContainer(config, containerPort, containerServiceName.trim() || undefined); if (result.success) { setContainerConfigJson(""); setContainerPort(undefined); + setContainerServiceName(""); setAddModalVisible(false); message.success(result.messageKey ? t(result.messageKey) : t("mcpService.message.addContainerSuccess")); } else { @@ -497,9 +498,28 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { title: t("mcpConfig.serverList.column.url"), dataIndex: "mcp_url", key: "mcp_url", - width: "35%", + width: "30%", ellipsis: true, }, + { + title: t("mcpConfig.serverList.column.enabled"), + key: "enabled", + width: "10%", + render: (_: any, record: McpServer) => { + const isEnabled = Boolean(record.status); + return isEnabled ? ( + + {t("mcpConfig.serverList.enabled.yes")} + + ) : ( + + + {t("mcpConfig.serverList.enabled.no")} + + + ); + }, + }, { title: t("mcpConfig.serverList.column.status"), key: "status", @@ -528,7 +548,7 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { { title: t("mcpConfig.serverList.column.action"), key: "action", - width: "25%", + width: "20%", render: (_: any, record: McpServer) => { const key = `${record.service_name}__${record.mcp_url}`; return ( @@ -735,7 +755,6 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { size="small" pagination={{ pageSize: 7 }} locale={{ emptyText: t("mcpConfig.serverList.empty") }} - scroll={{ x: true }} />
    @@ -853,7 +872,16 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { style={{ fontFamily: "monospace", fontSize: 12 }} />
    - {t("mcpConfig.addContainer.port")}: + {t("mcpConfig.addContainer.serviceName")}: + setContainerServiceName(e.target.value)} + style={{ width: 150 }} + maxLength={20} + disabled={actionsLocked} + /> + {t("mcpConfig.addContainer.port")}:
    + type="primary" + onClick={onAddContainer} + loading={addingContainer || updatingTools} + disabled={actionsLocked} + icon={addingContainer || updatingTools ? : } + > + {t("mcpConfig.addContainer.button.add")} +
    diff --git a/frontend/components/mcp/McpContainerLogsModal.tsx b/frontend/components/mcp/McpContainerLogsModal.tsx index 53ba70be3..b85344073 100644 --- a/frontend/components/mcp/McpContainerLogsModal.tsx +++ b/frontend/components/mcp/McpContainerLogsModal.tsx @@ -97,7 +97,7 @@ export default function McpContainerLogsModal({ width={800} footer={[]} > - +
    {t("mcpConfig.modal.close")}]}
         >
           
             {isLoading ? (
               
    - +
    ) : filteredKnowledgeBases.length > 0 ? (
    diff --git a/frontend/const/mcpTools.ts b/frontend/const/mcpTools.ts new file mode 100644 index 000000000..a8c9afec2 --- /dev/null +++ b/frontend/const/mcpTools.ts @@ -0,0 +1,126 @@ +import type { ModalProps } from "antd"; + +export enum McpSource { + LOCAL = "local", + REGISTRY = "mcp_registry", + COMMUNITY = "community", +} + +export enum McpTransportType { + HTTP = "http", + SSE = "sse", + URL = "url", + 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 enum McpVersionFilterMode { + ALL = "all", + LATEST = "latest", + CUSTOM = "custom", +} + +export enum McpServerStatus { + ACTIVE = "active", + DEPRECATED = "deprecated", + UNKNOWN = "unknown", +} + +/** Main MCP tools page: imported workspace services vs. published community list. */ +export enum McpToolsServicesTab { + IMPORTED = "imported", + PUBLISHED = "published", +} + +/** Sentinel value used by toolbar `Select`s to mean "no filter applied". */ +export const FILTER_ALL = "all"; + +/** Field length limits shared by every MCP form (used by rule builders). */ +export const MCP_FIELD_LIMITS = { + NAME: 100, + DESCRIPTION: 5000, + URL: 500, + AUTH_TOKEN: 500, + QUICK_ADD_FIELD: 2000, + VERSION: 100, +} as const; + +/** Valid range for a container port (TCP). */ +export const MCP_PORT_RANGE = { MIN: 1, MAX: 65535 } as const; + +/** Debounce for all text-filter inputs on MCP browsers. */ +export const MCP_SEARCH_DEBOUNCE_MS = 350; + +/** Add MCP modal width when the local (custom) tab is active. */ +export const MCP_ADD_SERVICE_MODAL_WIDTH_LOCAL = 560; + +/** Add MCP modal width for registry / community browser tabs. */ +export const MCP_ADD_SERVICE_MODAL_WIDTH_MARKETS = 1100; + +/** Fixed content column width for the local add-MCP form (matches local tab modal). */ +export const MCP_ADD_SERVICE_LOCAL_SECTION_WIDTH_PX = 560; + +/** Modal `wrapClassName`: whole dialog scrolls; clears Ant Design max-height on content. */ +export const MCP_TOOLS_MODAL_WRAP_CLASS = + "max-h-[100dvh] overflow-y-auto overflow-x-hidden py-6 [&_.ant-modal]:max-h-none [&_.ant-modal-content]:max-h-none"; + +export const MCP_TOOLS_MODAL_MASK_STYLE = { + background: "rgba(15,23,42,0.55)", + backdropFilter: "blur(3px)", +} as const; + +export const MCP_TOOLS_MODAL_BODY_CHROME = { + padding: 0, + maxHeight: "none", + overflow: "visible", +} as const; + +export const MCP_TOOLS_MODAL_BODY_SCROLL_UNLOCK = { + maxHeight: "none", + overflow: "visible", +} as const; + +export function mcpToolsModalChromeStyles(): NonNullable { + return { + mask: { ...MCP_TOOLS_MODAL_MASK_STYLE }, + body: { ...MCP_TOOLS_MODAL_BODY_CHROME }, + }; +} + +/** Inline height for MCP grid cards (avoids Tailwind scanning `frontend/const/`). */ +export const MCP_GRID_CARD_OUTER_STYLE = { + height: "12rem", +}; + +/** Layout and chrome for MCP grid cards; pair with `MCP_GRID_CARD_OUTER_STYLE` for height. */ +export const MCP_GRID_CARD_OUTER = + "group flex w-full shrink-0 cursor-pointer flex-col overflow-hidden rounded-md border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md"; + +/** + * Shared React Query cache keys for the MCP tools feature. Centralised so every + * hook touching the same data invalidates the same slot. + */ +export const MCP_TOOLS_QUERY_KEYS = { + services: ["mcp-tools", "services"] as const, + tools: (mcpId: number) => ["mcp-tools", "service-tools", mcpId] as const, + registryList: ["mcp-tools", "registry"] as const, + communityList: ["mcp-tools", "community"] as const, + communityTags: ["mcp-tools", "community-tags"] as const, + myCommunity: ["mcp-tools", "my-community"] as const, +}; diff --git a/frontend/hooks/agent/useToolList.ts b/frontend/hooks/agent/useToolList.ts index 30e5a2d74..1a9c00dba 100644 --- a/frontend/hooks/agent/useToolList.ts +++ b/frontend/hooks/agent/useToolList.ts @@ -17,6 +17,8 @@ export function useToolList(options?: { enabled?: boolean; staleTime?: number }) return res.data || []; }, staleTime: options?.staleTime ?? 60_000, + refetchOnMount: "always", + refetchOnWindowFocus: true, enabled: options?.enabled ?? true, }); diff --git a/frontend/hooks/mcpTools/useContainerPortAvailability.ts b/frontend/hooks/mcpTools/useContainerPortAvailability.ts new file mode 100644 index 000000000..f916ee924 --- /dev/null +++ b/frontend/hooks/mcpTools/useContainerPortAvailability.ts @@ -0,0 +1,91 @@ +// hooks/useContainerPortAvailability.ts + +import { useCallback, useEffect, useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + checkMcpContainerPortConflictService, + suggestMcpContainerPortService +} from "@/services/mcpToolsService"; +import { isValidPort } from "@/lib/mcpTools"; + +export async function checkContainerPortAvailable( + port: number | undefined +): Promise { + if (!isValidPort(port)) return false; + const result = await checkMcpContainerPortConflictService({ port }); + return result.data.available; +} + +interface UseContainerPortAvailabilityParams { + enabled?: boolean; + containerPort: number | undefined; + setContainerPort: (value: number | undefined) => void; +} + +export function useContainerPortAvailability({ + enabled = true, + containerPort, + setContainerPort, +}: UseContainerPortAvailabilityParams) { + const { t } = useTranslation("common"); + const [portCheckLoading, setPortCheckLoading] = useState(false); + const [portAvailable, setPortAvailable] = useState(null); + const [suggesting, setSuggesting] = useState(false); + const timerRef = useRef>(); + + // Check port + const checkPort = useCallback(async (port: number) => { + setPortCheckLoading(true); + try { + const result = await checkMcpContainerPortConflictService({ port }); + setPortAvailable(result.data.available); + } catch (error) { + setPortAvailable(false); + } finally { + setPortCheckLoading(false); + } + }, []); + + // Anti-shake Auto Check + useEffect(() => { + if (!enabled || !isValidPort(containerPort)) { + // Illegal or not enabled, clear status + setPortAvailable(null); + setPortCheckLoading(false); + return; + } + + // Legal port, check after debounce + + setPortCheckLoading(true); + timerRef.current = setTimeout(() => { + checkPort(containerPort); + }, 500); + + return () => { + clearTimeout(timerRef.current); + }; + }, [containerPort, enabled, checkPort]); + + // Suggest port + const suggestPort = useCallback(async () => { + setSuggesting(true); + try { + const result = await suggestMcpContainerPortService(); + const port = result.data.port; + if (isValidPort(port)) { + setContainerPort(port); + } + } catch (error) { + } finally { + setSuggesting(false); + } + }, [setContainerPort]); + + return { + portCheckLoading, + portAvailable, + suggesting, + suggestPort, + }; +} \ No newline at end of file diff --git a/frontend/hooks/mcpTools/useMcpAddLocal.ts b/frontend/hooks/mcpTools/useMcpAddLocal.ts new file mode 100644 index 000000000..5b356f03d --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpAddLocal.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { App } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import log from "@/lib/logger"; +import { + addContainerMcpToolService, + addMcpToolService, + parseContainerMcpConfigJson, +} from "@/services/mcpToolsService"; +import { checkContainerPortAvailable } from "./useContainerPortAvailability"; +import { McpSource, McpTransportType } from "@/const/mcpTools"; +import type { LocalAddMcpDraft } from "@/types/mcpTools"; +import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; +import { refreshToolListWithToast } from "./useRefreshToolListWithToast"; + +interface UseMcpAddLocalParams { + onSuccess: () => void; +} + +/** + * Submission mutation for the "Add local MCP" form. The component owns the + * draft; this hook only cares about the network call + cache invalidation. + */ +export function useMcpAddLocal({ onSuccess }: UseMcpAddLocalParams) { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + const [submitting, setSubmitting] = useState(false); + + const submit = async (draft: LocalAddMcpDraft): Promise => { + const trimmedName = draft.name.trim(); + if (!trimmedName) { + message.warning(t("mcpTools.add.validate.nameRequired")); + return false; + } + + const isContainer = draft.transportType === McpTransportType.CONTAINER; + if (isContainer) { + const available = await checkContainerPortAvailable(draft.containerPort); + if (!available) { + message.error( + t("mcpTools.addModal.portOccupied", { port: draft.containerPort }) + ); + return false; + } + } + + setSubmitting(true); + try { + if (isContainer) { + const mcpConfig = parseContainerMcpConfigJson(draft.containerConfigJson); + if (!mcpConfig) { + message.error(t("mcpTools.add.error.containerJsonInvalid")); + return false; + } + + await addContainerMcpToolService({ + name: trimmedName, + description: draft.description ?? "", + tags: draft.tags, + source: McpSource.LOCAL, + authorization_token: draft.authorizationToken?.trim() || undefined, + port: draft.containerPort as number, + mcp_config: mcpConfig, + }); + } else { + await addMcpToolService({ + name: trimmedName, + description: draft.description ?? "", + source: McpSource.LOCAL, + server_url: draft.serverUrl.trim(), + authorization_token: draft.authorizationToken?.trim() || undefined, + tags: draft.tags, + }); + } + + message.success(t("mcpTools.add.success")); + queryClient.invalidateQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.services, + }); + await refreshToolListWithToast({ + message, + t, + toastKey: "mcp-tools-refresh-tools-add-local", + }); + onSuccess(); + return true; + } catch (error) { + log.error("[useMcpAddLocal] Failed to add service", { error }); + message.error(t("mcpTools.add.failed")); + return false; + } finally { + setSubmitting(false); + } + }; + + return { submit, submitting }; +} diff --git a/frontend/hooks/mcpTools/useMcpCommunityBrowser.ts b/frontend/hooks/mcpTools/useMcpCommunityBrowser.ts new file mode 100644 index 000000000..aec9ad9cc --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpCommunityBrowser.ts @@ -0,0 +1,149 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + fetchCommunityMcpCards, + fetchCommunityMcpTagStats, +} from "@/services/mcpToolsService"; +import type { + CommunityMcpCard, + McpTagStat, + McpTransportFilter, +} from "@/types/mcpTools"; +import { FILTER_ALL } from "@/const/mcpTools"; +import { MCP_SEARCH_DEBOUNCE_MS, MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +export type CommunityTransportFilter = McpTransportFilter; + +interface CommunityFilters { + search: string; + transport: McpTransportFilter; + tag: string; +} + +const INITIAL_FILTERS: CommunityFilters = { + search: "", + transport: FILTER_ALL, + tag: FILTER_ALL, +}; + +/** + * Browsing state (search + filters + cursor pagination + tag stats) for the + * community MCP list. + */ +export function useMcpCommunityBrowser(enabled: boolean) { + const [filters, setFilters] = useState(INITIAL_FILTERS); + const [debouncedSearch, setDebouncedSearch] = useState( + INITIAL_FILTERS.search + ); + const [cursorHistory, setCursorHistory] = useState>([ + null, + ]); + const [pageIndex, setPageIndex] = useState(0); + + useEffect(() => { + const timer = window.setTimeout( + () => setDebouncedSearch(filters.search), + MCP_SEARCH_DEBOUNCE_MS + ); + return () => window.clearTimeout(timer); + }, [filters.search]); + + useEffect(() => { + setCursorHistory([null]); + setPageIndex(0); + }, [debouncedSearch, filters.transport, filters.tag]); + + const query = useQuery({ + queryKey: [ + ...MCP_TOOLS_QUERY_KEYS.communityList, + debouncedSearch, + filters.transport, + filters.tag, + cursorHistory[pageIndex], + ], + enabled, + queryFn: async () => { + const result = await fetchCommunityMcpCards({ + search: debouncedSearch || undefined, + transportType: filters.transport === FILTER_ALL ? undefined : filters.transport, + tag: filters.tag === FILTER_ALL ? undefined : filters.tag, + cursor: cursorHistory[pageIndex], + }); + return result.data; + }, + staleTime: 10_000, + refetchOnWindowFocus: false, + }); + + const tagStatsQuery = useQuery({ + queryKey: [...MCP_TOOLS_QUERY_KEYS.communityTags], + enabled, + queryFn: async () => { + const result = await fetchCommunityMcpTagStats(); + return result.data; + }, + staleTime: 60_000, + }); + + const services: CommunityMcpCard[] = useMemo( + () => query.data?.items ?? [], + [query.data?.items] + ); + const nextCursor = query.data?.nextCursor ?? null; + const tagStats: McpTagStat[] = useMemo( + () => tagStatsQuery.data ?? [], + [tagStatsQuery.data] + ); + + const hasPrevPage = pageIndex > 0; + const hasNextPage = Boolean(nextCursor); + + const nextPage = useCallback(() => { + if (!nextCursor) return; + setCursorHistory((prev) => { + const truncated = prev.slice(0, pageIndex + 1); + return [...truncated, nextCursor]; + }); + setPageIndex((prev) => prev + 1); + }, [nextCursor, pageIndex]); + + const prevPage = useCallback(() => { + setPageIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const updateFilter = ( + key: K, + value: CommunityFilters[K] + ) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + return useMemo( + () => ({ + services, + tagStats, + loading: query.isLoading || query.isFetching, + filters, + updateFilter, + page: pageIndex + 1, + hasPrevPage, + hasNextPage, + nextPage, + prevPage, + }), + [ + services, + tagStats, + query.isLoading, + query.isFetching, + filters, + pageIndex, + hasPrevPage, + hasNextPage, + nextPage, + prevPage, + ] + ); +} diff --git a/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts b/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts new file mode 100644 index 000000000..a2638235c --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts @@ -0,0 +1,151 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { App } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import log from "@/lib/logger"; +import { + addContainerMcpToolService, + addMcpToolService, + parseContainerMcpConfigJson, +} from "@/services/mcpToolsService"; +import { checkContainerPortAvailable } from "./useContainerPortAvailability"; +import { McpSource, McpTransportType } from "@/const/mcpTools"; +import type { CommunityMcpCard, CommunityQuickAddDraft } from "@/types/mcpTools"; +import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; +import { refreshToolListWithToast } from "./useRefreshToolListWithToast"; + +interface UseMcpCommunityQuickAddParams { + onSuccess: () => void; +} + +const draftFromSource = ( + service: CommunityMcpCard +): CommunityQuickAddDraft => ({ + name: service.name || "", + description: service.description || "", + transportType: + service.transportType === McpTransportType.CONTAINER ? McpTransportType.CONTAINER : McpTransportType.URL, + serverUrl: service.serverUrl || "", + authorizationToken: "", + containerConfigJson: service.configJson ? JSON.stringify(service.configJson, null, 2) : "", + containerPort: undefined, + tags: service.tags || [], + version: service.version || undefined, + registryJson: service.registryJson, +}); + +/** + * Confirmation modal state + submission flow for adding a community MCP into + * the local workspace. + */ +export function useMcpCommunityQuickAdd({ + onSuccess, +}: UseMcpCommunityQuickAddParams) { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + + const [source, setSource] = useState(null); + const [draft, setDraft] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const open = useCallback((service: CommunityMcpCard) => { + setSource(service); + setDraft(draftFromSource(service)); + }, []); + + const close = useCallback(() => { + setSource(null); + setDraft(null); + }, []); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + }, []); + + const confirm = useCallback(async () => { + if (!draft || !source) return; + const name = draft.name.trim(); + if (!name) { + message.warning(t("mcpTools.add.validate.nameRequired")); + return; + } + + const isContainer = draft.transportType === McpTransportType.CONTAINER; + if (isContainer) { + const available = await checkContainerPortAvailable(draft.containerPort); + if (!available) { + message.error( + t("mcpTools.addModal.portOccupied", { port: draft.containerPort }) + ); + return; + } + } + + setSubmitting(true); + try { + if (isContainer) { + const mcpConfig = parseContainerMcpConfigJson( + draft.containerConfigJson ?? "" + ); + if (!mcpConfig) { + message.error(t("mcpTools.add.error.containerJsonInvalid")); + return; + } + await addContainerMcpToolService({ + name, + description: draft.description ?? "", + tags: draft.tags, + source: McpSource.COMMUNITY, + authorization_token: draft.authorizationToken?.trim() || undefined, + registry_json: draft.registryJson, + port: draft.containerPort as number, + mcp_config: mcpConfig, + }); + } else { + await addMcpToolService({ + name, + description: draft.description ?? "", + source: McpSource.COMMUNITY, + server_url: draft.serverUrl.trim(), + authorization_token: draft.authorizationToken?.trim() || undefined, + tags: draft.tags, + version: draft.version, + registry_json: draft.registryJson, + }); + } + + message.success(t("mcpTools.add.success")); + queryClient.invalidateQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.services, + }); + await refreshToolListWithToast({ + message, + t, + toastKey: "mcp-tools-refresh-tools-add-community", + }); + onSuccess(); + close(); + } catch (error) { + log.error("[useMcpCommunityQuickAdd] Failed to add community service", { + error, + }); + message.error(t("mcpTools.add.failed")); + } finally { + setSubmitting(false); + } + }, [close, draft, message, onSuccess, queryClient, source, t]); + + return { + visible: Boolean(source), + source, + draft, + updateDraft, + open, + close, + confirm, + submitting, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpFormRules.ts b/frontend/hooks/mcpTools/useMcpFormRules.ts new file mode 100644 index 000000000..def83bee3 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpFormRules.ts @@ -0,0 +1,143 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { Rule } from "antd/es/form"; +import { MCP_FIELD_LIMITS, MCP_PORT_RANGE } from "@/const/mcpTools"; +import { isHttpUrl, isValidPort } from "@/lib/mcpTools"; +import { parseContainerMcpConfigJson } from "@/services/mcpToolsService"; + +/** + * Returns all AntD Form `Rule[]` arrays used across MCP add / edit forms. + * + * Using a hook (rather than plain functions) means callers never have to + * thread a translator around — `useTranslation` is called once here and the + * translated messages are memoised per-render. + */ +export function useMcpFormRules() { + const { t } = useTranslation("common"); + + return useMemo( + () => ({ + name: [ + { + required: true, + whitespace: true, + message: t("mcpTools.add.validate.nameRequired"), + }, + { + type: "string", + max: MCP_FIELD_LIMITS.NAME, + message: t("mcpTools.add.validate.nameMaxLength"), + }, + ] as Rule[], + + description: [ + { + type: "string", + max: MCP_FIELD_LIMITS.DESCRIPTION, + message: t("mcpTools.add.validate.descriptionMaxLength"), + }, + ] as Rule[], + + authToken: [ + { + type: "string", + max: MCP_FIELD_LIMITS.AUTH_TOKEN, + message: t("mcpTools.add.validate.authorizationTokenMaxLength"), + }, + ] as Rule[], + + httpUrl: [ + { + validator: async (_rule: Rule, value: unknown) => { + const text = String(value || "").trim(); + if (!text) + throw new Error(t("mcpTools.add.validate.httpUrlRequired")); + if (text.length > MCP_FIELD_LIMITS.URL) + throw new Error(t("mcpTools.add.validate.httpUrlMaxLength")); + if (!isHttpUrl(text)) + throw new Error(t("mcpTools.add.validate.httpUrlFormat")); + }, + }, + ] as Rule[], + + containerPort: [ + { + validator: async (_rule: Rule, value: unknown) => { + if (value === undefined || value === null || value === "") { + throw new Error(t("mcpTools.add.validate.containerRequired")); + } + const port = Number(value); + if ( + !isValidPort(port) + ) { + throw new Error(t("mcpTools.add.validate.containerPortRange")); + } + }, + }, + ] as Rule[], + + containerConfig: [ + { + validator: async (_rule: Rule, value: unknown) => { + const text = String(value || "").trim(); + if (!text) + throw new Error( + t("mcpTools.add.validate.containerConfigRequired") + ); + if (!parseContainerMcpConfigJson(text)) { + throw new Error(t("mcpTools.add.error.containerJsonInvalid")); + } + }, + }, + ] as Rule[], + + /** + * Rules for a free-text variable/argument inside the registry + * quick-add picker. `fieldLabel` is interpolated into the required + * error message so the user sees which field they missed. + */ + quickAddField: (fieldLabel: string, required: boolean): Rule[] => [ + ...(required + ? [ + { + required: true, + whitespace: true, + message: t( + "mcpTools.registry.quickAddPicker.variableRequiredMissing", + { key: fieldLabel } + ), + } as Rule, + ] + : []), + { + type: "string" as const, + max: MCP_FIELD_LIMITS.QUICK_ADD_FIELD, + message: t("mcpTools.registry.quickAddPicker.fieldMaxLength"), + }, + ], + + /** Optional version string (publish / my-community forms); empty is allowed. */ + version: [ + { + validator: async (_rule: Rule, value: unknown) => { + const text = String(value || "").trim(); + if (!text) return; + if (text.length > MCP_FIELD_LIMITS.VERSION) { + throw new Error(t("mcpTools.community.mine.versionMaxLength")); + } + }, + }, + ] as Rule[], + + transportType: [ + { + required: true, + message: t("mcpTools.add.validate.transportTypeRequired"), + }, + ] as Rule[], + }), + [t] + ); +} diff --git a/frontend/hooks/mcpTools/useMcpRegistryBrowser.ts b/frontend/hooks/mcpTools/useMcpRegistryBrowser.ts new file mode 100644 index 000000000..1e1d1d251 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpRegistryBrowser.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchRegistryMcpCards } from "@/services/mcpToolsService"; +import type { RegistryMcpCard } from "@/types/mcpTools"; +import { MCP_SEARCH_DEBOUNCE_MS, MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +interface RegistryFilters { + search: string; + version: string; + updatedSince: string; + includeDeleted: boolean; +} + +const INITIAL_FILTERS: RegistryFilters = { + search: "", + version: "latest", + updatedSince: "", + includeDeleted: false, +}; + +/** + * Browsing state (search + filters + cursor pagination) for the MCP registry. + * The caller renders whatever list/card UI it likes; this hook only maintains + * the fetch and pagination. + */ +export function useMcpRegistryBrowser(enabled: boolean) { + const [filters, setFilters] = useState(INITIAL_FILTERS); + const [debouncedSearch, setDebouncedSearch] = useState( + INITIAL_FILTERS.search + ); + const [cursorHistory, setCursorHistory] = useState>([ + null, + ]); + const [pageIndex, setPageIndex] = useState(0); + + useEffect(() => { + const timer = window.setTimeout( + () => setDebouncedSearch(filters.search), + MCP_SEARCH_DEBOUNCE_MS + ); + return () => window.clearTimeout(timer); + }, [filters.search]); + + useEffect(() => { + setCursorHistory([null]); + setPageIndex(0); + }, [ + debouncedSearch, + filters.version, + filters.updatedSince, + filters.includeDeleted, + ]); + + const query = useQuery({ + queryKey: [ + ...MCP_TOOLS_QUERY_KEYS.registryList, + debouncedSearch, + filters.version, + filters.updatedSince, + filters.includeDeleted, + cursorHistory[pageIndex], + ], + enabled, + queryFn: async () => { + const result = await fetchRegistryMcpCards({ + search: debouncedSearch || undefined, + version: filters.version || undefined, + updatedSince: filters.updatedSince || undefined, + includeDeleted: filters.includeDeleted, + cursor: cursorHistory[pageIndex], + }); + return result.data; + }, + staleTime: 10_000, + refetchOnWindowFocus: false, + }); + + const services: RegistryMcpCard[] = useMemo( + () => query.data?.items ?? [], + [query.data?.items] + ); + const nextCursor = query.data?.nextCursor ?? null; + + const hasPrevPage = pageIndex > 0; + const hasNextPage = Boolean(nextCursor); + + const nextPage = useCallback(() => { + if (!nextCursor) return; + setCursorHistory((prev) => { + const truncated = prev.slice(0, pageIndex + 1); + return [...truncated, nextCursor]; + }); + setPageIndex((prev) => prev + 1); + }, [nextCursor, pageIndex]); + + const prevPage = useCallback(() => { + setPageIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const updateFilter = ( + key: K, + value: RegistryFilters[K] + ) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + return useMemo( + () => ({ + services, + loading: query.isLoading || query.isFetching, + filters, + updateFilter, + page: pageIndex + 1, + hasPrevPage, + hasNextPage, + nextPage, + prevPage, + }), + [ + services, + query.isLoading, + query.isFetching, + filters, + pageIndex, + hasPrevPage, + hasNextPage, + nextPage, + prevPage, + ] + ); +} diff --git a/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts b/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts new file mode 100644 index 000000000..a1421e80a --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { App } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import log from "@/lib/logger"; +import { + addContainerMcpToolService, + addMcpToolService, +} from "@/services/mcpToolsService"; +import { checkContainerPortAvailable } from "./useContainerPortAvailability"; +import { McpSource, McpTransportType } from "@/const/mcpTools"; +import { refreshToolListWithToast } from "./useRefreshToolListWithToast"; +import { + buildInitialQuickAddValues, + collectPackageEnvValues, + findMissingRequiredField, + hasUnresolvedUrlTemplate, + inferContainerRuntimeCommand, + normalizeServerKey, + resolveAuthorizationFromHeaders, + resolveHttpServerUrl, + resolveQuickAddOptions, + resolveRuntimeArgs, +} from "@/lib/mcpTools"; +import type { + McpContainerConfigPayload, + RegistryMcpCard, + RegistryQuickAddOption, +} from "@/types/mcpTools"; +import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +interface UseMcpRegistryQuickAddParams { + onSuccess: () => void; +} + +/** + * Picker + submission flow launched from the registry list. The component + * owning this hook just renders a modal and wires in the returned values. + */ +export function useMcpRegistryQuickAdd({ + onSuccess, +}: UseMcpRegistryQuickAddParams) { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + + const [candidate, setCandidate] = useState(null); + const [options, setOptions] = useState([]); + const [selectedKey, setSelectedKey] = useState(""); + const [values, setValues] = useState>({}); + const [containerPort, setContainerPort] = useState( + undefined + ); + const [submitting, setSubmitting] = useState(false); + + const selectedOption = useMemo( + () => options.find((option) => option.key === selectedKey) || null, + [options, selectedKey] + ); + + const open = useCallback( + (service: RegistryMcpCard) => { + const nextOptions = resolveQuickAddOptions(service); + if (nextOptions.length === 0) { + message.info(t("mcpTools.registry.quickAddUnsupported")); + return; + } + setCandidate(service); + setOptions(nextOptions); + const firstKey = nextOptions[0].key; + setSelectedKey(firstKey); + setValues(buildInitialQuickAddValues(nextOptions[0])); + setContainerPort(undefined); + }, + [message, t] + ); + + const close = useCallback(() => { + setCandidate(null); + setOptions([]); + setSelectedKey(""); + setValues({}); + setContainerPort(undefined); + }, []); + + const chooseOption = useCallback( + (key: string) => { + setSelectedKey(key); + const next = options.find((option) => option.key === key) || null; + setValues(buildInitialQuickAddValues(next)); + }, + [options] + ); + + const setValue = useCallback((formKey: string, value: string) => { + setValues((prev) => ({ ...prev, [formKey]: value })); + }, []); + + const confirm = useCallback(async () => { + if (!candidate || !selectedOption) return; + const tags: string[] = []; + + const allFields = [ + ...(selectedOption.remoteVariables || []), + ...(selectedOption.remoteHeaders || []), + ...(selectedOption.packageEnvironmentVariables || []), + ...(selectedOption.packageTransportHeaders || []), + ...(selectedOption.packageTransportVariables || []), + ]; + const missingField = findMissingRequiredField(allFields, values); + if (missingField) { + message.warning( + t("mcpTools.registry.quickAddPicker.variableRequiredMissing", { + key: missingField.key, + }) + ); + return; + } + + setSubmitting(true); + try { + if (selectedOption.transportType === McpTransportType.CONTAINER) { + const available = await checkContainerPortAvailable(containerPort); + if (!available) { + message.error( + t("mcpTools.addModal.portOccupied", { port: containerPort }) + ); + return; + } + + const runtimeCommand = inferContainerRuntimeCommand( + selectedOption.packageRegistryType + ); + if (!runtimeCommand) { + message.error(t("mcpTools.registry.quickAddUnsupported")); + return; + } + const runtimeArgs = resolveRuntimeArgs(selectedOption, values); + const envValues = collectPackageEnvValues(selectedOption, values); + const serverKey = normalizeServerKey(candidate.server?.name); + + const mcpConfig: McpContainerConfigPayload = { + mcpServers: { + [serverKey]: { + command: runtimeCommand, + args: runtimeArgs, + env: envValues, + }, + }, + }; + + await addContainerMcpToolService({ + name: candidate.server?.name, + description: candidate.server?.description, + tags, + source: McpSource.REGISTRY, + port: containerPort as number, + mcp_config: mcpConfig, + }); + } else { + const finalUrl = resolveHttpServerUrl(selectedOption, values); + if (!finalUrl || hasUnresolvedUrlTemplate(finalUrl)) { + message.warning( + t("mcpTools.registry.quickAddPicker.variableRequiredMissing", { + key: "url", + }) + ); + return; + } + const authorization = resolveAuthorizationFromHeaders( + [ + ...(selectedOption.remoteHeaders || []), + ...(selectedOption.packageTransportHeaders || []), + ], + values + ); + + await addMcpToolService({ + name: candidate.server?.name, + description: candidate.server?.description || "", + source: McpSource.REGISTRY, + server_url: finalUrl, + tags, + authorization_token: authorization, + version: candidate.server?.version, + registry_json: candidate.server as unknown as Record, + }); + } + + message.success(t("mcpTools.add.success")); + queryClient.invalidateQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.services, + }); + await refreshToolListWithToast({ + message, + t, + toastKey: "mcp-tools-refresh-tools-add-registry", + }); + onSuccess(); + close(); + } catch (error) { + log.error("[useMcpRegistryQuickAdd] Failed to add from registry", { + error, + }); + message.error(t("mcpTools.add.failed")); + } finally { + setSubmitting(false); + } + }, [ + candidate, + close, + containerPort, + message, + onSuccess, + queryClient, + selectedOption, + t, + values, + ]); + + return { + visible: Boolean(candidate), + candidate, + options, + selectedOption, + selectedKey, + values, + containerPort, + setContainerPort, + open, + close, + chooseOption, + setValue, + confirm, + submitting, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpServiceDetail.ts b/frontend/hooks/mcpTools/useMcpServiceDetail.ts new file mode 100644 index 000000000..26e485996 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpServiceDetail.ts @@ -0,0 +1,296 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { App } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import log from "@/lib/logger"; +import { + deleteMcpToolService, + healthcheckMcpToolService, + listMcpRuntimeTools, + parseContainerMcpConfigJson, + publishCommunityMcpTool, + updateMcpToolService, +} from "@/services/mcpToolsService"; +import { refreshToolListWithToast } from "./useRefreshToolListWithToast"; +import { isHttpUrl, isSameStringArray } from "@/lib/mcpTools"; +import { McpHealthStatus, McpTransportType } from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; +import type { McpTool } from "@/types/agentConfig"; +import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +interface ToolsModalState { + visible: boolean; + tools: McpTool[]; +} + +interface UseMcpServiceDetailParams { + selectedService: McpServiceItem | null; + onClose: () => void; +} + +/** + * Encapsulates all state and side effects required by the service detail modal. + * The modal becomes a presentation component that just renders what this hook + * returns. + */ +export function useMcpServiceDetail({ + selectedService, + onClose, +}: UseMcpServiceDetailParams) { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + + const [draft, setDraft] = useState(null); + const [healthChecking, setHealthChecking] = useState(false); + const [toolsState, setToolsState] = useState({ + visible: false, + tools: [], + }); + const [loadingTools, setLoadingTools] = useState(false); + const [publishing, setPublishing] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + setDraft(selectedService ? { ...selectedService } : null); + }, [selectedService]); + + const invalidateServices = useCallback(() => { + queryClient.invalidateQueries({ queryKey: MCP_TOOLS_QUERY_KEYS.services }); + }, [queryClient]); + + const addTag = useCallback((tag: string) => { + const next = tag.trim(); + if (!next) return; + setDraft((prev) => { + if (!prev || prev.tags.includes(next)) return prev; + return { ...prev, tags: [...prev.tags, next] }; + }); + }, []); + + const removeTag = useCallback((index: number) => { + setDraft((prev) => + prev ? { ...prev, tags: prev.tags.filter((_, i) => i !== index) } : prev + ); + }, []); + + const runHealthCheck = useCallback(async () => { + if (!draft || draft.mcpId < 0) return; + setHealthChecking(true); + try { + const result = await healthcheckMcpToolService({ mcp_id: draft.mcpId }); + const nextStatus = + result.data?.health_status ?? McpHealthStatus.UNCHECKED; + setDraft((prev) => (prev ? { ...prev, healthStatus: nextStatus } : prev)); + message.success(t("mcpTools.service.healthOk")); + invalidateServices(); + } catch (error) { + log.error("[useMcpServiceDetail] Health check failed", { error }); + message.error(t("mcpTools.service.healthFailed")); + setDraft((prev) => + prev ? { ...prev, healthStatus: McpHealthStatus.UNHEALTHY } : prev + ); + } finally { + setHealthChecking(false); + } + }, [draft, invalidateServices, message, t]); + + const loadTools = useCallback(async () => { + if (!draft || draft.mcpId < 0) return; + setLoadingTools(true); + try { + const result = await listMcpRuntimeTools(draft.mcpId); + setToolsState({ visible: true, tools: result.data || [] }); + } catch (error) { + log.error("[useMcpServiceDetail] Failed to load tools", { error }); + message.error(t("mcpTools.tools.loadFailed")); + } finally { + setLoadingTools(false); + } + }, [draft, message, t]); + + const refreshTools = useCallback(async () => { + if (!draft || draft.mcpId < 0) return; + setLoadingTools(true); + try { + const result = await listMcpRuntimeTools(draft.mcpId); + setToolsState((prev) => ({ ...prev, tools: result.data || [] })); + } catch (error) { + log.error("[useMcpServiceDetail] Failed to refresh tools", { error }); + message.error(t("mcpTools.tools.loadFailed")); + } finally { + setLoadingTools(false); + } + }, [draft, message, t]); + + const closeToolsModal = useCallback(() => { + setToolsState({ visible: false, tools: [] }); + }, []); + + const hasUnsavedChanges = useMemo(() => { + if (!draft || !selectedService) return false; + return ( + draft.name.trim() !== selectedService.name || + draft.description !== selectedService.description || + draft.serverUrl.trim() !== selectedService.serverUrl || + !isSameStringArray(draft.tags, selectedService.tags) || + (draft.authorizationToken ?? "") !== + (selectedService.authorizationToken ?? "") || + (draft.version ?? "") !== (selectedService.version ?? "") + ); + }, [draft, selectedService]); + + const save = useCallback(async () => { + if (!draft || !selectedService) return; + const nextName = draft.name.trim(); + const nextUrl = draft.serverUrl.trim(); + const nextToken = (draft.authorizationToken ?? "").trim(); + const nextTags = draft.tags; + + if (!nextName) { + message.warning(t("mcpTools.add.validate.nameRequired")); + return; + } + if (draft.transportType === McpTransportType.URL && !isHttpUrl(nextUrl) + ) { + message.warning(t("mcpTools.add.validate.httpUrlFormat")); + return; + } + + setSaving(true); + try { + await updateMcpToolService({ + mcp_id: draft.mcpId, + name: nextName, + description: draft.description, + server_url: nextUrl, + tags: nextTags, + authorization_token: nextToken || undefined, + }); + message.success(t("mcpTools.service.saveSuccess")); + invalidateServices(); + await refreshToolListWithToast({ + message, + t, + toastKey: "mcp-tools-refresh-tools-save", + }); + } catch (error) { + log.error("[useMcpServiceDetail] Failed to save service", { error }); + message.error(t("mcpTools.service.saveFailed")); + } finally { + setSaving(false); + } + }, [draft, invalidateServices, message, selectedService, t]); + + const remove = useCallback(async () => { + if (!selectedService || selectedService.mcpId < 0) return; + setDeleting(true); + try { + await deleteMcpToolService(selectedService.mcpId); + message.success(t("mcpTools.service.deleted")); + invalidateServices(); + await refreshToolListWithToast({ + message, + t, + toastKey: "mcp-tools-refresh-tools-delete", + }); + onClose(); + } catch (error) { + log.error("[useMcpServiceDetail] Failed to delete service", { error }); + message.error(t("mcpTools.service.deleteFailed")); + } finally { + setDeleting(false); + } + }, [invalidateServices, message, onClose, selectedService, t]); + + /** + * Publishes the current service to the community. Optional modal fields + * override the snapshot stored on the new community row; the original MCP row + * is never mutated. + */ + const publish = useCallback( + async (override?: { + name?: string; + description?: string; + version?: string; + tags?: string[]; + serverUrl?: string; + containerConfigJson?: string; + }) => { + if (!selectedService || selectedService.mcpId < 0) return false; + setPublishing(true); + try { + const isContainer = + selectedService.transportType === McpTransportType.CONTAINER; + const editedConfigText = isContainer + ? (override?.containerConfigJson ?? "").trim() + : ""; + const parsedConfig = isContainer + ? parseContainerMcpConfigJson(editedConfigText) + : null; + if (isContainer && !parsedConfig) { + message.error(t("mcpTools.add.error.containerJsonInvalid")); + return false; + } + + const sourceName = (selectedService.name || "").trim(); + const sourceDesc = selectedService.description || ""; + const sourceVersion = (selectedService.version ?? "").trim(); + const editedName = (override?.name ?? sourceName).trim(); + const editedDesc = override?.description ?? sourceDesc; + const editedVersion = (override?.version ?? sourceVersion).trim(); + const editedTags = override?.tags ?? selectedService.tags ?? []; + const editedServerUrl = ( + override?.serverUrl ?? selectedService.serverUrl ?? "" + ).trim(); + + await publishCommunityMcpTool({ + mcp_id: selectedService.mcpId, + name: editedName, + description: editedDesc, + version: editedVersion, + tags: editedTags, + ...(!isContainer ? { mcp_server: editedServerUrl } : {}), + ...(parsedConfig ? { config_json: parsedConfig } : {}), + }); + + message.success(t("mcpTools.community.publishSuccess")); + queryClient.invalidateQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.myCommunity, + }); + return true; + } catch (error) { + log.error("[useMcpServiceDetail] Publish failed", { error }); + message.error(t("mcpTools.community.publishFailed")); + return false; + } finally { + setPublishing(false); + } + }, + [message, queryClient, selectedService, t] + ); + + return { + draft, + setDraft, + addTag, + removeTag, + hasUnsavedChanges, + healthChecking, + runHealthCheck, + toolsState, + loadingTools, + loadTools, + refreshTools, + closeToolsModal, + publishing, + publish, + saving, + save, + deleting, + remove, + }; +} diff --git a/frontend/hooks/mcpTools/useMcpServiceToggle.ts b/frontend/hooks/mcpTools/useMcpServiceToggle.ts new file mode 100644 index 000000000..ab92a3996 --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpServiceToggle.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { App } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import log from "@/lib/logger"; +import { + disableMcpToolService, + enableMcpToolService, +} from "@/services/mcpToolsService"; +import { refreshToolListWithToast } from "./useRefreshToolListWithToast"; +import { McpServiceStatus } from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; + +/** + * Toggles the enabled/disabled flag on an MCP service and refreshes caches that + * depend on it. Tracks per-service loading so multiple toggles can be in-flight + * at once without interfering. + */ +export function useMcpServiceToggle() { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + const [toggling, setToggling] = useState>({}); + const [refreshingTools, setRefreshingTools] = useState>( + {} + ); + + const isToggling = (mcpId?: number) => + typeof mcpId === "number" ? Boolean(toggling[mcpId]) : false; + + const setToggle = (mcpId: number, value: boolean) => + setToggling((prev) => ({ ...prev, [mcpId]: value })); + + const isRefreshing = (mcpId?: number) => + typeof mcpId === "number" ? Boolean(refreshingTools[mcpId]) : false; + + const toggle = async (service: McpServiceItem): Promise => { + if (typeof service.mcpId !== "number" || service.mcpId < 0) { + message.warning(t("mcpTools.service.toggle.missingId")); + throw new Error("Missing MCP id"); + } + const nextEnabled = service.enabled !== McpServiceStatus.ENABLED; + setToggle(service.mcpId, true); + try { + if (nextEnabled) { + await enableMcpToolService({ mcp_id: service.mcpId, enabled: true }); + } else { + await disableMcpToolService({ mcp_id: service.mcpId, enabled: false }); + } + message.success( + nextEnabled + ? t("mcpTools.service.enabled") + : t("mcpTools.service.disabled") + ); + const nextStatus = nextEnabled ? McpServiceStatus.ENABLED : McpServiceStatus.DISABLED; + + // Fire-and-forget tool scan / refresh. UI should update immediately after + // enable/disable succeeds, without waiting for scan_tools. + setRefreshingTools((prev) => ({ ...prev, [service.mcpId]: true })); + void refreshToolListWithToast({ + message, + t, + toastKey: `mcp-tools-refresh-${service.mcpId}`, + }) + .then(() => { + queryClient.invalidateQueries({ queryKey: ["tools"] }); + queryClient.invalidateQueries({ queryKey: ["agents"] }); + }) + .finally(() => { + setRefreshingTools((prev) => ({ ...prev, [service.mcpId]: false })); + }); + + return nextStatus; + } catch (error) { + log.error("[useMcpServiceToggle] Failed to toggle service", { + error, + serviceName: service.name, + serverUrl: service.serverUrl, + }); + message.error(t("mcpTools.service.toggleFailed")); + throw error; + } finally { + setToggle(service.mcpId, false); + } + }; + + return { toggle, isToggling, isRefreshing }; +} diff --git a/frontend/hooks/mcpTools/useMcpServicesList.ts b/frontend/hooks/mcpTools/useMcpServicesList.ts new file mode 100644 index 000000000..a1bd2cdbd --- /dev/null +++ b/frontend/hooks/mcpTools/useMcpServicesList.ts @@ -0,0 +1,94 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { listMcpTools } from "@/services/mcpToolsService"; +import { filterServiceCards } from "@/lib/mcpTools"; +import type { + McpServiceItem, + McpSourceFilter, + McpTagStat, + McpTransportFilter, +} from "@/types/mcpTools"; +import { FILTER_ALL } from "@/const/mcpTools"; +import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +export type McpServiceSourceFilter = McpSourceFilter; +export type McpServiceTransportFilter = McpTransportFilter; + +export interface McpServicesFilters { + search: string; + source: McpSourceFilter; + transport: McpTransportFilter; + tag: string; +} + +const INITIAL_FILTERS: McpServicesFilters = { + search: "", + source: FILTER_ALL, + transport: FILTER_ALL, + tag: FILTER_ALL, +}; + +/** + * Owns the cached list of MCP services + filter state. Keeps the page free of + * fetch / derive / filter plumbing. + */ +export function useMcpServicesList() { + const [filters, setFilters] = useState(INITIAL_FILTERS); + + const servicesQuery = useQuery({ + queryKey: [...MCP_TOOLS_QUERY_KEYS.services], + queryFn: async () => { + const result = await listMcpTools(); + return result.data; + }, + staleTime: 30_000, + }); + + const services: McpServiceItem[] = useMemo( + () => servicesQuery.data ?? [], + [servicesQuery.data] + ); + + const tagStats: McpTagStat[] = useMemo(() => { + const counts = new Map(); + for (const item of services) { + for (const raw of item.tags || []) { + const t = String(raw || "").trim(); + if (!t) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => a.tag.localeCompare(b.tag)); + }, [services]); + + const filteredServices = useMemo(() => { + const keywordFiltered = filterServiceCards(services, filters.search); + return keywordFiltered.filter((item) => { + if (filters.source !== FILTER_ALL && item.source !== filters.source) return false; + if (filters.transport !== FILTER_ALL && item.transportType !== filters.transport) return false; + if (filters.tag !== FILTER_ALL && !item.tags.includes(filters.tag)) return false; + return true; + }); + }, [services, filters.search, filters.source, filters.transport, filters.tag]); + + const updateFilter = ( + key: K, + value: McpServicesFilters[K] + ) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + return { + services, + filteredServices, + tagStats, + filters, + updateFilter, + loading: servicesQuery.isLoading, + refetch: servicesQuery.refetch, + }; +} diff --git a/frontend/hooks/mcpTools/useMyCommunityMcp.ts b/frontend/hooks/mcpTools/useMyCommunityMcp.ts new file mode 100644 index 000000000..a3fcd6c57 --- /dev/null +++ b/frontend/hooks/mcpTools/useMyCommunityMcp.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { listMyCommunityMcpTools } from "@/services/mcpToolsService"; +import type { + CommunityMcpCard, + McpTagStat, + McpTransportFilter, +} from "@/types/mcpTools"; +import { FILTER_ALL, MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +export interface MyCommunityMcpFilters { + search: string; + transport: McpTransportFilter; + tag: string; +} + +const INITIAL_FILTERS: MyCommunityMcpFilters = { + search: "", + transport: FILTER_ALL, + tag: FILTER_ALL, +}; + +/** + * Published tab: loads and filters "my community MCP" list. Edit/save/delete for + * a single row lives in {@link usePublishedServiceDetailEdit} inside the detail modal. + */ +export function useMyCommunityMcp(enabled: boolean) { + const [filters, setFilters] = useState(INITIAL_FILTERS); + + const query = useQuery({ + queryKey: [...MCP_TOOLS_QUERY_KEYS.myCommunity], + enabled, + queryFn: async () => { + const result = await listMyCommunityMcpTools(); + return result.data.items; + }, + staleTime: 30_000, + }); + + const items: CommunityMcpCard[] = useMemo( + () => query.data ?? [], + [query.data] + ); + + const tagStats: McpTagStat[] = useMemo(() => { + const counts = new Map(); + for (const item of items) { + for (const raw of item.tags || []) { + const t = String(raw || "").trim(); + if (!t) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => a.tag.localeCompare(b.tag)); + }, [items]); + + const filteredItems = useMemo(() => { + const keyword = filters.search.trim().toLowerCase(); + return items.filter((item) => { + if (keyword) { + const tags = (item.tags || []).join(",").toLowerCase(); + const hit = + (item.name || "").toLowerCase().includes(keyword) || + (item.description || "").toLowerCase().includes(keyword) || + tags.includes(keyword); + if (!hit) return false; + } + if ( + filters.transport !== FILTER_ALL && + item.transportType !== filters.transport + ) { + return false; + } + if ( + filters.tag !== FILTER_ALL && + !(item.tags || []).includes(filters.tag) + ) { + return false; + } + return true; + }); + }, [items, filters.search, filters.transport, filters.tag]); + + const updateFilter = ( + key: K, + value: MyCommunityMcpFilters[K] + ) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + return { + loading: query.isLoading, + items, + filteredItems, + tagStats, + filters, + updateFilter, + search: filters.search, + setSearch: (value: string) => updateFilter("search", value), + }; +} diff --git a/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts b/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts new file mode 100644 index 000000000..1d90bb455 --- /dev/null +++ b/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts @@ -0,0 +1,137 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { App } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import log from "@/lib/logger"; +import { + deleteCommunityMcpTool, + updateCommunityMcpTool, +} from "@/services/mcpToolsService"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; + +export interface PublishedServiceEditDraft { + communityId: number; + name: string; + description: string; + version: string; + tags: string[]; +} + +const draftFromItem = ( + item: CommunityMcpCard +): PublishedServiceEditDraft | null => { + if (!item.communityId) return null; + return { + communityId: item.communityId, + name: item.name || "", + description: item.description || "", + version: item.version || "", + tags: item.tags || [], + }; +}; + +/** + * Draft + save/delete for the published-service detail modal only. + * List data stays in {@link useMyCommunityMcp}; this hook invalidates that query on success. + */ +export function usePublishedServiceDetailEdit( + service: CommunityMcpCard | null, + open: boolean +) { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + if (!open || !service?.communityId) { + setDraft(null); + return; + } + setDraft(draftFromItem(service)); + }, [open, service]); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + }, []); + + const addDraftTag = useCallback((tag: string) => { + const next = tag.trim(); + if (!next) return; + setDraft((prev) => { + if (!prev || prev.tags.includes(next)) return prev; + return { ...prev, tags: [...prev.tags, next] }; + }); + }, []); + + const removeDraftTag = useCallback((index: number) => { + setDraft((prev) => + prev + ? { ...prev, tags: prev.tags.filter((_, idx) => idx !== index) } + : prev + ); + }, []); + + const save = useCallback(async () => { + if (!draft) return false; + setSaving(true); + try { + await updateCommunityMcpTool({ + community_id: draft.communityId, + name: draft.name.trim(), + description: draft.description.trim(), + version: draft.version.trim(), + tags: draft.tags, + }); + message.success(t("mcpTools.service.saveSuccess")); + queryClient.invalidateQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.myCommunity, + }); + return true; + } catch (error) { + log.error("[usePublishedServiceDetailEdit] Save failed", { error }); + message.error(t("mcpTools.service.saveFailed")); + return false; + } finally { + setSaving(false); + } + }, [draft, message, queryClient, t]); + + const remove = useCallback( + async (communityId: number): Promise => { + setDeleting(true); + try { + await deleteCommunityMcpTool(communityId); + message.success(t("mcpTools.community.mine.deleteSuccess")); + queryClient.invalidateQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.myCommunity, + }); + return true; + } catch (error) { + log.error("[usePublishedServiceDetailEdit] Delete failed", { error }); + message.error(t("mcpTools.community.mine.deleteFailed")); + return false; + } finally { + setDeleting(false); + } + }, + [message, queryClient, t] + ); + + return { + draft, + saving, + deleting, + updateDraft, + addDraftTag, + removeDraftTag, + save, + remove, + }; +} diff --git a/frontend/hooks/mcpTools/useRefreshToolListWithToast.ts b/frontend/hooks/mcpTools/useRefreshToolListWithToast.ts new file mode 100644 index 000000000..c616b7ba8 --- /dev/null +++ b/frontend/hooks/mcpTools/useRefreshToolListWithToast.ts @@ -0,0 +1,33 @@ +import type { MessageInstance } from "antd/es/message/interface"; +import type { TFunction } from "i18next"; +import log from "@/lib/logger"; +import { updateToolList } from "@/services/mcpService"; + +type RefreshToolListWithToastParams = { + message: MessageInstance; + t: TFunction; + toastKey: string; +}; + +export async function refreshToolListWithToast({ + message, + t, + toastKey, +}: RefreshToolListWithToastParams) { + message.open({ + key: toastKey, + type: "loading", + content: t("mcpTools.tools.refreshing"), + duration: 0, + }); + try { + await updateToolList(); + } catch (error) { + log.error("[refreshToolListWithToast] Failed to refresh tool list", { + error, + }); + } finally { + message.destroy(toastKey); + } +} + diff --git a/frontend/hooks/useMcpConfig.ts b/frontend/hooks/useMcpConfig.ts index 386a777bf..78c8b3786 100644 --- a/frontend/hooks/useMcpConfig.ts +++ b/frontend/hooks/useMcpConfig.ts @@ -136,8 +136,11 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { // Delete MCP server const handleDeleteServer = useCallback(async (server: McpServer) => { + if (!server.mcp_id) { + return { success: false, message: "MCP server ID not available", messageKey: "mcpConfig.message.mcpIdRequired" }; + } try { - const result = await deleteMcpServer(server.mcp_url, server.service_name, options.tenantId); + const result = await deleteMcpServer(server.mcp_id, options.tenantId); if (result.success) { invalidateMcpServers(); refreshToolsAndAgents().catch(e => log.error("Refresh failed:", e)); @@ -155,7 +158,10 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { // View server tools const handleViewTools = useCallback(async (server: McpServer) => { try { - const result = await getMcpTools(server.service_name, server.mcp_url); + if (!server.mcp_id) { + return { success: false, data: [], message: "MCP server ID not available", messageKey: "mcpConfig.message.mcpIdRequired" }; + } + const result = await getMcpTools(server.mcp_id); if (result.success) { return { success: true, data: result.data }; } else { @@ -169,10 +175,13 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { // Check server health const handleCheckHealth = useCallback(async (server: McpServer) => { + if (!server.mcp_id) { + return { success: false, message: "MCP server ID not available", messageKey: "mcpConfig.message.mcpIdRequired" }; + } const key = `${server.service_name}__${server.mcp_url}`; setHealthCheckLoading(prev => ({ ...prev, [key]: true })); try { - const result = await checkMcpServerHealth(server.mcp_url, server.service_name, options.tenantId); + const result = await checkMcpServerHealth(server.mcp_id); invalidateMcpServers(); invalidateMcpContainers(); await refreshToolsAndAgents(); @@ -194,14 +203,13 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { // Update MCP server const handleUpdateServer = useCallback(async ( - oldName: string, - oldUrl: string, + mcpId: number, newName: string, newUrl: string, newAuthorizationToken?: string | null ) => { try { - const result = await updateMcpServer(oldName, oldUrl, newName, newUrl, newAuthorizationToken, options.tenantId); + const result = await updateMcpServer(mcpId, newName, newUrl, newAuthorizationToken, undefined, undefined, options.tenantId); if (result.success) { // Best-effort optimistic status update for UI responsiveness queryClient.setQueryData([...MCP_SERVERS_QUERY_KEY, options.tenantId], (prev: any) => { @@ -209,7 +217,7 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { return { ...prev, data: (prev.data as McpServer[]).map((s) => - s.service_name === newName && s.mcp_url === newUrl ? { ...s, status: true } : s + s.mcp_id === mcpId ? { ...s, service_name: newName, mcp_url: newUrl, status: true } : s ), }; }); @@ -227,16 +235,47 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { }, [invalidateMcpServers, refreshToolsAndAgents, queryClient, options]); // Add container - const handleAddContainer = useCallback(async (config: any, port: number) => { - // Correctly process the mcpServers object from the config + const handleAddContainer = useCallback(async (config: any, port: number, serviceName?: string) => { + // Extract mcpServers from config const mcpServers = config.mcpServers || {}; - const configWithPorts = { - mcpServers: Object.fromEntries( - Object.entries(mcpServers as Record).map(([key, value]) => [ - key, - { ...value, port }, - ]) - ), + const serverEntries = Object.entries(mcpServers as Record); + + if (serverEntries.length === 0) { + return { success: false, message: "No mcpServers found in config", messageKey: "mcpConfig.message.invalidConfigStructure" }; + } + + // Use provided serviceName or extract from config + const mcpName = serviceName || serverEntries[0][0]; + + // Validate server name + if (!/^[a-zA-Z0-9_-]+$/.test(mcpName)) { + return { success: false, message: "Invalid service name", messageKey: "mcpConfig.message.invalidServerName" }; + } + if (mcpName.length > 20) { + return { success: false, message: "Service name too long", messageKey: "mcpConfig.message.serverNameTooLong" }; + } + + // Build the AddContainerMcpServiceRequest payload + const payload = { + name: mcpName, + description: null, + source: "local", + tags: [], + authorization_token: null, + registry_json: null, + port: port, + mcp_config: { + mcpServers: Object.fromEntries( + serverEntries.map(([key, value]) => [ + key, + { + command: value.command, + args: value.args || [], + env: value.env || {}, + }, + ]) + ), + }, }; if (delayedContainerRefreshRef.current) { @@ -247,7 +286,7 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { }, 3000); try { - const result = await addMcpFromConfig(configWithPorts as any, options.tenantId); + const result = await addMcpFromConfig(payload as any, options.tenantId); if (result.success) { invalidateMcpContainers(); invalidateMcpServers(); @@ -255,10 +294,10 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { options.onContainerAdded?.(); return { success: true, messageKey: "mcpService.message.addContainerSuccess" }; } else { - return { - success: false, - message: result.message, - messageKey: (result as any).messageKey || "mcpConfig.message.addContainerFailed" + return { + success: false, + message: result.message, + messageKey: (result as any).messageKey || "mcpConfig.message.addContainerFailed" }; } } catch (error) { diff --git a/frontend/lib/mcpTools.ts b/frontend/lib/mcpTools.ts new file mode 100644 index 000000000..8dad9510b --- /dev/null +++ b/frontend/lib/mcpTools.ts @@ -0,0 +1,702 @@ +import type { McpServer } from "@/types/agentConfig"; +import type { + McpServiceItem, + RegistryMcpCard, + RegistryPackageArgumentInput, + RegistryQuickAddOption, + RegistryRemoteVariable, +} from "@/types/mcpTools"; +import { + MCP_PORT_RANGE, + McpContainerStatus, + McpHealthStatus, + McpSource, + McpTransportType, +} from "@/const/mcpTools"; + +// --------------------------------------------------------------------------- +// Label resolvers (used by cards / detail modals) +// --------------------------------------------------------------------------- + +/** i18n key for the label shown next to a service's `source` enum. */ +export const getSourceLabelKey = (source: McpServiceItem["source"]): string => { + if (source === McpSource.LOCAL) return "mcpTools.source.local"; + if (source === McpSource.COMMUNITY) return "mcpTools.source.community"; + return "mcpTools.source.registry"; +}; + +/** i18n key for the label shown next to a service's `transportType` enum. */ +export const getTransportLabelKey = ( + transportType: McpTransportType | string +): string => { + if (transportType === McpTransportType.HTTP) + return "mcpTools.serverType.http"; + if (transportType === McpTransportType.SSE) + return "mcpTools.serverType.sse"; + if (transportType === McpTransportType.CONTAINER) + return "mcpTools.serverType.container"; + return "mcpTools.serverType.url"; +}; + +/** i18n key for a service's `healthStatus`. */ +export const getHealthStatusKey = (status: McpHealthStatus): string => { + if (status === McpHealthStatus.HEALTHY) return "mcpTools.health.healthy"; + if (status === McpHealthStatus.UNHEALTHY) + return "mcpTools.health.unhealthy"; + return "mcpTools.health.unchecked"; +}; + +/** i18n key for a service's container `containerStatus`. */ +export const getContainerStatusKey = ( + status: McpContainerStatus | undefined +): string => { + if (status === McpContainerStatus.RUNNING) + return "mcpTools.containerStatus.running"; + if (status === McpContainerStatus.STOPPED) + return "mcpTools.containerStatus.stopped"; + return "mcpTools.containerStatus.unknown"; +}; + +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)) + ); + }); +}; + +// --------------------------------------------------------------------------- +// Registry/community formatters +// --------------------------------------------------------------------------- + +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 formatRegistryVersion = (value: string): string => { + const version = (value || "").trim(); + if (!version) return "-"; + return /^v/i.test(version) ? version : `v${version}`; +}; + +export const extractRegistryLinks = ( + registryJson?: Record +) => { + 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 +) => { + return JSON.stringify(registryJson || {}, null, 2); +}; + +// --------------------------------------------------------------------------- +// Generic validators +// --------------------------------------------------------------------------- + +export const isHttpUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +}; + +export const isSameStringArray = ( + left: string[] = [], + right: string[] = [] +) => { + if (left.length !== right.length) return false; + return left.every((item, index) => item === right[index]); +}; + +// --------------------------------------------------------------------------- +// Registry quick-add builders +// --------------------------------------------------------------------------- + +const toStringOrUndefined = (value: unknown): string | undefined => { + if (value === null || value === undefined) return undefined; + return String(value); +}; + +const extractKeyValueInputs = ( + inputs: unknown, + formPrefix: string, + fallbackLabel: string +): RegistryRemoteVariable[] => { + if (!Array.isArray(inputs)) return []; + + return inputs + .filter( + (item): item is Record => + Boolean(item) && typeof item === "object" + ) + .map((item, index) => { + const name = + toStringOrUndefined(item.name)?.trim() || + `${fallbackLabel}_${index + 1}`; + return { + key: name, + formKey: `${formPrefix}:${name}`, + label: name, + description: toStringOrUndefined(item.description), + format: toStringOrUndefined(item.format), + default: toStringOrUndefined(item.default), + value: toStringOrUndefined(item.value), + placeholder: toStringOrUndefined(item.placeholder), + isRequired: + typeof item.isRequired === "boolean" ? item.isRequired : undefined, + isSecret: + typeof item.isSecret === "boolean" ? item.isSecret : undefined, + choices: Array.isArray(item.choices) + ? item.choices.filter( + (choice): choice is string => typeof choice === "string" + ) + : undefined, + variables: + item.variables && typeof item.variables === "object" + ? (item.variables as Record) + : undefined, + }; + }); +}; + +const extractVariableMapInputs = ( + variables: unknown, + formPrefix: string +): RegistryRemoteVariable[] => { + if (!variables || typeof variables !== "object") return []; + + return Object.entries(variables as Record) + .filter(([, value]) => Boolean(value) && typeof value === "object") + .map(([key, value]) => { + const item = value as Record; + return { + key, + formKey: `${formPrefix}:${key}`, + label: key, + description: toStringOrUndefined(item.description), + format: toStringOrUndefined(item.format), + default: toStringOrUndefined(item.default), + value: toStringOrUndefined(item.value), + placeholder: toStringOrUndefined(item.placeholder), + isRequired: + typeof item.isRequired === "boolean" ? item.isRequired : undefined, + isSecret: + typeof item.isSecret === "boolean" ? item.isSecret : undefined, + choices: Array.isArray(item.choices) + ? item.choices.filter( + (choice): choice is string => typeof choice === "string" + ) + : undefined, + variables: + item.variables && typeof item.variables === "object" + ? (item.variables as Record) + : undefined, + }; + }); +}; + +const extractRuntimeArguments = ( + runtimeArguments: unknown, + formPrefix: string +): RegistryPackageArgumentInput[] => { + if (!Array.isArray(runtimeArguments)) return []; + + return runtimeArguments + .filter( + (item): item is Record => + Boolean(item) && typeof item === "object" + ) + .map((item, index) => { + const argType = + String(item.type || "").toLowerCase() === "named" + ? "named" + : "positional"; + const name = toStringOrUndefined(item.name)?.trim(); + const valueHint = toStringOrUndefined(item.valueHint)?.trim(); + const keyBase = + argType === "named" + ? name || `named_${index + 1}` + : valueHint || `arg_${index + 1}`; + return { + key: keyBase, + formKey: `${formPrefix}:${keyBase}:${index}`, + label: + argType === "named" + ? name || `--arg-${index + 1}` + : valueHint || `arg-${index + 1}`, + type: argType, + name, + valueHint, + description: toStringOrUndefined(item.description), + format: toStringOrUndefined(item.format), + default: toStringOrUndefined(item.default), + value: toStringOrUndefined(item.value), + isRequired: + typeof item.isRequired === "boolean" ? item.isRequired : undefined, + isSecret: + typeof item.isSecret === "boolean" ? item.isSecret : undefined, + isRepeated: + typeof item.isRepeated === "boolean" ? item.isRepeated : undefined, + }; + }); +}; + +const resolveQuickAddTarget = ( + type?: string | null, + url?: string | null +): { transportType: "http" | "sse"; serverUrl: string } | null => { + const serverUrl = String(url || "").trim(); + if (!serverUrl) return null; + + const normalizedType = String(type || "") + .trim() + .toLowerCase(); + if (normalizedType === "sse") { + return { transportType: McpTransportType.SSE, serverUrl }; + } + if (normalizedType === "streamable-http" || normalizedType === "http") { + return { transportType: McpTransportType.HTTP, serverUrl }; + } + if (/^https?:\/\//i.test(serverUrl)) { + return { transportType: McpTransportType.HTTP, serverUrl }; + } + + return null; +}; + +const findMatchedRemote = ( + service: RegistryMcpCard, + remoteType?: string, + remoteUrl?: string +): Record | null => { + const rawRemotes = service.server?.remotes; + if (!Array.isArray(rawRemotes)) return null; + + const matched = rawRemotes.find((entry) => { + if (!entry || typeof entry !== "object") return false; + const candidate = entry as { type?: unknown; url?: unknown }; + 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 Record | undefined; + + return matched || null; +}; + +const extractPackageEnvTemplate = ( + service: RegistryMcpCard, + pkgIdentifier?: string +): Record => { + if (!pkgIdentifier) return {}; + const rawPackages = service.server?.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 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[] => (headers || []).filter(isAuthorizationHeader); + +const collectUnsupportedRequiredHeaderNames = ( + headers: RegistryRemoteVariable[] | undefined +): string[] => { + return (headers || []) + .filter((header) => header.isRequired && !isAuthorizationHeader(header)) + .map((header) => (header.label || header.key || "header").trim()) + .filter((name, index, arr) => Boolean(name) && arr.indexOf(name) === index); +}; + +export const inferContainerRuntimeCommand = ( + registryType?: string +): string | null => { + const normalized = (registryType || "").trim().toLowerCase(); + if (normalized === "npm") return "npx"; + if (normalized === "pypi") return "uvx"; + return null; +}; + +const inferContainerRuntimeArgs = ( + 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]; +}; + +export const normalizeServerKey = (raw: string): string => { + const normalized = raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return normalized; +}; + +/** + * Build the list of quick-add targets (remote URLs + packages) that a registry + * service exposes. The caller only needs to pick one option. + */ +export const resolveQuickAddOptions = ( + service: RegistryMcpCard +): RegistryQuickAddOption[] => { + const options: RegistryQuickAddOption[] = []; + const packageCandidates = Array.isArray(service.server?.packages) + ? service.server.packages.filter( + (pkg): pkg is Record => + Boolean(pkg) && typeof pkg === "object" + ) + : []; + + (service.server?.remotes || []).forEach((remote, index) => { + const remoteTarget = resolveQuickAddTarget(remote.type, remote.url); + if (!remoteTarget) return; + + const matchedRemote = findMatchedRemote( + service, + remote.type, + remote.url + ) as { variables?: Record; headers?: unknown } | null; + const remoteVariables = matchedRemote?.variables + ? extractVariableMapInputs(matchedRemote.variables, "remote-var") + : []; + const allRemoteHeaders = matchedRemote + ? extractKeyValueInputs(matchedRemote.headers, "remote-header", "header") + : []; + + options.push({ + key: `remote-${index}`, + sourceType: "remote", + sourceLabel: `${remote.type || "remote"} - ${remote.url}`, + transportType: remoteTarget.transportType as McpTransportType, + serverUrl: remoteTarget.serverUrl, + remoteVariables, + remoteHeaders: pickSupportedAuthorizationHeaders(allRemoteHeaders), + unsupportedRequiredHeaders: + collectUnsupportedRequiredHeaderNames(allRemoteHeaders), + }); + }); + + packageCandidates.forEach((rawPackage, index) => { + const packageIdentifier = + toStringOrUndefined(rawPackage.identifier)?.trim() || "package"; + const packageRegistryType = + toStringOrUndefined(rawPackage.registryType)?.trim() || ""; + const packageTransport = + rawPackage.transport && typeof rawPackage.transport === "object" + ? (rawPackage.transport as Record) + : undefined; + const transportType = toStringOrUndefined(packageTransport?.type) || ""; + const transportUrl = toStringOrUndefined(packageTransport?.url) || ""; + + const packageTarget = resolveQuickAddTarget(transportType, transportUrl); + const allPackageTransportHeaders = extractKeyValueInputs( + packageTransport?.headers, + `pkg-transport-header:${index}`, + "header" + ); + 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}` + ); + const packageArguments = extractRuntimeArguments( + rawPackage?.packageArguments, + `pkg-arg:${index}` + ); + const packageRuntimeHint = + toStringOrUndefined(rawPackage?.runtimeHint) || undefined; + + const basePackageOption = { + sourceType: "package" as const, + packageRuntimeHint, + packageEnvironmentVariables, + packageTransportHeaders: pickSupportedAuthorizationHeaders( + allPackageTransportHeaders + ), + unsupportedRequiredHeaders: collectUnsupportedRequiredHeaderNames( + allPackageTransportHeaders + ), + packageTransportVariables, + packageRuntimeArguments, + packageArguments, + packageIdentifier, + packageRegistryType, + }; + + if (packageTarget) { + options.push({ + ...basePackageOption, + key: `package-${index}`, + sourceLabel: `${packageIdentifier} - ${transportType} - ${transportUrl}`, + transportType: packageTarget.transportType as McpTransportType, + serverUrl: packageTarget.serverUrl, + }); + return; + } + + if (transportType.trim().toLowerCase() === "stdio") { + options.push({ + ...basePackageOption, + key: `package-${index}`, + sourceLabel: `${packageIdentifier} - stdio`, + transportType: McpTransportType.CONTAINER, + packageEnvTemplate: extractPackageEnvTemplate( + service, + packageIdentifier + ), + }); + } + }); + + return options; +}; + +export const buildInitialQuickAddValues = ( + option: RegistryQuickAddOption | null +): Record => { + if (!option) return {}; + + const fields: RegistryRemoteVariable[] = [ + ...(option.remoteVariables || []), + ...(option.remoteHeaders || []), + ...(option.packageEnvironmentVariables || []), + ...(option.packageTransportHeaders || []), + ...(option.packageTransportVariables || []), + ]; + + const values = fields.reduce>((acc, field) => { + if (!field.formKey) return acc; + const initial = + typeof field.value === "string" + ? field.value + : typeof field.default === "string" + ? field.default + : ""; + acc[field.formKey] = initial; + return acc; + }, {}); + + (option.packageRuntimeArguments || []).forEach((arg) => { + const initial = + typeof arg.value === "string" + ? arg.value + : typeof arg.default === "string" + ? arg.default + : ""; + values[arg.formKey] = initial; + }); + + return values; +}; + +const applyUrlTemplateVariables = ( + template: string, + values: Record +): string => { + return template.replace(/\{([^{}]+)\}/g, (_match, variableName) => { + const key = String(variableName || "").trim(); + return Object.prototype.hasOwnProperty.call(values, key) + ? values[key] + : _match; + }); +}; + +const getValueByFormKey = ( + values: Record, + formKey?: string +): string => { + if (!formKey) return ""; + return String(values[formKey] || "").trim(); +}; + +export const resolveRuntimeArgs = ( + option: RegistryQuickAddOption, + values: Record +): string[] => { + const runtimeArgs = option.packageRuntimeArguments || []; + if (runtimeArgs.length === 0) { + return inferContainerRuntimeArgs( + option.packageRegistryType, + option.packageIdentifier + ); + } + + const args: string[] = []; + runtimeArgs.forEach((arg) => { + const finalValue = getValueByFormKey(values, arg.formKey); + if (!finalValue) return; + + if (arg.type === "named") { + const flag = (arg.name || "").trim(); + if (!flag) return; + args.push(`${flag}=${finalValue}`); + return; + } + args.push(finalValue); + }); + return args; +}; + +export const resolveAuthorizationFromHeaders = ( + headers: RegistryRemoteVariable[] | undefined, + values: Record +): string | undefined => { + const authorizationHeader = (headers || []).find( + (header) => header.key.toLowerCase() === "authorization" + ); + if (!authorizationHeader?.formKey) return undefined; + const value = getValueByFormKey(values, authorizationHeader.formKey); + return value || undefined; +}; + +export const resolveHttpServerUrl = ( + option: RegistryQuickAddOption, + values: Record +): string => { + const mergedValues = { + ...(option.remoteVariables || []).reduce>( + (acc, variable) => { + if (!variable.formKey) return acc; + const value = getValueByFormKey(values, variable.formKey); + if (value) acc[variable.key] = value; + return acc; + }, + {} + ), + ...(option.packageTransportVariables || []).reduce>( + (acc, variable) => { + if (!variable.formKey) return acc; + const value = getValueByFormKey(values, variable.formKey); + if (value) acc[variable.key] = value; + return acc; + }, + {} + ), + }; + + return applyUrlTemplateVariables(option.serverUrl || "", mergedValues); +}; + +export const hasUnresolvedUrlTemplate = (url: string): boolean => + /\{[^{}]+\}/.test(url); + +export const findMissingRequiredField = ( + fields: Array<{ + formKey?: string; + isRequired?: boolean; + label?: string; + key: string; + }>, + values: Record +): { key: string } | null => { + for (const field of fields) { + if (!field.isRequired) continue; + const value = getValueByFormKey(values, field.formKey); + if (!value) { + return { + key: + typeof field.label === "string" && field.label.trim() + ? field.label + : field.key, + }; + } + } + return null; +}; + +export const collectPackageEnvValues = ( + option: RegistryQuickAddOption, + values: Record +): Record => { + return (option.packageEnvironmentVariables || []).reduce< + Record + >((acc, envVar) => { + const value = getValueByFormKey(values, envVar.formKey); + if (!value) return acc; + acc[envVar.key] = value; + return acc; + }, {}); +}; + +export const isValidPort = (port: number | undefined): port is number => { + return typeof port === "number" && Number.isInteger(port) && port >= MCP_PORT_RANGE.MIN && port <= MCP_PORT_RANGE.MAX; +}; + diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index eae1f6f95..589058a0f 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1103,6 +1103,10 @@ "mcpConfig.serverList.column.url": "URL", "mcpConfig.serverList.column.status": "Status", "mcpConfig.serverList.column.action": "Actions", + "mcpConfig.serverList.column.enabled": "Enabled", + "mcpConfig.serverList.enabled.yes": "Enabled", + "mcpConfig.serverList.enabled.no": "Disabled", + "mcpConfig.serverList.enabled.tooltip": "Please contact administrator to enable MCP service", "mcpConfig.serverList.button.viewTools": "View Tools", "mcpConfig.serverList.button.healthCheck": "Health Check", "mcpConfig.serverList.button.edit": "Edit", @@ -1139,6 +1143,8 @@ "mcpConfig.addContainer.configPlaceholder": "Please enter MCP server configuration JSON", "mcpConfig.addContainer.port": "Port", "mcpConfig.addContainer.portPlaceholder": "Please enter port number", +"mcpConfig.addContainer.serviceName": "Service Name", +"mcpConfig.addContainer.serviceNamePlaceholder": "Enter service name", "mcpConfig.addContainer.button.add": "Add", "mcpConfig.addContainer.button.updating": "Adding...", "mcpConfig.editServer.title": "Edit MCP Server", @@ -1664,6 +1670,265 @@ "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.searchPlaceholder": "Search by MCP service name, description, or tags", + "mcpTools.page.resultCount": "{{count}} results", + "mcpTools.page.sourceFilter.all": "All Sources", + "mcpTools.page.transportFilter.all": "All Types", + "mcpTools.page.tagFilter.all": "All Tags", + "mcpTools.page.addService": "Add MCP Service", + "mcpTools.page.tab.imported": "Imported services", + "mcpTools.page.tab.published": "Published services", + "mcpTools.page.loading": "Loading MCP services...", + "mcpTools.page.empty": "No MCP services yet. Add or import one first.", + "mcpTools.publish.confirmTitle": "Confirm publishing to community", + "mcpTools.publish.confirmHint": "Edits here only affect the published copy; the current service is left untouched.", + "mcpTools.published.detailTitle": "Published service", + "mcpTools.service.enabled": "Service enabled", + "mcpTools.service.enableNameConflict": "Enable failed: another enabled service already uses this name. Please rename first.", + "mcpTools.service.disabled": "Service disabled", + "mcpTools.service.toggleFailed": "Failed to toggle service status", + "mcpTools.service.toggleMissingId": "Failed to toggle service status: missing service ID", + "mcpTools.service.saveFailed": "Failed to save changes", + "mcpTools.service.saveSuccess": "Saved successfully", + "mcpTools.service.healthOk": "Health check successful", + "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.status.active": "Active", + "mcpTools.status.deprecated": "Deprecated", + "mcpTools.status.unknown": "Unknown", + "mcpTools.source.local": "Local", + "mcpTools.source.registry": "MCP Registry", + "mcpTools.source.community": "Community Market", + "mcpTools.serverType.url": "URL", + "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.delete.confirmOk": "OK", + "mcpTools.delete.confirmCancel": "Cancel", + "mcpTools.add.failed": "Failed to add MCP service", + "mcpTools.add.enableNameConflict": "An enabled service with the same name already exists", + "mcpTools.add.success": "MCP service added successfully", + "mcpTools.add.validate.nameRequired": "Please enter an MCP service name", + "mcpTools.add.validate.nameMaxLength": "MCP service name cannot exceed 100 characters", + "mcpTools.add.validate.httpUrlRequired": "Please enter an HTTP service URL", + "mcpTools.add.validate.httpUrlMaxLength": "HTTP service URL cannot exceed 500 characters", + "mcpTools.add.validate.httpUrlFormat": "Please enter a valid http(s) URL", + "mcpTools.add.validate.containerConfigRequired": "Please provide container JSON config", + "mcpTools.add.validate.containerRequired": "Please enter container port", + "mcpTools.add.validate.containerPortRange": "Container port must be between 1 and 65535", + "mcpTools.add.validate.descriptionMaxLength": "Description cannot exceed 5000 characters", + "mcpTools.add.validate.authorizationTokenMaxLength": "Bearer token cannot exceed 500 characters", + "mcpTools.add.validate.transportTypeRequired": "Please select a service type", + "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.tabRegistry": "MCP Registry", + "mcpTools.addModal.tabCommunity": "Community Market", + "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.containerConfig": "Container Config (JSON)", + "mcpTools.addModal.containerConfigPlaceholder": "{\"image\": \"mcp-server:latest\", \"env\": {}}", + "mcpTools.addModal.containerPort": "Container Port", + "mcpTools.addModal.containerPortPlaceholder": "8080", + "mcpTools.addModal.suggestPort": "Suggest Port", + "mcpTools.addModal.portChecking": "Checking port...", + "mcpTools.addModal.portAvailable": "Port {{port}} is available.", + "mcpTools.addModal.portOccupied": "Port {{port}} is occupied.", + "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.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.versionAll": "All Versions", + "mcpTools.registry.versionLatest": "latest (most recent)", + "mcpTools.registry.versionCustom": "Custom Version", + "mcpTools.registry.updatedSince": "Updated Since ", + "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.loading": "Loading public market MCP services...", + "mcpTools.registry.empty": "No matching public market MCP services found.", + "mcpTools.registry.quickAdd": "Quick Add", + "mcpTools.registry.quickAddUnsupported": "This type of MCP service is not supported at the moment", + "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.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", + "mcpTools.registry.quickAddPicker.runtimeArgumentsTitle": "Package Runtime Arguments", + "mcpTools.registry.quickAddPicker.fieldMaxLength": "Field value cannot exceed 2000 characters", + "mcpTools.registry.quickAddPicker.targetRequired": "Please select a quick add target", + "mcpTools.registry.quickAddPicker.runtimeNamed": "Named Argument", + "mcpTools.registry.quickAddPicker.runtimePositional": "Positional Argument", + "mcpTools.registry.quickAddPicker.variablePlaceholder": "Enter variable value", + "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.market.more": "Find More MCP?", + "mcpTools.registry.market.modelscope": "ModelScope MCP Market", + "mcpTools.registry.market.mcpso": "MCP.so", + "mcpTools.registry.prevPage": "Previous", + "mcpTools.registry.nextPage": "Next", + "mcpTools.registry.website": "Website:", + "mcpTools.registry.repository": "Repository:", + "mcpTools.registry.remotes": "Remotes", + "mcpTools.registry.remoteVariables": "Variables", + "mcpTools.registry.remoteHeaders": "Headers", + "mcpTools.registry.headerRequired": "Required", + "mcpTools.registry.headerSecret": "Secret", + "mcpTools.registry.headerFallback": "Header #{{index}}", + "mcpTools.registry.variableFallback": "Variable #{{index}}", + "mcpTools.registry.headerField.name": "Name", + "mcpTools.registry.headerField.url": "URL", + "mcpTools.registry.headerField.description": "Description", + "mcpTools.registry.headerField.isRequired": "Required", + "mcpTools.registry.headerField.isSecret": "Secret", + "mcpTools.registry.headerField.isRepeated": "Repeated", + "mcpTools.registry.headerField.format": "Format", + "mcpTools.registry.headerField.valueHint": "Value Hint", + "mcpTools.registry.headerField.value": "Value", + "mcpTools.registry.headerField.default": "Default", + "mcpTools.registry.headerField.placeholder": "Placeholder", + "mcpTools.registry.headerField.choices": "Choices", + "mcpTools.registry.headerField.variables": "Variables", + "mcpTools.registry.headerField.type": "Type", + "mcpTools.registry.variableField.description": "Description", + "mcpTools.registry.variableField.name": "Name", + "mcpTools.registry.variableField.url": "URL", + "mcpTools.registry.variableField.format": "Format", + "mcpTools.registry.variableField.valueHint": "Value Hint", + "mcpTools.registry.variableField.value": "Value", + "mcpTools.registry.variableField.default": "Default", + "mcpTools.registry.variableField.placeholder": "Placeholder", + "mcpTools.registry.variableField.choices": "Choices", + "mcpTools.registry.variableField.variables": "Variables", + "mcpTools.registry.variableField.type": "Type", + "mcpTools.registry.variableField.isRequired": "Required", + "mcpTools.registry.variableField.isSecret": "Secret", + "mcpTools.registry.variableField.isRepeated": "Repeated", + "mcpTools.registry.packageField.registryType": "Registry Type", + "mcpTools.registry.packageField.identifier": "Identifier", + "mcpTools.registry.packageField.version": "Version", + "mcpTools.registry.packageField.runtimeHint": "Runtime Hint", + "mcpTools.registry.packageField.registryBaseUrl": "Registry Base URL", + "mcpTools.registry.packageField.fileSha256": "File SHA256", + "mcpTools.registry.packageField.environmentVariables": "Environment Variables", + "mcpTools.registry.packageField.runtimeArguments": "Runtime Arguments", + "mcpTools.registry.packageField.packageArguments": "Package Arguments", + "mcpTools.registry.packageField.transport": "Transport", + "mcpTools.registry.packages": "Packages", + "mcpTools.registry.remoteFallback": "remote", + "mcpTools.registry.viewServerJson": "View full server.json", + "mcpTools.registry.serverJsonTitle": "{{name}} - server.json", + "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.targetRequired": "Please select a quick add target", + "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.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.versionMaxLength": "Version cannot exceed 100 characters", + "mcpTools.community.mine.tagsPlaceholder": "Separate tags with commas", + "mcpTools.community.mine.deleteSuccess": "MCP service deleted successfully", + "mcpTools.community.mine.deleteFailed": "Failed to delete MCP service", + "mcpTools.community.descriptionMarkdownPlaceholder": "Supports Markdown. You can add headings, lists, links, and code blocks.", + "mcpTools.community.descriptionMarkdownHint": "Tip: This description is shown to community users and supports Markdown formatting.", + "mcpTools.community.descriptionPreview": "Markdown Preview", + "mcpTools.tools.loadFailed": "Failed to load tools", + "mcpTools.tools.refreshing": "Refreshing tools…", + "mcpTools.detail.title": "MCP Service Details", + "mcpTools.detail.name": "Name", + "mcpTools.detail.description": "Description", + "mcpTools.detail.descriptionExpand": "Expand", + "mcpTools.detail.descriptionCollapse": "Collapse", + "mcpTools.detail.descriptionClickToEdit": "Click the description area to edit", + "mcpTools.detail.descriptionEditDone": "Done", + "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.version": "Version", + "mcpTools.detail.website": "Website", + "mcpTools.detail.repository": "Repository", + "mcpTools.detail.status": "Status", + "mcpTools.detail.createdAt": "Created At", + "mcpTools.detail.updatedAt": "Updated At", + "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}}", + "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.", @@ -1850,6 +2115,7 @@ "common.preview": "Preview", "common.fullscreen": "Fullscreen", "common.delete": "Delete", + "common.add": "Add", "common.notice": "Notice", "common.button.close": "Close", "common.button.cancel": "Cancel", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index fb521b68d..1c7726cf0 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1104,6 +1104,10 @@ "mcpConfig.serverList.column.url": "URL", "mcpConfig.serverList.column.status": "状态", "mcpConfig.serverList.column.action": "操作", + "mcpConfig.serverList.column.enabled": "启用状态", + "mcpConfig.serverList.enabled.yes": "已启用", + "mcpConfig.serverList.enabled.no": "未启用", + "mcpConfig.serverList.enabled.tooltip": "请联系管理员启用MCP服务", "mcpConfig.serverList.button.viewTools": "查看工具", "mcpConfig.serverList.button.healthCheck": "连通性校验", "mcpConfig.serverList.button.edit": "编辑", @@ -1140,6 +1144,8 @@ "mcpConfig.addContainer.configPlaceholder": "请输入MCP服务器配置JSON", "mcpConfig.addContainer.port": "端口", "mcpConfig.addContainer.portPlaceholder": "请输入端口号", +"mcpConfig.addContainer.serviceName": "服务名称", +"mcpConfig.addContainer.serviceNamePlaceholder": "请填写服务名称", "mcpConfig.addContainer.button.add": "添加", "mcpConfig.addContainer.button.updating": "添加中...", "mcpConfig.editServer.title": "编辑MCP服务器", @@ -1821,6 +1827,265 @@ "mcpTools.comingSoon.feature2": "同步、查看和组织 MCP 工具列表", "mcpTools.comingSoon.feature3": "监控 MCP 连接状态和使用情况", "mcpTools.comingSoon.badge": "即将推出", + "mcpTools.page.title": "MCP 服务管理", + "mcpTools.page.subtitle": "统一管理本地与公共市场的 MCP 服务,支持搜索、添加与启用配置。", + "mcpTools.page.searchPlaceholder": "搜索 MCP 服务名称、描述或标签", + "mcpTools.page.resultCount": "{{count}} 个结果", + "mcpTools.page.sourceFilter.all": "全部来源", + "mcpTools.page.transportFilter.all": "全部类型", + "mcpTools.page.tagFilter.all": "全部标签", + "mcpTools.page.addService": "添加 MCP 服务", + "mcpTools.page.tab.imported": "导入的服务", + "mcpTools.page.tab.published": "发布的服务", + "mcpTools.page.loading": "正在加载 MCP 服务列表...", + "mcpTools.page.empty": "暂无 MCP 服务数据,请先添加或导入。", + "mcpTools.publish.confirmTitle": "确认发布到社区", + "mcpTools.publish.confirmHint": "此处的修改只会影响发布到社区的副本,不会改动当前服务。", + "mcpTools.published.detailTitle": "发布详情", + "mcpTools.service.enabled": "服务已启用", + "mcpTools.service.disabled": "服务已关闭", + "mcpTools.service.enableNameConflict": "启用失败:已存在同名的启用服务,请先改名", + "mcpTools.service.toggleFailed": "切换服务状态失败", + "mcpTools.service.toggleMissingId": "切换服务状态失败:缺少服务 ID", + "mcpTools.service.saveFailed": "保存失败", + "mcpTools.service.saveSuccess": "保存成功", + "mcpTools.service.healthOk": "连通性校验成功", + "mcpTools.service.healthFailed": "连通性校验失败", + "mcpTools.service.deleteFailed": "删除服务失败", + "mcpTools.service.deleted": "服务已删除", + "mcpTools.service.defaultName": "MCP 服务", + "mcpTools.status.enabled": "已启用", + "mcpTools.status.disabled": "未启用", + "mcpTools.status.active": "活动", + "mcpTools.status.deprecated": "弃用", + "mcpTools.status.unknown": "未知", + "mcpTools.source.local": "自定义", + "mcpTools.source.registry": "外部市场", + "mcpTools.source.community": "社区市场", + "mcpTools.serverType.url": "远程链接", + "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.delete.confirmOk": "确认", + "mcpTools.delete.confirmCancel": "取消", + "mcpTools.add.failed": "添加 MCP 服务失败", + "mcpTools.add.enableNameConflict": "已存在同名已启用服务", + "mcpTools.add.success": "MCP 服务添加成功", + "mcpTools.add.validate.nameRequired": "请填写 MCP 名称", + "mcpTools.add.validate.nameMaxLength": "MCP 名称不能超过 100 个字符", + "mcpTools.add.validate.httpUrlRequired": "请填写 HTTP 服务地址", + "mcpTools.add.validate.httpUrlMaxLength": "HTTP 服务地址不能超过 500 个字符", + "mcpTools.add.validate.httpUrlFormat": "请输入有效的 http(s) URL", + "mcpTools.add.validate.containerConfigRequired": "请填写容器配置 JSON", + "mcpTools.add.validate.containerRequired": "请填写容器端口", + "mcpTools.add.validate.containerPortRange": "容器端口必须在 1 到 65535 之间", + "mcpTools.add.validate.descriptionMaxLength": "描述不能超过 5000 个字符", + "mcpTools.add.validate.authorizationTokenMaxLength": "Bearer Token 不能超过 500 个字符", + "mcpTools.add.validate.transportTypeRequired": "请选择服务类型", + "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.tabRegistry": "外部市场", + "mcpTools.addModal.tabCommunity": "社区市场", + "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.containerConfig": "容器配置 (JSON)", + "mcpTools.addModal.containerConfigPlaceholder": "{\"image\": \"mcp-server:latest\", \"env\": {}}", + "mcpTools.addModal.containerPort": "容器端口", + "mcpTools.addModal.containerPortPlaceholder": "8080", + "mcpTools.addModal.suggestPort": "推荐端口", + "mcpTools.addModal.portChecking": "正在检查端口...", + "mcpTools.addModal.portAvailable": "端口 {{port}} 可用。", + "mcpTools.addModal.portOccupied": "端口 {{port}} 已被占用。", + "mcpTools.addModal.tags": "标签", + "mcpTools.addModal.removeTagAria": "删除标签 {{tag}}", + "mcpTools.addModal.tagInputPlaceholder": "输入标签后回车", + "mcpTools.addModal.saveAndAdd": "保存并添加", + "mcpTools.registry.loadFailed": "获取公共市场列表失败", + "mcpTools.registry.searchPlaceholder": "搜索公共市场 MCP", + "mcpTools.registry.pageResult": "第 {{page}} 页 · {{count}} 个结果", + "mcpTools.registry.versionAll": "全部版本", + "mcpTools.registry.versionLatest": "最新版本", + "mcpTools.registry.versionCustom": "自定义版本", + "mcpTools.registry.updatedSince": "更新时间下限", + "mcpTools.registry.updatedSincePlaceholder": "选择更新时间", + "mcpTools.registry.includeDeleted": "包含已删除", + "mcpTools.registry.includeDeletedDesc": "包含已删除服务器", + "mcpTools.registry.customVersion": "自定义版本号", + "mcpTools.registry.customVersionPlaceholder": "例如 1.2.3", + "mcpTools.registry.loading": "正在加载公共市场 MCP...", + "mcpTools.registry.empty": "未找到匹配的公共市场 MCP。", + "mcpTools.registry.quickAdd": "快速添加", + "mcpTools.registry.quickAddUnsupported": "暂不支持该类型的MCP服务", + "mcpTools.registry.quickAddPicker.title": "选择快速添加目标", + "mcpTools.registry.quickAddPicker.description": "为 {{name}} 选择一个要快速添加的地址或安装包。", + "mcpTools.registry.quickAddPicker.sourceRemote": "来源: 远程地址", + "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 环境变量", + "mcpTools.registry.quickAddPicker.runtimeArgumentsTitle": "Package 运行参数", + "mcpTools.registry.quickAddPicker.fieldMaxLength": "字段值不能超过 2000 个字符", + "mcpTools.registry.quickAddPicker.targetRequired": "请选择一个快速添加目标", + "mcpTools.registry.quickAddPicker.runtimeNamed": "命名参数", + "mcpTools.registry.quickAddPicker.runtimePositional": "位置参数", + "mcpTools.registry.quickAddPicker.variablePlaceholder": "请输入变量值", + "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.market.more": "寻找更多MCP?", + "mcpTools.registry.market.modelscope": "魔搭 MCP 广场", + "mcpTools.registry.market.mcpso": "MCP.so", + "mcpTools.registry.prevPage": "上一页", + "mcpTools.registry.nextPage": "下一页", + "mcpTools.registry.website": "网站:", + "mcpTools.registry.repository": "仓库:", + "mcpTools.registry.remotes": "远程地址", + "mcpTools.registry.remoteVariables": "远程变量", + "mcpTools.registry.remoteHeaders": "请求头", + "mcpTools.registry.headerRequired": "必填", + "mcpTools.registry.headerSecret": "密文", + "mcpTools.registry.headerFallback": "请求头 #{{index}}", + "mcpTools.registry.variableFallback": "变量 #{{index}}", + "mcpTools.registry.headerField.name": "名称", + "mcpTools.registry.headerField.url": "地址", + "mcpTools.registry.headerField.description": "描述", + "mcpTools.registry.headerField.isRequired": "必填", + "mcpTools.registry.headerField.isSecret": "密文", + "mcpTools.registry.headerField.isRepeated": "可重复", + "mcpTools.registry.headerField.format": "格式", + "mcpTools.registry.headerField.valueHint": "值提示", + "mcpTools.registry.headerField.value": "值", + "mcpTools.registry.headerField.default": "默认值", + "mcpTools.registry.headerField.placeholder": "占位提示", + "mcpTools.registry.headerField.choices": "可选值", + "mcpTools.registry.headerField.variables": "变量", + "mcpTools.registry.headerField.type": "类型", + "mcpTools.registry.variableField.description": "描述", + "mcpTools.registry.variableField.name": "名称", + "mcpTools.registry.variableField.url": "地址", + "mcpTools.registry.variableField.format": "格式", + "mcpTools.registry.variableField.valueHint": "值提示", + "mcpTools.registry.variableField.value": "值", + "mcpTools.registry.variableField.default": "默认值", + "mcpTools.registry.variableField.placeholder": "占位提示", + "mcpTools.registry.variableField.choices": "可选值", + "mcpTools.registry.variableField.variables": "变量", + "mcpTools.registry.variableField.type": "类型", + "mcpTools.registry.variableField.isRequired": "必填", + "mcpTools.registry.variableField.isSecret": "密文", + "mcpTools.registry.variableField.isRepeated": "可重复", + "mcpTools.registry.packageField.registryType": "注册表类型", + "mcpTools.registry.packageField.identifier": "标识", + "mcpTools.registry.packageField.version": "版本", + "mcpTools.registry.packageField.runtimeHint": "运行时提示", + "mcpTools.registry.packageField.registryBaseUrl": "注册表地址", + "mcpTools.registry.packageField.fileSha256": "文件 SHA256", + "mcpTools.registry.packageField.environmentVariables": "环境变量", + "mcpTools.registry.packageField.runtimeArguments": "运行参数", + "mcpTools.registry.packageField.packageArguments": "包参数", + "mcpTools.registry.packageField.transport": "传输配置", + "mcpTools.registry.packages": "安装包", + "mcpTools.registry.remoteFallback": "远程", + "mcpTools.registry.viewServerJson": "查看完整 server.json", + "mcpTools.registry.serverJsonTitle": "{{name}} - server.json", + "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.targetRequired": "请选择一个快速添加目标", + "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.mine.title": "我的发布", + "mcpTools.community.mine.empty": "你还没有发布过 MCP。", + "mcpTools.community.mine.edit": "编辑", + "mcpTools.community.mine.delete": "删除", + "mcpTools.community.mine.versionMaxLength": "版本不能超过 100 个字符", + "mcpTools.community.mine.tagsPlaceholder": "多个标签使用英文逗号分隔", + "mcpTools.community.mine.deleteSuccess": "MCP 服务删除成功", + "mcpTools.community.mine.deleteFailed": "MCP 服务删除失败", + "mcpTools.community.descriptionMarkdownPlaceholder": "支持 Markdown,可填写标题、列表、链接、代码块等内容", + "mcpTools.community.descriptionMarkdownHint": "提示:该描述会展示给社区用户,支持 Markdown 格式化。", + "mcpTools.community.descriptionPreview": "Markdown 预览", + "mcpTools.tools.loadFailed": "获取工具列表失败", + "mcpTools.tools.refreshing": "正在刷新工具列表…", + "mcpTools.detail.title": "MCP 服务详情", + "mcpTools.detail.name": "名称", + "mcpTools.detail.description": "描述", + "mcpTools.detail.descriptionExpand": "展开", + "mcpTools.detail.descriptionCollapse": "收起", + "mcpTools.detail.descriptionClickToEdit": "点击描述区域进入编辑", + "mcpTools.detail.descriptionEditDone": "完成编辑", + "mcpTools.detail.serverUrl": "服务地址", + "mcpTools.detail.bearerTokenOptional": "Bearer Token(可选)", + "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.createdAt": "创建时间", + "mcpTools.detail.updatedAt": "更新时间", + "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}}", + "mcpTools.detail.tagInputPlaceholder": "输入标签后回车", + "mcpTools.detail.save": "保存修改", + "mcpTools.detail.disable": "关闭服务", + "mcpTools.detail.enable": "启用服务", "monitoring.comingSoon.title": "监控与运维中心即将推出", "monitoring.comingSoon.description": "面向智能体的统一监控与运维中心,用于实时跟踪健康状态、性能指标与异常事件。", @@ -1907,6 +2172,7 @@ "common.preview": "预览", "common.fullscreen": "全屏", "common.delete": "删除", + "common.add": "添加", "common.button.cancel": "取消", "common.button.save": "保存", "common.button.saving": "保存中", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 5eb1b85c3..de96e84c6 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -234,7 +234,7 @@ export const API_ENDPOINTS = { tools: `${API_BASE_URL}/mcp/tools`, add: `${API_BASE_URL}/mcp/add`, update: `${API_BASE_URL}/mcp/update`, - delete: `${API_BASE_URL}/mcp`, + delete: (mcpId: number) => `${API_BASE_URL}/mcp/${mcpId}`, list: `${API_BASE_URL}/mcp/list`, healthcheck: `${API_BASE_URL}/mcp/healthcheck`, addFromConfig: `${API_BASE_URL}/mcp/add-from-config`, @@ -245,6 +245,20 @@ export const API_ENDPOINTS = { deleteContainer: (containerId: string) => `${API_BASE_URL}/mcp/container/${containerId}`, record: (mcpId: number) => `${API_BASE_URL}/mcp/record/${mcpId}`, + portCheck: `${API_BASE_URL}/mcp/port/check`, + portSuggest: `${API_BASE_URL}/mcp/port/suggest`, + enable: `${API_BASE_URL}/mcp/enable`, + disable: `${API_BASE_URL}/mcp/disable`, + }, + mcpTools: { + // Community and Registry endpoints remain under /mcp-tools prefix + 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`, + communityTagsStats: `${API_BASE_URL}/mcp-tools/community/tags/stats`, }, // A2A Client endpoints a2a: { diff --git a/frontend/services/mcpService.ts b/frontend/services/mcpService.ts index 20383809f..bfa714bb0 100644 --- a/frontend/services/mcpService.ts +++ b/frontend/services/mcpService.ts @@ -39,6 +39,18 @@ export const getMcpServerList = async (tenantId?: string | null) => { status: server.status || false, permission: server.permission, mcp_id: server.mcp_id, + // New fields from merged endpoint + container_id: server.container_id, + description: server.description, + enabled: server.enabled, + source: server.source, + update_time: server.update_time, + tags: server.tags || [], + container_port: server.container_port, + registry_json: server.registry_json, + config_json: server.config_json, + container_status: server.container_status, + authorization_token: server.authorization_token, }; }); @@ -84,23 +96,22 @@ export const getMcpServerList = async (tenantId?: string | null) => { */ export const addMcpServer = async (mcpUrl: string, serviceName: string, authorizationToken?: string | null, tenantId?: string | null) => { try { - const params = new URLSearchParams({ - mcp_url: mcpUrl, - service_name: serviceName, - }); + const url = tenantId + ? `${API_ENDPOINTS.mcp.add}?tenant_id=${encodeURIComponent(tenantId)}` + : API_ENDPOINTS.mcp.add; + const body: any = { + name: serviceName, + server_url: mcpUrl, + enabled: true, + }; if (authorizationToken) { - params.append('authorization_token', authorizationToken); - } - if (tenantId) { - params.append('tenant_id', tenantId); + body.authorization_token = authorizationToken; } - const response = await fetch( - `${API_ENDPOINTS.mcp.add}?${params.toString()}`, - { - method: 'POST', - headers: getAuthHeaders(), - } - ); + const response = await fetch(url, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(body), + }); const data = await response.json(); @@ -112,7 +123,7 @@ export const addMcpServer = async (mcpUrl: string, serviceName: string, authoriz }; } else { // Handle specific error status codes and error information - let errorMessage = data.message || t('mcpService.message.addServerFailed'); + let errorMessage = data.detail || data.message || t('mcpService.message.addServerFailed'); if (response.status === 409) { errorMessage = t('mcpService.message.nameAlreadyUsed'); @@ -142,11 +153,12 @@ export const addMcpServer = async (mcpUrl: string, serviceName: string, authoriz * Update MCP server */ export const updateMcpServer = async ( - currentServiceName: string, - currentMcpUrl: string, + mcpId: number, newServiceName: string, newMcpUrl: string, newAuthorizationToken?: string | null, + description?: string | null, + tags?: string[], tenantId?: string | null ) => { try { @@ -154,13 +166,14 @@ export const updateMcpServer = async ( ? `${API_ENDPOINTS.mcp.update}?tenant_id=${encodeURIComponent(tenantId)}` : API_ENDPOINTS.mcp.update; const body: any = { - current_service_name: currentServiceName, - current_mcp_url: currentMcpUrl, - new_service_name: newServiceName, - new_mcp_url: newMcpUrl, + mcp_id: mcpId, + name: newServiceName, + server_url: newMcpUrl, + description: description ?? null, + tags: tags ?? [], }; if (newAuthorizationToken !== undefined) { - body.new_authorization_token = newAuthorizationToken; + body.authorization_token = newAuthorizationToken; } const response = await fetch(url, { method: "PUT", @@ -206,24 +219,17 @@ export const updateMcpServer = async ( }; /** - * Delete MCP server + * Delete MCP server by ID */ -export const deleteMcpServer = async (mcpUrl: string, serviceName: string, tenantId?: string | null) => { +export const deleteMcpServer = async (mcpId: number, tenantId?: string | null) => { try { - const params = new URLSearchParams({ - mcp_url: mcpUrl, - service_name: serviceName, + const url = tenantId + ? `${API_ENDPOINTS.mcp.delete(mcpId)}?tenant_id=${encodeURIComponent(tenantId)}` + : API_ENDPOINTS.mcp.delete(mcpId); + const response = await fetch(url, { + method: 'DELETE', + headers: getAuthHeaders(), }); - if (tenantId) { - params.append('tenant_id', tenantId); - } - const response = await fetch( - `${API_ENDPOINTS.mcp.delete}?${params.toString()}`, - { - method: 'DELETE', - headers: getAuthHeaders(), - } - ); const data = await response.json(); @@ -234,15 +240,17 @@ export const deleteMcpServer = async (mcpUrl: string, serviceName: string, tenan message: data.message || t('mcpService.message.deleteServerSuccess') }; } else { - // Handle specific error information based on HTTP status code - let errorMessage = data.message || t('mcpService.message.deleteServerFailed'); + let errorMessage = data.detail || data.message || t('mcpService.message.deleteServerFailed'); switch (response.status) { + case 404: + errorMessage = t('mcpService.message.mcpServerNotFound'); + break; case 500: errorMessage = t('mcpService.message.deleteProxyFailed'); break; default: - errorMessage = data.message || t('mcpService.message.deleteServerFailed'); + errorMessage = data.detail || data.message || t('mcpService.message.deleteServerFailed'); } return { @@ -262,14 +270,16 @@ export const deleteMcpServer = async (mcpUrl: string, serviceName: string, tenan }; /** - * Get tool list from remote MCP server + * Get tool list from remote MCP server by ID */ -export const getMcpTools = async (serviceName: string, mcpUrl: string) => { +export const getMcpTools = async (mcpId: number) => { try { + const query = new URLSearchParams(); + query.set('mcp_id', mcpId.toString()); const response = await fetch( - `${API_ENDPOINTS.mcp.tools}?service_name=${encodeURIComponent(serviceName)}&mcp_url=${encodeURIComponent(mcpUrl)}`, + `${API_ENDPOINTS.mcp.tools}?${query.toString()}`, { - method: 'POST', + method: 'GET', headers: getAuthHeaders(), } ); @@ -283,8 +293,7 @@ export const getMcpTools = async (serviceName: string, mcpUrl: string) => { message: '' }; } else { - // Handle specific error information based on HTTP status code - let errorMessage = data.message || t('mcpService.message.getToolsFailed'); + let errorMessage = data.detail || data.message || t('mcpService.message.getToolsFailed'); switch (response.status) { case 500: @@ -293,8 +302,11 @@ export const getMcpTools = async (serviceName: string, mcpUrl: string) => { case 503: errorMessage = t('mcpService.message.cannotConnectToServer'); break; + case 404: + errorMessage = t('mcpService.message.mcpServerNotFound'); + break; default: - errorMessage = data.message || t('mcpService.message.getToolsFailed'); + errorMessage = data.detail || data.message || t('mcpService.message.getToolsFailed'); } return { @@ -314,7 +326,7 @@ export const getMcpTools = async (serviceName: string, mcpUrl: string) => { }; /** - * 更新工具列表及状态 + * Update tool list and status */ export const updateToolList = async () => { try { @@ -364,21 +376,14 @@ export const updateToolList = async () => { /** * checkMcpServerHealth */ -export const checkMcpServerHealth = async (mcpUrl: string, serviceName: string, tenantId?: string | null) => { +export const checkMcpServerHealth = async (mcpId: number) => { try { - const params = new URLSearchParams({ - mcp_url: mcpUrl, - service_name: serviceName, + const query = new URLSearchParams(); + query.set('mcp_id', mcpId.toString()); + const response = await fetch(`${API_ENDPOINTS.mcp.healthcheck}?${query.toString()}`, { + method: 'GET', + headers: getAuthHeaders(), }); - if (tenantId) { - params.append('tenant_id', tenantId); - } - const response = await fetch( - `${API_ENDPOINTS.mcp.healthcheck}?${params.toString()}`, - { - headers: getAuthHeaders(), - } - ); const data = await response.json(); diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts new file mode 100644 index 000000000..a86b50219 --- /dev/null +++ b/frontend/services/mcpToolsService.ts @@ -0,0 +1,544 @@ +import log from "@/lib/logger"; +import { fetchWithAuth } from "@/lib/auth"; +import { + McpContainerStatus, + McpHealthStatus, + McpServiceStatus, + McpSource, + McpTransportType, +} from "@/const/mcpTools"; +import { API_ENDPOINTS } from "@/services/api"; +import type { + AddMcpServicePayload, + HealthcheckMcpServicePayload, + McpContainerConfigPayload, + McpContainerServerEntry, + RegistryMcpCard, + CommunityMcpCard, + McpTagStat, + McpServiceItem, + ToggleMcpServicePayload, + UpdateMcpServicePayload, +} from "@/types/mcpTools"; +import type { McpTool } from "@/types/agentConfig"; + +export type McpToolsApiResult = { + success: boolean; + data: T; +}; + +export type { RegistryMcpCard as RegistryMcpCard } from "@/types/mcpTools"; + +type ApiEnvelope = { + status: string; + message?: string; + detail?: string; + data: T; + tools?: McpTool[]; + results?: Array<{ mcp_url?: string }>; + mcp_url?: string; +}; + +type AddContainerMcpToolPayload = { + name: string; + description?: string; + tags: string[]; + source: McpSource; + authorization_token?: string; + registry_json?: Record; + port: number; + mcp_config: McpContainerConfigPayload; +}; + +type PortConflictResult = { + available: boolean; +}; + +const parseJson = async (response: Response): Promise => { + return (await response.json()) as T; +}; + +type HealthcheckPayload = { + health_status: McpHealthStatus; +}; + +export const fetchRegistryMcpCards = 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 listRegistryMcpTools(query); + const payload = result.data; + + return { + success: true, + data: { + items: payload.items, + nextCursor: payload.nextCursor ?? null, + }, + } as McpToolsApiResult<{ items: RegistryMcpCard[]; nextCursor: string | null }>; +}; + +export const fetchCommunityMcpCards = async (params: { + search?: string; + cursor?: string | null; + transportType?: McpTransportType; + tag?: string; + limit?: number; +}) => { + const result = await listCommunityMcpTools({ + search: params.search?.trim() || undefined, + cursor: params.cursor || undefined, + transport_type: params.transportType, + tag: params.tag?.trim() || undefined, + 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 fetchCommunityMcpTagStats = async () => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.communityTagsStats); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to load community MCP tag stats"); + } + return { success: true, data: data.data } as McpToolsApiResult; + } catch (error) { + log.error("fetchCommunityMcpTagStats failed", error); + throw error; + } +}; + +export const checkMcpContainerPortConflictService = async (payload: { + port: number; +}) => { + try { + const query = new URLSearchParams(); + query.set('port', payload.port.toString()); + const response = await fetchWithAuth(`${API_ENDPOINTS.mcp.portCheck}?${query.toString()}`); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to check MCP port conflict"); + } + return { success: true, data: data.data } as McpToolsApiResult; + } catch (error) { + log.error("checkMcpContainerPortConflictService failed", error); + throw error; + } +}; + +export const suggestMcpContainerPortService = async () => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.portSuggest); + const data = await parseJson>(response); + if (data.status !== "success") { + throw new Error("Failed to suggest MCP port"); + } + return { success: true, data: data.data } as McpToolsApiResult<{ port: number }>; + } catch (error) { + log.error("suggestMcpContainerPortService failed", error); + throw error; + } +}; + +/** + * Parses and validates container config JSON for add-from-config. Returns a + * typed payload or null (single `JSON.parse`; no network I/O). Each server + * entry requires `command` and `args`; `env` is optional when valid. + */ +export function parseContainerMcpConfigJson( + raw: string +): McpContainerConfigPayload | null { + const text = raw.trim(); + if (!text) return null; + + let root: unknown; + try { + root = JSON.parse(text); + } catch { + return null; + } + + if (!root || typeof root !== "object" || Array.isArray(root)) return null; + const rk = Object.keys(root); + if (rk.length !== 1 || rk[0] !== "mcpServers") return null; + + const ms = (root as { mcpServers: unknown }).mcpServers; + if (!ms || typeof ms !== "object" || Array.isArray(ms)) return null; + + const names = Object.keys(ms); + if (names.length !== 1) return null; + + const entry = (ms as Record)[names[0]!]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + + const entryObj = entry as Record; + const keys = Object.keys(entryObj); + const allow = new Set(["command", "args", "env"]); + if (!keys.every((k) => allow.has(k))) return null; + if (!keys.includes("command") || !keys.includes("args")) return null; + + const command = entryObj.command; + const args = entryObj.args; + if (typeof command !== "string" || !command.trim()) return null; + if (!Array.isArray(args) || !args.every((a) => typeof a === "string")) + return null; + + const server: McpContainerServerEntry = { + command: command.trim(), + args: args as string[], + }; + + if ("env" in entryObj) { + const envRaw = entryObj.env; + if (envRaw === null) return null; + if (typeof envRaw !== "object" || Array.isArray(envRaw)) return null; + const envOut: Record = {}; + for (const [k, v] of Object.entries(envRaw as Record)) { + if (typeof k !== "string" || typeof v !== "string") return null; + envOut[k] = v; + } + server.env = envOut; + } + + return { + mcpServers: { + [names[0]]: server, + }, + }; +} + +export const addContainerMcpToolService = async (payload: AddContainerMcpToolPayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.addFromConfig, { + 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 (params?: { tag?: string }) => { + const { getMcpServerList } = await import("./mcpService"); + const res = await getMcpServerList(); + + const items = (res.data || []).map((s: any) => { + return { + mcpId: s.mcp_id, + containerId: s.container_id, + containerPort: s.container_port ?? undefined, + name: s.service_name, + description: s.description, + source: (s.source as McpSource), + enabled: s.enabled ? McpServiceStatus.ENABLED : McpServiceStatus.DISABLED, + updatedAt: s.update_time, + tags: s.tags || [], + transportType: (s.config_json !== undefined && s.config_json !== null) ? McpTransportType.CONTAINER : McpTransportType.URL, + serverUrl: s.mcp_url, + version: s.version ?? undefined, + registryJson: s.registry_json ?? undefined, + configJson: s.config_json ?? undefined, + tools: [], + healthStatus: s.status ? McpHealthStatus.HEALTHY : McpHealthStatus.UNCHECKED, + containerStatus: s.container_status as McpContainerStatus, + authorizationToken: s.authorization_token, + } as McpServiceItem; + }); + return { success: true, data: items } as McpToolsApiResult; +}; + +export const listRegistryMcpTools = async (query: URLSearchParams) => { + try { + const response = await fetchWithAuth(`${API_ENDPOINTS.mcpTools.registryList}?${query.toString()}`); + const data = await parseJson<{ servers?: RegistryMcpCard[]; metadata?: { nextCursor?: string | null } }>(response); + if (!data || !Array.isArray(data.servers)) { + throw new Error("Failed to load registry mcp list"); + } + return { + success: true, + data: { + items: data.servers, + nextCursor: data.metadata?.nextCursor ?? null, + }, + } as McpToolsApiResult<{ items: RegistryMcpCard[]; nextCursor: string | null }>; + } catch (error) { + log.error("listRegistryMcpTools failed", error); + throw error; + } +}; + +export const listCommunityMcpTools = async (payload: { + search?: string; + tag?: string; + transport_type?: McpTransportType; + cursor?: string; + limit?: number; +}) => { + try { + const query = new URLSearchParams(); + if (payload.search) query.set("search", payload.search); + if (payload.tag) query.set("tag", payload.tag); + if (payload.transport_type) query.set("transport_type", payload.transport_type.toString()); + + if (payload.cursor) query.set("cursor", payload.cursor); + if (typeof payload.limit === "number") query.set("limit", String(payload.limit)); + + const queryString = query.toString(); + const url = queryString + ? `${API_ENDPOINTS.mcpTools.communityList}?${queryString}` + : API_ENDPOINTS.mcpTools.communityList; + + const response = await fetchWithAuth(url); + 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; + } +}; + +/** Body for POST /mcp-tools/community/publish (optional fields override the local MCP snapshot). */ +export type PublishCommunityMcpToolPayload = { + mcp_id: number; + name?: string; + description?: string; + version?: string; + tags?: string[]; + mcp_server?: string; + config_json?: McpContainerConfigPayload; +}; + +export const publishCommunityMcpTool = async ( + payload: PublishCommunityMcpToolPayload +) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcpTools.communityPublish, { + method: "POST", + body: JSON.stringify(payload), + }); + 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; + config_json?: McpContainerConfigPayload; +}) => { + 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.mcp.add, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + 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); + throw error; + } +}; + +export const updateMcpToolService = async (payload: UpdateMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.update, { + method: "PUT", + body: JSON.stringify(payload), + }); + const data = await parseJson(response); + 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); + throw error; + } +}; + +export const enableMcpToolService = async (payload: ToggleMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.enable, { + 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("enableMcpToolService failed", error); + throw error; + } +}; + +export const disableMcpToolService = async (payload: ToggleMcpServicePayload) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.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; + } +}; + +export const healthcheckMcpToolService = async (payload: HealthcheckMcpServicePayload) => { + try { + const query = new URLSearchParams(); + query.set('mcp_id', payload.mcp_id.toString()); + const response = await fetchWithAuth(`${API_ENDPOINTS.mcp.healthcheck}?${query.toString()}`, { + method: "GET", + }); + const data = await parseJson>( + response + ); + 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); + throw error; + } +}; + +export const deleteMcpToolService = async (mcpId: number) => { + try { + const response = await fetchWithAuth(API_ENDPOINTS.mcp.delete(mcpId), { + method: "DELETE", + }); + const data = await parseJson(response); + 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); + throw error; + } +}; + +export const listMcpRuntimeTools = async (mcpId: number) => { + try { + const query = new URLSearchParams(); + query.set('mcp_id', mcpId.toString()); + const response = await fetchWithAuth(`${API_ENDPOINTS.mcp.tools}?${query.toString()}`); + const data = await parseJson(response); + 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); + throw error; + } +}; + +// Intentionally keep AddFromConfigApiResult type for backward compatibility in other modules. diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts index e6d36daaf..f69af3366 100644 --- a/frontend/types/agentConfig.ts +++ b/frontend/types/agentConfig.ts @@ -370,7 +370,7 @@ export interface McpServer { remote_mcp_server_name?: string; remote_mcp_server?: string; authorization_token?: string | null; - mcp_id?: number; + mcp_id: number; /** * Per-item permission returned by /mcp/list. * EDIT: editable, READ_ONLY: read-only. diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts new file mode 100644 index 000000000..aacb4e52c --- /dev/null +++ b/frontend/types/mcpTools.ts @@ -0,0 +1,244 @@ +import { + FILTER_ALL, + McpSource, + type McpContainerStatus, + type McpHealthStatus, + type McpServiceStatus, + type McpTransportType, +} from "@/const/mcpTools"; + +export type FilterAll = typeof FILTER_ALL; + +/** Source-filter for the main service list (all | local | registry | community). */ +export type McpSourceFilter = McpSource | FilterAll; +/** Transport-filter for toolbars (all | http | sse | container). */ +export type McpTransportFilter = McpTransportType | FilterAll; + + +export interface RegistryServerPayload { + name: string; + version?: string; + description?: string; + websiteUrl?: string; + repository?: { + url?: string; + source?: string; + id?: string; + }; + remotes: Array<{ + type: string; + url: string; + variables?: Record; + headers?: Array<{ + name?: string; + description?: string; + isRequired?: boolean; + isSecret?: boolean; + format?: string; + value?: string; + default?: string; + placeholder?: string; + choices?: string[]; + variables?: Record; + [key: string]: unknown; + }>; + [key: string]: unknown; + }>; + packages: Array<{ + registryType?: string; + identifier?: string; + version?: string; + runtimeHint?: string; + transport?: { + type?: string; + url?: string; + headers?: unknown; + variables?: unknown; + [key: string]: unknown; + }; + environmentVariables?: unknown; + runtimeArguments?: unknown; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +export interface RegistryMcpCard { + server: RegistryServerPayload; + _meta?: Record; + [key: string]: unknown; +} + +export interface RegistryRemoteVariable { + key: string; + formKey?: string; + label?: string; + description?: string; + format?: string; + default?: string; + placeholder?: string; + value?: string; + isRequired?: boolean; + isSecret?: boolean; + choices?: string[]; + variables?: Record; + [key: string]: unknown; +} + +export interface RegistryPackageArgumentInput { + key: string; + formKey: string; + label: string; + type: "named" | "positional"; + name?: string; + valueHint?: string; + description?: string; + format?: string; + default?: string; + value?: string; + isRequired?: boolean; + isSecret?: boolean; + isRepeated?: boolean; +} + +export interface RegistryQuickAddOption { + key: string; + sourceType: "remote" | "package"; + sourceLabel: string; + transportType: McpTransportType; + serverUrl?: string; + remoteVariables?: RegistryRemoteVariable[]; + remoteHeaders?: RegistryRemoteVariable[]; + unsupportedRequiredHeaders?: string[]; + packageRuntimeHint?: string; + packageEnvironmentVariables?: RegistryRemoteVariable[]; + packageTransportHeaders?: RegistryRemoteVariable[]; + packageTransportVariables?: RegistryRemoteVariable[]; + packageRuntimeArguments?: RegistryPackageArgumentInput[]; + packageArguments?: RegistryPackageArgumentInput[]; + packageIdentifier?: string; + packageRegistryType?: string; + packageEnvTemplate?: Record; +} + +export interface CommunityMcpCard { + communityId?: number; + name: string; + version?: string; + description: string; + status: string; + createdAt: string; + updatedAt?: string; + remotes: Array<{ type: string; url: string }>; + packages: Array>; + source?: McpSource.COMMUNITY; + transportType: McpTransportType; + serverUrl: string; + configJson?: Record; + registryJson?: Record; + tags?: string[]; +} + +export interface McpServiceItem { + mcpId: number; + containerId?: string; + containerPort?: number; + name: string; + description: string; + source: McpSource; + enabled: McpServiceStatus; + updatedAt: string; + tags: string[]; + transportType: McpTransportType; + serverUrl: string; + version?: string; + registryJson?: Record; + configJson?: Record; + tools: string[]; + healthStatus: McpHealthStatus; + containerStatus?: McpContainerStatus; + authorizationToken?: string; +} + +export interface McpTagStat { + tag: string; + count: number; +} + +export interface AddMcpServicePayload { + name: string; + description: string; + source: McpSource; + //transport_type: McpTransportType; + server_url: string; + tags: string[]; + authorization_token?: string; + container_config?: Record; + version?: string; + registry_json?: Record; +} + +export interface UpdateMcpServicePayload { + mcp_id: number; + name: string; + description: string; + server_url: string; + tags: string[]; + authorization_token?: string; +} + +export interface ToggleMcpServicePayload { + mcp_id: number; + enabled: boolean; +} + +export interface HealthcheckMcpServicePayload { + mcp_id: number; +} + +/** One MCP server entry under `mcpServers` for container-based add-from-config. */ +export interface McpContainerServerEntry { + command: string; + args: string[]; + env?: Record; +} + +/** Root JSON shape for container add-from-config (`parseContainerMcpConfigJson`). */ +export interface McpContainerConfigPayload { + mcpServers: Record; +} + +// --------------------------------------------------------------------------- +// Feature-local draft interfaces +// --------------------------------------------------------------------------- + +/** + * Form state owned by the local-add section. Components manage this directly; + * the shared shape makes it easy to pass the whole draft into a submit helper. + */ +export interface LocalAddMcpDraft { + name: string; + description?: string; + transportType: McpTransportType; + serverUrl: string; + authorizationToken?: string; + containerConfigJson: string; + containerPort?: number; + tags: string[]; +} + +/** + * Form state for the community quick-add confirmation modal. + */ +export interface CommunityQuickAddDraft { + name: string; + description?: string; + transportType: McpTransportType; + serverUrl: string; + authorizationToken?: string; + containerConfigJson?: string; + containerPort?: number; + tags: string[]; + version?: string; + registryJson?: Record; +} diff --git a/sdk/nexent/container/docker_client.py b/sdk/nexent/container/docker_client.py index ef13d26d7..63d5988a2 100644 --- a/sdk/nexent/container/docker_client.py +++ b/sdk/nexent/container/docker_client.py @@ -7,6 +7,7 @@ import socket from pathlib import Path from typing import Dict, List, Optional, Any +import uuid import docker from docker.errors import APIError, DockerException, NotFound @@ -183,7 +184,8 @@ def _generate_container_name(self, service_name: str, tenant_id: str, user_id: s "-" else "-" for c in service_name) tenant_part = (tenant_id or "")[:8] user_part = (user_id or "")[:8] - return f"mcp-{safe_name}-{tenant_part}-{user_part}" + uuid_part = uuid.uuid4().hex[:8] + return f"mcp-{safe_name}-{tenant_part}-{user_part}-{uuid_part}" async def start_container( self, diff --git a/sdk/nexent/container/k8s_client.py b/sdk/nexent/container/k8s_client.py index f84513323..9ba35658f 100644 --- a/sdk/nexent/container/k8s_client.py +++ b/sdk/nexent/container/k8s_client.py @@ -8,6 +8,7 @@ import asyncio import logging import socket +import uuid import kubernetes from typing import Any, Dict, List, Optional @@ -78,7 +79,8 @@ def _generate_pod_name(self, service_name: str, tenant_id: str, user_id: str) -> safe_name = "".join(c if c.isalnum() or c == "-" else "-" for c in service_name) tenant_part = (tenant_id or "")[:8] user_part = (user_id or "")[:8] - return f"mcp-{safe_name}-{tenant_part}-{user_part}" + uuid_part = uuid.uuid4().hex[:8] + return f"mcp-{safe_name}-{tenant_part}-{user_part}-{uuid_part}" def _get_labels(self, service_name: str, tenant_id: str, user_id: str) -> Dict[str, str]: """Generate labels for pod and service.""" 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