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/doc/docs/en/user-guide/mcp-tools.md b/doc/docs/en/user-guide/mcp-tools.md index b55859cbe..cd1190e0e 100644 --- a/doc/docs/en/user-guide/mcp-tools.md +++ b/doc/docs/en/user-guide/mcp-tools.md @@ -1,28 +1,159 @@ # MCP Tools -The upcoming MCP Tools management module will let you centrally manage MCP servers and tools on a single page, easily completing connection configuration, tool synchronization, and health status monitoring. +In the MCP Tools module, you can centrally manage all MCP (Model Context Protocol) servers and tools. It supports custom addition, Registry import, and Community import, covering connection configuration, tool synchronization, health monitoring, and community sharing. -## 🎯 Feature Preview +The MCP Tools page has two parallel tabs: -1. Register and manage multiple MCP servers -2. Quickly sync, view, and organize MCP tool lists -3. Monitor MCP connection status and usage in real time +- **Imported Services**: Manage MCP services already accessed by the current tenant — configure, monitor, and maintain your MCP services here. +- **Published Services**: Manage the MCP services you have published to the community — browse, edit, and unpublish. -## ⏳ Stay Tuned +--- -The MCP Tools management feature is under development. We are committed to building an efficient and intuitive management platform that enables you to: +## ➕ Add MCP Services -1. Centrally manage all MCP servers -2. Conveniently sync and organize tools -3. Monitor server connections and tool runtime status in real time +Click the **Add MCP Service** button to open the add dialog. The dialog provides three tabs, each corresponding to a different source. -## 🚀 Related Features +### Local Add -While waiting for **MCP Tools** to launch, you can: +The **Local Add** tab lets you manually configure an MCP service with two transport types. -1. Manage your MCP tools in **[Agent Development](./agent-development)** -2. View agent and MCP collaboration relationships through **[Agent Space](./agent-space)** -3. Experience platform features in **[Start Chat](./start-chat)** +#### Add via URL -If you encounter any issues during use, please refer to our **[FAQ](../quick-start/faq)** or ask for support in [GitHub Discussions](https://github.com/ModelEngine-Group/nexent/discussions). +For independently deployed MCP services (HTTP / SSE), connect by entering the endpoint URL. + +1. In the **Local Add** tab, set **Transport Type** to "URL" +2. Fill in the service details: + - **Service Name (required)**: A recognizable name for the MCP service + - **Service URL (required)**: The MCP service endpoint address + - **Description** (optional): A brief description of the service + - **Authorization Token** (optional): Bearer token if the service requires authentication +3. Click **Confirm** — the system will connect to the service and retrieve the available tool list + +#### Add via Container Configuration + +For MCP services that need to run locally in a container (e.g., services launched via npx), the system automatically creates and manages a container based on your JSON configuration. + +1. In the **Local Add** tab, set **Transport Type** to "Container" +2. Fill in the container configuration: + - **Service Name (required)**: A recognizable name for the MCP service + - **Description** (optional): A brief description of the service + - **Container Configuration JSON (required)**: Enter the standard MCP configuration format, for example: + ```json + { + "mcpServers": { + "service-name": { + "args": ["mcp-package-name@version"], + "command": "npx", + "env": { + "API_KEY": "xxxx" + } + } + } + } + ``` + - **Port**: The port exposed by the container service — the system automatically detects port conflicts and suggests available ports +3. Click **Confirm** — the system parses the JSON, creates the container, and registers the service + +### Import from MCP Registry + +Nexent integrates with the MCP Registry, allowing you to browse and import community-maintained MCP services in one click. + +1. Switch to the **MCP Registry** tab +2. Browse the available MCP services — search by name or tags +3. Click a service to view its details (description, version, required parameters, etc.) +4. Configure required parameters (e.g., API Key and other environment variables) +5. Click **Import** — the system automatically installs and configures the service + +### Import from Community + +Browse MCP services published by other Nexent users and quickly import them. + +1. Switch to the **Community Market** tab +2. Browse published community MCP services — filter by name, tags, or transport type +3. Click a service to view details, then click **Import** to add it to your service list + +--- + +## 📋 Imported Services + +The **Imported Services** tab displays all MCP services accessed by the current tenant as cards. View, edit, monitor, and publish your services here. + +### View & Filter + +Each service card shows: + +- Service name and description +- Source indicator (Custom / Registry / Community) +- Enable / Disable toggle +- Tags + +Use the filter bar at the top to filter by **Source**, **Transport Type**, and **Tags**, or use the search box to quickly locate services by name. + +### Edit Service Details + +Click any service card to open the detail modal, where you can: + +- **Edit basic info**: Modify name, description, URL, Authorization Token, and tags +- **Enable / Disable**: Toggle the service on or off — tools from a disabled service will not appear in agent tool selection +- **Delete**: Remove the MCP service record — containerized services will also have their container resources cleaned up + +### View Tool List + +In the service detail modal, click **Tool List** to view all tools provided by this MCP service. + +### Health Check + +Click the **Health Check** button in the detail modal to test the connection to the MCP service. Possible statuses: + +- **Healthy**: The service is reachable +- **Unhealthy**: The service cannot be reached or responded abnormally +- **Unchecked**: A health check has not been performed yet + +### Container Management + +For containerized MCP services, the detail modal also provides: + +- **View Container Logs**: Real-time logs from the running container for troubleshooting +- **View Container Config**: The configuration JSON used when creating the container + +### Publish to Community + +In the service detail modal, click **Publish to Community**: + +1. Review or edit the publication info (name, description, tags, etc.) +2. Click **Confirm Publish** — the service will be published to the community +3. Other users can then browse and import it from the **Community Market** tab in the add dialog + +--- + +## 🌐 Published Services + +The **Published Services** tab shows all MCP services you have published to the community. Manage your published content here. + +Each card shows the service name, description, version, and tags. Filter by name, tags, and transport type. + +Click a service card to view details, where you can: + +- **Edit published service**: Modify the published service's name, description, and tags +- **Delete published service**: Withdraw the service from the community — it will no longer be visible to other users + +--- + +## 🔗 Integrating with Agents + +Once an MCP service is added, its tools are automatically synced to the agent tool selection list. When configuring an agent on the **[Agent Development](./agent-development)** page: + +1. In the **Select Agent Tools** tab, locate the corresponding MCP service group +2. Click a tool name to enable it +3. Click ⚙️ to view the tool description and configure its parameters + +## 🚀 Next Steps + +After configuring MCP services, we recommend: + +1. **[Agent Development](./agent-development)** — Assign MCP tools to your agents +2. **[Agent Space](./agent-space)** — View collaboration between agents and MCP services +3. **[Start Chat](./start-chat)** — Experience agents calling MCP tools in conversations + +If you encounter any issues, please refer to our **[FAQ](../quick-start/faq)** or ask for support in [GitHub Discussions](https://github.com/ModelEngine-Group/nexent/discussions). diff --git a/doc/docs/zh/user-guide/mcp-tools.md b/doc/docs/zh/user-guide/mcp-tools.md index 912306284..94bf7c656 100755 --- a/doc/docs/zh/user-guide/mcp-tools.md +++ b/doc/docs/zh/user-guide/mcp-tools.md @@ -1,27 +1,158 @@ # MCP 工具 -即将推出的 MCP 工具管理模块将让您在一个页面集中管理 MCP 服务器与工具,轻松完成连接配置、工具同步和健康状态监控 +在 MCP 工具模块中,您可以集中管理所有 MCP(Model Context Protocol)服务器与工具,支持自定义添加、注册表导入和社区导入等多种接入方式,完成连接配置、工具同步、健康监控以及社区共享。 -## 🎯 功能预览 +MCP 工具页面包含两个并列页签: -1. 注册并管理多个 MCP 服务器 -2. 快速同步、查看并整理 MCP 工具列表 -3. 实时监控 MCP 连接状态和使用情况 +- **导入的服务**:管理当前租户已接入的 MCP 服务,在此配置、监控和维护您的 MCP 服务。 +- **发布的服务**:管理当前租户发布到社区的 MCP 服务,支持浏览、编辑和取消发布。 -## ⏳ 敬请期待 +--- -MCP 工具管理功能正在开发中,我们致力于打造一个高效、直观的管理平台,让您能够: +## ➕ 添加 MCP 服务 -1. 集中管理所有 MCP 服务器 -2. 便捷同步和组织工具 -3. 实时掌握服务器连接与工具运行状态 +点击页面上的"添加 MCP 服务"按钮,打开添加弹窗。弹窗提供三个页签,对应不同的接入来源。 -## 🚀 相关功能 +### 自定义添加 -在等待 **MCP 工具** 上线期间,您可以: +"自定义添加"页签支持手动配置 MCP 服务,分为两种传输类型。 -1. 在 **[智能体开发](./agent-development)** 中管理您的 MCP 工具 -2. 通过 **[智能体空间](./agent-market)** 查看智能体与 MCP 的协作关系 -3. 在 **[开始问答](./start-chat)** 中体验平台功能 +#### 通过 URL 添加 -如果您在使用过程中遇到任何问题,请参考我们的 **[常见问题](../quick-start/faq)** 或在[GitHub Discussions](https://github.com/ModelEngine-Group/nexent/discussions)中进行提问获取支持。 \ No newline at end of file +适用于已有独立部署的 MCP 服务(支持 HTTP / SSE 协议),通过输入端点 URL 直接接入。 + +1. 在"本地添加"页签中,**传输类型**选择"URL" +2. 填写服务信息: + - **服务名称(必填)**:为 MCP 服务设置一个易于识别的名称 + - **服务 URL(必填)**:输入 MCP 服务的端点地址 + - **描述**:可选,填写服务的用途说明 + - **Authorization Token**:可选,若服务需要认证,在此填入 Bearer Token +3. 点击"确定"完成添加,系统会自动连接服务并获取可用工具列表 + +#### 通过容器配置添加 + +适用于需要本地容器化运行的 MCP 服务(如通过 npx 启动的服务),系统会根据您提供的 JSON 配置自动创建并管理容器。 + +1. 在"本地添加"页签中,**传输类型**选择"容器" +2. 填写容器配置信息: + - **服务名称(必填)**:为 MCP 服务设置一个易于识别的名称 + - **描述**:可选,填写服务的用途说明 + - **容器配置 JSON(必填)**:按标准 MCP 配置格式填写,例如: + ```json + { + "mcpServers": { + "service-name": { + "args": ["mcp-package-name@version"], + "command": "npx", + "env": { + "API_KEY": "xxxx" + } + } + } + } + ``` + - **端口号**:填写容器服务暴露的端口,系统会自动检测端口冲突并提示可用端口 +3. 点击"确定",系统将解析 JSON 配置、创建容器并完成服务注册 + +### 从 MCP Registry 导入 + +Nexent 集成了 MCP Registry,您可以浏览并一键导入社区维护的 MCP 服务。 + +1. 切换到"外部市场"页签 +2. 浏览可用的 MCP 服务列表,支持按名称或标签搜索 +3. 点击目标服务,查看服务详情(描述、版本、所需参数等) +4. 配置必填参数(如 API Key 等环境变量) +5. 点击"导入",系统会自动安装并配置该 MCP 服务 + +### 从社区导入 + +浏览其他用户在 Nexent 平台内发布的 MCP 服务,快速导入使用。 + +1. 切换到"社区市场"页签 +2. 浏览社区已发布的 MCP 服务,支持按名称、标签或传输协议筛选 +3. 点击目标服务查看详情,点击"导入"即可添加到您的服务列表中 + +--- + +## 📋 导入的服务 + +"导入的服务"页签以卡片形式展示当前租户所有已接入的 MCP 服务,您可以在此查看、编辑、监控和发布。 + +### 查看与筛选 + +每张服务卡片展示以下信息: + +- 服务名称与描述 +- 来源标识(本地 / 注册表 / 社区) +- 启用 / 禁用开关 +- 标签 + +您可以使用顶部的筛选栏,按**来源**、**传输类型**和**标签**进行过滤,也可以通过搜索框按名称快速定位服务。 + +### 编辑服务详情 + +点击任意服务卡片,打开详情弹窗,可以进行以下操作: + +- **编辑基本信息**:修改服务名称、描述、URL、Authorization Token 和标签 +- **启用 / 禁用服务**:通过开关控制服务的启用状态,禁用后该服务的工具将不会出现在智能体工具选择中 +- **删除服务**:移除 MCP 服务记录,容器化服务会同步清理容器资源 + +### 查看工具列表 + +在服务详情弹窗中,点击"工具列表"按钮,可以查看该 MCP 服务提供的所有工具。 + +### 健康检查 + +点击详情弹窗中的"健康检查"按钮,系统会对 MCP 服务发起连接测试并返回当前状态: + +- **正常**:服务可正常连接 +- **异常**:服务无法连接或响应异常 +- **未检测**:尚未进行健康检查 + +### 容器管理 + +对于容器化部署的 MCP 服务,详情弹窗中还提供以下操作: + +- **查看容器日志**:实时查看运行中容器的输出日志,方便排查问题 +- **查看容器配置**:查看创建容器时使用的配置 JSON + +### 发布到社区 + +在服务详情弹窗中,点击"发布到社区"按钮: + +1. 确认或修改发布信息(名称、描述、标签等) +2. 点击"确认发布",该服务将发布到社区 +3. 发布后其他用户可在添加服务的"社区市场"页签中浏览和导入 + +--- + +## 🌐 发布的服务 + +"发布的服务"页签展示您自己发布到社区的所有 MCP 服务,您可以在此集中管理已发布的内容。 + +每张卡片展示服务名称、描述、版本和标签,支持按名称、标签和传输协议进行筛选。 + +点击服务卡片可查看详细信息,您可以: + +- **编辑发布的服务**:修改已发布服务的名称、描述和标签 +- **删除发布的服务**:将服务从社区撤回,不再对其他用户可见 + +--- + +## 🔗 与智能体协作 + +添加 MCP 服务后,其提供的工具会自动同步到智能体的工具选择列表中。在 **[智能体开发](./agent-development)** 页面配置智能体时: + +1. 在"选择智能体的工具"页签下,找到对应 MCP 服务分组 +2. 点击工具名称即可启用该工具 +3. 可点击 ⚙️ 查看工具描述并进行参数配置 + +## 🚀 下一步 + +完成 MCP 服务配置后,建议您: + +1. **[智能体开发](./agent-development)** - 将 MCP 工具配置给智能体使用 +2. **[智能体空间](./agent-space)** - 查看智能体与 MCP 的协作关系 +3. **[开始问答](./start-chat)** - 在对话中体验智能体调用 MCP 工具的效果 + +如果您在使用过程中遇到任何问题,请参考我们的 **[常见问题](../quick-start/faq)** 或在 [GitHub Discussions](https://github.com/ModelEngine-Group/nexent/discussions) 中进行提问获取支持。 \ No newline at end of file diff --git a/docker/init.sql b/docker/init.sql index 2df9665c7..93b887aa7 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -492,6 +492,13 @@ CREATE TABLE IF NOT EXISTS nexent.mcp_record_t ( status BOOLEAN DEFAULT NULL, container_id VARCHAR(200) DEFAULT NULL, authorization_token VARCHAR(500) DEFAULT NULL, + source VARCHAR(30), + registry_json JSONB, + config_json JSON, + enabled BOOLEAN DEFAULT TRUE, + tags TEXT[], + description TEXT, + container_port INTEGER, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -516,6 +523,13 @@ COMMENT ON COLUMN nexent.mcp_record_t.update_time IS 'Update time, audit field'; COMMENT ON COLUMN nexent.mcp_record_t.created_by IS 'Creator ID, audit field'; COMMENT ON COLUMN nexent.mcp_record_t.updated_by IS 'Last updater ID, audit field'; COMMENT ON COLUMN nexent.mcp_record_t.delete_flag IS 'When deleted by user frontend, delete flag will be set to true, achieving soft delete effect. Optional values Y/N'; +COMMENT ON COLUMN nexent.mcp_record_t.source IS 'Source type: local/mcp_registry/community'; +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'; -- Create a function to update the update_time column CREATE OR REPLACE FUNCTION update_mcp_record_update_time() @@ -538,6 +552,19 @@ EXECUTE FUNCTION update_mcp_record_update_time(); -- Add comment to the trigger COMMENT ON TRIGGER update_mcp_record_update_time_trigger ON nexent.mcp_record_t IS 'Trigger to call update_mcp_record_update_time function before each update on mcp_record_t table'; +-- 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); + -- Create user tenant relationship table CREATE TABLE IF NOT EXISTS nexent.user_tenant_t ( user_tenant_id SERIAL PRIMARY KEY, @@ -1640,3 +1667,78 @@ COMMENT ON COLUMN nexent.user_oauth_account_t.delete_flag IS 'Whether it is dele -- Create index for user_id queries CREATE INDEX IF NOT EXISTS idx_user_oauth_account_t_user_id ON nexent.user_oauth_account_t (user_id); + +-- mcp_community_record_t: Community MCP market table +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: url/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'; 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..83f9d9a56 --- /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: url/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..6c92a392e --- /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/community'; +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..39820a877 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": "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/app/test_mcp_management_app.py b/test/backend/app/test_mcp_management_app.py new file mode 100644 index 000000000..f78ab8d38 --- /dev/null +++ b/test/backend/app/test_mcp_management_app.py @@ -0,0 +1,233 @@ +""" +Unit tests for backend/apps/mcp_management_app.py + +Tests community/registry management REST API endpoints. +""" + +import sys +import os +from unittest.mock import patch, MagicMock, AsyncMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) +sys.modules['boto3'] = MagicMock() +patch('botocore.client.BaseClient._make_api_call', return_value={}).start() + +storage_client_mock = MagicMock() +minio_mock = MagicMock() +minio_mock._ensure_bucket_exists = MagicMock() +minio_mock.client = MagicMock() +patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock).start() +patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() +patch('backend.database.client.MinioClient', return_value=minio_mock).start() +patch('database.client.MinioClient', return_value=minio_mock).start() +patch('backend.database.client.minio_client', minio_mock).start() +patch('elasticsearch.Elasticsearch', return_value=MagicMock()).start() + +from backend.consts.exceptions import ( + McpNotFoundError, McpValidationError, UnauthorizedError, +) +from fastapi.testclient import TestClient +from fastapi import FastAPI +from http import HTTPStatus + +from apps.mcp_management_app import router + +import apps.mcp_management_app as mgmt_app +mgmt_app.McpNotFoundError = McpNotFoundError +mgmt_app.McpValidationError = McpValidationError +mgmt_app.UnauthorizedError = UnauthorizedError + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + +AUTH_HEADER = {"Authorization": "Bearer test_token"} + + +# ============================================================================ +# GET /mcp-tools/registry/list +# ============================================================================ + +class TestRegistryList: + """Test GET /mcp-tools/registry/list""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.list_registry_mcp_services') + def test_list_success(self, mock_list, mock_auth): + """Test successful registry list retrieval.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = {"servers": [{"name": "s1"}], "metadata": {}} + resp = client.get("/mcp-tools/registry/list", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert len(resp.json()["servers"]) == 1 + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.list_registry_mcp_services') + def test_list_with_filters(self, mock_list, mock_auth): + """Test registry list with search and limit filters.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = {"servers": [], "metadata": {}} + resp = client.get("/mcp-tools/registry/list?search=test&limit=10", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + + +# ============================================================================ +# GET /mcp-tools/community/list +# ============================================================================ + +class TestCommunityList: + """Test GET /mcp-tools/community/list""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.list_community_mcp_services') + def test_list_success(self, mock_list, mock_auth): + """Test successful community list retrieval.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = {"count": 1, "nextCursor": None, "items": []} + resp = client.get("/mcp-tools/community/list", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["status"] == "success" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.list_community_mcp_services') + def test_list_with_tag_filter(self, mock_list, mock_auth): + """Test community list with tag and transport type filters.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = {"count": 0, "nextCursor": None, "items": []} + resp = client.get("/mcp-tools/community/list?tag=python&transport_type=url", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + + +# ============================================================================ +# GET /mcp-tools/community/tags/stats +# ============================================================================ + +class TestCommunityTagStats: + """Test GET /mcp-tools/community/tags/stats""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.list_community_mcp_tag_stats') + def test_tag_stats(self, mock_stats, mock_auth): + """Test community tag statistics retrieval.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_stats.return_value = [{"tag": "python", "count": 10}] + resp = client.get("/mcp-tools/community/tags/stats", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["data"][0]["tag"] == "python" + + +# ============================================================================ +# POST /mcp-tools/community/publish +# ============================================================================ + +class TestCommunityPublish: + """Test POST /mcp-tools/community/publish""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.publish_community_mcp_service') + def test_publish_success(self, mock_publish, mock_auth): + """Test successful publishing of a community MCP service.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_publish.return_value = 42 + resp = client.post("/mcp-tools/community/publish", json={ + "mcp_id": 1, "name": "svc", "description": "desc", + "version": "1.0", "tags": ["a"], + "mcp_server": "http://srv", "config_json": None, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["data"]["community_id"] == 42 + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.publish_community_mcp_service') + def test_publish_not_found(self, mock_publish, mock_auth): + """Test publishing fails when source MCP record is not found.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_publish.side_effect = McpNotFoundError("not found") + resp = client.post("/mcp-tools/community/publish", json={ + "mcp_id": 999, "name": "x", "description": "d", + "version": "1.0", "tags": [], + "mcp_server": "http://srv", "config_json": None, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# PUT /mcp-tools/community/update +# ============================================================================ + +class TestCommunityUpdate: + """Test PUT /mcp-tools/community/update""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.update_community_mcp_service') + def test_update_success(self, mock_update, mock_auth): + """Test successful community MCP service update.""" + mock_auth.return_value = ("uid", "tid", "en") + resp = client.put("/mcp-tools/community/update", json={ + "community_id": 1, "name": "new-name", + "description": "desc", "tags": [], "version": "2.0", + "registry_json": None, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.update_community_mcp_service') + def test_update_not_found(self, mock_update, mock_auth): + """Test update fails when community record is not found.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_update.side_effect = McpNotFoundError("not found") + resp = client.put("/mcp-tools/community/update", json={ + "community_id": 999, "name": "x", + "description": "d", "tags": [], "version": "1.0", + "registry_json": None, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# DELETE /mcp-tools/community/delete +# ============================================================================ + +class TestCommunityDelete: + """Test DELETE /mcp-tools/community/delete""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.delete_community_mcp_service') + def test_delete_success(self, mock_delete, mock_auth): + """Test successful deletion of a community MCP service.""" + mock_auth.return_value = ("uid", "tid", "en") + resp = client.delete("/mcp-tools/community/delete?community_id=1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.delete_community_mcp_service') + def test_delete_not_found(self, mock_delete, mock_auth): + """Test deletion fails when community record is not found.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_delete.side_effect = McpNotFoundError("not found") + resp = client.delete("/mcp-tools/community/delete?community_id=999", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# GET /mcp-tools/community/mine +# ============================================================================ + +class TestCommunityMine: + """Test GET /mcp-tools/community/mine""" + + @patch('apps.mcp_management_app.get_current_user_info') + @patch('apps.mcp_management_app.list_my_community_mcp_services') + def test_list_mine(self, mock_list, mock_auth): + """Test listing of current user's published community services.""" + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = {"count": 1, "items": []} + resp = client.get("/mcp-tools/community/mine", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["status"] == "success" + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"]) diff --git a/test/backend/app/test_remote_mcp_app.py b/test/backend/app/test_remote_mcp_app.py index d8701cb9d..72aab678c 100644 --- a/test/backend/app/test_remote_mcp_app.py +++ b/test/backend/app/test_remote_mcp_app.py @@ -1,13 +1,20 @@ -from unittest.mock import patch, MagicMock, AsyncMock +""" +Unit tests for backend/apps/remote_mcp_app.py + +Tests all MCP REST API endpoints covering: tools, add, update, delete, +list, healthcheck, port management, enable/disable, and container operations. +""" + import sys import os +from unittest.mock import patch, MagicMock, AsyncMock # Add path for correct imports sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) -sys.modules['boto3'] = MagicMock() # Apply critical patches before importing any modules # This prevents real AWS/MinIO/Elasticsearch calls during import +sys.modules['boto3'] = MagicMock() patch('botocore.client.BaseClient._make_api_call', return_value={}).start() # Patch storage factory and MinIO config validation to avoid errors during initialization @@ -18,2743 +25,493 @@ minio_mock.client = MagicMock() patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock).start() -patch('nexent.storage.minio_config.MinIOStorageConfig.validate', - lambda self: None).start() +patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() patch('backend.database.client.MinioClient', return_value=minio_mock).start() patch('database.client.MinioClient', return_value=minio_mock).start() patch('backend.database.client.minio_client', minio_mock).start() patch('elasticsearch.Elasticsearch', return_value=MagicMock()).start() - -# Enable upload image feature for tests patch('consts.const.ENABLE_UPLOAD_IMAGE', True).start() - -# Patch container service dependencies to avoid Docker connections patch('services.mcp_container_service.create_container_client_from_config').start() patch('services.mcp_container_service.DockerContainerConfig').start() -# Import exception classes -from consts.exceptions import MCPConnectionError, MCPNameIllegal, MCPContainerError - -# Import the modules we need -import pytest +from backend.consts.exceptions import ( + MCPConnectionError, MCPNameIllegal, MCPContainerError, + McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, +) from fastapi.testclient import TestClient +from fastapi import FastAPI from http import HTTPStatus -# Create a test client with a fresh FastAPI app from apps.remote_mcp_app import router -from fastapi import FastAPI -# Patch exception classes to ensure tests use correct exceptions import apps.remote_mcp_app as remote_app remote_app.MCPConnectionError = MCPConnectionError remote_app.MCPNameIllegal = MCPNameIllegal remote_app.MCPContainerError = MCPContainerError +remote_app.McpNotFoundError = McpNotFoundError +remote_app.McpValidationError = McpValidationError +remote_app.McpNameConflictError = McpNameConflictError +remote_app.McpPortConflictError = McpPortConflictError app = FastAPI() app.include_router(router) client = TestClient(app) +AUTH_HEADER = {"Authorization": "Bearer test_token"} -class MockToolInfo: - """Mock ToolInfo class for testing""" - - def __init__(self, name, description, params=None): - self.name = name - self.description = description - self.params = params or [] - - @property - def __dict__(self): - return { - "name": self.name, - "description": self.description, - "params": self.params - } - - -class TestGetToolsFromRemoteMCP: - """Test endpoint for getting tools from remote MCP server""" - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_tool_from_remote_mcp_server') - def test_get_tools_success(self, mock_get_tools, mock_get_user_info): - """Test successful retrieval of tool information""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - # Mock tool information - mock_tools = [ - MockToolInfo("tool1", "Tool 1 description"), - MockToolInfo("tool2", "Tool 2 description") - ] - mock_get_tools.return_value = mock_tools - - response = client.post( - "/mcp/tools", - params={"service_name": "test_service", - "mcp_url": "http://test.com"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert "tools" in data - assert len(data["tools"]) == 2 - assert data["status"] == "success" - - mock_get_user_info.assert_called_once() - mock_get_tools.assert_called_once_with( - mcp_server_name="test_service", - remote_mcp_server="http://test.com", - tenant_id="tenant456" - ) - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_tool_from_remote_mcp_server') - def test_get_tools_connection_error(self, mock_get_tools, mock_get_user_info): - """Test MCP connection error when retrieving tool information""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_tools.side_effect = MCPConnectionError( - "MCP connection failed") - - response = client.post( - "/mcp/tools", - params={"service_name": "test_service", - "mcp_url": "http://unreachable.com"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "MCP connection failed" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_tool_from_remote_mcp_server') - def test_get_tools_general_failure(self, mock_get_tools, mock_get_user_info): - """Test general failure to retrieve tool information""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_tools.side_effect = Exception("Unexpected error") +# ============================================================================ +# GET /mcp/tools +# ============================================================================ - response = client.post( - "/mcp/tools", - params={"service_name": "test_service", - "mcp_url": "http://test.com"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to get tools from remote MCP server" in data["detail"] - - -class TestAddRemoteProxies: - """Test endpoint for adding remote MCP servers""" +class TestGetTools: + """Test GET /mcp/tools""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_remote_proxy_success(self, mock_add_server, mock_get_user_info): - """Test successful addition of remote MCP proxy""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.return_value = None # No exception means success - - response = client.post( - "/mcp/add", - params={"mcp_url": "http://test.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert "Successfully added remote MCP proxy" in data["message"] - - mock_get_user_info.assert_called_once() - mock_add_server.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - remote_mcp_server="http://test.com", - remote_mcp_server_name="test_service", - container_id=None, - authorization_token=None, - ) + @patch('apps.remote_mcp_app.list_mcp_service_tools_by_id') + def test_get_tools_success(self, mock_list_tools, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_tool = MagicMock() + mock_tool.model_dump.return_value = {"name": "tool1", "description": "desc"} + mock_list_tools.return_value = [mock_tool] - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_remote_proxy_with_tenant_id_param(self, mock_add_server, mock_get_user_info): - """Test adding remote MCP proxy with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.return_value = None - - response = client.post( - "/mcp/add", - params={ - "mcp_url": "http://test.com", - "service_name": "test_service", - "tenant_id": "explicit_tenant789" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() + resp = client.get("/mcp/tools?mcp_id=1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + data = resp.json() assert data["status"] == "success" - - # Verify that explicit tenant_id is used instead of auth tenant_id - mock_add_server.assert_called_once_with( - tenant_id="explicit_tenant789", # Should use explicit tenant_id - user_id="user123", - remote_mcp_server="http://test.com", - remote_mcp_server_name="test_service", - container_id=None, - authorization_token=None, - ) - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_remote_proxy_name_exists(self, mock_add_server, mock_get_user_info): - """Test adding MCP server with existing name""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.side_effect = MCPNameIllegal("MCP name already exists") - - response = client.post( - "/mcp/add", - params={"mcp_url": "http://test.com", - "service_name": "existing_service"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.CONFLICT - data = response.json() - assert "MCP name already exists" in data["detail"] + assert len(data["tools"]) == 1 @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_remote_proxy_connection_failed(self, mock_add_server, mock_get_user_info): - """Test MCP connection failure""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.side_effect = MCPConnectionError( - "MCP connection failed") - - response = client.post( - "/mcp/add", - params={"mcp_url": "http://unreachable.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "MCP connection failed" in data["detail"] + @patch('apps.remote_mcp_app.list_mcp_service_tools_by_id') + def test_get_tools_not_found(self, mock_list_tools, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_list_tools.side_effect = McpNotFoundError("not found") - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_remote_proxy_with_authorization_token(self, mock_add_server, mock_get_user_info): - """Test adding remote MCP proxy with authorization token""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.return_value = None - - response = client.post( - "/mcp/add", - params={ - "mcp_url": "http://test.com", - "service_name": "test_service", - "authorization_token": "Bearer token123" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - - # Verify that authorization_token is passed to service - mock_add_server.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - remote_mcp_server="http://test.com", - remote_mcp_server_name="test_service", - container_id=None, - authorization_token="Bearer token123", - ) + resp = client.get("/mcp/tools?mcp_id=999", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_remote_proxy_database_error(self, mock_add_server, mock_get_user_info): - """Test database error - should be handled as general exception""" - from sqlalchemy.exc import SQLAlchemyError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.side_effect = SQLAlchemyError("Database error") + @patch('apps.remote_mcp_app.list_mcp_service_tools_by_id') + def test_get_tools_connection_error(self, mock_list_tools, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_list_tools.side_effect = MCPConnectionError("connection failed") - response = client.post( - "/mcp/add", - params={"mcp_url": "http://test.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) + resp = client.get("/mcp/tools?mcp_id=1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to add remote MCP proxy" in data["detail"] +# ============================================================================ +# POST /mcp/add +# ============================================================================ -class TestDeleteRemoteProxies: - """Test endpoint for deleting remote MCP servers""" +class TestAddMcpService: + """Test POST /mcp/add""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.delete_remote_mcp_server_list') - def test_delete_remote_proxy_success(self, mock_delete_server, mock_get_user_info): - """Test successful deletion of remote MCP proxy""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_delete_server.return_value = None # No exception means success - - response = client.delete( - "/mcp/", - params={"service_name": "test_service", - "mcp_url": "http://test.com"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert "Successfully deleted remote MCP proxy" in data["message"] - - mock_get_user_info.assert_called_once() - mock_delete_server.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - remote_mcp_server="http://test.com", - remote_mcp_server_name="test_service" - ) + @patch('apps.remote_mcp_app.add_mcp_service') + def test_add_success(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + resp = client.post("/mcp/add", json={ + "name": "test-svc", "description": "desc", + "source": "local", "server_url": "http://srv/mcp", + "tags": [], "enabled": False, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["status"] == "success" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.delete_remote_mcp_server_list') - def test_delete_remote_proxy_with_tenant_id_param(self, mock_delete_server, mock_get_user_info): - """Test deleting remote MCP proxy with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_delete_server.return_value = None - - response = client.delete( - "/mcp/", - params={ - "service_name": "test_service", - "mcp_url": "http://test.com", - "tenant_id": "explicit_tenant789" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used - mock_delete_server.assert_called_once_with( - tenant_id="explicit_tenant789", - user_id="user123", - remote_mcp_server="http://test.com", - remote_mcp_server_name="test_service" - ) + @patch('apps.remote_mcp_app.add_mcp_service') + def test_add_name_conflict(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_add.side_effect = MCPNameIllegal("name exists") + resp = client.post("/mcp/add", json={ + "name": "dup", "source": "local", "server_url": "http://srv", + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.CONFLICT @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.delete_remote_mcp_server_list') - def test_delete_remote_proxy_database_error(self, mock_delete_server, mock_get_user_info): - """Test database error during deletion - should be handled as general exception""" - from sqlalchemy.exc import SQLAlchemyError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_delete_server.side_effect = SQLAlchemyError("Database error") - - response = client.delete( - "/mcp/", - params={"service_name": "test_service", - "mcp_url": "http://test.com"}, - headers={"Authorization": "Bearer test_token"} - ) + @patch('apps.remote_mcp_app.add_mcp_service') + def test_add_validation_error(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_add.side_effect = McpValidationError("bad input") + resp = client.post("/mcp/add", json={ + "name": "x", "source": "local", "server_url": "http://srv", + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.BAD_REQUEST - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to delete remote MCP proxy" in data["detail"] +# ============================================================================ +# POST /mcp/add-from-config +# ============================================================================ -class TestGetRemoteProxies: - """Test endpoint for getting remote MCP server list""" +class TestAddFromConfig: + """Test POST /mcp/add-from-config""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list') - def test_get_remote_proxies_success(self, mock_get_list, mock_get_user_info): - """Test successful retrieval of remote MCP proxy list""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_server_list = [ - { - "remote_mcp_server_name": "server1", - "remote_mcp_server": "http://server1.com", - "status": True, - "permission": "EDIT", - }, - { - "remote_mcp_server_name": "server2", - "remote_mcp_server": "http://server2.com", - "status": False, - "permission": "READ_ONLY", - } - ] - mock_get_list.return_value = mock_server_list - - response = client.get( - "/mcp/list", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert "remote_mcp_server_list" in data - assert len(data["remote_mcp_server_list"]) == 2 + @patch('apps.remote_mcp_app.add_container_mcp_service') + def test_add_from_config_success(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_add.return_value = { + "service_name": "svc", "mcp_url": "http://localhost:8080/mcp", + "container_id": "cid", "container_name": "svc-uid", "host_port": 8080, + } + resp = client.post("/mcp/add-from-config", json={ + "name": "svc", "source": "local", "port": 8080, + "mcp_config": {"mcpServers": {"svc": {"command": "echo", "args": []}}}, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + data = resp.json() assert data["status"] == "success" - assert data["remote_mcp_server_list"][0]["permission"] == "EDIT" - assert data["remote_mcp_server_list"][1]["permission"] == "READ_ONLY" - - mock_get_user_info.assert_called_once() - mock_get_list.assert_called_once_with(tenant_id="tenant456", user_id="user123", is_need_auth=False) + assert data["data"]["container_id"] == "cid" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list') - def test_get_remote_proxies_with_tenant_id_param(self, mock_get_list, mock_get_user_info): - """Test getting remote MCP proxy list with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_list.return_value = [] - - response = client.get( - "/mcp/list", - params={"tenant_id": "explicit_tenant789"}, - headers={"Authorization": "Bearer test_token"} - ) + @patch('apps.remote_mcp_app.add_container_mcp_service') + def test_add_from_config_name_conflict(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_add.side_effect = McpNameConflictError("name exists") + resp = client.post("/mcp/add-from-config", json={ + "name": "dup", "source": "local", "port": 8080, + "mcp_config": {"mcpServers": {"dup": {"command": "echo"}}}, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.CONFLICT - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used and is_need_auth=False - mock_get_list.assert_called_once_with(tenant_id="explicit_tenant789", user_id="user123", is_need_auth=False) - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list') - def test_get_remote_proxies_error(self, mock_get_list, mock_get_user_info): - """Test error when getting list""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_list.side_effect = Exception("Database connection failed") +# ============================================================================ +# PUT /mcp/update +# ============================================================================ - response = client.get( - "/mcp/list", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to get remote MCP proxy" in data["detail"] +class TestUpdateMcpService: + """Test PUT /mcp/update""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list') - def test_get_remote_proxies_is_need_auth_false_excludes_token(self, mock_get_list, mock_get_user_info): - """Test that get_remote_mcp_server_list is called with is_need_auth=False and excludes authorization_token""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - # Mock return value without authorization_token (when is_need_auth=False) - mock_server_list = [ - { - "remote_mcp_server_name": "server1", - "remote_mcp_server": "http://server1.com", - "status": True, - "permission": "EDIT", - "mcp_id": 1 - }, - { - "remote_mcp_server_name": "server2", - "remote_mcp_server": "http://server2.com", - "status": False, - "permission": "READ_ONLY", - "mcp_id": 2 - } - ] - mock_get_list.return_value = mock_server_list - - response = client.get( - "/mcp/list", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert "remote_mcp_server_list" in data - assert len(data["remote_mcp_server_list"]) == 2 - - # Verify that authorization_token is not present in the response - assert "authorization_token" not in data["remote_mcp_server_list"][0] - assert "authorization_token" not in data["remote_mcp_server_list"][1] - - # Verify that other fields are present - assert data["remote_mcp_server_list"][0]["mcp_id"] == 1 - assert data["remote_mcp_server_list"][1]["mcp_id"] == 2 - - # Verify that get_remote_mcp_server_list was called with is_need_auth=False - mock_get_list.assert_called_once_with(tenant_id="tenant456", user_id="user123", is_need_auth=False) - - -class TestGetMCPRecord: - """Test endpoint for getting single MCP record by ID""" + @patch('apps.remote_mcp_app.update_mcp_service') + def test_update_success(self, mock_update, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + resp = client.put("/mcp/update", json={ + "mcp_id": 1, "name": "new-name", "server_url": "http://new.url", + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_mcp_record_by_id') - def test_get_mcp_record_success(self, mock_get_record, mock_get_user_info): - """Test successful retrieval of MCP record""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_record = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": "token123" - } - mock_get_record.return_value = mock_record + @patch('apps.remote_mcp_app.update_mcp_service') + def test_update_not_found(self, mock_update, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_update.side_effect = McpNotFoundError("not found") + resp = client.put("/mcp/update", json={ + "mcp_id": 999, "name": "x", "server_url": "http://u", + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND - response = client.get( - "/mcp/record/1", - headers={"Authorization": "Bearer test_token"} - ) - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert data["mcp_name"] == "test-service" - assert data["mcp_server"] == "http://test.com/mcp" - assert data["authorization_token"] == "token123" +# ============================================================================ +# DELETE /mcp/{mcp_id} +# ============================================================================ - mock_get_user_info.assert_called_once() - mock_get_record.assert_called_once_with( - mcp_id=1, - tenant_id="tenant456" - ) +class TestDeleteMcpService: + """Test DELETE /mcp/{mcp_id}""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_mcp_record_by_id') - def test_get_mcp_record_with_tenant_id_param(self, mock_get_record, mock_get_user_info): - """Test getting MCP record with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_record = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": "token123" - } - mock_get_record.return_value = mock_record - - response = client.get( - "/mcp/record/1", - params={"tenant_id": "explicit_tenant789"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used - mock_get_record.assert_called_once_with( - mcp_id=1, - tenant_id="explicit_tenant789" - ) + @patch('apps.remote_mcp_app.delete_mcp_service') + def test_delete_success(self, mock_delete, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + resp = client.delete("/mcp/1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_mcp_record_by_id') - def test_get_mcp_record_not_found(self, mock_get_record, mock_get_user_info): - """Test getting MCP record when record does not exist""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_record.return_value = None # Record not found + @patch('apps.remote_mcp_app.delete_mcp_service') + def test_delete_not_found(self, mock_delete, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_delete.side_effect = McpNotFoundError("not found") + resp = client.delete("/mcp/999", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND - response = client.get( - "/mcp/record/999", - headers={"Authorization": "Bearer test_token"} - ) - assert response.status_code == HTTPStatus.NOT_FOUND - data = response.json() - assert "MCP record not found" in data["detail"] +# ============================================================================ +# DELETE /mcp/container/{container_id} +# ============================================================================ - mock_get_record.assert_called_once_with( - mcp_id=999, - tenant_id="tenant456" - ) +class TestStopMcpContainer: + """Test DELETE /mcp/container/{container_id}""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_mcp_record_by_id') - def test_get_mcp_record_with_none_values(self, mock_get_record, mock_get_user_info): - """Test getting MCP record when some fields are None""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_record = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": None # Token can be None - } - mock_get_record.return_value = mock_record - - response = client.get( - "/mcp/record/1", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert data["mcp_name"] == "test-service" - assert data["mcp_server"] == "http://test.com/mcp" - assert data["authorization_token"] is None - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.get_mcp_record_by_id') - def test_get_mcp_record_exception(self, mock_get_record, mock_get_user_info): - """Test getting MCP record when exception occurs""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_record.side_effect = Exception("Database error") - - response = client.get( - "/mcp/record/1", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to get MCP record" in data["detail"] - - -class TestCheckMCPHealth: - """Test MCP health check endpoint""" - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.check_mcp_health_and_update_db') - def test_check_mcp_health_success(self, mock_health_check, mock_get_user_info): - """Test successful health check""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_health_check.return_value = None # No exception means success - - response = client.get( - "/mcp/healthcheck", - params={"mcp_url": "http://test.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" + @patch('apps.remote_mcp_app.delete_mcp_by_container_id') + @patch('apps.remote_mcp_app.MCPContainerManager') + def test_stop_container_success(self, mock_mgr_cls, mock_delete, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr = MagicMock() + mock_mgr.stop_mcp_container = AsyncMock(return_value=True) + mock_mgr_cls.return_value = mock_mgr - mock_get_user_info.assert_called_once() - mock_health_check.assert_called_once_with( - "http://test.com", "test_service", "tenant456", "user123" - ) + resp = client.delete("/mcp/container/container-123", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.check_mcp_health_and_update_db') - def test_check_mcp_health_with_tenant_id_param(self, mock_health_check, mock_get_user_info): - """Test health check with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_health_check.return_value = None - - response = client.get( - "/mcp/healthcheck", - params={ - "mcp_url": "http://test.com", - "service_name": "test_service", - "tenant_id": "explicit_tenant789" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used - mock_health_check.assert_called_once_with( - "http://test.com", "test_service", "explicit_tenant789", "user123" - ) + @patch('apps.remote_mcp_app.MCPContainerManager') + def test_stop_container_not_found(self, mock_mgr_cls, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr = MagicMock() + mock_mgr.stop_mcp_container = AsyncMock(return_value=False) + mock_mgr_cls.return_value = mock_mgr - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.check_mcp_health_and_update_db') - def test_check_mcp_health_connection_error(self, mock_health_check, mock_get_user_info): - """Test MCP connection error during health check""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_health_check.side_effect = MCPConnectionError( - "MCP connection failed") - - response = client.get( - "/mcp/healthcheck", - params={"mcp_url": "http://unreachable.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "MCP connection failed" in data["detail"] - - mock_get_user_info.assert_called_once() - mock_health_check.assert_called_once_with( - "http://unreachable.com", "test_service", "tenant456", "user123" - ) + resp = client.delete("/mcp/container/nonexistent", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.check_mcp_health_and_update_db') - def test_check_mcp_health_database_error(self, mock_health_check, mock_get_user_info): - """Test database error during health check - should be handled as general exception""" - from sqlalchemy.exc import SQLAlchemyError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_health_check.side_effect = SQLAlchemyError("Database error") + @patch('apps.remote_mcp_app.MCPContainerManager') + def test_stop_container_docker_unavailable(self, mock_mgr_cls, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr_cls.side_effect = MCPContainerError("Docker unavailable") - response = client.get( - "/mcp/healthcheck", - params={"mcp_url": "http://test.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) + resp = client.delete("/mcp/container/container-123", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to check the health of the MCP server" in data["detail"] +# ============================================================================ +# GET /mcp/list +# ============================================================================ -class TestIntegration: - """Integration tests""" +class TestGetMcpList: + """Test GET /mcp/list""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') @patch('apps.remote_mcp_app.get_remote_mcp_server_list') - @patch('apps.remote_mcp_app.delete_remote_mcp_server_list') - def test_full_lifecycle(self, mock_delete, mock_get_list, mock_add, mock_get_user_info): - """Test complete MCP server lifecycle""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # 1. Add server - mock_add.return_value = None - add_response = client.post( - "/mcp/add", - params={"mcp_url": "http://test.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) - assert add_response.status_code == HTTPStatus.OK - - # 2. Get server list - mock_get_list.return_value = [ - {"remote_mcp_server_name": "test_service", - "remote_mcp_server": "http://test.com", - "status": True, - "permission": "EDIT"} + def test_list_success(self, mock_list, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = [ + {"remote_mcp_server_name": "svc1", "remote_mcp_server": "http://srv1", "status": True}, ] - list_response = client.get( - "/mcp/list", - headers={"Authorization": "Bearer test_token"} - ) - assert list_response.status_code == HTTPStatus.OK - data = list_response.json() - assert len(data["remote_mcp_server_list"]) == 1 - assert data["remote_mcp_server_list"][0]["permission"] == "EDIT" - - # 3. Delete server - mock_delete.return_value = None - delete_response = client.delete( - "/mcp/", - params={"service_name": "test_service", - "mcp_url": "http://test.com"}, - headers={"Authorization": "Bearer test_token"} - ) - assert delete_response.status_code == HTTPStatus.OK - - -class TestErrorHandling: - """Error handling tests""" + resp = client.get("/mcp/list", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert len(resp.json()["remote_mcp_server_list"]) == 1 @patch('apps.remote_mcp_app.get_current_user_info') @patch('apps.remote_mcp_app.get_remote_mcp_server_list') - def test_authorization_header_handling(self, mock_get_list, mock_get_user_info): - """Test authorization header handling""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_get_list.return_value = [] # Mock empty list - - # Test case without Authorization header - response = client.get("/mcp/list") - # Should return OK with empty list - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert "remote_mcp_server_list" in data - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_unexpected_error_handling(self, mock_add_server, mock_get_user_info): - """Test unexpected error handling""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.side_effect = Exception("Unexpected error") - - response = client.post( - "/mcp/add", - params={"mcp_url": "http://test.com", - "service_name": "test_service"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to add remote MCP proxy" in data["detail"] - + def test_list_with_tenant_id(self, mock_list, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_list.return_value = [] + resp = client.get("/mcp/list?tenant_id=explicit_tid", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK -class TestDataValidation: - """Data validation tests""" - def test_missing_parameters(self): - """Test missing required parameters""" - # Test missing parameters - response = client.post("/mcp/add") - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY +# ============================================================================ +# GET /mcp/record/{mcp_id} +# ============================================================================ - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_invalid_url_format(self, mock_add_server, mock_get_user_info): - """Test invalid URL format with valid authentication""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_add_server.side_effect = MCPConnectionError("Invalid URL format") - - response = client.post( - "/mcp/add", - params={"mcp_url": "invalid-url", - "service_name": "test_service_invalid"}, - headers={"Authorization": "Bearer valid_token"} - ) - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - - -# --------------------------------------------------------------------------- -# Test add_mcp_from_config -# --------------------------------------------------------------------------- - - -class TestAddMCPFromConfig: - """Test endpoint for adding MCP servers from configuration""" +class TestGetMcpRecord: + """Test GET /mcp/record/{mcp_id}""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_success(self, mock_check_name, mock_add_server, mock_container_manager_class, mock_get_user_info): - """Test successful addition of MCP server from config""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" - }) - - mock_add_server.return_value = None - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "env": {"NODE_ENV": "production"}, - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert len(data["results"]) == 1 - assert data["results"][0]["service_name"] == "test-service" - assert data["results"][0]["status"] == "success" + @patch('apps.remote_mcp_app.get_mcp_record_by_id') + def test_get_record_success(self, mock_get, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_get.return_value = {"mcp_name": "svc", "mcp_server": "http://srv", "authorization_token": "tok"} + resp = client.get("/mcp/record/1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["mcp_name"] == "svc" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_with_tenant_id_param(self, mock_check_name, mock_add_server, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server from config with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" - }) - - mock_add_server.return_value = None - - response = client.post( - "/mcp/add-from-config", - params={"tenant_id": "explicit_tenant789"}, - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "env": {"NODE_ENV": "production"}, - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - # Verify that explicit tenant_id is used - mock_check_name.assert_called_once_with(mcp_name="test-service", tenant_id="explicit_tenant789") - mock_container_manager.start_mcp_container.assert_called_once() - call_kwargs = mock_container_manager.start_mcp_container.call_args[1] - assert call_kwargs["tenant_id"] == "explicit_tenant789" - mock_add_server.assert_called_once() - add_call_kwargs = mock_add_server.call_args[1] - assert add_call_kwargs["tenant_id"] == "explicit_tenant789" + @patch('apps.remote_mcp_app.get_mcp_record_by_id') + def test_get_record_not_found(self, mock_get, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_get.return_value = None + resp = client.get("/mcp/record/999", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_multiple_servers(self, mock_check_name, mock_add_server, mock_container_manager_class, mock_get_user_info): - """Test adding multiple MCP servers from config""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container = AsyncMock(side_effect=[ - { - "container_id": "container-1", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "service1-user1234" - }, - { - "container_id": "container-2", - "mcp_url": "http://localhost:5021/mcp", - "host_port": "5021", - "status": "started", - "container_name": "service2-user1234" - } - ]) - - mock_add_server.return_value = None - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "service1": { - "command": "npx", - "args": ["-y", "service1"], - "port": 5020 - }, - "service2": { - "command": "npx", - "args": ["-y", "service2"], - "port": 5021 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert len(data["results"]) == 2 - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_missing_command(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server with missing command""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - data = response.json() - assert "command" in str(data["detail"]).lower() +# ============================================================================ +# GET /mcp/healthcheck +# ============================================================================ - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_empty_command(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server with empty command string (covers line 189-191)""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "command is required" in data["detail"] +class TestHealthcheck: + """Test GET /mcp/healthcheck""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_missing_port(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server with missing port""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"] - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "port is required" in data["detail"] - - @patch('apps.remote_mcp_app.check_mcp_name_exists') - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_mcp_from_config_name_exists(self, mock_add_server, mock_container_manager_class, mock_get_user_info, mock_check_name): - """Test adding MCP server when name already exists""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_check_name.return_value = True # Name already exists - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "MCP name already exists" in data["detail"] - # Container should not be started when name already exists - mock_container_manager.start_mcp_container.assert_not_called() - - @patch('apps.remote_mcp_app.check_mcp_name_exists') - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - def test_add_mcp_from_config_name_exists_early_check(self, mock_add_server, mock_container_manager_class, mock_get_user_info, mock_check_name): - """Test adding MCP server when name exists (checked before starting container)""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_check_name.return_value = True # Name already exists - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "MCP name already exists" in data["detail"] - # Container should not be started when name already exists - mock_container_manager.start_mcp_container.assert_not_called() + @patch('apps.remote_mcp_app.check_mcp_service_health') + def test_healthcheck_healthy(self, mock_check, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_check.return_value = "healthy" + resp = client.get("/mcp/healthcheck?mcp_id=1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["data"]["health_status"] == "healthy" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_container_error(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server when container startup fails""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container = AsyncMock( - side_effect=MCPContainerError("Container failed")) - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "Container failed" in data["detail"] + @patch('apps.remote_mcp_app.check_mcp_service_health') + def test_healthcheck_not_found(self, mock_check, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_check.side_effect = McpNotFoundError("not found") + resp = client.get("/mcp/healthcheck?mcp_id=999", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_image_not_found_lowercase(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server when image not found (lowercase 'not found')""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - # Error message contains "not found" (lowercase) - mock_container_manager.start_mcp_container = AsyncMock( - side_effect=MCPContainerError("Container startup failed: Container startup failed: 404 Client Error for http+docker://localnpipe/v1.52/images/create?tag=latest&fromImage=nexent%2Fnexent-mcp: Not Found (\"failed to resolve reference \"docker.io/nexent/nexent-mcp:latest\": docker.io/nexent/nexent-mcp:latest: not found\")")) - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "Image not found - MCP service startup image is missing" in data["detail"] - assert "test-service" in data["detail"] + @patch('apps.remote_mcp_app.check_mcp_service_health') + def test_healthcheck_connection_error(self, mock_check, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_check.side_effect = MCPConnectionError("unreachable") + resp = client.get("/mcp/healthcheck?mcp_id=1", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_image_not_found_uppercase(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server when image not found (uppercase 'Not Found')""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - # Error message contains "Not Found" (uppercase) - mock_container_manager.start_mcp_container = AsyncMock( - side_effect=MCPContainerError("Container startup failed: Image Not Found")) - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "Image not found - MCP service startup image is missing" in data["detail"] - assert "test-service" in data["detail"] - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_image_not_found_with_404(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server when image not found (contains '404')""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - # Error message contains "404" - mock_container_manager.start_mcp_container = AsyncMock( - side_effect=MCPContainerError("Container startup failed: 404 Client Error for http+docker://localnpipe/v1.52/images/create")) - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "Image not found - MCP service startup image is missing" in data["detail"] - assert "test-service" in data["detail"] +# ============================================================================ +# GET /mcp/port/check +# ============================================================================ - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_image_not_found_multiple_services(self, mock_check_name, mock_add_server, mock_container_manager_class, mock_get_user_info): - """Test adding multiple MCP servers when one has image not found error""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - # First service fails with image not found, second succeeds - mock_container_manager.start_mcp_container = AsyncMock(side_effect=[ - MCPContainerError("Container startup failed: Image not found"), - { - "container_id": "container-2", - "mcp_url": "http://localhost:5021/mcp", - "host_port": "5021", - "status": "started", - "container_name": "service2-user1234" - } - ]) - mock_add_server.return_value = None - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "service1": { - "command": "npx", - "args": ["-y", "service1"], - "port": 5020 - }, - "service2": { - "command": "npx", - "args": ["-y", "service2"], - "port": 5021 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert len(data["results"]) == 1 - assert data["results"][0]["service_name"] == "service2" - assert len(data["errors"]) == 1 - assert "Image not found - MCP service startup image is missing" in data["errors"][0] +class TestPortCheck: + """Test GET /mcp/port/check""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_unexpected_error_in_loop(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server when unexpected exception occurs in loop (covers line 253-255)""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - # Raise a non-MCPContainerError exception to trigger the general Exception handler - mock_container_manager.start_mcp_container = AsyncMock( - side_effect=ValueError("Unexpected error")) - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] - assert "Unexpected error" in data["detail"] + @patch('apps.remote_mcp_app.check_container_port_conflict') + def test_port_available(self, mock_check, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_check.return_value = True + resp = client.get("/mcp/port/check?port=8080", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["data"]["available"] is True @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_all_fail(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP servers when all fail""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container = AsyncMock( - side_effect=MCPContainerError("Container failed")) - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "service1": { - "command": "npx", - "args": ["-y", "service1"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "All MCP servers failed" in data["detail"] + @patch('apps.remote_mcp_app.check_container_port_conflict') + def test_port_in_use(self, mock_check, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_check.return_value = False + resp = client.get("/mcp/port/check?port=8080", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["data"]["available"] is False - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_docker_unavailable(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server when Docker is unavailable""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_container_manager_class.side_effect = MCPContainerError( - "Docker unavailable") - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Docker service unavailable" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.add_remote_mcp_server_list') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_with_custom_image(self, mock_check_name, mock_add_server, mock_container_manager_class, mock_get_user_info): - """Test adding MCP server with custom Docker image""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" - }) - - mock_add_server.return_value = None - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "python", - "args": ["script.py"], - "port": 5020, - "image": "custom-image:latest" - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify custom image was passed - mock_container_manager.start_mcp_container.assert_called_once() - call_kwargs = mock_container_manager.start_mcp_container.call_args[1] - assert call_kwargs["image"] == "custom-image:latest" - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_add_mcp_from_config_outer_exception(self, mock_check_name, mock_get_user_info): - """Test adding MCP server when exception occurs outside loop (covers line 275-277)""" - # Make get_current_user_info raise an exception to trigger outer exception handler - mock_get_user_info.side_effect = RuntimeError("Failed to get user ID") - - response = client.post( - "/mcp/add-from-config", - json={ - "mcpServers": { - "test-service": { - "command": "npx", - "args": ["-y", "test-mcp"], - "port": 5020 - } - } - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to add MCP servers" in data["detail"] - - -# --------------------------------------------------------------------------- -# Test stop_mcp_container -# --------------------------------------------------------------------------- - - -class TestStopMCPContainer: - """Test endpoint for stopping MCP container""" +# ============================================================================ +# GET /mcp/port/suggest +# ============================================================================ - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.delete_mcp_by_container_id') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_stop_mcp_container_success(self, mock_container_manager_class, mock_delete_mcp, mock_get_user_info): - """Test successful stopping of MCP container""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.stop_mcp_container = AsyncMock( - return_value=True) - - response = client.delete( - "/mcp/container/container-123", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert "stopped successfully" in data["message"] - mock_container_manager.stop_mcp_container.assert_called_once_with( - "container-123") - mock_delete_mcp.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - container_id="container-123", - ) +class TestPortSuggest: + """Test GET /mcp/port/suggest""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_stop_mcp_container_not_found(self, mock_container_manager_class, mock_get_user_info): - """Test stopping non-existent container""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") + @patch('apps.remote_mcp_app.suggest_container_port') + def test_port_suggest(self, mock_suggest, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_suggest.return_value = 5000 + resp = client.get("/mcp/port/suggest", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["data"]["port"] == 5000 - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.stop_mcp_container = AsyncMock( - return_value=False) - response = client.delete( - "/mcp/container/non-existent", - headers={"Authorization": "Bearer test_token"} - ) +# ============================================================================ +# POST /mcp/enable +# ============================================================================ - assert response.status_code == HTTPStatus.NOT_FOUND - data = response.json() - assert data["status"] == "error" - assert "not found" in data["message"] +class TestEnableMcpService: + """Test POST /mcp/enable""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_stop_mcp_container_docker_unavailable(self, mock_container_manager_class, mock_get_user_info): - """Test stopping container when Docker is unavailable""" - from consts.exceptions import MCPContainerError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_container_manager_class.side_effect = MCPContainerError( - "Docker unavailable") - - response = client.delete( - "/mcp/container/container-123", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Docker service unavailable" in data["detail"] + @patch('apps.remote_mcp_app.update_mcp_service_enabled') + def test_enable_success(self, mock_enable, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + resp = client.post("/mcp/enable", json={"mcp_id": 1}, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + mock_enable.assert_called_once_with(tenant_id="tid", user_id="uid", mcp_id=1, enabled=True) @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_stop_mcp_container_exception(self, mock_container_manager_class, mock_get_user_info): - """Test stopping container when exception occurs""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.stop_mcp_container = AsyncMock( - side_effect=Exception("Unexpected error")) - - response = client.delete( - "/mcp/container/container-123", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to stop container" in data["detail"] - - -# --------------------------------------------------------------------------- -# Test list_mcp_containers -# --------------------------------------------------------------------------- - - -class TestListMCPContainers: - """Test endpoint for listing MCP containers""" + @patch('apps.remote_mcp_app.update_mcp_service_enabled') + def test_enable_not_found(self, mock_enable, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_enable.side_effect = McpNotFoundError("not found") + resp = client.post("/mcp/enable", json={"mcp_id": 999}, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.attach_mcp_container_permissions') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list', return_value=[]) - def test_list_mcp_containers_success(self, mock_get_list, mock_attach_perm, mock_container_manager_class, mock_get_user_info): - """Test successful listing of MCP containers""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - raw_containers = [ - { - "container_id": "container-1", - "name": "service1-user1234", - "status": "running", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020" - }, - { - "container_id": "container-2", - "name": "service2-user1234", - "status": "running", - "mcp_url": "http://localhost:5021/mcp", - "host_port": "5021" - } - ] - mock_container_manager.list_mcp_containers.return_value = raw_containers - mock_attach_perm.return_value = [ - {**raw_containers[0], "permission": "EDIT"}, - {**raw_containers[1], "permission": "READ_ONLY"}, - ] + @patch('apps.remote_mcp_app.update_mcp_service_enabled') + def test_enable_name_conflict(self, mock_enable, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_enable.side_effect = McpNameConflictError("name conflict") + resp = client.post("/mcp/enable", json={"mcp_id": 1}, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.CONFLICT - response = client.get( - "/mcp/containers", - headers={"Authorization": "Bearer test_token"} - ) - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert len(data["containers"]) == 2 - assert data["containers"][0]["permission"] == "EDIT" - assert data["containers"][1]["permission"] == "READ_ONLY" - mock_container_manager.list_mcp_containers.assert_called_once_with( - tenant_id="tenant456") - mock_attach_perm.assert_called_once_with( - containers=raw_containers, - tenant_id="tenant456", - user_id="user123", - ) +# ============================================================================ +# POST /mcp/disable +# ============================================================================ - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.attach_mcp_container_permissions') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list', return_value=[]) - def test_list_mcp_containers_with_tenant_id_param(self, mock_get_list, mock_attach_perm, mock_container_manager_class, mock_get_user_info): - """Test listing MCP containers with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.list_mcp_containers.return_value = [] - mock_attach_perm.return_value = [] - - response = client.get( - "/mcp/containers", - params={"tenant_id": "explicit_tenant789"}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used - mock_container_manager.list_mcp_containers.assert_called_once_with( - tenant_id="explicit_tenant789") - mock_attach_perm.assert_called_once_with( - containers=[], - tenant_id="explicit_tenant789", - user_id="user123", - ) +class TestDisableMcpService: + """Test POST /mcp/disable""" @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.attach_mcp_container_permissions', return_value=[]) - @patch('apps.remote_mcp_app.get_remote_mcp_server_list', return_value=[]) - def test_list_mcp_containers_empty(self, mock_get_list, mock_attach_perm, mock_container_manager_class, mock_get_user_info): - """Test listing containers when none exist""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.list_mcp_containers.return_value = [] - - response = client.get( - "/mcp/containers", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert len(data["containers"]) == 0 - mock_attach_perm.assert_called_once_with( - containers=[], - tenant_id="tenant456", - user_id="user123", - ) + @patch('apps.remote_mcp_app.update_mcp_service_enabled') + def test_disable_success(self, mock_enable, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + resp = client.post("/mcp/disable", json={"mcp_id": 1}, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + mock_enable.assert_called_once_with(tenant_id="tid", user_id="uid", mcp_id=1, enabled=False) @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list', return_value=[]) - def test_list_mcp_containers_docker_unavailable(self, mock_get_list, mock_container_manager_class, mock_get_user_info): - """Test listing containers when Docker is unavailable""" - from consts.exceptions import MCPContainerError + @patch('apps.remote_mcp_app.update_mcp_service_enabled') + def test_disable_not_found(self, mock_enable, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_enable.side_effect = McpNotFoundError("not found") + resp = client.post("/mcp/disable", json={"mcp_id": 999}, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.NOT_FOUND - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_container_manager_class.side_effect = MCPContainerError( - "Docker unavailable") - response = client.get( - "/mcp/containers", - headers={"Authorization": "Bearer test_token"} - ) +# ============================================================================ +# GET /mcp/containers +# ============================================================================ - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Docker service unavailable" in data["detail"] +class TestListContainers: + """Test GET /mcp/containers""" @patch('apps.remote_mcp_app.get_current_user_info') + @patch('apps.remote_mcp_app.attach_mcp_container_permissions') @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.get_remote_mcp_server_list', side_effect=Exception("Unexpected error")) - def test_list_mcp_containers_exception(self, mock_get_list, mock_container_manager_class, mock_get_user_info): - """Test listing containers when exception occurs""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.list_mcp_containers.side_effect = Exception( - "Unexpected error") + def test_list_containers_success(self, mock_mgr_cls, mock_attach, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr = MagicMock() + mock_mgr.list_mcp_containers.return_value = [{"container_id": "c1"}] + mock_mgr_cls.return_value = mock_mgr + mock_attach.return_value = [{"container_id": "c1", "permission": "EDIT"}] - response = client.get( - "/mcp/containers", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to list containers" in data["detail"] - - -# --------------------------------------------------------------------------- -# Test upload_mcp_image -# --------------------------------------------------------------------------- - - -class TestUploadMCPImageValidation: - """Test endpoint for uploading MCP image and starting container""" - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_success(self, mock_get_user_info, mock_upload_service): - """Test successful upload and start of MCP image""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_upload_service.return_value = { - "message": "MCP container started successfully from uploaded image", - "status": "success", - "service_name": "test-service", - "mcp_url": "http://localhost:5020/mcp", - "container_id": "container-123", - "container_name": "test-image-user1234", - "host_port": "5020" - } - - # Use actual file content - file_content = b"fake tar content" - - response = client.post( - "/mcp/upload-image", - data={ - "port": 5020, - "service_name": "test-service", - "env_vars": '{"NODE_ENV": "production"}' - }, - files={"file": ("test-image.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert "MCP container started successfully" in data["message"] - assert data["service_name"] == "test-service" - assert data["mcp_url"] == "http://localhost:5020/mcp" - assert data["container_id"] == "container-123" - - mock_get_user_info.assert_called_once() - mock_upload_service.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - file_content=file_content, - filename="test-image.tar", - port=5020, - service_name="test-service", - env_vars='{"NODE_ENV": "production"}' - ) - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - def test_upload_mcp_image_with_tenant_id_param(self, mock_upload_service, mock_get_user_info): - """Test upload MCP image with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_upload_service.return_value = { - "message": "MCP container started successfully from uploaded image", - "status": "success", - "service_name": "test-service", - "mcp_url": "http://localhost:5020/mcp", - "container_id": "container-123", - "container_name": "test-image-user1234", - "host_port": "5020" - } - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={ - "port": 5020, - "service_name": "test-service", - "tenant_id": "explicit_tenant789", - "env_vars": '{"NODE_ENV": "production"}' - }, - files={"file": ("test-image.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used - mock_upload_service.assert_called_once_with( - tenant_id="explicit_tenant789", - user_id="user123", - file_content=file_content, - filename="test-image.tar", - port=5020, - service_name="test-service", - env_vars='{"NODE_ENV": "production"}' - ) - - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_invalid_file_type(self, mock_get_user_info): - """Test upload with invalid file type""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("test.txt", "content", "text/plain")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "Only .tar files are allowed" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_file_too_large(self, mock_get_user_info): - """Test upload with file exceeding size limit""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Create a large file content (over 1GB) - use smaller size for test - large_content = b"x" * (1024 * 1024 * 1024 + 1) - - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("large.tar", large_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "File size exceeds 1GB limit" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_auto_service_name(self, mock_get_user_info, mock_upload_service): - """Test upload with auto-generated service name""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_upload_service.return_value = { - "message": "MCP container started successfully from uploaded image", - "status": "success", - "service_name": "my-image", # Auto-generated from filename - "mcp_url": "http://localhost:5020/mcp", - "container_id": "container-123", - "container_name": "my-image-user1234", - "host_port": "5020" - } - - file_content = b"fake tar content" - - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, # No service_name provided - files={"file": ("my-image.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - # Should use filename without extension - assert data["service_name"] == "my-image" + resp = client.get("/mcp/containers", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK + assert len(resp.json()["containers"]) == 1 @patch('apps.remote_mcp_app.get_current_user_info') @patch('apps.remote_mcp_app.MCPContainerManager') - @patch('apps.remote_mcp_app.check_mcp_name_exists', return_value=False) - def test_upload_mcp_image_invalid_env_vars_json(self, mock_check_name, mock_container_manager_class, mock_get_user_info): - """Test upload with invalid JSON in env_vars""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - file_content = b"fake tar content" - - response = client.post( - "/mcp/upload-image", - data={ - "port": 5020, - "env_vars": "invalid json {" - }, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "Invalid environment variables format" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_name_conflict(self, mock_get_user_info, mock_upload_service): - """Test upload when MCP service name already exists""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises MCPNameIllegal for name conflict - mock_upload_service.side_effect = MCPNameIllegal( - "MCP service name already exists") - - file_content = b"fake tar content" - - response = client.post( - "/mcp/upload-image", - data={"port": 5020, "service_name": "existing-service"}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.CONFLICT - data = response.json() - assert "MCP service name already exists" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_container_error(self, mock_get_user_info, mock_upload_service): - """Test upload when container startup fails""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") + def test_list_containers_docker_unavailable(self, mock_mgr_cls, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr_cls.side_effect = MCPContainerError("Docker unavailable") + resp = client.get("/mcp/containers", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE - # Service layer raises MCPContainerError - mock_upload_service.side_effect = MCPContainerError("Container failed") - - file_content = b"fake tar content" - - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Container failed" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_docker_unavailable(self, mock_get_user_info, mock_upload_service): - """Test upload when Docker service is unavailable""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises MCPContainerError for Docker unavailable - mock_upload_service.side_effect = MCPContainerError( - "Docker unavailable") - - file_content = b"fake tar content" - - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Docker unavailable" in data["detail"] - - -# --------------------------------------------------------------------------- -# Test get_container_logs (SSE streaming) -# --------------------------------------------------------------------------- +# ============================================================================ +# GET /mcp/container/{container_id}/logs +# ============================================================================ class TestGetContainerLogs: - """Test endpoint for getting container logs via SSE stream""" - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_success(self, mock_container_manager_class, mock_get_user_info): - """Test successful SSE streaming of container logs""" - import json - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - # Mock async generator for stream_container_logs - # Create an async generator function that yields 3 log lines - async def mock_stream_logs(container_id, tail, follow): - yield "Log line 1" - yield "Log line 2" - yield "Log line 3" - - # Assign the async generator function directly - # FastAPI will call it and iterate the generator - mock_container_manager.stream_container_logs = mock_stream_logs - - response = client.get( - "/mcp/container/container-123/logs?tail=100&follow=false", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - assert "text/event-stream" in response.headers["content-type"] - assert "Cache-Control" in response.headers - assert "no-cache" in response.headers["Cache-Control"] - assert "Connection" in response.headers - assert "keep-alive" in response.headers["Connection"] - - # Parse SSE content - TestClient should read the full stream - # Use response.content.decode() to ensure we get all bytes - content = response.content.decode('utf-8') - - # Split by double newlines to get SSE messages - # Filter out empty lines and lines that don't start with 'data: ' - lines = [l.strip() for l in content.split('\n\n') if l.strip()] - data_lines = [l for l in lines if l.startswith('data: ')] - - # Should have 3 SSE messages (each log line becomes one SSE message) - assert len(data_lines) == 3, f"Expected 3 SSE messages, got {len(data_lines)}. Content: {content[:500]}" - - # Verify all 3 log lines are present in the response - # Parse each SSE message - log_lines = [] - for line in data_lines: - data_str = line.replace('data: ', '') - data_json = json.loads(data_str) - assert data_json["status"] == "success" - log_lines.append(data_json["logs"]) - - assert log_lines == ["Log line 1", "Log line 2", "Log line 3"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_with_follow(self, mock_container_manager_class, mock_get_user_info): - """Test SSE streaming with follow=True""" - import json - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - async def mock_stream_logs(container_id, tail, follow): - yield "Initial log" - yield "New log 1" - - # Use AsyncMock to wrap the generator function - mock_container_manager.stream_container_logs = AsyncMock(side_effect=mock_stream_logs) - - response = client.get( - "/mcp/container/container-123/logs?tail=50&follow=true", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - assert "text/event-stream" in response.headers["content-type"] - - # Verify follow parameter - call_args = mock_container_manager.stream_container_logs.call_args - assert call_args[1]["follow"] is True - assert call_args[1]["tail"] == 50 - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_default_follow(self, mock_container_manager_class, mock_get_user_info): - """Test that follow defaults to True""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - async def mock_stream_logs(container_id, tail, follow): - yield "Log line" - - # Use AsyncMock to wrap the generator function - mock_container_manager.stream_container_logs = AsyncMock(side_effect=mock_stream_logs) - - response = client.get( - "/mcp/container/container-123/logs", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - call_args = mock_container_manager.stream_container_logs.call_args - assert call_args[1]["follow"] is True # Default should be True + """Test GET /mcp/container/{container_id}/logs""" @patch('apps.remote_mcp_app.get_current_user_info') @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_docker_unavailable(self, mock_container_manager_class, mock_get_user_info): - """Test getting logs when Docker is unavailable""" - from consts.exceptions import MCPContainerError + def test_get_logs_success(self, mock_mgr_cls, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr = MagicMock() - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_container_manager_class.side_effect = MCPContainerError( - "Docker unavailable") - - response = client.get( - "/mcp/container/container-123/logs", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Docker service unavailable" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_stream_error(self, mock_container_manager_class, mock_get_user_info): - """Test SSE streaming when stream raises exception""" - import json - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - # Mock stream that raises exception - async def mock_stream_logs(container_id, tail, follow): - yield "Log line 1" - raise Exception("Stream error") - - mock_container_manager.stream_container_logs = mock_stream_logs - - response = client.get( - "/mcp/container/container-123/logs?tail=100&follow=false", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - assert "text/event-stream" in response.headers["content-type"] - - # Should have error message in stream - content = response.text - assert "Error" in content or "error" in content.lower() + async def mock_stream(container_id, tail=100, follow=True): + yield "line1" + yield "line2" - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_exception(self, mock_container_manager_class, mock_get_user_info): - """Test getting logs when exception occurs during stream iteration""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - # Exception during stream_container_logs iteration - # When async for tries to iterate, the exception is raised - # This is caught by generate_log_stream's try-except (line 564) and sent as SSE error - async def mock_stream_logs_raises(container_id, tail, follow): - # Exception is raised during iteration (when async for starts) - raise Exception("Unexpected error") - yield # Unreachable but needed for async generator syntax - - # Assign the async generator function that raises exception - mock_container_manager.stream_container_logs = mock_stream_logs_raises - - response = client.get( - "/mcp/container/container-123/logs", - headers={"Authorization": "Bearer test_token"} - ) - - # The exception is caught in generate_log_stream (line 564) and sent as SSE error message - # So we get 200 OK with error in the stream, not 500 - assert response.status_code == HTTPStatus.OK - assert "text/event-stream" in response.headers["content-type"] - content = response.text - # Should have error message in stream - assert "Error" in content or "error" in content.lower() or "Unexpected error" in content + mock_mgr.stream_container_logs = mock_stream + mock_mgr_cls.return_value = mock_mgr - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_with_tenant_id(self, mock_container_manager_class, mock_get_user_info): - """Test that explicit tenant_id parameter is used""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - async def mock_stream_logs(container_id, tail, follow): - yield "Log line" - - # Use AsyncMock to wrap the generator function - mock_container_manager.stream_container_logs = AsyncMock(side_effect=mock_stream_logs) - - response = client.get( - "/mcp/container/container-123/logs?tenant_id=explicit-tenant", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify get_current_user_info was called (tenant_id handling) - mock_get_user_info.assert_called_once() + resp = client.get("/mcp/container/cid/logs?follow=false", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.OK @patch('apps.remote_mcp_app.get_current_user_info') @patch('apps.remote_mcp_app.MCPContainerManager') - def test_get_container_logs_sse_format(self, mock_container_manager_class, mock_get_user_info): - """Test that SSE format is correct""" - import json - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - - async def mock_stream_logs(container_id, tail, follow): - yield "Test log line" - - # Use AsyncMock to wrap the generator function - mock_container_manager.stream_container_logs = AsyncMock(side_effect=mock_stream_logs) - - response = client.get( - "/mcp/container/container-123/logs?tail=100&follow=false", - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - content = response.text - - # Verify SSE format: data: {json}\n\n - lines = content.strip().split('\n\n') - for line in lines: - if line.startswith('data: '): - data_str = line.replace('data: ', '') - data_json = json.loads(data_str) - assert "logs" in data_json - assert "status" in data_json - assert data_json["status"] in ["success", "error"] - - -# --------------------------------------------------------------------------- -# Test upload_and_start_mcp_image endpoint with service layer -# --------------------------------------------------------------------------- - - -class TestUploadMCPImageWithServiceLayer: - """Test upload_mcp_image endpoint using the new service layer approach""" - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_success_service_layer(self, mock_get_user_info, mock_upload_service): - """Test successful upload using service layer""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_upload_service.return_value = { - "message": "MCP container started successfully from uploaded image", - "status": "success", - "service_name": "test-service", - "mcp_url": "http://localhost:5020/mcp", - "container_id": "container-123", - "container_name": "test-service-user1234", - "host_port": "5020" - } - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={ - "port": 5020, - "service_name": "test-service", - "env_vars": '{"NODE_ENV": "production"}' - }, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert data["service_name"] == "test-service" - assert data["mcp_url"] == "http://localhost:5020/mcp" - - # Verify service layer was called correctly - mock_upload_service.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - file_content=file_content, - filename="test.tar", - port=5020, - service_name="test-service", - env_vars='{"NODE_ENV": "production"}' - ) - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_auto_service_name(self, mock_get_user_info, mock_upload_service): - """Test upload with auto-generated service name""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - mock_upload_service.return_value = { - "message": "MCP container started successfully from uploaded image", - "status": "success", - "service_name": "my-image", # Auto-generated from filename - "mcp_url": "http://localhost:5020/mcp", - "container_id": "container-123" - } - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, # No service_name provided - files={"file": ("my-image.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["service_name"] == "my-image" - - # Verify service was called with None for service_name - mock_upload_service.assert_called_once_with( - tenant_id="tenant456", - user_id="user123", - file_content=file_content, - filename="my-image.tar", - port=5020, - service_name=None, - env_vars=None - ) - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_validation_error_from_service(self, mock_get_user_info, mock_upload_service): - """Test validation error from service layer""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises ValueError for invalid file type - mock_upload_service.side_effect = ValueError( - "Only .tar files are allowed") - - file_content = b"fake content" - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - # Wrong file type - files={"file": ("test.txt", file_content, "text/plain")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "Only .tar files are allowed" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_name_conflict(self, mock_get_user_info, mock_upload_service): - """Test MCP service name conflict""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises MCPNameIllegal for name conflict - mock_upload_service.side_effect = MCPNameIllegal( - "MCP service name already exists") - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={"port": 5020, "service_name": "existing-service"}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.CONFLICT - data = response.json() - assert "MCP service name already exists" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_container_error(self, mock_get_user_info, mock_upload_service): - """Test container startup error""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises MCPContainerError - mock_upload_service.side_effect = MCPContainerError("Container failed") - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Container failed" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_docker_unavailable(self, mock_get_user_info, mock_upload_service): - """Test Docker service unavailable""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises MCPContainerError for Docker unavailable - mock_upload_service.side_effect = MCPContainerError( - "Docker unavailable") - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "Docker unavailable" in data["detail"] - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_general_exception(self, mock_get_user_info, mock_upload_service): - """Test general exception handling""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Service layer raises unexpected exception - mock_upload_service.side_effect = Exception("Unexpected error") - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={"port": 5020}, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to upload and start MCP container" in data["detail"] - assert "Unexpected error" in data["detail"] - - -# --------------------------------------------------------------------------- -# Additional test cases for upload_mcp_image validation -# --------------------------------------------------------------------------- - - -class TestUploadMCPImageValidationAdditional: - """Additional test cases for upload_mcp_image endpoint validation""" - - def test_upload_mcp_image_invalid_port_range_fastapi_validation(self): - """Test upload with invalid port range using FastAPI native validation""" - file_content = b"fake tar content" - - # Test port <= 0 - should fail FastAPI validation - response = client.post( - "/mcp/upload-image", - data={"port": 0}, # Invalid port - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - # FastAPI validation error - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - data = response.json() - assert "port" in str(data["detail"]).lower() - - # Test port > 65535 - should fail FastAPI validation - response = client.post( - "/mcp/upload-image", - data={"port": 70000}, # Invalid port - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - # FastAPI validation error - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - data = response.json() - assert "port" in str(data["detail"]).lower() - - @patch('apps.remote_mcp_app.upload_and_start_mcp_image') - @patch('apps.remote_mcp_app.get_current_user_info') - def test_upload_mcp_image_env_vars_validation_in_service(self, mock_get_user_info, mock_upload_service): - """Test environment variables validation now handled in service layer""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - - # Test with array instead of object - now handled in service layer - mock_upload_service.side_effect = ValueError( - "Invalid environment variables format: Environment variables must be a JSON object") - - file_content = b"fake tar content" - response = client.post( - "/mcp/upload-image", - data={ - "port": 5020, - "env_vars": '["VAR1", "VAR2"]' # Array instead of object - }, - files={"file": ("test.tar", file_content, - "application/octet-stream")}, - headers={"Authorization": "Bearer test_token"} - ) - assert response.status_code == HTTPStatus.BAD_REQUEST - data = response.json() - assert "Invalid environment variables format" in data["detail"] - assert "Environment variables must be a JSON object" in data["detail"] - - -class MockMCPUpdateRequest: - """Mock MCPUpdateRequest for testing""" - - def __init__(self, current_service_name, current_mcp_url, new_service_name, new_mcp_url): - self.current_service_name = current_service_name - self.current_mcp_url = current_mcp_url - self.new_service_name = new_service_name - self.new_mcp_url = new_mcp_url - - -class TestUpdateRemoteProxy: - """Test endpoint for updating remote MCP servers""" - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_success(self, mock_update_server, mock_get_user_info): - """Test successful update of remote MCP proxy""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.return_value = None # No exception means success - - update_data = MockMCPUpdateRequest( - current_service_name="old_service", - current_mcp_url="http://old.url", - new_service_name="new_service", - new_mcp_url="http://new.url" - ) - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "old_service", - "current_mcp_url": "http://old.url", - "new_service_name": "new_service", - "new_mcp_url": "http://new.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - assert "Successfully updated remote MCP proxy" in data["message"] - - mock_get_user_info.assert_called_once() - # Verify the service was called with correct tenant_id and user_id - # The update_data parameter is automatically parsed by FastAPI from the JSON request - mock_update_server.assert_called_once() - call_kwargs = mock_update_server.call_args[1] - assert call_kwargs["tenant_id"] == "tenant456" - assert call_kwargs["user_id"] == "user123" - # Verify that update_data parameter exists and is not None - assert "update_data" in call_kwargs - assert call_kwargs["update_data"] is not None - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_with_tenant_id_param(self, mock_update_server, mock_get_user_info): - """Test updating remote MCP proxy with explicit tenant_id parameter""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.return_value = None - - response = client.put( - "/mcp/update", - params={"tenant_id": "explicit_tenant789"}, - json={ - "current_service_name": "old_service", - "current_mcp_url": "http://old.url", - "new_service_name": "new_service", - "new_mcp_url": "http://new.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - # Verify that explicit tenant_id is used - mock_update_server.assert_called_once() - call_kwargs = mock_update_server.call_args[1] - assert call_kwargs["tenant_id"] == "explicit_tenant789" - assert call_kwargs["user_id"] == "user123" - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_name_conflict(self, mock_update_server, mock_get_user_info): - """Test update MCP proxy with name conflict""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.side_effect = MCPNameIllegal( - "New MCP name already exists") - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "old_service", - "current_mcp_url": "http://old.url", - "new_service_name": "existing_service", - "new_mcp_url": "http://new.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.CONFLICT - data = response.json() - assert "New MCP name already exists" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_connection_failed(self, mock_update_server, mock_get_user_info): - """Test update MCP proxy with connection failure""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.side_effect = MCPConnectionError( - "New MCP server connection failed") - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "old_service", - "current_mcp_url": "http://old.url", - "new_service_name": "new_service", - "new_mcp_url": "http://unreachable.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - data = response.json() - assert "New MCP server connection failed" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_current_name_not_exist(self, mock_update_server, mock_get_user_info): - """Test update MCP proxy when current name doesn't exist""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.side_effect = MCPNameIllegal( - "MCP name does not exist") - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "nonexistent_service", - "current_mcp_url": "http://old.url", - "new_service_name": "new_service", - "new_mcp_url": "http://new.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.CONFLICT - data = response.json() - assert "MCP name does not exist" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_database_error(self, mock_update_server, mock_get_user_info): - """Test update MCP proxy with database error""" - from sqlalchemy.exc import SQLAlchemyError - - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.side_effect = SQLAlchemyError( - "Database connection failed") - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "old_service", - "current_mcp_url": "http://old.url", - "new_service_name": "new_service", - "new_mcp_url": "http://new.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - data = response.json() - assert "Failed to update remote MCP proxy" in data["detail"] - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_same_name_and_url(self, mock_update_server, mock_get_user_info): - """Test update MCP proxy with same name and URL (no-op update)""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.return_value = None - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "same_service", - "current_mcp_url": "http://same.url", - "new_service_name": "same_service", - "new_mcp_url": "http://same.url" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" - - def test_update_remote_proxy_invalid_request_data(self): - """Test update MCP proxy with invalid request data""" - # Missing required fields - response = client.put( - "/mcp/update", - json={ - "current_service_name": "old_service" - # Missing other required fields - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - @patch('apps.remote_mcp_app.get_current_user_info') - @patch('apps.remote_mcp_app.update_remote_mcp_server_list') - def test_update_remote_proxy_with_special_characters(self, mock_update_server, mock_get_user_info): - """Test update MCP proxy with special characters in names and URLs""" - mock_get_user_info.return_value = ("user123", "tenant456", "en") - mock_update_server.return_value = None - - response = client.put( - "/mcp/update", - json={ - "current_service_name": "old-service_123", - "current_mcp_url": "http://old-server.com:8080/path", - "new_service_name": "new-service_456", - "new_mcp_url": "http://new-server.com:9090/api" - }, - headers={"Authorization": "Bearer test_token"} - ) - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert data["status"] == "success" + def test_get_logs_docker_unavailable(self, mock_mgr_cls, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_mgr_cls.side_effect = MCPContainerError("Docker unavailable") + resp = client.get("/mcp/container/cid/logs", headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE if __name__ == "__main__": - pytest.main([__file__]) + import pytest + pytest.main([__file__, "-v"]) diff --git a/test/backend/database/test_community_mcp_db.py b/test/backend/database/test_community_mcp_db.py new file mode 100644 index 000000000..548ebce93 --- /dev/null +++ b/test/backend/database/test_community_mcp_db.py @@ -0,0 +1,383 @@ +""" +Unit tests for backend/database/community_mcp_db.py + +Tests community MCP record database operations. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) + +import pytest +from unittest.mock import MagicMock + +# Mock modules +consts_mock = MagicMock() +consts_mock.const = MagicMock() +consts_mock.const.MINIO_ENDPOINT = "http://localhost:9000" +consts_mock.const.MINIO_ACCESS_KEY = "test_access_key" +consts_mock.const.MINIO_SECRET_KEY = "test_secret_key" +consts_mock.const.MINIO_REGION = "us-east-1" +consts_mock.const.MINIO_DEFAULT_BUCKET = "test-bucket" +consts_mock.const.POSTGRES_HOST = "localhost" +consts_mock.const.POSTGRES_USER = "test_user" +consts_mock.const.NEXENT_POSTGRES_PASSWORD = "test_password" +consts_mock.const.POSTGRES_DB = "test_db" +consts_mock.const.POSTGRES_PORT = 5432 +sys.modules['consts'] = consts_mock +sys.modules['consts.const'] = consts_mock.const + +client_mock = MagicMock() +client_mock.get_db_session = MagicMock() +client_mock.as_dict = MagicMock() +client_mock.filter_property = MagicMock() +sys.modules['database.client'] = client_mock + +db_models_mock = MagicMock() +db_models_mock.McpCommunityRecord = MagicMock() +sys.modules['database.db_models'] = db_models_mock + +from backend.database.community_mcp_db import ( + get_mcp_community_records, + get_mcp_community_tag_stats, + create_mcp_community_record, + get_mcp_community_record_by_id_and_tenant, + update_mcp_community_record_by_id, + delete_mcp_community_record_by_id, + list_mcp_community_records_by_tenant, + get_mcp_community_tag_stats_by_tenant, +) + + +class MockCommunityRecord: + def __init__(self, community_id=1, name="test", tags=None): + self.community_id = community_id + self.mcp_name = name + self.description = "desc" + self.tags = tags or ["tag1"] + self.transport_type = "url" + self.mcp_server = "http://srv" + self.version = "1.0" + self.config_json = None + self.registry_json = None + self.delete_flag = "N" + self.tenant_id = "tenant1" + self.create_time = "2024-01-01" + self.update_time = "2024-01-01" + + +@pytest.fixture +def mock_session(): + session = MagicMock() + query = MagicMock() + session.query.return_value = query + return session, query + + +# ============================================================================ +# get_mcp_community_records +# ============================================================================ + +def test_get_community_records(monkeypatch, mock_session): + """Test basic retrieval of community records without filters.""" + session, query = mock_session + r1 = MockCommunityRecord(1, "svc1") + r2 = MockCommunityRecord(2, "svc2") + + mock_limit = MagicMock() + mock_limit.all.return_value = [r1, r2] + mock_order = MagicMock() + mock_order.limit.return_value = mock_limit + mock_filter = MagicMock() + mock_filter.order_by.return_value = mock_order + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.community_mcp_db.as_dict", lambda obj: { + "community_id": obj.community_id, "mcp_name": obj.mcp_name, + "description": obj.description, "tags": obj.tags, + "transport_type": obj.transport_type, "mcp_server": obj.mcp_server, + "version": obj.version, "config_json": obj.config_json, + "registry_json": obj.registry_json, "create_time": obj.create_time, + "update_time": obj.update_time, + }) + + result = get_mcp_community_records(limit=30) + assert result["count"] == 2 + assert len(result["items"]) == 2 + assert result["nextCursor"] is None + + +def test_get_community_records_pagination(monkeypatch, mock_session): + """Test pagination returns nextCursor when items exceed limit.""" + session, query = mock_session + # Return limit+1 items to trigger nextCursor + records = [MockCommunityRecord(i, f"svc{i}") for i in range(1, 32)] # 31 items, limit=30 + + mock_limit = MagicMock() + mock_limit.all.return_value = records + mock_order = MagicMock() + mock_order.limit.return_value = mock_limit + mock_filter = MagicMock() + mock_filter.order_by.return_value = mock_order + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.community_mcp_db.as_dict", lambda obj: { + "community_id": obj.community_id, "mcp_name": obj.mcp_name, + "description": obj.description, "tags": obj.tags, + "transport_type": obj.transport_type, "mcp_server": obj.mcp_server, + "version": obj.version, "config_json": obj.config_json, + "registry_json": obj.registry_json, "create_time": obj.create_time, + "update_time": obj.update_time, + }) + + result = get_mcp_community_records(limit=30) + assert result["count"] == 30 + assert result["nextCursor"] == "30" + + +# ============================================================================ +# get_mcp_community_tag_stats +# ============================================================================ + +def test_get_community_tag_stats(monkeypatch, mock_session): + """Test retrieval of community tag statistics.""" + session, query = mock_session + + # Create mock rows with tag and count attributes + mock_row1 = MagicMock() + mock_row1.tag = "tag1" + mock_row1.count = 5 + mock_row2 = MagicMock() + mock_row2.tag = "tag2" + mock_row2.count = 3 + + mock_all = MagicMock() + mock_all.all.return_value = [mock_row1, mock_row2] + mock_group = MagicMock() + mock_group.order_by.return_value = mock_all + mock_filter = MagicMock() + mock_filter.group_by.return_value = mock_group + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + + result = get_mcp_community_tag_stats() + assert len(result) == 2 + assert result[0] == {"tag": "tag1", "count": 5} + + +# ============================================================================ +# create_mcp_community_record +# ============================================================================ + +def test_create_community_record(monkeypatch, mock_session): + """Test successful creation of a community MCP record.""" + session, _ = mock_session + session.add = MagicMock() + session.flush = MagicMock() + + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = session + mock_ctx.__exit__.return_value = None + monkeypatch.setattr("backend.database.community_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.community_mcp_db.filter_property", lambda data, model: data) + + mock_record = MagicMock() + mock_record.community_id = 42 + monkeypatch.setattr("backend.database.community_mcp_db.McpCommunityRecord", lambda **kw: mock_record) + + result = create_mcp_community_record( + {"mcp_name": "test", "mcp_server": "http://srv"}, + tenant_id="tid", user_id="uid", + ) + assert result == 42 + session.add.assert_called_once() + + +# ============================================================================ +# get_mcp_community_record_by_id_and_tenant +# ============================================================================ + +def test_get_community_record_by_id_found(monkeypatch, mock_session): + """Test retrieval of community record by ID when record exists.""" + session, query = mock_session + r = MockCommunityRecord(1) + + mock_first = MagicMock(return_value=r) + mock_filter = MagicMock() + mock_filter.first = mock_first + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.community_mcp_db.as_dict", lambda obj: {"community_id": obj.community_id, "mcp_name": obj.mcp_name}) + + result = get_mcp_community_record_by_id_and_tenant(1, "tid") + assert result is not None + assert result["community_id"] == 1 + + +def test_get_community_record_by_id_not_found(monkeypatch, mock_session): + """Test retrieval of community record by ID when record does not exist.""" + session, query = mock_session + + mock_first = MagicMock(return_value=None) + mock_filter = MagicMock() + mock_filter.first = mock_first + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + + result = get_mcp_community_record_by_id_and_tenant(999, "tid") + assert result is None + + +# ============================================================================ +# update_mcp_community_record_by_id +# ============================================================================ + +def test_update_community_record(monkeypatch, mock_session): + """Test updating a community MCP record with all fields.""" + session, query = mock_session + mock_update = MagicMock() + mock_filter = MagicMock() + mock_filter.update = mock_update + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + + update_mcp_community_record_by_id( + community_id=1, tenant_id="tid", user_id="uid", + name="new-name", description="new-desc", tags=["a", "b"], + ) + mock_update.assert_called_once() + call_args = mock_update.call_args[0][0] + assert call_args["mcp_name"] == "new-name" + assert call_args["description"] == "new-desc" + assert call_args["tags"] == ["a", "b"] + + +def test_update_community_record_partial(monkeypatch, mock_session): + """Test partial update - only provided fields should be in update.""" + session, query = mock_session + mock_update = MagicMock() + mock_filter = MagicMock() + mock_filter.update = mock_update + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + + update_mcp_community_record_by_id( + community_id=1, tenant_id="tid", user_id="uid", + name="only-name", + ) + call_args = mock_update.call_args[0][0] + assert "mcp_name" in call_args + assert "description" not in call_args + + +# ============================================================================ +# delete_mcp_community_record_by_id +# ============================================================================ + +def test_delete_community_record(monkeypatch, mock_session): + """Test soft-deletion of a community MCP record.""" + session, query = mock_session + mock_update = MagicMock() + mock_filter = MagicMock() + mock_filter.update = mock_update + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + + delete_mcp_community_record_by_id(community_id=1, tenant_id="tid", user_id="uid") + mock_update.assert_called_once_with({"delete_flag": "Y", "updated_by": "uid"}) + + +# ============================================================================ +# list_mcp_community_records_by_tenant +# ============================================================================ + +def test_list_community_records_by_tenant(monkeypatch, mock_session): + """Test listing community records for a specific tenant.""" + session, query = mock_session + r1 = MockCommunityRecord(1, "svc1") + r2 = MockCommunityRecord(2, "svc2") + + mock_all = MagicMock(return_value=[r1, r2]) + mock_order = MagicMock() + mock_order.all = mock_all + mock_filter = MagicMock() + mock_filter.order_by.return_value = mock_order + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.community_mcp_db.as_dict", lambda obj: { + "community_id": obj.community_id, "mcp_name": obj.mcp_name, + }) + + result = list_mcp_community_records_by_tenant("tid") + assert len(result) == 2 + + +# ============================================================================ +# get_mcp_community_tag_stats_by_tenant +# ============================================================================ + +def test_get_community_tag_stats_by_tenant(monkeypatch, mock_session): + """Test retrieval of community tag statistics for a tenant.""" + session, query = mock_session + + mock_row = MagicMock() + mock_row.tag = "tagA" + mock_row.count = 10 + + mock_all = MagicMock() + mock_all.all.return_value = [mock_row] + mock_group = MagicMock() + mock_group.order_by.return_value = mock_all + mock_filter = MagicMock() + mock_filter.group_by.return_value = mock_group + 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.community_mcp_db.get_db_session", lambda: mock_ctx) + + result = get_mcp_community_tag_stats_by_tenant("tid") + assert len(result) == 1 + assert result[0] == {"tag": "tagA", "count": 10} + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/test/backend/database/test_remote_mcp_db.py b/test/backend/database/test_remote_mcp_db.py index a46fe857a..42d5584d8 100644 --- a/test/backend/database/test_remote_mcp_db.py +++ b/test/backend/database/test_remote_mcp_db.py @@ -1,5 +1,13 @@ +""" +Unit tests for backend/database/remote_mcp_db.py + +Tests all MCP record database operations with comprehensive coverage. +Uses mocked database sessions to avoid real DB connections. +""" + import sys import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) import pytest @@ -25,22 +33,18 @@ sys.modules['consts'] = consts_mock sys.modules['consts.const'] = consts_mock.const -# Mock utils module +# Mock utils utils_mock = MagicMock() utils_mock.auth_utils = MagicMock() -utils_mock.auth_utils.get_current_user_id_from_token = MagicMock( - return_value="test_user_id") - -# Add the mocked utils module to sys.modules +utils_mock.auth_utils.get_current_user_id_from_token = MagicMock(return_value="test_user_id") sys.modules['utils'] = utils_mock sys.modules['utils.auth_utils'] = utils_mock.auth_utils -# Provide a stub for the `boto3` module so that it can be imported safely even -# if the testing environment does not have it available. +# Mock boto3 boto3_mock = MagicMock() sys.modules['boto3'] = boto3_mock -# Mock the entire client module +# Mock client module client_mock = MagicMock() client_mock.MinioClient = MagicMock() client_mock.PostgresClient = MagicMock() @@ -48,18 +52,16 @@ client_mock.get_db_session = MagicMock() client_mock.as_dict = MagicMock() client_mock.filter_property = MagicMock() - -# Add the mocked client module to sys.modules sys.modules['database.client'] = client_mock sys.modules['backend.database.client'] = client_mock -# Mock db_models module +# Mock db_models db_models_mock = MagicMock() db_models_mock.McpRecord = MagicMock() sys.modules['database.db_models'] = db_models_mock sys.modules['backend.database.db_models'] = db_models_mock -# Mock exceptions module +# Mock exceptions exceptions_mock = MagicMock() sys.modules['consts.exceptions'] = exceptions_mock sys.modules['backend.consts.exceptions'] = exceptions_mock @@ -71,13 +73,21 @@ delete_mcp_record_by_container_id, 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_status_by_id, + update_mcp_record_container_fields_by_id, + delete_mcp_record_by_id, get_mcp_records_by_tenant, + get_mcp_records_by_container_port, get_mcp_server_by_name_and_tenant, get_mcp_authorization_token_by_name_and_url, get_mcp_record_by_id_and_tenant, check_mcp_name_exists, + check_enabled_mcp_name_exists, ) + class MockMcpRecord: def __init__(self): self.mcp_id = 1 @@ -100,76 +110,58 @@ def __init__(self): "delete_flag": "N", "container_id": "container-1", "authorization_token": "test_token_123", - "create_time": "2024-01-01 00:00:00" + "create_time": "2024-01-01 00:00:00", } @pytest.fixture def mock_session(): - """Create mock database session""" mock_session = MagicMock() mock_query = MagicMock() mock_session.query.return_value = mock_query return mock_session, mock_query +# ============================================================================ +# create_mcp_record +# ============================================================================ + def test_create_mcp_record_success(monkeypatch, mock_session): - """Test successful creation of MCP record""" session, _ = mock_session session.add = MagicMock() - 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) - monkeypatch.setattr( - "backend.database.remote_mcp_db.filter_property", lambda data, model: data) - monkeypatch.setattr( - "backend.database.remote_mcp_db.McpRecord", lambda **kwargs: MagicMock()) - - mcp_data = { - "mcp_name": "test_mcp", - "mcp_server": "http://test.server.com", - "status": True - } - - # Should not raise any exception - create_mcp_record(mcp_data, "tenant1", "user1") + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.filter_property", lambda data, model: data) + monkeypatch.setattr("backend.database.remote_mcp_db.McpRecord", lambda **kwargs: MagicMock()) + mcp_data = {"mcp_name": "test_mcp", "mcp_server": "http://test.server.com", "status": True} + create_mcp_record(mcp_data, "tenant1", "user1") session.add.assert_called_once() def test_create_mcp_record_failure(monkeypatch, mock_session): - """Test failure of MCP record creation - exception should propagate""" from sqlalchemy.exc import SQLAlchemyError session, _ = mock_session session.add = MagicMock(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) - monkeypatch.setattr( - "backend.database.remote_mcp_db.filter_property", lambda data, model: data) - monkeypatch.setattr( - "backend.database.remote_mcp_db.McpRecord", lambda **kwargs: MagicMock()) - - mcp_data = { - "mcp_name": "test_mcp", - "mcp_server": "http://test.server.com", - "status": True - } - - # Should raise SQLAlchemyError + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.filter_property", lambda data, model: data) + monkeypatch.setattr("backend.database.remote_mcp_db.McpRecord", lambda **kwargs: MagicMock()) + with pytest.raises(SQLAlchemyError): - create_mcp_record(mcp_data, "tenant1", "user1") + create_mcp_record({"mcp_name": "test_mcp"}, "tenant1", "user1") + +# ============================================================================ +# delete_mcp_record_by_name_and_url +# ============================================================================ def test_delete_mcp_record_by_name_and_url_success(monkeypatch, mock_session): - """Test successful deletion of MCP record""" session, query = mock_session mock_update = MagicMock() mock_filter = MagicMock() @@ -179,38 +171,17 @@ def test_delete_mcp_record_by_name_and_url_success(monkeypatch, mock_session): 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - delete_mcp_record_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1", "user1") + delete_mcp_record_by_name_and_url("test_mcp", "http://test.server.com", "tenant1", "user1") + mock_update.assert_called_once_with({"delete_flag": "Y", "updated_by": "user1"}) - mock_update.assert_called_once_with( - {"delete_flag": "Y", "updated_by": "user1"}) - - -def test_delete_mcp_record_by_name_and_url_failure(monkeypatch, mock_session): - """Test failure of MCP record deletion - 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) - - # Should raise SQLAlchemyError - with pytest.raises(SQLAlchemyError): - delete_mcp_record_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1", "user1") +# ============================================================================ +# delete_mcp_record_by_container_id +# ============================================================================ 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 mock_update = MagicMock() mock_filter = MagicMock() @@ -220,36 +191,17 @@ def test_delete_mcp_record_by_container_id_success(monkeypatch, mock_session): 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception delete_mcp_record_by_container_id("container-1", "tenant1", "user1") + mock_update.assert_called_once_with({"delete_flag": "Y", "updated_by": "user1"}) - mock_update.assert_called_once_with( - {"delete_flag": "Y", "updated_by": "user1"}) - - -def test_delete_mcp_record_by_container_id_failure(monkeypatch, mock_session): - """Test failure of MCP record deletion by container ID - 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) - - # Should raise SQLAlchemyError - with pytest.raises(SQLAlchemyError): - delete_mcp_record_by_container_id("container-1", "tenant1", "user1") +# ============================================================================ +# update_mcp_status_by_name_and_url +# ============================================================================ def test_update_mcp_status_by_name_and_url_success(monkeypatch, mock_session): - """Test successful update of MCP status""" session, query = mock_session mock_update = MagicMock() mock_filter = MagicMock() @@ -259,38 +211,17 @@ def test_update_mcp_status_by_name_and_url_success(monkeypatch, mock_session): 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - update_mcp_status_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1", "user1", False) + update_mcp_status_by_name_and_url("test_mcp", "http://test.server.com", "tenant1", "user1", False) + mock_update.assert_called_once_with({"status": False, "updated_by": "user1"}) - mock_update.assert_called_once_with( - {"status": False, "updated_by": "user1"}) - - -def test_update_mcp_status_by_name_and_url_failure(monkeypatch, mock_session): - """Test failure of MCP status update - 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) - - # Should raise SQLAlchemyError - with pytest.raises(SQLAlchemyError): - update_mcp_status_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1", "user1", True) +# ============================================================================ +# get_mcp_records_by_tenant +# ============================================================================ def test_get_mcp_records_by_tenant_success(monkeypatch, mock_session): - """Test successful retrieval of MCP records list by tenant""" session, query = mock_session mock_mcp1 = MockMcpRecord() mock_mcp2 = MockMcpRecord() @@ -306,153 +237,202 @@ def test_get_mcp_records_by_tenant_success(monkeypatch, mock_session): 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) - monkeypatch.setattr( - "backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) result = get_mcp_records_by_tenant("tenant1") - assert len(result) == 2 assert result[0]["mcp_name"] == "test_mcp" - assert result[1]["mcp_name"] == "test_mcp2" -def test_get_mcp_server_by_name_and_tenant_success(monkeypatch, mock_session): - """Test successful retrieval of MCP server address by name and tenant""" +def test_get_mcp_records_by_tenant_with_tag(monkeypatch, mock_session): session, query = mock_session mock_mcp = MockMcpRecord() - mock_first = MagicMock() - mock_first.return_value = mock_mcp + mock_order_by = MagicMock() + mock_order_by.all.return_value = [mock_mcp] + mock_filter2 = MagicMock() + mock_filter2.order_by.return_value = mock_order_by mock_filter = MagicMock() - mock_filter.first = mock_first + mock_filter.filter.return_value = mock_filter2 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) - result = get_mcp_server_by_name_and_tenant("test_mcp", "tenant1") + result = get_mcp_records_by_tenant("tenant1", tag="test-tag") + assert len(result) == 1 - assert result == "http://test.server.com" +# ============================================================================ +# get_mcp_records_by_container_port (NEW) +# ============================================================================ -def test_get_mcp_server_by_name_and_tenant_not_found(monkeypatch, mock_session): - """Test retrieval of MCP server address by name and tenant when record does not exist""" +def test_get_mcp_records_by_container_port_found(monkeypatch, mock_session): session, query = mock_session + mock_mcp = MockMcpRecord() - mock_first = MagicMock() - mock_first.return_value = None + mock_order_by = MagicMock() + mock_order_by.all.return_value = [mock_mcp] mock_filter = MagicMock() - mock_filter.first = mock_first + mock_filter.order_by.return_value = mock_order_by 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) - result = get_mcp_server_by_name_and_tenant("nonexistent_mcp", "tenant1") + result = get_mcp_records_by_container_port(8080) + assert len(result) == 1 - assert result == "" +def test_get_mcp_records_by_container_port_empty(monkeypatch, mock_session): + session, query = mock_session -def test_get_mcp_server_by_name_and_tenant_database_error(monkeypatch, mock_session): - """Test database error when retrieving MCP server address - exception should propagate""" - from sqlalchemy.exc import SQLAlchemyError + mock_order_by = MagicMock() + mock_order_by.all.return_value = [] + mock_filter = MagicMock() + mock_filter.order_by.return_value = mock_order_by + 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) + + result = get_mcp_records_by_container_port(8080) + assert len(result) == 0 + +# ============================================================================ +# update_mcp_record_manage_fields_by_id (NEW) +# ============================================================================ + +def test_update_mcp_record_manage_fields_by_id_success(monkeypatch, mock_session): session, query = mock_session - query.filter.side_effect = SQLAlchemyError("Database error") + mock_update = MagicMock() + mock_filter = MagicMock() + mock_filter.update = mock_update + 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should raise SQLAlchemyError, not MCPDatabaseError - with pytest.raises(SQLAlchemyError): - get_mcp_server_by_name_and_tenant("test_mcp", "tenant1") + update_mcp_record_manage_fields_by_id( + mcp_id=1, tenant_id="tid", user_id="uid", + name="new-name", server_url="http://new.url", + description="desc", tags=["a"], source="local", + authorization_token="tok", config_json={"key": "val"}, + ) + mock_update.assert_called_once() + call_args = mock_update.call_args[0][0] + assert call_args["mcp_name"] == "new-name" + assert call_args["mcp_server"] == "http://new.url" + assert call_args["tags"] == ["a"] + assert call_args["config_json"] == {"key": "val"} -def test_check_mcp_name_exists_true(monkeypatch, mock_session): - """Test checking MCP name exists, returns True""" +def test_update_mcp_record_manage_fields_by_id_none_tags(monkeypatch, mock_session): session, query = mock_session - mock_mcp = MockMcpRecord() - - mock_first = MagicMock() - mock_first.return_value = mock_mcp + mock_update = MagicMock() mock_filter = MagicMock() - mock_filter.first = mock_first + mock_filter.update = mock_update 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - result = check_mcp_name_exists("test_mcp", "tenant1") + update_mcp_record_manage_fields_by_id( + mcp_id=1, tenant_id="tid", user_id="uid", + name="n", server_url="u", description=None, + tags=None, source="local", authorization_token=None, config_json=None, + ) + call_args = mock_update.call_args[0][0] + assert call_args["tags"] == [] - assert result is True +# ============================================================================ +# update_mcp_record_enabled_by_id (NEW) +# ============================================================================ -def test_check_mcp_name_exists_false(monkeypatch, mock_session): - """Test checking MCP name exists, returns False""" +def test_update_mcp_record_enabled_by_id(monkeypatch, mock_session): session, query = mock_session - - mock_first = MagicMock() - mock_first.return_value = None + mock_update = MagicMock() mock_filter = MagicMock() - mock_filter.first = mock_first + mock_filter.update = mock_update 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - result = check_mcp_name_exists("nonexistent_mcp", "tenant1") + update_mcp_record_enabled_by_id(mcp_id=1, tenant_id="tid", user_id="uid", enabled=True) + mock_update.assert_called_once_with({"enabled": True, "updated_by": "uid"}) - assert result is False + update_mcp_record_enabled_by_id(mcp_id=2, tenant_id="tid", user_id="uid", enabled=False) + assert mock_update.call_count == 2 -def test_check_mcp_name_exists_database_error(monkeypatch, mock_session): - """Test database error when checking if MCP name exists - exception should propagate""" - from sqlalchemy.exc import SQLAlchemyError +# ============================================================================ +# update_mcp_record_status_by_id (NEW) +# ============================================================================ +def test_update_mcp_record_status_by_id(monkeypatch, mock_session): session, query = mock_session - query.filter.side_effect = SQLAlchemyError("Database error") + mock_update = MagicMock() + mock_filter = MagicMock() + mock_filter.update = mock_update + 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should raise SQLAlchemyError, not MCPDatabaseError - with pytest.raises(SQLAlchemyError): - check_mcp_name_exists("test_mcp", "tenant1") + update_mcp_record_status_by_id(mcp_id=1, tenant_id="tid", user_id="uid", status=True) + mock_update.assert_called_once_with({"status": True, "updated_by": "uid"}) -# Mock class for MCPUpdateRequest +# ============================================================================ +# update_mcp_record_container_fields_by_id (NEW) +# ============================================================================ -class MockMCPUpdateRequest: - def __init__(self, current_service_name, current_mcp_url, new_service_name, new_mcp_url, new_authorization_token=None): - self.current_service_name = current_service_name - self.current_mcp_url = current_mcp_url - self.new_service_name = new_service_name - self.new_mcp_url = new_mcp_url - self.new_authorization_token = new_authorization_token +def test_update_mcp_record_container_fields_by_id(monkeypatch, mock_session): + session, query = mock_session + mock_update = MagicMock() + mock_filter = MagicMock() + mock_filter.update = mock_update + 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) + update_mcp_record_container_fields_by_id( + mcp_id=1, tenant_id="tid", user_id="uid", + container_id="cid", container_port=8080, + mcp_server="http://srv/mcp", status=True, + ) + mock_update.assert_called_once() + call_args = mock_update.call_args[0][0] + assert call_args["container_id"] == "cid" + assert call_args["container_port"] == 8080 + assert call_args["mcp_server"] == "http://srv/mcp" + assert call_args["status"] is True -def test_update_mcp_record_by_name_and_url_success(monkeypatch, mock_session): - """Test successful update of MCP record by name and URL""" + +def test_update_mcp_record_container_fields_by_id_none_values(monkeypatch, mock_session): session, query = mock_session mock_update = MagicMock() mock_filter = MagicMock() @@ -462,36 +442,23 @@ def test_update_mcp_record_by_name_and_url_success(monkeypatch, mock_session): 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) - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1", - status=True + update_mcp_record_container_fields_by_id( + mcp_id=1, tenant_id="tid", user_id="uid", + container_id=None, container_port=None, + mcp_server="http://srv/mcp", status=None, ) + call_args = mock_update.call_args[0][0] + assert call_args["container_id"] is None + assert call_args["status"] is None - # Verify the update was called with correct fields - mock_update.assert_called_once_with({ - "mcp_name": "new_name", - "mcp_server": "http://new.url", - "updated_by": "user1", - "status": True, - "authorization_token": None - }) +# ============================================================================ +# delete_mcp_record_by_id (NEW) +# ============================================================================ -def test_update_mcp_record_by_name_and_url_without_status(monkeypatch, mock_session): - """Test update of MCP record by name and URL without status parameter""" +def test_delete_mcp_record_by_id(monkeypatch, mock_session): session, query = mock_session mock_update = MagicMock() mock_filter = MagicMock() @@ -501,130 +468,103 @@ def test_update_mcp_record_by_name_and_url_without_status(monkeypatch, mock_sess 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) - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1" - ) + delete_mcp_record_by_id(mcp_id=1, tenant_id="tid", user_id="uid") + mock_update.assert_called_once_with({"delete_flag": "Y", "updated_by": "uid"}) - # Verify the update was called with correct fields (no status) - mock_update.assert_called_once_with({ - "mcp_name": "new_name", - "mcp_server": "http://new.url", - "updated_by": "user1", - "authorization_token": None - }) +# ============================================================================ +# get_mcp_server_by_name_and_tenant +# ============================================================================ -def test_update_mcp_record_by_name_and_url_failure(monkeypatch, mock_session): - """Test failure of MCP record update - exception should propagate""" - from sqlalchemy.exc import SQLAlchemyError - +def test_get_mcp_server_by_name_and_tenant_success(monkeypatch, mock_session): session, query = mock_session - query.filter.side_effect = SQLAlchemyError("Database error") + mock_mcp = MockMcpRecord() + + mock_first = MagicMock() + mock_first.return_value = mock_mcp + mock_filter = MagicMock() + mock_filter.first = mock_first + 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) - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should raise SQLAlchemyError - with pytest.raises(SQLAlchemyError): - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1", - status=False - ) + result = get_mcp_server_by_name_and_tenant("test_mcp", "tenant1") + assert result == "http://test.server.com" -# Integration test -def test_mcp_record_lifecycle(monkeypatch, mock_session): - """Test complete MCP record lifecycle: create, query, update status, delete""" +def test_get_mcp_server_by_name_and_tenant_not_found(monkeypatch, mock_session): session, query = mock_session - # Mock database operations - session.add = MagicMock() + mock_first = MagicMock() + mock_first.return_value = None + mock_filter = MagicMock() + mock_filter.first = mock_first + 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) + + result = get_mcp_server_by_name_and_tenant("nonexistent", "tenant1") + assert result == "" + + +# ============================================================================ +# get_mcp_authorization_token_by_name_and_url +# ============================================================================ + +def test_get_mcp_authorization_token_success(monkeypatch, mock_session): + session, query = mock_session mock_mcp = MockMcpRecord() + mock_mcp.authorization_token = "bearer_token_123" + mock_first = MagicMock() mock_first.return_value = mock_mcp mock_filter = MagicMock() mock_filter.first = mock_first - mock_filter.update = MagicMock() query.filter.return_value = mock_filter - # Create a Mock class to simulate McpRecord - mock_mcp_record_class = MagicMock() - mock_mcp_record_class.mcp_name = MagicMock() - mock_mcp_record_class.tenant_id = MagicMock() - mock_mcp_record_class.delete_flag = MagicMock() - mock_mcp_record_class.mcp_server = MagicMock() - mock_mcp_record_class.container_id = MagicMock() - 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) - monkeypatch.setattr( - "backend.database.remote_mcp_db.filter_property", lambda data, model: data) - monkeypatch.setattr( - "backend.database.remote_mcp_db.McpRecord", mock_mcp_record_class) - - # 1. Create MCP record - should not raise exception - mcp_data = { - "mcp_name": "test_mcp", - "mcp_server": "http://test.server.com", - "status": True, - "container_id": "container-1", - } - create_mcp_record(mcp_data, "tenant1", "user1") + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # 2. Check if MCP name exists - exists_result = check_mcp_name_exists("test_mcp", "tenant1") - assert exists_result is True + result = get_mcp_authorization_token_by_name_and_url("test_mcp", "http://test.server.com", "tenant1") + assert result == "bearer_token_123" - # 3. Get MCP server address - server_result = get_mcp_server_by_name_and_tenant("test_mcp", "tenant1") - assert server_result == "http://test.server.com" - # 4. Update MCP status - should not raise exception - update_mcp_status_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1", "user1", False) +def test_get_mcp_authorization_token_not_found(monkeypatch, mock_session): + session, query = mock_session + + mock_first = MagicMock() + mock_first.return_value = None + mock_filter = MagicMock() + mock_filter.first = mock_first + 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) - # 5. Delete MCP record by name/url - should not raise exception - delete_mcp_record_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1", "user1") + result = get_mcp_authorization_token_by_name_and_url("nonexistent", "http://test.server.com", "tenant1") + assert result is None - # 6. Delete MCP record by container_id - should not raise exception - delete_mcp_record_by_container_id("container-1", "tenant1", "user1") +# ============================================================================ +# get_mcp_record_by_id_and_tenant +# ============================================================================ -def test_get_mcp_authorization_token_by_name_and_url_success(monkeypatch, mock_session): - """Test successful retrieval of MCP authorization token by name and URL""" +def test_get_mcp_record_by_id_and_tenant_success(monkeypatch, mock_session): session, query = mock_session mock_mcp = MockMcpRecord() - mock_mcp.authorization_token = "bearer_token_123" + mock_mcp.mcp_id = 123 mock_first = MagicMock() mock_first.return_value = mock_mcp @@ -635,17 +575,15 @@ def test_get_mcp_authorization_token_by_name_and_url_success(monkeypatch, mock_s 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) - result = get_mcp_authorization_token_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1") - - assert result == "bearer_token_123" + result = get_mcp_record_by_id_and_tenant(123, "tenant1") + assert result is not None + assert result["mcp_id"] == 123 -def test_get_mcp_authorization_token_by_name_and_url_not_found(monkeypatch, mock_session): - """Test retrieval of MCP authorization token when record does not exist""" +def test_get_mcp_record_by_id_and_tenant_not_found(monkeypatch, mock_session): session, query = mock_session mock_first = MagicMock() @@ -657,116 +595,108 @@ def test_get_mcp_authorization_token_by_name_and_url_not_found(monkeypatch, mock 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) - - result = get_mcp_authorization_token_by_name_and_url( - "nonexistent_mcp", "http://test.server.com", "tenant1") + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + result = get_mcp_record_by_id_and_tenant(999, "tenant1") assert result is None -def test_get_mcp_authorization_token_by_name_and_url_database_error(monkeypatch, mock_session): - """Test database error when retrieving MCP authorization token - exception should propagate""" - from sqlalchemy.exc import SQLAlchemyError +# ============================================================================ +# check_mcp_name_exists +# ============================================================================ +def test_check_mcp_name_exists_true(monkeypatch, mock_session): session, query = mock_session - query.filter.side_effect = SQLAlchemyError("Database error") + mock_mcp = MockMcpRecord() + + mock_first = MagicMock() + mock_first.return_value = mock_mcp + mock_filter = MagicMock() + mock_filter.first = mock_first + 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should raise SQLAlchemyError - with pytest.raises(SQLAlchemyError): - get_mcp_authorization_token_by_name_and_url( - "test_mcp", "http://test.server.com", "tenant1") + result = check_mcp_name_exists("test_mcp", "tenant1") + assert result is True -def test_update_mcp_record_by_name_and_url_with_authorization_token(monkeypatch, mock_session): - """Test update of MCP record with authorization token""" +def test_check_mcp_name_exists_false(monkeypatch, mock_session): session, query = mock_session - mock_update = MagicMock() + + mock_first = MagicMock() + mock_first.return_value = None mock_filter = MagicMock() - mock_filter.update = mock_update + mock_filter.first = mock_first 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) - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url", - new_authorization_token="new_token_456" - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1", - status=True - ) + result = check_mcp_name_exists("nonexistent", "tenant1") + assert result is False - # Verify the update was called with authorization_token - mock_update.assert_called_once_with({ - "mcp_name": "new_name", - "mcp_server": "http://new.url", - "updated_by": "user1", - "status": True, - "authorization_token": "new_token_456" - }) +# ============================================================================ +# check_enabled_mcp_name_exists (NEW) +# ============================================================================ -def test_update_mcp_record_by_name_and_url_without_authorization_token(monkeypatch, mock_session): - """Test update of MCP record without authorization token (None will be included in update)""" +def test_check_enabled_mcp_name_exists_true(monkeypatch, mock_session): session, query = mock_session - mock_update = MagicMock() + mock_mcp = MockMcpRecord() + + mock_first = MagicMock() + mock_first.return_value = mock_mcp mock_filter = MagicMock() - mock_filter.update = mock_update + mock_filter.first = mock_first 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) - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - # new_authorization_token is None by default - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1", - status=True - ) + result = check_enabled_mcp_name_exists("test_mcp", "tenant1") + assert result is True - # Verify the update was called with authorization_token as None - mock_update.assert_called_once_with({ - "mcp_name": "new_name", - "mcp_server": "http://new.url", - "updated_by": "user1", - "status": True, - "authorization_token": None - }) +def test_check_enabled_mcp_name_exists_false(monkeypatch, mock_session): + session, query = mock_session -def test_update_mcp_record_by_name_and_url_with_none_authorization_token(monkeypatch, mock_session): - """Test update of MCP record with None authorization token (None will be included in update)""" + mock_first = MagicMock() + mock_first.return_value = None + mock_filter = MagicMock() + mock_filter.first = mock_first + 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) + + result = check_enabled_mcp_name_exists("nonexistent", "tenant1") + assert result is False + + +# ============================================================================ +# update_mcp_record_by_name_and_url +# ============================================================================ + +class MockMCPUpdateRequest: + def __init__(self, current_service_name, current_mcp_url, new_service_name, new_mcp_url, new_authorization_token=None): + self.current_service_name = current_service_name + self.current_mcp_url = current_mcp_url + self.new_service_name = new_service_name + self.new_mcp_url = new_mcp_url + self.new_authorization_token = new_authorization_token + + +def test_update_mcp_record_by_name_and_url_success(monkeypatch, mock_session): session, query = mock_session mock_update = MagicMock() mock_filter = MagicMock() @@ -776,35 +706,18 @@ def test_update_mcp_record_by_name_and_url_with_none_authorization_token(monkeyp 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) - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url", - new_authorization_token=None # Explicitly None - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - # Should not raise any exception - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1" - ) + update_data = MockMCPUpdateRequest("old", "http://old.url", "new", "http://new.url") + update_mcp_record_by_name_and_url(update_data=update_data, tenant_id="tenant1", user_id="user1", status=True) - # Verify the update was called with authorization_token as None mock_update.assert_called_once_with({ - "mcp_name": "new_name", - "mcp_server": "http://new.url", - "updated_by": "user1", - "authorization_token": None + "mcp_name": "new", "mcp_server": "http://new.url", + "updated_by": "user1", "status": True, "authorization_token": None, }) -def test_update_mcp_record_by_name_and_url_without_authorization_token_attribute(monkeypatch, mock_session): - """Test update of MCP record when object does not have new_authorization_token attribute""" +def test_update_mcp_record_by_name_and_url_without_status(monkeypatch, mock_session): session, query = mock_session mock_update = MagicMock() mock_filter = MagicMock() @@ -814,99 +727,80 @@ def test_update_mcp_record_by_name_and_url_without_authorization_token_attribute 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) - - # Create an object without new_authorization_token attribute - class UpdateDataWithoutToken: - def __init__(self): - self.current_service_name = "old_name" - self.current_mcp_url = "http://old.url" - self.new_service_name = "new_name" - self.new_mcp_url = "http://new.url" - # No new_authorization_token attribute - - update_data = UpdateDataWithoutToken() - - # Should not raise any exception - update_mcp_record_by_name_and_url( - update_data=update_data, - tenant_id="tenant1", - user_id="user1", - status=False - ) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + + update_data = MockMCPUpdateRequest("old", "http://old.url", "new", "http://new.url") + update_mcp_record_by_name_and_url(update_data=update_data, tenant_id="tenant1", user_id="user1") - # Verify the update was called without authorization_token mock_update.assert_called_once_with({ - "mcp_name": "new_name", - "mcp_server": "http://new.url", - "updated_by": "user1", - "status": False + "mcp_name": "new", "mcp_server": "http://new.url", + "updated_by": "user1", "authorization_token": None, }) -def test_get_mcp_record_by_id_and_tenant_success(monkeypatch, mock_session): - """Test successful retrieval of MCP record by ID and tenant""" +def test_update_mcp_record_by_name_and_url_with_token(monkeypatch, mock_session): session, query = mock_session - mock_mcp = MockMcpRecord() - mock_mcp.mcp_id = 123 - - mock_first = MagicMock() - mock_first.return_value = mock_mcp + mock_update = MagicMock() mock_filter = MagicMock() - mock_filter.first = mock_first + mock_filter.update = mock_update 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) - monkeypatch.setattr( - "backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) - result = get_mcp_record_by_id_and_tenant(123, "tenant1") + update_data = MockMCPUpdateRequest("old", "http://old.url", "new", "http://new.url", "new_token_456") + update_mcp_record_by_name_and_url(update_data=update_data, tenant_id="tenant1", user_id="user1", status=True) - assert result is not None - assert result["mcp_id"] == 123 - assert result["mcp_name"] == "test_mcp" - assert result["mcp_server"] == "http://test.server.com" + mock_update.assert_called_once_with({ + "mcp_name": "new", "mcp_server": "http://new.url", + "updated_by": "user1", "status": True, "authorization_token": "new_token_456", + }) -def test_get_mcp_record_by_id_and_tenant_not_found(monkeypatch, mock_session): - """Test retrieval of MCP record by ID and tenant when record does not exist""" +# ============================================================================ +# Integration: MCP record lifecycle +# ============================================================================ + +def test_mcp_record_lifecycle(monkeypatch, mock_session): session, query = mock_session + session.add = MagicMock() + + mock_mcp = MockMcpRecord() mock_first = MagicMock() - mock_first.return_value = None + mock_first.return_value = mock_mcp mock_filter = MagicMock() mock_filter.first = mock_first + mock_filter.update = MagicMock() 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) + monkeypatch.setattr("backend.database.remote_mcp_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.remote_mcp_db.filter_property", lambda data, model: data) + monkeypatch.setattr("backend.database.remote_mcp_db.McpRecord", MagicMock()) - result = get_mcp_record_by_id_and_tenant(999, "tenant1") + # 1. Create + mcp_data = {"mcp_name": "test_mcp", "mcp_server": "http://test.server.com", "status": True} + create_mcp_record(mcp_data, "tenant1", "user1") - assert result is None + # 2. Check exists + assert check_mcp_name_exists("test_mcp", "tenant1") is True + # 3. Get by ID + monkeypatch.setattr("backend.database.remote_mcp_db.as_dict", lambda obj: obj.__dict__) + record = get_mcp_record_by_id_and_tenant(1, "tenant1") + assert record is not None -def test_get_mcp_record_by_id_and_tenant_database_error(monkeypatch, mock_session): - """Test database error when retrieving MCP record by ID - exception should propagate""" - from sqlalchemy.exc import SQLAlchemyError + # 4. Update enabled + update_mcp_record_enabled_by_id(mcp_id=1, tenant_id="tenant1", user_id="user1", enabled=True) - session, query = mock_session - query.filter.side_effect = SQLAlchemyError("Database error") + # 5. Delete by ID + delete_mcp_record_by_id(mcp_id=1, tenant_id="tenant1", user_id="user1") - 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) - # Should raise SQLAlchemyError - with pytest.raises(SQLAlchemyError): - get_mcp_record_by_id_and_tenant(123, "tenant1") +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/test/backend/services/test_mcp_management_service.py b/test/backend/services/test_mcp_management_service.py new file mode 100644 index 000000000..f40486e3e --- /dev/null +++ b/test/backend/services/test_mcp_management_service.py @@ -0,0 +1,208 @@ +""" +Unit tests for backend/services/mcp_management_service.py +""" + +import unittest +from unittest.mock import patch, MagicMock, AsyncMock +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) +sys.modules['boto3'] = MagicMock() +patch('botocore.client.BaseClient._make_api_call', return_value={}).start() + +# Mock all database dependencies before imports +# Create proper mock package hierarchy +db_client_mock = MagicMock() +db_client_mock.get_db_session = MagicMock() +db_client_mock.as_dict = MagicMock() +db_client_mock.filter_property = MagicMock() +db_client_mock.MinioClient = MagicMock() + +# Mock database.client at all possible import paths +sys.modules['database.client'] = db_client_mock +sys.modules['backend.database.client'] = db_client_mock + +# Mock database submodules +sys.modules['database.community_mcp_db'] = MagicMock() +sys.modules['database.remote_mcp_db'] = MagicMock() +sys.modules['database.db_models'] = MagicMock() +sys.modules['database.user_tenant_db'] = MagicMock() + +# Also mock backend.database submodules +sys.modules['backend.database.community_mcp_db'] = sys.modules['database.community_mcp_db'] +sys.modules['backend.database.remote_mcp_db'] = sys.modules['database.remote_mcp_db'] +sys.modules['backend.database.db_models'] = sys.modules['database.db_models'] +sys.modules['backend.database.user_tenant_db'] = sys.modules['database.user_tenant_db'] + +storage_client_mock = MagicMock() +minio_mock = MagicMock() +minio_mock._ensure_bucket_exists = MagicMock() +minio_mock.client = MagicMock() +patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock).start() +patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() +patch('elasticsearch.Elasticsearch', return_value=MagicMock()).start() + +# Import real exception classes - use same path as source code +from consts.exceptions import McpNotFoundError, McpValidationError + +from backend.services.mcp_management_service import ( + list_community_mcp_services, + list_community_mcp_tag_stats, + publish_community_mcp_service, + update_community_mcp_service, + delete_community_mcp_service, + list_my_community_mcp_services, + list_registry_mcp_services, +) + + +class TestListCommunityMcpServices(unittest.IsolatedAsyncioTestCase): + + @patch('backend.services.mcp_management_service.get_mcp_community_records') + async def test_list_empty(self, mock_get): + """Test listing community services returns empty result.""" + mock_get.return_value = {"count": 0, "nextCursor": None, "items": []} + result = await list_community_mcp_services(limit=30) + self.assertEqual(result["count"], 0) + + @patch('backend.services.mcp_management_service.get_mcp_community_records') + async def test_list_with_items(self, mock_get): + """Test listing community services with items returns mapped result.""" + mock_get.return_value = { + "count": 2, "nextCursor": None, + "items": [ + {"community_id": 1, "mcp_name": "svc1", "version": "1.0", + "description": "d", "transport_type": "url", + "mcp_server": "http://srv", "config_json": None, + "registry_json": None, "tags": ["a"], + "create_time": "t", "update_time": "t"}, + ], + } + result = await list_community_mcp_services() + self.assertEqual(result["count"], 1) + self.assertEqual(result["items"][0]["name"], "svc1") + + +class TestListCommunityMcpTagStats(unittest.TestCase): + + @patch('backend.services.mcp_management_service.get_mcp_community_tag_stats') + def test_list_tag_stats(self, mock_get): + """Test community tag statistics retrieval.""" + mock_get.return_value = [{"tag": "python", "count": 5}] + result = list_community_mcp_tag_stats() + self.assertEqual(len(result), 1) + + +class TestPublishCommunityMcpService(unittest.IsolatedAsyncioTestCase): + + @patch('backend.services.mcp_management_service.create_mcp_community_record') + @patch('backend.services.mcp_management_service.get_mcp_record_by_id_and_tenant') + async def test_publish_success(self, mock_get, mock_create): + """Test successful publishing of a local MCP service to community.""" + mock_get.return_value = { + "mcp_id": 1, "mcp_name": "svc", "mcp_server": "http://srv", + "description": "desc", "version": "1.0", "tags": ["a"], + "registry_json": None, "config_json": None, + } + mock_create.return_value = 42 + result = await publish_community_mcp_service(tenant_id="tid", user_id="uid", mcp_id=1) + self.assertEqual(result, 42) + + @patch('backend.services.mcp_management_service.get_mcp_record_by_id_and_tenant') + async def test_publish_not_found(self, mock_get): + """Test publishing fails when source MCP record is not found.""" + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await publish_community_mcp_service(tenant_id="tid", user_id="uid", mcp_id=999) + + +class TestUpdateCommunityMcpService(unittest.IsolatedAsyncioTestCase): + + @patch('backend.services.mcp_management_service.update_mcp_community_record_by_id') + @patch('backend.services.mcp_management_service.get_mcp_community_record_by_id_and_tenant') + async def test_update_success(self, mock_get, mock_update): + """Test successful community MCP service update.""" + mock_get.return_value = {"community_id": 1, "config_json": None, "registry_json": None} + await update_community_mcp_service( + tenant_id="tid", user_id="uid", community_id=1, + name="new", description="d", tags=["a"], version="2.0", registry_json=None, + ) + mock_update.assert_called_once() + + @patch('backend.services.mcp_management_service.get_mcp_community_record_by_id_and_tenant') + async def test_update_not_found(self, mock_get): + """Test update fails when community record is not found.""" + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await update_community_mcp_service( + tenant_id="tid", user_id="uid", community_id=999, + name="x", description="d", tags=[], version="1.0", registry_json=None, + ) + + +class TestDeleteCommunityMcpService(unittest.IsolatedAsyncioTestCase): + + @patch('backend.services.mcp_management_service.delete_mcp_community_record_by_id') + @patch('backend.services.mcp_management_service.get_mcp_community_record_by_id_and_tenant') + async def test_delete_success(self, mock_get, mock_delete): + """Test successful deletion of a community MCP service.""" + mock_get.return_value = {"community_id": 1} + await delete_community_mcp_service(tenant_id="tid", user_id="uid", community_id=1) + mock_delete.assert_called_once() + + @patch('backend.services.mcp_management_service.get_mcp_community_record_by_id_and_tenant') + async def test_delete_not_found(self, mock_get): + """Test deletion fails when community record is not found.""" + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await delete_community_mcp_service(tenant_id="tid", user_id="uid", community_id=999) + + +class TestListMyCommunityMcpServices(unittest.IsolatedAsyncioTestCase): + + @patch('backend.services.mcp_management_service.list_mcp_community_records_by_tenant') + async def test_list_empty(self, mock_list): + """Test listing current user's published services returns empty.""" + mock_list.return_value = [] + result = await list_my_community_mcp_services(tenant_id="tid") + self.assertEqual(result["count"], 0) + + +class TestListRegistryMcpServices(unittest.IsolatedAsyncioTestCase): + + @patch('backend.services.mcp_management_service.aiohttp.ClientSession') + async def test_list_success(self, mock_session_cls): + """Test successful registry service listing via HTTP.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"servers": [{"name": "s1"}], "metadata": {}}) + mock_response.__aenter__.return_value = mock_response + + mock_session = MagicMock() + mock_session.__aenter__.return_value = mock_session + mock_session.get = MagicMock(return_value=mock_response) + mock_session_cls.return_value = mock_session + + result = await list_registry_mcp_services() + self.assertEqual(len(result["servers"]), 1) + + @patch('backend.services.mcp_management_service.aiohttp.ClientSession') + async def test_list_error(self, mock_session_cls): + """Test registry listing raises RuntimeError on HTTP error status.""" + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.__aenter__.return_value = mock_response + + mock_session = MagicMock() + mock_session.__aenter__.return_value = mock_session + mock_session.get = MagicMock(return_value=mock_response) + mock_session_cls.return_value = mock_session + + with self.assertRaises(RuntimeError): + await list_registry_mcp_services() + + +if __name__ == '__main__': + unittest.main() diff --git a/test/backend/services/test_remote_mcp_service.py b/test/backend/services/test_remote_mcp_service.py index 69fb64c58..9d8d8ef58 100644 --- a/test/backend/services/test_remote_mcp_service.py +++ b/test/backend/services/test_remote_mcp_service.py @@ -1,15 +1,24 @@ +""" +Unit tests for backend/services/remote_mcp_service.py + +Tests the MCP service business logic layer with comprehensive coverage. +Covers: health checks, CRUD operations, container management, port management, +enable/disable lifecycle, and tool listing. +""" + import unittest from unittest.mock import patch, MagicMock, AsyncMock import sys import os + # Add path for correct imports sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) sys.modules['boto3'] = MagicMock() # Apply critical patches before importing any modules # This prevents real AWS/MinIO/Elasticsearch calls during import -patch('botocore.client.BaseClient._make_api_call', return_value={}).start() # Patch storage factory and MinIO config validation to avoid errors during initialization # These patches must be started before any imports that use MinioClient +# Patch storage factory and MinIO config validation storage_client_mock = MagicMock() minio_mock = MagicMock() minio_mock._ensure_bucket_exists = MagicMock() @@ -24,18 +33,34 @@ patch('elasticsearch.Elasticsearch', return_value=MagicMock()).start() # Import exception classes -from backend.consts.exceptions import MCPConnectionError, MCPNameIllegal +from backend.consts.exceptions import ( + MCPConnectionError, MCPNameIllegal, MCPContainerError, + McpNotFoundError, McpValidationError, McpNameConflictError, + McpPortConflictError, +) +from backend.consts.model import MCPConfigRequest # Functions to test from backend.services.remote_mcp_service import ( mcp_server_health, + _is_container_record, + check_container_port_conflict_records, + check_runtime_host_port_available, + check_container_port_conflict, + suggest_container_port, add_remote_mcp_server_list, - delete_remote_mcp_server_list, + add_mcp_service, + add_container_mcp_service, update_remote_mcp_server_list, - get_remote_mcp_server_list, - check_mcp_health_and_update_db, + update_mcp_service, + update_mcp_service_enabled, + delete_mcp_service, delete_mcp_by_container_id, + get_remote_mcp_server_list, get_mcp_record_by_id, + check_mcp_health_and_update_db, + check_mcp_service_health, + list_mcp_service_tools_by_id, upload_and_start_mcp_image, attach_mcp_container_permissions, ) @@ -43,1859 +68,1099 @@ import backend.services.remote_mcp_service as remote_service remote_service.MCPConnectionError = MCPConnectionError remote_service.MCPNameIllegal = MCPNameIllegal +remote_service.McpNotFoundError = McpNotFoundError +remote_service.McpValidationError = McpValidationError +remote_service.McpNameConflictError = McpNameConflictError +remote_service.McpPortConflictError = McpPortConflictError + + +# ============================================================================ +# Helper: _is_container_record +# ============================================================================ + +class TestIsContainerRecord(unittest.TestCase): + """Test _is_container_record helper""" + + def test_container_record_with_both(self): + self.assertTrue(_is_container_record({"container_id": "abc", "config_json": {}})) + + def test_container_record_with_container_id_only(self): + self.assertTrue(_is_container_record({"container_id": "abc"})) + + def test_container_record_with_config_json_only(self): + self.assertTrue(_is_container_record({"config_json": {"mcpServers": {}}})) + + def test_non_container_record(self): + self.assertFalse(_is_container_record({"mcp_server": "http://url"})) + + def test_none_record(self): + self.assertFalse(_is_container_record(None)) + + def test_empty_record(self): + self.assertFalse(_is_container_record({})) + + +# ============================================================================ +# Port Management Functions +# ============================================================================ + +class TestCheckContainerPortConflictRecords(unittest.TestCase): + """Test check_container_port_conflict_records""" + + @patch('database.remote_mcp_db.get_mcp_records_by_container_port') + def test_port_available_no_records(self, mock_get): + mock_get.return_value = [] + result = check_container_port_conflict_records(8080) + self.assertTrue(result) + mock_get.assert_called_once_with(container_port=8080) + + @patch('database.remote_mcp_db.get_mcp_records_by_container_port') + def test_port_in_use(self, mock_get): + mock_get.return_value = [{"mcp_id": 1}] + result = check_container_port_conflict_records(8080) + self.assertFalse(result) + + +class TestCheckRuntimeHostPortAvailable(unittest.TestCase): + """Test check_runtime_host_port_available""" + + @patch('backend.services.remote_mcp_service.socket.has_ipv6', False) + @patch('socket.socket') + @patch('socket.getaddrinfo') + def test_port_available(self, mock_getaddrinfo, mock_socket_cls): + mock_sock = MagicMock() + mock_sock.connect_ex.return_value = 1 # Non-zero = not in use + mock_socket_cls.return_value = mock_sock + + result = check_runtime_host_port_available(8080) + self.assertTrue(result) + + @patch('backend.services.remote_mcp_service.socket.has_ipv6', False) + @patch('socket.socket') + @patch('socket.getaddrinfo', side_effect=OSError("no route")) + def test_port_available_no_docker_host(self, mock_getaddrinfo, mock_socket_cls): + mock_sock = MagicMock() + mock_sock.connect_ex.return_value = 1 + mock_socket_cls.return_value = mock_sock + + result = check_runtime_host_port_available(8080) + self.assertTrue(result) + + def test_port_in_use_connect(self): + # Simulate port in use: mock the entire socket module inside remote_mcp_service + mock_socket_module = MagicMock() + mock_socket_module.has_ipv6 = False + mock_socket_module.AF_INET = 2 + mock_socket_module.SOCK_STREAM = 1 + mock_socket_module.SOL_SOCKET = 1 + mock_socket_module.SO_REUSEADDR = 2 + + mock_sock = MagicMock() + mock_sock.connect_ex.return_value = 0 # Zero = port in use + mock_sock.__enter__.return_value = mock_sock + mock_sock.__exit__.return_value = None + mock_socket_module.socket.return_value = mock_sock + mock_socket_module.getaddrinfo.side_effect = OSError("no route") + + with patch.object(remote_service, 'socket', mock_socket_module): + result = check_runtime_host_port_available(8080) + self.assertFalse(result) + + +class TestCheckContainerPortConflict(unittest.TestCase): + """Test check_container_port_conflict""" + + @patch('backend.services.remote_mcp_service.check_container_port_conflict_records', return_value=True) + @patch('backend.services.remote_mcp_service.check_runtime_host_port_available', return_value=True) + def test_port_available(self, mock_runtime, mock_records): + result = check_container_port_conflict(port=8080) + self.assertTrue(result) + + @patch('backend.services.remote_mcp_service.check_container_port_conflict_records', return_value=False) + @patch('backend.services.remote_mcp_service.check_runtime_host_port_available', return_value=True) + def test_record_conflict(self, mock_runtime, mock_records): + result = check_container_port_conflict(port=8080) + self.assertFalse(result) + + @patch('backend.services.remote_mcp_service.check_container_port_conflict_records', return_value=True) + @patch('backend.services.remote_mcp_service.check_runtime_host_port_available', return_value=False) + def test_runtime_conflict(self, mock_runtime, mock_records): + result = check_container_port_conflict(port=8080) + self.assertFalse(result) + +class TestSuggestContainerPort(unittest.TestCase): + """Test suggest_container_port""" + + @patch('backend.services.remote_mcp_service.random.randint', return_value=5000) + @patch('backend.services.remote_mcp_service.check_container_port_conflict', return_value=True) + def test_success_first_try(self, mock_check, mock_randint): + result = suggest_container_port() + self.assertEqual(result, 5000) + + @patch('backend.services.remote_mcp_service.random.randint', side_effect=[5000, 5001, 5002]) + @patch('backend.services.remote_mcp_service.check_container_port_conflict', side_effect=[False, False, True]) + def test_success_after_retries(self, mock_check, mock_randint): + result = suggest_container_port() + self.assertEqual(result, 5002) + self.assertEqual(mock_check.call_count, 3) + + @patch('backend.services.remote_mcp_service.random.randint', return_value=5000) + @patch('backend.services.remote_mcp_service.check_container_port_conflict', return_value=False) + def test_no_available_port(self, mock_check, mock_randint): + with self.assertRaises(McpPortConflictError): + suggest_container_port() + + +# ============================================================================ +# mcp_server_health +# ============================================================================ class TestMcpServerHealth(unittest.IsolatedAsyncioTestCase): - """Test mcp_server_health""" @patch('backend.services.remote_mcp_service.Client') async def test_health_success(self, mock_client_cls): - """Test successful health check""" mock_client = AsyncMock() mock_client.__aenter__.return_value = mock_client - mock_client.is_connected = MagicMock(return_value=True) # Sync mock + mock_client.is_connected = MagicMock(return_value=True) mock_client_cls.return_value = mock_client - result = await mcp_server_health('http://test-server') self.assertTrue(result) @patch('backend.services.remote_mcp_service.Client') async def test_health_fail_connection(self, mock_client_cls): - """Test connection failure""" mock_client = AsyncMock() mock_client.__aenter__.return_value = mock_client - mock_client.is_connected = MagicMock(return_value=False) # Sync mock + mock_client.is_connected = MagicMock(return_value=False) mock_client_cls.return_value = mock_client - result = await mcp_server_health('http://test-server') self.assertFalse(result) @patch('backend.services.remote_mcp_service.Client') async def test_health_exception(self, mock_client_cls): - """Test exception case""" mock_client_cls.side_effect = Exception('Connection failed') - - with self.assertRaises(MCPConnectionError) as context: + with self.assertRaises(MCPConnectionError): await mcp_server_health('http://test-server') - self.assertEqual(str(context.exception), "MCP connection failed") - - @patch('backend.services.remote_mcp_service.Client') - async def test_health_with_https_url(self, mock_client_cls): - """Test health check with HTTPS URL""" - mock_client = AsyncMock() - mock_client.__aenter__.return_value = mock_client - mock_client.is_connected = MagicMock(return_value=True) # Sync mock - mock_client_cls.return_value = mock_client - - result = await mcp_server_health('https://secure-server.com') - self.assertTrue(result) - - @patch('backend.services.remote_mcp_service.Client') - async def test_health_with_port(self, mock_client_cls): - """Test health check with URL containing port""" - mock_client = AsyncMock() - mock_client.__aenter__.return_value = mock_client - mock_client.is_connected = MagicMock(return_value=True) # Sync mock - mock_client_cls.return_value = mock_client - - result = await mcp_server_health('http://test-server:8080') - self.assertTrue(result) @patch('backend.services.remote_mcp_service.Client') async def test_health_with_authorization_token(self, mock_client_cls): - """Test health check with authorization token""" from fastmcp.client.transports import StreamableHttpTransport - mock_client = AsyncMock() mock_client.__aenter__.return_value = mock_client mock_client.is_connected = MagicMock(return_value=True) mock_client_cls.return_value = mock_client - result = await mcp_server_health('http://test-server', authorization_token='Bearer token123') self.assertTrue(result) - - # Verify Client was called with transport containing headers - mock_client_cls.assert_called_once() call_args = mock_client_cls.call_args transport = call_args[1]['transport'] self.assertIsInstance(transport, StreamableHttpTransport) self.assertEqual(transport.headers, {"Authorization": "Bearer token123"}) - @patch('backend.services.remote_mcp_service.Client') - async def test_health_without_authorization_token(self, mock_client_cls): - """Test health check without authorization token""" - from fastmcp.client.transports import StreamableHttpTransport - - mock_client = AsyncMock() - mock_client.__aenter__.return_value = mock_client - mock_client.is_connected = MagicMock(return_value=True) - mock_client_cls.return_value = mock_client - - result = await mcp_server_health('http://test-server', authorization_token=None) - self.assertTrue(result) - - # Verify Client was called with transport containing empty headers - mock_client_cls.assert_called_once() - call_args = mock_client_cls.call_args - transport = call_args[1]['transport'] - self.assertIsInstance(transport, StreamableHttpTransport) - self.assertEqual(transport.headers, {}) - @patch('backend.services.remote_mcp_service.Client') async def test_health_with_sse_url(self, mock_client_cls): - """Test health check with /sse URL ending - should use SSETransport""" from fastmcp.client.transports import SSETransport - mock_client = AsyncMock() mock_client.__aenter__.return_value = mock_client mock_client.is_connected = MagicMock(return_value=True) mock_client_cls.return_value = mock_client - result = await mcp_server_health('http://test-server/sse', authorization_token='token123') self.assertTrue(result) - - # Verify SSETransport was used - mock_client_cls.assert_called_once() call_args = mock_client_cls.call_args transport = call_args[1]['transport'] self.assertIsInstance(transport, SSETransport) - self.assertEqual(transport.url, 'http://test-server/sse') - self.assertEqual(transport.headers, {"Authorization": "token123"}) @patch('backend.services.remote_mcp_service.Client') async def test_health_with_mcp_url(self, mock_client_cls): - """Test health check with /mcp URL ending - should use StreamableHttpTransport""" - from fastmcp.client.transports import StreamableHttpTransport - - mock_client = AsyncMock() - mock_client.__aenter__.return_value = mock_client - mock_client.is_connected = MagicMock(return_value=True) - mock_client_cls.return_value = mock_client - - result = await mcp_server_health('http://test-server/mcp', authorization_token='token123') - self.assertTrue(result) - - # Verify StreamableHttpTransport was used - mock_client_cls.assert_called_once() - call_args = mock_client_cls.call_args - transport = call_args[1]['transport'] - self.assertIsInstance(transport, StreamableHttpTransport) - self.assertEqual(transport.url, 'http://test-server/mcp') - self.assertEqual(transport.headers, {"Authorization": "token123"}) - - @patch('backend.services.remote_mcp_service.Client') - async def test_health_with_unknown_url_format(self, mock_client_cls): - """Test health check with unknown URL format - should default to StreamableHttpTransport""" from fastmcp.client.transports import StreamableHttpTransport - mock_client = AsyncMock() mock_client.__aenter__.return_value = mock_client mock_client.is_connected = MagicMock(return_value=True) mock_client_cls.return_value = mock_client - - result = await mcp_server_health('http://test-server/api', authorization_token='token123') + result = await mcp_server_health('http://test-server/mcp') self.assertTrue(result) - # Verify StreamableHttpTransport was used as default - mock_client_cls.assert_called_once() - call_args = mock_client_cls.call_args - transport = call_args[1]['transport'] - self.assertIsInstance(transport, StreamableHttpTransport) - self.assertEqual(transport.url, 'http://test-server/api') - self.assertEqual(transport.headers, {"Authorization": "token123"}) - @patch('backend.services.remote_mcp_service.Client') async def test_health_with_url_whitespace(self, mock_client_cls): - """Test health check with URL containing whitespace - should be stripped""" - from fastmcp.client.transports import StreamableHttpTransport - mock_client = AsyncMock() mock_client.__aenter__.return_value = mock_client mock_client.is_connected = MagicMock(return_value=True) mock_client_cls.return_value = mock_client - - result = await mcp_server_health(' http://test-server/mcp ', authorization_token='token123') + result = await mcp_server_health(' http://test-server/mcp ') self.assertTrue(result) - # Verify URL was stripped and StreamableHttpTransport was used - mock_client_cls.assert_called_once() - call_args = mock_client_cls.call_args - transport = call_args[1]['transport'] - self.assertIsInstance(transport, StreamableHttpTransport) - # URL should be stripped before being passed to transport - self.assertEqual(transport.url, 'http://test-server/mcp') +# ============================================================================ +# add_remote_mcp_server_list +# ============================================================================ class TestAddRemoteMcpServerList(unittest.IsolatedAsyncioTestCase): - """Test add_remote_mcp_server_list""" @patch('backend.services.remote_mcp_service.create_mcp_record') @patch('backend.services.remote_mcp_service.mcp_server_health') @patch('backend.services.remote_mcp_service.check_mcp_name_exists') async def test_add_success(self, mock_check_name, mock_health, mock_create): - """Test successful MCP server addition""" - mock_check_name.return_value = False # Name doesn't exist - mock_health.return_value = True # Health check passes - - # Should execute successfully without exception + mock_check_name.return_value = False + mock_health.return_value = True await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') - - # Verify calls - mock_check_name.assert_called_once_with( - mcp_name='name', tenant_id='tid') + mock_check_name.assert_called_once_with(mcp_name='name', tenant_id='tid') mock_health.assert_called_once_with(remote_mcp_server='http://srv', authorization_token=None) mock_create.assert_called_once() - @patch('backend.services.remote_mcp_service.create_mcp_record') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_add_success_with_authorization_token(self, mock_check_name, mock_health, mock_create): - """Test successful MCP server addition with authorization token""" - mock_check_name.return_value = False # Name doesn't exist - mock_health.return_value = True # Health check passes - - # Should execute successfully without exception - await add_remote_mcp_server_list( - 'tid', 'uid', 'http://srv', 'name', - container_id='container-123', - authorization_token='Bearer token123' - ) - - # Verify calls - mock_check_name.assert_called_once_with( - mcp_name='name', tenant_id='tid') - mock_health.assert_called_once_with( - remote_mcp_server='http://srv', - authorization_token='Bearer token123' - ) - mock_create.assert_called_once() - # Verify authorization_token was passed to create_mcp_record - create_call_kwargs = mock_create.call_args[1] - self.assertEqual(create_call_kwargs['mcp_data']['authorization_token'], 'Bearer token123') - self.assertEqual(create_call_kwargs['mcp_data']['container_id'], 'container-123') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') async def test_add_name_exists(self, mock_check_name): - """Test MCP name already exists""" mock_check_name.return_value = True - - with self.assertRaises(MCPNameIllegal) as context: + with self.assertRaises(MCPNameIllegal): await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') - self.assertEqual(str(context.exception), "MCP name already exists") @patch('backend.services.remote_mcp_service.mcp_server_health') @patch('backend.services.remote_mcp_service.check_mcp_name_exists') async def test_add_health_fail(self, mock_check_name, mock_health): - """Test health check failure""" mock_check_name.return_value = False - mock_health.return_value = False # Health check returns False - + mock_health.return_value = False with self.assertRaises(MCPConnectionError): await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') + @patch('backend.services.remote_mcp_service.create_mcp_record') @patch('backend.services.remote_mcp_service.mcp_server_health') @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_add_health_fail_with_exception(self, mock_check_name, mock_health): - """Test health check failure with exception""" + async def test_add_success_with_authorization_token(self, mock_check_name, mock_health, mock_create): mock_check_name.return_value = False - mock_health.side_effect = MCPConnectionError("MCP connection failed") + mock_health.return_value = True + await add_remote_mcp_server_list( + 'tid', 'uid', 'http://srv', 'name', + container_id='container-123', authorization_token='Bearer token123', + ) + create_call_kwargs = mock_create.call_args[1] + self.assertEqual(create_call_kwargs['mcp_data']['authorization_token'], 'Bearer token123') + self.assertEqual(create_call_kwargs['mcp_data']['container_id'], 'container-123') - with self.assertRaises(MCPConnectionError): - await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') + +# ============================================================================ +# add_mcp_service (NEW) +# ============================================================================ + +class TestAddMcpService(unittest.IsolatedAsyncioTestCase): + """Test add_mcp_service - the unified MCP service creation function""" @patch('backend.services.remote_mcp_service.create_mcp_record') @patch('backend.services.remote_mcp_service.mcp_server_health') @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_add_db_fail(self, mock_check_name, mock_health, mock_create): - """Test database operation failure - exception should propagate from database layer""" - from sqlalchemy.exc import SQLAlchemyError + async def test_add_url_based_disabled(self, mock_check_name, mock_health, mock_create): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='http://srv/mcp', + tags=['tag1'], authorization_token=None, container_config=None, + registry_json=None, enabled=False, + ) + mock_create.assert_called_once() + call_data = mock_create.call_args[1]['mcp_data'] + self.assertEqual(call_data['mcp_name'], 'test-svc') + self.assertEqual(call_data['enabled'], False) + self.assertIsNone(call_data['status']) + @patch('backend.services.remote_mcp_service.create_mcp_record') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_add_url_based_enabled(self, mock_check_name, mock_health, mock_create): mock_check_name.return_value = False mock_health.return_value = True - mock_create.side_effect = SQLAlchemyError("Database error") - - with self.assertRaises(SQLAlchemyError): - await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='http://srv/mcp', + tags=None, authorization_token='tok', container_config=None, + registry_json=None, enabled=True, + ) + call_data = mock_create.call_args[1]['mcp_data'] + self.assertTrue(call_data['status']) - @patch('backend.services.remote_mcp_service.create_mcp_record') @patch('backend.services.remote_mcp_service.mcp_server_health') @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_add_with_special_characters(self, mock_check_name, mock_health, mock_create): - """Test server name with special characters""" + async def test_add_enabled_name_conflict(self, mock_check_name, mock_health): + mock_check_name.return_value = True + with self.assertRaises(MCPNameIllegal): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='http://srv/mcp', + tags=None, authorization_token=None, container_config=None, + registry_json=None, enabled=True, + ) + + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_add_enabled_health_fail(self, mock_check_name): mock_check_name.return_value = False - mock_health.return_value = True + with patch('backend.services.remote_mcp_service.mcp_server_health', return_value=False): + with self.assertRaises(MCPConnectionError): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='http://srv/mcp', + tags=None, authorization_token=None, container_config=None, + registry_json=None, enabled=True, + ) + + @patch('backend.services.remote_mcp_service.create_mcp_record') + async def test_add_container_based(self, mock_create): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='http://srv/mcp', + tags=None, authorization_token=None, + container_config={"mcpServers": {"s": {"command": "echo"}}}, + registry_json=None, enabled=False, container_id='cid', container_port=8080, + ) + call_data = mock_create.call_args[1]['mcp_data'] + self.assertEqual(call_data['container_id'], 'cid') + self.assertEqual(call_data['container_port'], 8080) + self.assertIsNotNone(call_data['config_json']) + + @patch('backend.services.remote_mcp_service.create_mcp_record') + async def test_add_container_null_config(self, mock_create): + """container_config=None + container_id=None should result in config_json=None""" + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='http://srv/mcp', + tags=None, authorization_token=None, + container_config=None, container_id=None, container_port=None, + registry_json=None, enabled=False, + ) + call_data = mock_create.call_args[1]['mcp_data'] + self.assertIsNone(call_data['config_json']) + self.assertIsNone(call_data['container_id']) - await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'test-server_123') - # Verify successful execution without exception +# ============================================================================ +# add_container_mcp_service (NEW) +# ============================================================================ -class TestDeleteRemoteMcpServerList(unittest.IsolatedAsyncioTestCase): - """Test delete_remote_mcp_server_list""" +class TestAddContainerMcpService(unittest.IsolatedAsyncioTestCase): + """Test add_container_mcp_service""" - @patch('backend.services.remote_mcp_service.delete_mcp_record_by_name_and_url') - async def test_delete_success(self, mock_delete): - """Test successful deletion""" + def _make_mcp_config(self, command="echo", args=None): + return MCPConfigRequest(mcpServers={ + "test-svc": { + "command": command, + "args": args or [], + "env": {}, + } + }) - # Should execute successfully without exception - await delete_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') + @patch('backend.services.remote_mcp_service.add_mcp_service') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.check_container_port_conflict') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_success(self, mock_check_name, mock_port_check, mock_mgr_cls, mock_add): + mock_check_name.return_value = False + mock_port_check.return_value = True + mock_mgr = MagicMock() + mock_mgr.start_mcp_container = AsyncMock(return_value={ + "container_id": "cid", "mcp_url": "http://localhost:8080/mcp", + "host_port": 8080, "container_name": "test-svc-xyz", + }) + mock_mgr_cls.return_value = mock_mgr - mock_delete.assert_called_once_with( - mcp_name='name', - mcp_server='http://srv', - tenant_id='tid', - user_id='uid' + result = await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token='tok', registry_json=None, + port=8080, mcp_config=self._make_mcp_config(), ) + self.assertEqual(result['container_id'], 'cid') + self.assertEqual(result['mcp_url'], 'http://localhost:8080/mcp') + mock_add.assert_called_once() - @patch('backend.services.remote_mcp_service.delete_mcp_record_by_name_and_url') - async def test_delete_fail(self, mock_delete): - """Test deletion failure - exception should propagate from database layer""" - from sqlalchemy.exc import SQLAlchemyError + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_name_conflict(self, mock_check_name): + mock_check_name.return_value = True + with self.assertRaises(McpNameConflictError): + await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token=None, registry_json=None, + port=8080, mcp_config=self._make_mcp_config(), + ) - mock_delete.side_effect = SQLAlchemyError("Database error") + @patch('backend.services.remote_mcp_service.check_container_port_conflict') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_port_conflict(self, mock_check_name, mock_port_check): + mock_check_name.return_value = False + mock_port_check.return_value = False + with self.assertRaises(McpPortConflictError): + await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token=None, registry_json=None, + port=8080, mcp_config=self._make_mcp_config(), + ) - with self.assertRaises(SQLAlchemyError): - await delete_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') + @patch('backend.services.remote_mcp_service.check_container_port_conflict') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_empty_mcp_servers(self, mock_check_name, mock_port_check): + mock_check_name.return_value = False + mock_port_check.return_value = True + cfg = MCPConfigRequest(mcpServers={}) + with self.assertRaises(McpValidationError): + await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token=None, registry_json=None, + port=8080, mcp_config=cfg, + ) - @patch('backend.services.remote_mcp_service.delete_mcp_record_by_name_and_url') - async def test_delete_nonexistent_server(self, mock_delete): - """Test deletion of non-existent server - exception should propagate from database layer""" - from sqlalchemy.exc import SQLAlchemyError + @patch('backend.services.remote_mcp_service.check_container_port_conflict') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_docker_command_rejected(self, mock_check_name, mock_port_check): + mock_check_name.return_value = False + mock_port_check.return_value = True + cfg = self._make_mcp_config(command="docker") + with self.assertRaises(McpValidationError): + await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token=None, registry_json=None, + port=8080, mcp_config=cfg, + ) - mock_delete.side_effect = SQLAlchemyError("Record not found") + @patch('backend.services.remote_mcp_service.check_container_port_conflict') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_empty_command(self, mock_check_name, mock_port_check): + mock_check_name.return_value = False + mock_port_check.return_value = True + cfg = self._make_mcp_config(command="") + with self.assertRaises(McpValidationError): + await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token=None, registry_json=None, + port=8080, mcp_config=cfg, + ) - with self.assertRaises(SQLAlchemyError): - await delete_remote_mcp_server_list('tid', 'uid', 'http://nonexistent', 'nonexistent') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.check_container_port_conflict') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_container_start_failure(self, mock_check_name, mock_port_check, mock_mgr_cls): + mock_check_name.return_value = False + mock_port_check.return_value = True + mock_mgr = MagicMock() + mock_mgr.start_mcp_container = AsyncMock(side_effect=MCPContainerError("start failed")) + mock_mgr_cls.return_value = mock_mgr + + with self.assertRaises(MCPContainerError): + await add_container_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', tags=[], + authorization_token=None, registry_json=None, + port=8080, mcp_config=self._make_mcp_config(), + ) - @patch('backend.services.remote_mcp_service.delete_mcp_record_by_name_and_url') - async def test_delete_with_special_characters(self, mock_delete): - """Test deletion of server with special characters""" - await delete_remote_mcp_server_list('tid', 'uid', 'http://srv', 'test-server_123') - # Verify successful execution +# ============================================================================ +# update_remote_mcp_server_list +# ============================================================================ +class MockMCPUpdateRequest: + def __init__(self, current_service_name, current_mcp_url, new_service_name, new_mcp_url, new_authorization_token=None): + self.current_service_name = current_service_name + self.current_mcp_url = current_mcp_url + self.new_service_name = new_service_name + self.new_mcp_url = new_mcp_url + self.new_authorization_token = new_authorization_token -class TestGetRemoteMcpServerList(unittest.IsolatedAsyncioTestCase): - """Test get_remote_mcp_server_list""" - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list(self, mock_get): - """Test getting server list""" - mock_get.return_value = [ - {"mcp_name": "n1", "mcp_server": "u1", "status": True}, - {"mcp_name": "n2", "mcp_server": "u2", "status": False} - ] +class TestUpdateRemoteMcpServerList(unittest.IsolatedAsyncioTestCase): - result = await get_remote_mcp_server_list('tid') + @patch('backend.services.remote_mcp_service.update_mcp_record_by_name_and_url') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_update_success(self, mock_check_name, mock_health, mock_update_record): + mock_check_name.side_effect = [True, False] + mock_health.return_value = True + update_data = MockMCPUpdateRequest("old", "http://old.url", "new", "http://new.url") + await update_remote_mcp_server_list(update_data, 'tid', 'uid') + mock_update_record.assert_called_once() - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["remote_mcp_server_name"], "n1") - self.assertEqual(result[0]["remote_mcp_server"], "u1") - self.assertTrue(result[0]["status"]) - self.assertEqual(result[0]["permission"], "READ_ONLY") - self.assertEqual(result[1]["remote_mcp_server_name"], "n2") - self.assertFalse(result[1]["status"]) - self.assertEqual(result[1]["permission"], "READ_ONLY") + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_update_current_name_not_exist(self, mock_check_name): + mock_check_name.return_value = False + update_data = MockMCPUpdateRequest("noexist", "http://old.url", "new", "http://new.url") + with self.assertRaises(MCPNameIllegal): + await update_remote_mcp_server_list(update_data, 'tid', 'uid') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_empty(self, mock_get): - """Test getting empty list""" - mock_get.return_value = [] + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_update_new_name_exists(self, mock_check_name, mock_health): + mock_check_name.side_effect = [True, True] + update_data = MockMCPUpdateRequest("old", "http://old.url", "existing", "http://new.url") + with self.assertRaises(MCPNameIllegal): + await update_remote_mcp_server_list(update_data, 'tid', 'uid') - result = await get_remote_mcp_server_list('tid') - self.assertEqual(result, []) + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_update_health_fail(self, mock_check_name, mock_health): + mock_check_name.side_effect = [True, False] + mock_health.return_value = False + update_data = MockMCPUpdateRequest("old", "http://old.url", "new", "http://unreachable.url") + with self.assertRaises(MCPConnectionError): + await update_remote_mcp_server_list(update_data, 'tid', 'uid') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_single_record(self, mock_get): - """Test getting single record""" - mock_get.return_value = [ - {"mcp_name": "single_server", - "mcp_server": "http://single.com", "status": True} - ] - result = await get_remote_mcp_server_list('tid') - self.assertEqual(len(result), 1) - self.assertEqual(result[0]["remote_mcp_server_name"], "single_server") - self.assertEqual(result[0]["remote_mcp_server"], "http://single.com") - self.assertTrue(result[0]["status"]) - self.assertEqual(result[0]["permission"], "READ_ONLY") +# ============================================================================ +# update_mcp_service (NEW) +# ============================================================================ - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_large_list(self, mock_get): - """Test getting large list of records""" - large_list = [] - for i in range(100): - large_list.append({ - "mcp_name": f"server_{i}", - "mcp_server": f"http://server_{i}.com", - "status": i % 2 == 0 # Alternating status - }) - mock_get.return_value = large_list +class TestUpdateMcpService(unittest.TestCase): + """Test update_mcp_service""" - result = await get_remote_mcp_server_list('tid') - self.assertEqual(len(result), 100) - self.assertEqual(result[0]["remote_mcp_server_name"], "server_0") - self.assertEqual(result[99]["remote_mcp_server_name"], "server_99") + @patch('backend.services.remote_mcp_service.update_mcp_record_manage_fields_by_id') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + def test_update_success(self, mock_get, mock_update): + mock_get.return_value = {"mcp_id": 1, "source": "local", "config_json": None} + update_mcp_service( + tenant_id='tid', user_id='uid', mcp_id=1, + new_name='new-name', description='desc', + server_url='http://new.url', authorization_token='tok', + tags=['a', 'b'], + ) + mock_update.assert_called_once() - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_with_special_characters(self, mock_get): - """Test records with special characters""" - mock_get.return_value = [ - {"mcp_name": "test-server_123", - "mcp_server": "http://test-server.com:8080", "status": True} - ] + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + def test_update_not_found(self, mock_get): + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + update_mcp_service( + tenant_id='tid', user_id='uid', mcp_id=999, + new_name='x', description='d', server_url='u', + authorization_token=None, tags=None, + ) - result = await get_remote_mcp_server_list('tid') - self.assertEqual( - result[0]["remote_mcp_server_name"], "test-server_123") - self.assertEqual(result[0]["remote_mcp_server"], - "http://test-server.com:8080") - self.assertEqual(result[0]["permission"], "READ_ONLY") + @patch('backend.services.remote_mcp_service.update_mcp_record_manage_fields_by_id') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + def test_update_container_record_preserves_config(self, mock_get, mock_update): + mock_get.return_value = { + "mcp_id": 1, "source": "local", + "config_json": {"mcpServers": {}}, "container_id": "cid", + } + update_mcp_service( + tenant_id='tid', user_id='uid', mcp_id=1, + new_name='new-name', description='d', + server_url='http://u', authorization_token=None, tags=None, + ) + call_kwargs = mock_update.call_args[1] + self.assertEqual(call_kwargs['config_json'], {"mcpServers": {}}) - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list_permission_by_creator(self, mock_get, mock_get_user_tenant): - """Test permission: creator can edit, others read when not admin""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get.return_value = [ - {"mcp_name": "n1", "mcp_server": "u1", - "status": True, "created_by": "user123"}, - {"mcp_name": "n2", "mcp_server": "u2", - "status": True, "created_by": "other"}, - ] - result = await get_remote_mcp_server_list('tid', user_id="user123") - self.assertEqual(result[0]["permission"], "EDIT") - self.assertEqual(result[1]["permission"], "READ_ONLY") +# ============================================================================ +# update_mcp_service_enabled (NEW - most complex function) +# ============================================================================ - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list_permission_admin_can_edit_all(self, mock_get, mock_get_user_tenant): - """Test permission: admin can edit all""" - mock_get_user_tenant.return_value = {"user_role": "ADMIN"} - mock_get.return_value = [ - {"mcp_name": "n1", "mcp_server": "u1", - "status": True, "created_by": "someone"}, - {"mcp_name": "n2", "mcp_server": "u2", - "status": True, "created_by": "other"}, - ] +class TestUpdateMcpServiceEnabled(unittest.IsolatedAsyncioTestCase): + """Test update_mcp_service_enabled - enable/disable lifecycle""" - result = await get_remote_mcp_server_list('tid', user_id="user123") - self.assertEqual(result[0]["permission"], "EDIT") - self.assertEqual(result[1]["permission"], "EDIT") + def _make_record(self, **overrides): + base = { + "mcp_id": 1, "mcp_name": "test-svc", "mcp_server": "http://srv/mcp", + "container_id": None, "container_port": None, "config_json": None, + "authorization_token": None, "enabled": False, "source": "local", + } + base.update(overrides) + return base + # --- Non-container: enable --- + + @patch('backend.services.remote_mcp_service.update_mcp_record_enabled_by_id') + @patch('backend.services.remote_mcp_service.update_mcp_record_status_by_id') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list_with_is_need_auth_true(self, mock_get): - """Test getting server list with is_need_auth=True (default) includes authorization_token""" - mock_get.return_value = [ - { - "mcp_name": "n1", - "mcp_server": "u1", - "status": True, - "authorization_token": "token123", - "mcp_id": 1 - }, - { - "mcp_name": "n2", - "mcp_server": "u2", - "status": False, - "authorization_token": None, - "mcp_id": 2 - } - ] + async def test_non_container_enable_success(self, mock_records, mock_get, mock_health, mock_status, mock_enabled): + mock_get.return_value = self._make_record() + mock_records.return_value = [] + mock_health.return_value = True + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) + mock_status.assert_called_once() + mock_enabled.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', enabled=True) + + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.update_mcp_record_status_by_id') + async def test_non_container_enable_health_fail(self, mock_status, mock_records, mock_get, mock_health): + mock_get.return_value = self._make_record() + mock_records.return_value = [] + mock_health.return_value = False + with self.assertRaises(MCPConnectionError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) - result = await get_remote_mcp_server_list('tid', is_need_auth=True) + # --- Non-container: disable --- - self.assertEqual(len(result), 2) - self.assertIn("authorization_token", result[0]) - self.assertEqual(result[0]["authorization_token"], "token123") - self.assertIn("authorization_token", result[1]) - self.assertIsNone(result[1]["authorization_token"]) + @patch('backend.services.remote_mcp_service.update_mcp_record_enabled_by_id') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_non_container_disable(self, mock_get, mock_enabled): + mock_get.return_value = self._make_record() + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=False) + mock_enabled.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', enabled=False) + + # --- Container: enable (rebuild) --- + @patch('backend.services.remote_mcp_service.update_mcp_record_enabled_by_id') + @patch('backend.services.remote_mcp_service.update_mcp_record_container_fields_by_id') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list_with_is_need_auth_false(self, mock_get): - """Test getting server list with is_need_auth=False excludes authorization_token""" - mock_get.return_value = [ - { - "mcp_name": "n1", - "mcp_server": "u1", - "status": True, - "authorization_token": "token123", - "mcp_id": 1 - }, - { - "mcp_name": "n2", - "mcp_server": "u2", - "status": False, - "authorization_token": "token456", - "mcp_id": 2 - } - ] + async def test_container_enable_rebuild_success(self, mock_records, mock_get, mock_mgr_cls, mock_health, mock_cont_fields, mock_enabled): + mock_get.return_value = self._make_record( + container_port=8080, + config_json={"mcpServers": {"s": {"command": "echo", "args": [], "env": {}}}}, + ) + mock_records.return_value = [] + mock_mgr = MagicMock() + mock_mgr.start_mcp_container = AsyncMock(return_value={ + "container_id": "new-cid", "mcp_url": "http://localhost:8080/mcp", "host_port": 8080, + }) + mock_mgr_cls.return_value = mock_mgr + mock_health.return_value = True - result = await get_remote_mcp_server_list('tid', is_need_auth=False) + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) - self.assertEqual(len(result), 2) - self.assertNotIn("authorization_token", result[0]) - self.assertNotIn("authorization_token", result[1]) - # Verify other fields are still present - self.assertEqual(result[0]["remote_mcp_server_name"], "n1") - self.assertEqual(result[0]["mcp_id"], 1) - self.assertEqual(result[1]["remote_mcp_server_name"], "n2") - self.assertEqual(result[1]["mcp_id"], 2) + mock_mgr.start_mcp_container.assert_called_once() + mock_cont_fields.assert_called_once() + mock_enabled.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', enabled=True) + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list_default_is_need_auth_true(self, mock_get): - """Test that default behavior (is_need_auth not specified) includes authorization_token""" - mock_get.return_value = [ - { - "mcp_name": "n1", - "mcp_server": "u1", - "status": True, - "authorization_token": "token123", - "mcp_id": 1 - } - ] + async def test_container_enable_missing_port(self, mock_records, mock_get, mock_mgr_cls): + mock_get.return_value = self._make_record( + container_port=None, + config_json={"mcpServers": {"s": {"command": "echo"}}}, + ) + mock_records.return_value = [] + with self.assertRaises(McpValidationError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) - result = await get_remote_mcp_server_list('tid') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + async def test_container_enable_missing_config(self, mock_records, mock_get): + # Must have container_id or config_json for _is_container_record to return True + mock_get.return_value = self._make_record(container_id="cid", config_json=None) + mock_records.return_value = [] + with self.assertRaises(McpValidationError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) + + @patch('backend.services.remote_mcp_service.update_mcp_record_container_fields_by_id') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + async def test_container_enable_health_fail_cleans_up(self, mock_records, mock_get, mock_mgr_cls, mock_health, mock_cont_fields): + mock_get.return_value = self._make_record( + container_port=8080, + config_json={"mcpServers": {"s": {"command": "echo", "args": [], "env": {}}}}, + ) + mock_records.return_value = [] + mock_mgr = MagicMock() + mock_mgr.start_mcp_container = AsyncMock(return_value={ + "container_id": "cid", "mcp_url": "http://localhost:8080/mcp", "host_port": 8080, + }) + mock_mgr.stop_mcp_container = AsyncMock(return_value=True) + mock_mgr_cls.return_value = mock_mgr + mock_health.return_value = False - self.assertEqual(len(result), 1) - self.assertIn("authorization_token", result[0]) - self.assertEqual(result[0]["authorization_token"], "token123") + with self.assertRaises(MCPConnectionError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) + # Should have attempted cleanup + mock_mgr.stop_mcp_container.assert_called_once() - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - async def test_get_list_with_user_id_and_is_need_auth_false(self, mock_get, mock_get_user_tenant): - """Test getting server list with user_id and is_need_auth=False""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get.return_value = [ - { - "mcp_name": "n1", - "mcp_server": "u1", - "status": True, - "created_by": "user123", - "authorization_token": "token123", - "mcp_id": 1 - } + async def test_enable_name_conflict_with_other_enabled(self, mock_records, mock_get): + mock_get.return_value = self._make_record(mcp_name="duplicate") + mock_records.return_value = [ + {"mcp_id": 2, "mcp_name": "duplicate", "enabled": True}, ] + with self.assertRaises(McpNameConflictError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) - result = await get_remote_mcp_server_list('tid', user_id="user123", is_need_auth=False) + # --- Container: disable --- - self.assertEqual(len(result), 1) - self.assertNotIn("authorization_token", result[0]) - self.assertEqual(result[0]["permission"], "EDIT") - self.assertEqual(result[0]["mcp_id"], 1) + @patch('backend.services.remote_mcp_service.update_mcp_record_enabled_by_id') + @patch('backend.services.remote_mcp_service.update_mcp_record_container_fields_by_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_container_disable_success(self, mock_get, mock_mgr_cls, mock_cont_fields, mock_enabled): + mock_get.return_value = self._make_record( + container_id="old-cid", container_port=8080, mcp_server="http://old/mcp", + ) + mock_mgr = MagicMock() + mock_mgr.stop_mcp_container = AsyncMock(return_value=True) + mock_mgr_cls.return_value = mock_mgr + + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=False) + + mock_mgr.stop_mcp_container.assert_called_once_with("old-cid") + mock_cont_fields.assert_called_once() + call_kwargs = mock_cont_fields.call_args[1] + self.assertIsNone(call_kwargs['container_id']) + self.assertEqual(call_kwargs['container_port'], 8080) + self.assertIsNone(call_kwargs['status']) + mock_enabled.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', enabled=False) + + @patch('backend.services.remote_mcp_service.update_mcp_record_enabled_by_id') + @patch('backend.services.remote_mcp_service.update_mcp_record_container_fields_by_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_container_disable_no_container_id(self, mock_get, mock_mgr_cls, mock_cont_fields, mock_enabled): + mock_get.return_value = self._make_record(container_id=None, container_port=8080) + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=False) + mock_mgr_cls.assert_not_called() + mock_enabled.assert_called_once() + + @patch('backend.services.remote_mcp_service.update_mcp_record_enabled_by_id') + @patch('backend.services.remote_mcp_service.update_mcp_record_container_fields_by_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_container_disable_stop_fails_still_disables(self, mock_get, mock_mgr_cls, mock_cont_fields, mock_enabled): + mock_get.return_value = self._make_record(container_id="cid", container_port=8080) + mock_mgr = MagicMock() + mock_mgr.stop_mcp_container = AsyncMock(side_effect=Exception("stop failed")) + mock_mgr_cls.return_value = mock_mgr + # Should not raise - stop failure is logged but doesn't block disable + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=False) + mock_enabled.assert_called_once() -class TestCheckMcpHealthAndUpdateDb(unittest.IsolatedAsyncioTestCase): - """Test check_mcp_health_and_update_db""" + # --- Not found --- - @patch('backend.services.remote_mcp_service.update_mcp_status_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.get_mcp_authorization_token_by_name_and_url') - async def test_check_health_success(self, mock_get_token, mock_health, mock_update): - """Test successful health check and update""" - mock_get_token.return_value = 'Bearer token123' - mock_health.return_value = True + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_not_found(self, mock_get): + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=999, enabled=True) - # Should execute successfully without exception - await check_mcp_health_and_update_db('http://srv', 'name', 'tid', 'uid') - mock_get_token.assert_called_once_with( - mcp_name='name', - mcp_server='http://srv', - tenant_id='tid' - ) - mock_health.assert_called_once_with( - remote_mcp_server='http://srv', - authorization_token='Bearer token123' - ) - mock_update.assert_called_once_with( - mcp_name='name', - mcp_server='http://srv', - tenant_id='tid', - user_id='uid', - status=True - ) +# ============================================================================ +# delete_mcp_service (NEW) +# ============================================================================ - @patch('backend.services.remote_mcp_service.update_mcp_status_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.get_mcp_authorization_token_by_name_and_url') - async def test_check_health_with_none_token(self, mock_get_token, mock_health, mock_update): - """Test health check with None authorization token""" - mock_get_token.return_value = None - mock_health.return_value = True +class TestDeleteMcpService(unittest.IsolatedAsyncioTestCase): + """Test delete_mcp_service""" - await check_mcp_health_and_update_db('http://srv', 'name', 'tid', 'uid') + @patch('backend.services.remote_mcp_service.delete_mcp_record_by_id') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_delete_url_based(self, mock_get, mock_delete): + mock_get.return_value = {"mcp_id": 1, "container_id": None} + await delete_mcp_service(tenant_id='tid', user_id='uid', mcp_id=1) + mock_delete.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid') - mock_health.assert_called_once_with( - remote_mcp_server='http://srv', - authorization_token=None - ) + @patch('backend.services.remote_mcp_service.delete_mcp_record_by_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_delete_container_based(self, mock_get, mock_mgr_cls, mock_delete): + mock_get.return_value = {"mcp_id": 1, "container_id": "cid"} + mock_mgr = MagicMock() + mock_mgr.stop_mcp_container = AsyncMock(return_value=True) + mock_mgr_cls.return_value = mock_mgr + await delete_mcp_service(tenant_id='tid', user_id='uid', mcp_id=1) + mock_mgr.stop_mcp_container.assert_called_once_with(container_id="cid") + mock_delete.assert_called_once() + + @patch('backend.services.remote_mcp_service.delete_mcp_record_by_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_delete_container_stop_fails_still_deletes(self, mock_get, mock_mgr_cls, mock_delete): + mock_get.return_value = {"mcp_id": 1, "container_id": "cid"} + mock_mgr = MagicMock() + mock_mgr.stop_mcp_container = AsyncMock(side_effect=Exception("stop failed")) + mock_mgr_cls.return_value = mock_mgr + await delete_mcp_service(tenant_id='tid', user_id='uid', mcp_id=1) + mock_delete.assert_called_once() - @patch('backend.services.remote_mcp_service.update_mcp_status_by_name_and_url') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_delete_not_found(self, mock_get): + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await delete_mcp_service(tenant_id='tid', user_id='uid', mcp_id=999) + + +# ============================================================================ +# check_mcp_service_health (NEW) +# ============================================================================ + +class TestCheckMcpServiceHealth(unittest.IsolatedAsyncioTestCase): + """Test check_mcp_service_health""" + + @patch('backend.services.remote_mcp_service.update_mcp_record_status_by_id') @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.get_mcp_authorization_token_by_name_and_url') - async def test_check_health_false(self, mock_get_token, mock_health, mock_update): - """Test health check failure - should raise MCPConnectionError when status is False""" - mock_get_token.return_value = 'Bearer token123' + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_healthy(self, mock_get, mock_health, mock_status): + mock_get.return_value = {"mcp_server": "http://srv/mcp", "authorization_token": "tok"} + mock_health.return_value = True + result = await check_mcp_service_health(tenant_id='tid', user_id='uid', mcp_id=1) + self.assertEqual(result, "healthy") + mock_status.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', status=True) + + @patch('backend.services.remote_mcp_service.update_mcp_record_status_by_id') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_unhealthy(self, mock_get, mock_health, mock_status): + mock_get.return_value = {"mcp_server": "http://srv/mcp", "authorization_token": None} mock_health.return_value = False + with self.assertRaises(MCPConnectionError): + await check_mcp_service_health(tenant_id='tid', user_id='uid', mcp_id=1) + mock_status.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', status=False) - with self.assertRaises(MCPConnectionError) as context: - await check_mcp_health_and_update_db('http://srv', 'name', 'tid', 'uid') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_not_found(self, mock_get): + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await check_mcp_service_health(tenant_id='tid', user_id='uid', mcp_id=1) - self.assertEqual(str(context.exception), "MCP connection failed") - mock_update.assert_called_once_with( - mcp_name='name', - mcp_server='http://srv', - tenant_id='tid', - user_id='uid', - status=False - ) + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_empty_server_url(self, mock_get): + mock_get.return_value = {"mcp_server": "", "authorization_token": None} + with self.assertRaises(McpValidationError): + await check_mcp_service_health(tenant_id='tid', user_id='uid', mcp_id=1) - @patch('backend.services.remote_mcp_service.update_mcp_status_by_name_and_url') + @patch('backend.services.remote_mcp_service.update_mcp_record_status_by_id') @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.get_mcp_authorization_token_by_name_and_url') - async def test_update_db_fail(self, mock_get_token, mock_health, mock_update): - """Test database update failure - exception should propagate from database layer""" - from sqlalchemy.exc import SQLAlchemyError + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_health_exception_updates_status(self, mock_get, mock_health, mock_status): + mock_get.return_value = {"mcp_server": "http://srv/mcp", "authorization_token": "tok"} + mock_health.side_effect = MCPConnectionError("timeout") + with self.assertRaises(MCPConnectionError): + await check_mcp_service_health(tenant_id='tid', user_id='uid', mcp_id=1) + mock_status.assert_called_once_with(mcp_id=1, tenant_id='tid', user_id='uid', status=False) - mock_get_token.return_value = 'Bearer token123' - mock_health.return_value = True - mock_update.side_effect = SQLAlchemyError("Database error") - with self.assertRaises(SQLAlchemyError): - await check_mcp_health_and_update_db('http://srv', 'name', 'tid', 'uid') +# ============================================================================ +# list_mcp_service_tools_by_id (NEW) +# ============================================================================ - @patch('backend.services.remote_mcp_service.update_mcp_status_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.get_mcp_authorization_token_by_name_and_url') - async def test_health_check_exception(self, mock_get_token, mock_health, mock_update): - """Test health check exception - should catch exception, set status to False, and raise MCPConnectionError""" - mock_get_token.return_value = 'Bearer token123' - mock_health.side_effect = MCPConnectionError("Connection failed") +class TestListMcpServiceToolsById(unittest.IsolatedAsyncioTestCase): + """Test list_mcp_service_tools_by_id""" - # Should catch the exception from mcp_server_health, set status to False, and then raise MCPConnectionError - with self.assertRaises(MCPConnectionError) as context: - await check_mcp_health_and_update_db('http://srv', 'name', 'tid', 'uid') + @patch('backend.services.remote_mcp_service.get_tool_from_remote_mcp_server') + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_success(self, mock_get, mock_get_tools): + mock_get.return_value = {"mcp_name": "svc", "mcp_server": "http://srv/mcp"} + mock_tool = MagicMock() + mock_tool.__dict__ = {"name": "tool1", "description": "desc"} + mock_get_tools.return_value = [mock_tool] + result = await list_mcp_service_tools_by_id(tenant_id='tid', mcp_id=1) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "tool1") - self.assertEqual(str(context.exception), "MCP connection failed") - mock_health.assert_called_once_with( - remote_mcp_server='http://srv', - authorization_token='Bearer token123' - ) - mock_update.assert_called_once_with( - mcp_name='name', - mcp_server='http://srv', - tenant_id='tid', - user_id='uid', - status=False # Should be False due to exception - ) + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_not_found(self, mock_get): + mock_get.return_value = None + with self.assertRaises(McpNotFoundError): + await list_mcp_service_tools_by_id(tenant_id='tid', mcp_id=1) + + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_missing_fields(self, mock_get): + mock_get.return_value = {"mcp_name": None, "mcp_server": None} + with self.assertRaises(McpValidationError): + await list_mcp_service_tools_by_id(tenant_id='tid', mcp_id=1) +# ============================================================================ +# delete_mcp_by_container_id +# ============================================================================ + class TestDeleteMcpByContainerId(unittest.IsolatedAsyncioTestCase): - """Test delete_mcp_by_container_id service helper""" @patch('backend.services.remote_mcp_service.delete_mcp_record_by_container_id') async def test_delete_by_container_id_success(self, mock_delete): - """Test successful soft delete by container ID""" - await delete_mcp_by_container_id( - tenant_id='tid', - user_id='uid', - container_id='container-123', - ) - - mock_delete.assert_called_once_with( - container_id='container-123', - tenant_id='tid', - user_id='uid', - ) + await delete_mcp_by_container_id(tenant_id='tid', user_id='uid', container_id='container-123') + mock_delete.assert_called_once_with(container_id='container-123', tenant_id='tid', user_id='uid') @patch('backend.services.remote_mcp_service.delete_mcp_record_by_container_id') async def test_delete_by_container_id_db_error(self, mock_delete): - """Test database error when deleting by container ID - should propagate""" from sqlalchemy.exc import SQLAlchemyError - mock_delete.side_effect = SQLAlchemyError("Database error") - with self.assertRaises(SQLAlchemyError): - await delete_mcp_by_container_id( - tenant_id='tid', - user_id='uid', - container_id='container-123', - ) - + await delete_mcp_by_container_id(tenant_id='tid', user_id='uid', container_id='container-123') -class TestIntegrationScenarios(unittest.IsolatedAsyncioTestCase): - """Integration test scenarios""" - @patch('backend.services.remote_mcp_service.create_mcp_record') - @patch('backend.services.remote_mcp_service.delete_mcp_record_by_name_and_url') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_full_lifecycle(self, mock_check_name, mock_health, mock_get, mock_delete, mock_create): - """Test complete MCP server lifecycle""" - # 1. Add server - mock_check_name.return_value = False - mock_health.return_value = True +# ============================================================================ +# get_remote_mcp_server_list +# ============================================================================ - # Add server - should succeed without exception - await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') +class TestGetRemoteMcpServerList(unittest.IsolatedAsyncioTestCase): - # 2. Get server list - mock_get.return_value = [{"mcp_name": "name", - "mcp_server": "http://srv", "status": True}] - list_result = await get_remote_mcp_server_list('tid') - self.assertEqual(len(list_result), 1) - self.assertEqual(list_result[0]["remote_mcp_server_name"], "name") + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + async def test_get_list(self, mock_get): + mock_get.return_value = [ + {"mcp_name": "n1", "mcp_server": "u1", "status": True}, + {"mcp_name": "n2", "mcp_server": "u2", "status": False} + ] + result = await get_remote_mcp_server_list('tid') + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["remote_mcp_server_name"], "n1") - # 3. Delete server - await delete_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + async def test_get_empty(self, mock_get): + mock_get.return_value = [] + result = await get_remote_mcp_server_list('tid') + self.assertEqual(result, []) - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_duplicate_name_scenario(self, mock_check_name): - """Test duplicate name scenario""" - mock_check_name.return_value = True + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + async def test_get_list_permission_by_creator(self, mock_get, mock_get_user_tenant): + mock_get_user_tenant.return_value = {"user_role": "USER"} + mock_get.return_value = [ + {"mcp_name": "n1", "mcp_server": "u1", "status": True, "created_by": "user123"}, + ] + result = await get_remote_mcp_server_list('tid', user_id="user123") + self.assertEqual(result[0]["permission"], "EDIT") - with self.assertRaises(MCPNameIllegal): - await add_remote_mcp_server_list('tid', 'uid', 'http://srv1', 'duplicate_name') - with self.assertRaises(MCPNameIllegal): - await add_remote_mcp_server_list('tid', 'uid', 'http://srv2', 'duplicate_name') +# ============================================================================ +# check_mcp_health_and_update_db +# ============================================================================ +class TestCheckMcpHealthAndUpdateDb(unittest.IsolatedAsyncioTestCase): -class TestUploadAndStartMcpImage(unittest.IsolatedAsyncioTestCase): - """Test upload_and_start_mcp_image function""" + @patch('backend.services.remote_mcp_service.update_mcp_status_by_name_and_url') + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.get_mcp_authorization_token_by_name_and_url') + async def test_check_health_success(self, mock_get_token, mock_health, mock_update): + mock_get_token.return_value = 'Bearer token123' + mock_health.return_value = True + await check_mcp_health_and_update_db('http://srv', 'name', 'tid', 'uid') + mock_update.assert_called_once() - @patch('backend.services.remote_mcp_service.add_remote_mcp_server_list') - @patch('backend.services.remote_mcp_service.MCPContainerManager') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - @patch('tempfile.NamedTemporaryFile') - async def test_upload_success(self, mock_temp_file, mock_check_name, mock_container_manager_class, mock_add_server): - """Test successful upload and container start""" - # Mock tempfile - mock_temp_file_obj = MagicMock() - mock_temp_file_obj.__enter__.return_value = mock_temp_file_obj - mock_temp_file_obj.__exit__.return_value = None - mock_temp_file_obj.name = "/tmp/test.tar" - mock_temp_file.return_value = mock_temp_file_obj - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container_from_tar = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" - }) +# ============================================================================ +# get_mcp_record_by_id +# ============================================================================ - mock_check_name.return_value = False - mock_add_server.return_value = None +class TestGetMcpRecordById(unittest.IsolatedAsyncioTestCase): - result = await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"fake tar content", - filename="test.tar", - port=5020, - service_name="test-service", - env_vars='{"NODE_ENV": "production"}' - ) + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_get_mcp_record_success(self, mock_get_record): + mock_get_record.return_value = { + "mcp_name": "test-service", "mcp_server": "http://test.com/mcp", + "authorization_token": "Bearer token123", + } + result = await get_mcp_record_by_id(mcp_id=1, tenant_id="tenant123") + self.assertIsNotNone(result) + self.assertEqual(result["mcp_name"], "test-service") - self.assertEqual(result["status"], "success") - self.assertEqual(result["service_name"], "test-service") - self.assertEqual(result["mcp_url"], "http://localhost:5020/mcp") - self.assertEqual(result["container_id"], "container-123") + @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') + async def test_get_mcp_record_not_found(self, mock_get_record): + mock_get_record.return_value = None + result = await get_mcp_record_by_id(mcp_id=999, tenant_id="tenant123") + self.assertIsNone(result) - # Verify tempfile was created with correct parameters - mock_temp_file.assert_called_once_with(delete=False, suffix='.tar') - # Verify container manager was called - mock_container_manager.start_mcp_container_from_tar.assert_called_once() - call_kwargs = mock_container_manager.start_mcp_container_from_tar.call_args[1] - self.assertEqual(call_kwargs["service_name"], "test-service") - self.assertEqual(call_kwargs["tenant_id"], "tenant123") - self.assertEqual(call_kwargs["user_id"], "user456") - self.assertEqual(call_kwargs["host_port"], 5020) - self.assertEqual(call_kwargs["env_vars"], {"NODE_ENV": "production"}) +# ============================================================================ +# upload_and_start_mcp_image +# ============================================================================ - # Verify MCP server was registered - mock_add_server.assert_called_once() +class TestUploadAndStartMcpImage(unittest.IsolatedAsyncioTestCase): @patch('backend.services.remote_mcp_service.add_remote_mcp_server_list') @patch('backend.services.remote_mcp_service.MCPContainerManager') @patch('backend.services.remote_mcp_service.check_mcp_name_exists') @patch('tempfile.NamedTemporaryFile') - async def test_upload_success_with_authorization_token_in_env_vars(self, mock_temp_file, mock_check_name, mock_container_manager_class, mock_add_server): - """Test successful upload with authorization_token in env_vars""" - # Mock tempfile + async def test_upload_success(self, mock_temp_file, mock_check_name, mock_mgr_cls, mock_add_server): mock_temp_file_obj = MagicMock() mock_temp_file_obj.__enter__.return_value = mock_temp_file_obj mock_temp_file_obj.__exit__.return_value = None mock_temp_file_obj.name = "/tmp/test.tar" mock_temp_file.return_value = mock_temp_file_obj - - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container_from_tar = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" + mock_mgr = MagicMock() + mock_mgr.start_mcp_container_from_tar = AsyncMock(return_value={ + "container_id": "cid", "mcp_url": "http://localhost:5020/mcp", + "host_port": "5020", "status": "started", "container_name": "test", }) - + mock_mgr_cls.return_value = mock_mgr mock_check_name.return_value = False - mock_add_server.return_value = None result = await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"fake tar content", - filename="test.tar", - port=5020, - service_name="test-service", - env_vars='{"NODE_ENV": "production", "authorization_token": "Bearer token123"}' + tenant_id="tenant123", user_id="user456", file_content=b"fake", + filename="test.tar", port=5020, service_name="test-service", + env_vars='{"NODE_ENV": "production"}', ) - self.assertEqual(result["status"], "success") - # Verify authorization_token was extracted from env_vars and passed to add_remote_mcp_server_list - mock_add_server.assert_called_once() - call_kwargs = mock_add_server.call_args[1] - self.assertEqual(call_kwargs["authorization_token"], "Bearer token123") + async def test_upload_invalid_file_type(self): + with self.assertRaises(ValueError): + await upload_and_start_mcp_image( + tenant_id="t", user_id="u", file_content=b"c", + filename="test.txt", port=5020, + ) - @patch('backend.services.remote_mcp_service.add_remote_mcp_server_list') - @patch('backend.services.remote_mcp_service.MCPContainerManager') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - @patch('tempfile.NamedTemporaryFile') - async def test_upload_success_without_authorization_token_in_env_vars(self, mock_temp_file, mock_check_name, mock_container_manager_class, mock_add_server): - """Test successful upload without authorization_token in env_vars""" - # Mock tempfile - mock_temp_file_obj = MagicMock() - mock_temp_file_obj.__enter__.return_value = mock_temp_file_obj - mock_temp_file_obj.__exit__.return_value = None - mock_temp_file_obj.name = "/tmp/test.tar" - mock_temp_file.return_value = mock_temp_file_obj + async def test_upload_file_too_large(self): + large = b"x" * (1024 * 1024 * 1024 + 1) + with self.assertRaises(ValueError): + await upload_and_start_mcp_image( + tenant_id="t", user_id="u", file_content=large, + filename="large.tar", port=5020, + ) - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container_from_tar = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" - }) - mock_check_name.return_value = False - mock_add_server.return_value = None +# ============================================================================ +# attach_mcp_container_permissions +# ============================================================================ - result = await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"fake tar content", - filename="test.tar", - port=5020, - service_name="test-service", - env_vars='{"NODE_ENV": "production"}' # No authorization_token - ) +class TestAttachMcpContainerPermissions(unittest.TestCase): - self.assertEqual(result["status"], "success") - - # Verify authorization_token is None when not in env_vars - mock_add_server.assert_called_once() - call_kwargs = mock_add_server.call_args[1] - self.assertIsNone(call_kwargs["authorization_token"]) - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_upload_invalid_file_type(self, mock_check_name): - """Test upload with invalid file type""" - mock_check_name.return_value = False - - with self.assertRaises(ValueError) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.txt", # Not .tar - port=5020 - ) - - self.assertEqual(str(context.exception), "Only .tar files are allowed") - - async def test_upload_file_too_large(self): - """Test upload with file exceeding size limit""" - large_content = b"x" * (1024 * 1024 * 1024 + 1) # Over 1GB - - with self.assertRaises(ValueError) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=large_content, - filename="large.tar", - port=5020 - ) - - self.assertEqual(str(context.exception), "File size exceeds 1GB limit") - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_upload_invalid_env_vars_json(self, mock_check_name): - """Test upload with invalid JSON in env_vars""" - mock_check_name.return_value = False - - with self.assertRaises(ValueError) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.tar", - port=5020, - env_vars="invalid json {" - ) - - self.assertIn("Invalid environment variables format", - str(context.exception)) - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_upload_env_vars_not_dict(self, mock_check_name): - """Test upload with environment variables that are not a JSON object""" - mock_check_name.return_value = False - - with self.assertRaises(ValueError) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.tar", - port=5020, - env_vars='["VAR1", "VAR2"]' # Array instead of object - ) - - self.assertEqual(str(context.exception), - "Invalid environment variables format: Environment variables must be a JSON object") - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_upload_auto_service_name(self, mock_check_name): - """Test upload with auto-generated service name""" - mock_check_name.return_value = False - - with patch('backend.services.remote_mcp_service.add_remote_mcp_server_list'), \ - patch('backend.services.remote_mcp_service.MCPContainerManager') as mock_container_manager_class, \ - patch('tempfile.NamedTemporaryFile') as mock_temp_file: - - # Mock tempfile - mock_temp_file_obj = MagicMock() - mock_temp_file_obj.__enter__.return_value = mock_temp_file_obj - mock_temp_file_obj.__exit__.return_value = None - mock_temp_file_obj.name = "/tmp/test.tar" - mock_temp_file.return_value = mock_temp_file_obj - - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container_from_tar = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "my-image-user1234" - }) - - result = await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="my-image.tar", - port=5020 - # No service_name provided - should auto-generate - ) - - # Should use filename without extension - self.assertEqual(result["service_name"], "my-image") - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_upload_name_conflict(self, mock_check_name): - """Test upload when MCP service name already exists""" - mock_check_name.return_value = True # Name already exists - - with self.assertRaises(MCPNameIllegal) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.tar", - port=5020, - service_name="existing-service" - ) - - self.assertEqual(str(context.exception), - "MCP service name already exists") - - @patch('backend.services.remote_mcp_service.add_remote_mcp_server_list') - @patch('backend.services.remote_mcp_service.MCPContainerManager') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - @patch('tempfile.NamedTemporaryFile') - async def test_upload_container_error(self, mock_temp_file, mock_check_name, mock_container_manager_class, mock_add_server): - """Test upload when container startup fails""" - from backend.consts.exceptions import MCPContainerError - - # Mock tempfile - mock_temp_file_obj = MagicMock() - mock_temp_file_obj.__enter__.return_value = mock_temp_file_obj - mock_temp_file_obj.__exit__.return_value = None - mock_temp_file_obj.name = "/tmp/test.tar" - mock_temp_file.return_value = mock_temp_file_obj - - # Mock container manager to raise error - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container_from_tar = AsyncMock( - side_effect=MCPContainerError("Container failed")) - - mock_check_name.return_value = False - - with self.assertRaises(MCPContainerError) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.tar", - port=5020 - ) - - self.assertEqual(str(context.exception), "Container failed") - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - @patch('backend.services.remote_mcp_service.MCPContainerManager') - async def test_upload_docker_unavailable(self, mock_container_manager_class, mock_check_name): - """Test upload when Docker service is unavailable""" - from backend.consts.exceptions import MCPContainerError - - mock_check_name.return_value = False # Name doesn't exist - mock_container_manager_class.side_effect = MCPContainerError( - "Docker unavailable") - - with self.assertRaises(MCPContainerError) as context: - await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.tar", - port=5020 - ) - - self.assertEqual(str(context.exception), "Docker unavailable") - - @patch('backend.services.remote_mcp_service.add_remote_mcp_server_list') - @patch('backend.services.remote_mcp_service.MCPContainerManager') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - @patch('tempfile.NamedTemporaryFile') - @patch('os.unlink', side_effect=OSError("Permission denied")) - @patch('backend.services.remote_mcp_service.logger') - async def test_upload_temp_file_cleanup_warning(self, mock_logger, mock_unlink, mock_temp_file, mock_check_name, mock_container_manager_class, mock_add_server): - """Test upload with temporary file cleanup failure - should log warning but succeed""" - # Mock tempfile - mock_temp_file_obj = MagicMock() - mock_temp_file_obj.__enter__.return_value = mock_temp_file_obj - mock_temp_file_obj.__exit__.return_value = None - mock_temp_file_obj.name = "/tmp/test.tar" - mock_temp_file.return_value = mock_temp_file_obj - - # Mock container manager - mock_container_manager = MagicMock() - mock_container_manager_class.return_value = mock_container_manager - mock_container_manager.start_mcp_container_from_tar = AsyncMock(return_value={ - "container_id": "container-123", - "mcp_url": "http://localhost:5020/mcp", - "host_port": "5020", - "status": "started", - "container_name": "test-service-user1234" - }) - - mock_check_name.return_value = False - mock_add_server.return_value = None - - result = await upload_and_start_mcp_image( - tenant_id="tenant123", - user_id="user456", - file_content=b"content", - filename="test.tar", - port=5020 - ) - - # Should still succeed despite cleanup failure - self.assertEqual(result["status"], "success") - - # Verify warning was logged - mock_logger.warning.assert_called_once() - warning_call_args = mock_logger.warning.call_args[0][0] - self.assertIn( - "Failed to clean up temporary file /tmp/test.tar", warning_call_args) - - -class MockMCPUpdateRequest: - """Mock MCPUpdateRequest for testing""" - - def __init__(self, current_service_name, current_mcp_url, new_service_name, new_mcp_url, new_authorization_token=None): - self.current_service_name = current_service_name - self.current_mcp_url = current_mcp_url - self.new_service_name = new_service_name - self.new_mcp_url = new_mcp_url - self.new_authorization_token = new_authorization_token - - -class TestUpdateRemoteMcpServerList(unittest.IsolatedAsyncioTestCase): - """Test update_remote_mcp_server_list""" - - @patch('backend.services.remote_mcp_service.update_mcp_record_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_success(self, mock_check_name, mock_health, mock_update_record): - """Test successful MCP server update""" - # Current name exists, new name is different and doesn't exist, health check passes - # current exists, new doesn't - mock_check_name.side_effect = [True, False] - mock_health.return_value = True - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - ) - - # Should execute successfully without exception - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - # Verify calls - mock_check_name.assert_any_call(mcp_name='old_name', tenant_id='tid') - mock_check_name.assert_any_call(mcp_name='new_name', tenant_id='tid') - mock_health.assert_called_once_with( - remote_mcp_server='http://new.url', - authorization_token=None - ) - mock_update_record.assert_called_once_with( - update_data=update_data, - tenant_id='tid', - user_id='uid', - status=True - ) - - @patch('backend.services.remote_mcp_service.update_mcp_record_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_success_with_new_authorization_token(self, mock_check_name, mock_health, mock_update_record): - """Test successful MCP server update with new authorization token""" - mock_check_name.side_effect = [True, False] - mock_health.return_value = True - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url", - new_authorization_token='Bearer new_token123' - ) - - # Should execute successfully without exception - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - # Verify that new authorization token was used (not fetched from DB) - mock_health.assert_called_once_with( - remote_mcp_server='http://new.url', - authorization_token='Bearer new_token123' - ) - - @patch('backend.services.remote_mcp_service.update_mcp_record_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_success_same_name(self, mock_check_name, mock_health, mock_update_record): - """Test successful MCP server update with same name (only URL change)""" - # Current name exists, new name is same so no additional check, health check passes - mock_check_name.return_value = True # current exists - mock_health.return_value = True - - update_data = MockMCPUpdateRequest( - current_service_name="same_name", - current_mcp_url="http://old.url", - new_service_name="same_name", - new_mcp_url="http://new.url" - ) - - # Should execute successfully without exception - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - # Verify calls - check_mcp_name_exists should only be called once for current name - self.assertEqual(mock_check_name.call_count, 1) - mock_check_name.assert_called_with( - mcp_name='same_name', tenant_id='tid') - mock_health.assert_called_once_with( - remote_mcp_server='http://new.url', - authorization_token=None - ) - mock_update_record.assert_called_once_with( - update_data=update_data, - tenant_id='tid', - user_id='uid', - status=True - ) - - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_current_name_not_exist(self, mock_check_name): - """Test update when current MCP name does not exist""" - mock_check_name.return_value = False # current name doesn't exist - - update_data = MockMCPUpdateRequest( - current_service_name="nonexistent_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - ) - - with self.assertRaises(MCPNameIllegal) as context: - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - self.assertEqual(str(context.exception), "MCP name does not exist") - # Should only check current name - mock_check_name.assert_called_once_with( - mcp_name='nonexistent_name', tenant_id='tid') - - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_new_name_exists(self, mock_check_name, mock_health): - """Test update when new MCP name already exists""" - mock_check_name.side_effect = [ - True, True] # current exists, new exists - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="existing_name", - new_mcp_url="http://new.url" - ) - - with self.assertRaises(MCPNameIllegal) as context: - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - self.assertEqual(str(context.exception), "New MCP name already exists") - - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_health_check_fail(self, mock_check_name, mock_health): - """Test update when health check fails""" - mock_check_name.side_effect = [ - True, False] # current exists, new doesn't - mock_health.return_value = False # health check fails - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://unreachable.url" - ) - - with self.assertRaises(MCPConnectionError) as context: - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - self.assertEqual(str(context.exception), - "New MCP server connection failed") - mock_health.assert_called_once_with( - remote_mcp_server='http://unreachable.url', - authorization_token=None - ) - - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_health_check_exception(self, mock_check_name, mock_health): - """Test update when health check raises exception""" - mock_check_name.side_effect = [ - True, False] # current exists, new doesn't - mock_health.side_effect = MCPConnectionError("Connection failed") - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://failing.url" - ) - - with self.assertRaises(MCPConnectionError) as context: - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - self.assertEqual(str(context.exception), - "New MCP server connection failed") - mock_health.assert_called_once_with( - remote_mcp_server='http://failing.url', - authorization_token=None - ) - - @patch('backend.services.remote_mcp_service.update_mcp_record_by_name_and_url') - @patch('backend.services.remote_mcp_service.mcp_server_health') - @patch('backend.services.remote_mcp_service.check_mcp_name_exists') - async def test_update_db_error(self, mock_check_name, mock_health, mock_update_record): - """Test update when database operation fails""" - from sqlalchemy.exc import SQLAlchemyError - - # current exists, new doesn't - mock_check_name.side_effect = [True, False] - mock_health.return_value = True - mock_update_record.side_effect = SQLAlchemyError("Database error") - - update_data = MockMCPUpdateRequest( - current_service_name="old_name", - current_mcp_url="http://old.url", - new_service_name="new_name", - new_mcp_url="http://new.url" - ) - - # Should raise SQLAlchemyError from database layer - with self.assertRaises(SQLAlchemyError): - await update_remote_mcp_server_list(update_data, 'tid', 'uid') - - -class TestAttachMcpContainerPermissions(unittest.TestCase): - """Test attach_mcp_container_permissions function""" - - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_empty_containers(self, mock_get_records): - """Test with empty containers list""" - result = attach_mcp_container_permissions( - containers=[], - tenant_id='tid', - user_id='uid' - ) - self.assertEqual(result, []) - mock_get_records.assert_not_called() + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + def test_empty_containers(self, mock_get_records): + result = attach_mcp_container_permissions(containers=[], tenant_id='tid', user_id='uid') + self.assertEqual(result, []) @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') def test_no_user_id_all_read(self, mock_get_records): - """Test when user_id is None - all containers should have READ_ONLY permission""" mock_get_records.return_value = [] - containers = [ - {"container_id": "c1", "name": "container1"}, - {"container_id": "c2", "name": "container2"} - ] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id=None - ) - - self.assertEqual(len(result), 2) + containers = [{"container_id": "c1", "name": "container1"}] + result = attach_mcp_container_permissions(containers=containers, tenant_id='tid', user_id=None) self.assertEqual(result[0]["permission"], "READ_ONLY") - self.assertEqual(result[1]["permission"], "READ_ONLY") - self.assertEqual(result[0]["container_id"], "c1") - self.assertEqual(result[1]["container_id"], "c2") @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') def test_admin_user_all_edit(self, mock_get_records, mock_get_user_tenant): - """Test when user has ADMIN role - all containers should have EDIT permission""" mock_get_user_tenant.return_value = {"user_role": "ADMIN"} mock_get_records.return_value = [] - containers = [ - {"container_id": "c1", "name": "container1"}, - {"container_id": "c2", "name": "container2"} - ] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='admin_user' - ) - - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["permission"], "EDIT") - self.assertEqual(result[1]["permission"], "EDIT") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_su_user_all_edit(self, mock_get_records, mock_get_user_tenant): - """Test when user has SU role - all containers should have EDIT permission""" - mock_get_user_tenant.return_value = {"user_role": "SU"} - mock_get_records.return_value = [] containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='su_user' - ) - - self.assertEqual(result[0]["permission"], "EDIT") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_speed_user_all_edit(self, mock_get_records, mock_get_user_tenant): - """Test when user has SPEED role - all containers should have EDIT permission""" - mock_get_user_tenant.return_value = {"user_role": "SPEED"} - mock_get_records.return_value = [] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='speed_user' - ) - + result = attach_mcp_container_permissions(containers=containers, tenant_id='tid', user_id='admin') self.assertEqual(result[0]["permission"], "EDIT") @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') def test_regular_user_own_container_edit(self, mock_get_records, mock_get_user_tenant): - """Test when regular user owns container - should have EDIT permission""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": "c1", "created_by": "user123"} - ] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "EDIT") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_regular_user_other_container_read(self, mock_get_records, mock_get_user_tenant): - """Test when regular user doesn't own container - should have READ_ONLY permission""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": "c1", "created_by": "other_user"} - ] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "READ_ONLY") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_regular_user_no_record_read(self, mock_get_records, mock_get_user_tenant): - """Test when container has no associated MCP record - should have READ_ONLY permission""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "READ_ONLY") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_record_uses_user_id_fallback(self, mock_get_records, mock_get_user_tenant): - """Test when record uses user_id instead of created_by""" mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": "c1", "user_id": "user123"} # No created_by, uses user_id - ] + mock_get_records.return_value = [{"container_id": "c1", "created_by": "user123"}] containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - + result = attach_mcp_container_permissions(containers=containers, tenant_id='tid', user_id='user123') self.assertEqual(result[0]["permission"], "EDIT") - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_record_no_created_by_no_user_id(self, mock_get_records, mock_get_user_tenant): - """Test when record has neither created_by nor user_id""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": "c1"} # No created_by or user_id - ] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "READ_ONLY") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_record_without_container_id_skipped(self, mock_get_records, mock_get_user_tenant): - """Test that records without container_id are skipped""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"created_by": "user123"}, # No container_id - should be skipped - {"container_id": "c2", "created_by": "user123"} - ] - containers = [ - {"container_id": "c1", "name": "container1"}, # No record for c1 - {"container_id": "c2", "name": "container2"} # Has record for c2 - ] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "READ_ONLY") # c1 has no record - self.assertEqual(result[1]["permission"], "EDIT") # c2 owned by user123 - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_get_records_returns_none(self, mock_get_records, mock_get_user_tenant): - """Test when get_mcp_records_by_tenant returns None""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = None - containers = [{"container_id": "c1", "name": "container1"}] - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "READ_ONLY") +# ============================================================================ +# Integration Scenarios +# ============================================================================ - @patch('backend.services.remote_mcp_service.logger') - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_get_records_exception_handled(self, mock_get_records, mock_get_user_tenant, mock_logger): - """Test when get_mcp_records_by_tenant raises exception - should log warning and continue""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.side_effect = Exception("Database error") - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - # Should still return result with READ_ONLY permission - self.assertEqual(result[0]["permission"], "READ_ONLY") - # Should log warning - mock_logger.warning.assert_called_once() - warning_msg = mock_logger.warning.call_args[0][0] - self.assertIn("Failed to load MCP records for permission mapping", warning_msg) - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_user_tenant_record_none(self, mock_get_records, mock_get_user_tenant): - """Test when get_user_tenant_by_user_id returns None""" - mock_get_user_tenant.return_value = None - mock_get_records.return_value = [] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - # Should default to READ_ONLY when no user role - self.assertEqual(result[0]["permission"], "READ_ONLY") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_user_tenant_record_empty_dict(self, mock_get_records, mock_get_user_tenant): - """Test when get_user_tenant_by_user_id returns empty dict""" - mock_get_user_tenant.return_value = {} - mock_get_records.return_value = [] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["permission"], "READ_ONLY") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_user_role_case_insensitive(self, mock_get_records, mock_get_user_tenant): - """Test that user role comparison is case-insensitive (converted to uppercase)""" - mock_get_user_tenant.return_value = {"user_role": "admin"} # lowercase - mock_get_records.return_value = [] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='admin_user' - ) - - # Should still get EDIT permission because "admin" -> "ADMIN" matches CAN_EDIT_ALL_USER_ROLES - self.assertEqual(result[0]["permission"], "EDIT") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_user_role_none_or_empty(self, mock_get_records, mock_get_user_tenant): - """Test when user_role is None or empty string""" - mock_get_user_tenant.return_value = {"user_role": None} - mock_get_records.return_value = [ - {"container_id": "c1", "created_by": "user123"} - ] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - # Should check ownership since role is not in CAN_EDIT_ALL_USER_ROLES - self.assertEqual(result[0]["permission"], "EDIT") # Owned by user123 - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_container_id_none_converted_to_string(self, mock_get_records, mock_get_user_tenant): - """Test when container_id is None - should be converted to string""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [] - containers = [{"container_id": None, "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - # Should handle None container_id gracefully - self.assertEqual(result[0]["permission"], "READ_ONLY") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_mixed_scenario_multiple_containers(self, mock_get_records, mock_get_user_tenant): - """Test complex scenario with multiple containers and mixed permissions""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": "c1", "created_by": "user123"}, # Owned by user - {"container_id": "c2", "created_by": "other_user"}, # Owned by other - {"container_id": "c3", "user_id": "user123"}, # Owned by user (via user_id) - ] - containers = [ - {"container_id": "c1", "name": "container1"}, - {"container_id": "c2", "name": "container2"}, - {"container_id": "c3", "name": "container3"}, - {"container_id": "c4", "name": "container4"}, # No record - ] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(len(result), 4) - self.assertEqual(result[0]["permission"], "EDIT") # c1 owned by user123 - self.assertEqual(result[1]["permission"], "READ_ONLY") # c2 owned by other - self.assertEqual(result[2]["permission"], "EDIT") # c3 owned by user123 - self.assertEqual(result[3]["permission"], "READ_ONLY") # c4 no record - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_container_id_string_matching(self, mock_get_records, mock_get_user_tenant): - """Test that container_id string matching works correctly""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": 123, "created_by": "user123"}, # Numeric container_id - ] - containers = [ - {"container_id": "123", "name": "container1"}, # String container_id - ] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - # Should match because both are converted to strings - self.assertEqual(result[0]["permission"], "EDIT") - - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') - @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_created_by_string_matching(self, mock_get_records, mock_get_user_tenant): - """Test that created_by and user_id string matching works correctly""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [ - {"container_id": "c1", "created_by": 123}, # Numeric created_by - ] - containers = [{"container_id": "c1", "name": "container1"}] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id=123 # Numeric user_id - ) - - # Should match because both are converted to strings - self.assertEqual(result[0]["permission"], "EDIT") +class TestIntegrationScenarios(unittest.IsolatedAsyncioTestCase): - @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.create_mcp_record') @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') - def test_container_preserves_original_fields(self, mock_get_records, mock_get_user_tenant): - """Test that original container fields are preserved in result""" - mock_get_user_tenant.return_value = {"user_role": "USER"} - mock_get_records.return_value = [] - containers = [ - { - "container_id": "c1", - "name": "container1", - "status": "running", - "port": 8080 - } - ] - - result = attach_mcp_container_permissions( - containers=containers, - tenant_id='tid', - user_id='user123' - ) - - self.assertEqual(result[0]["container_id"], "c1") - self.assertEqual(result[0]["name"], "container1") - self.assertEqual(result[0]["status"], "running") - self.assertEqual(result[0]["port"], 8080) - self.assertEqual(result[0]["permission"], "READ_ONLY") - - -class TestGetMcpRecordById(unittest.IsolatedAsyncioTestCase): - """Test get_mcp_record_by_id function""" - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_success(self, mock_get_record): - """Test successful retrieval of MCP record""" - mock_get_record.return_value = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": "Bearer token123", - "status": True, - "mcp_id": 1 - } - - result = await get_mcp_record_by_id(mcp_id=1, tenant_id="tenant123") - - self.assertIsNotNone(result) - self.assertEqual(result["mcp_name"], "test-service") - self.assertEqual(result["mcp_server"], "http://test.com/mcp") - self.assertEqual(result["authorization_token"], "Bearer token123") - - mock_get_record.assert_called_once_with(mcp_id=1, tenant_id="tenant123") - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_not_found(self, mock_get_record): - """Test when MCP record does not exist""" - mock_get_record.return_value = None - - result = await get_mcp_record_by_id(mcp_id=999, tenant_id="tenant123") - - self.assertIsNone(result) - mock_get_record.assert_called_once_with(mcp_id=999, tenant_id="tenant123") - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_with_none_authorization_token(self, mock_get_record): - """Test MCP record with None authorization token""" - mock_get_record.return_value = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": None, - "status": True, - "mcp_id": 1 - } - - result = await get_mcp_record_by_id(mcp_id=1, tenant_id="tenant123") - - self.assertIsNotNone(result) - self.assertEqual(result["mcp_name"], "test-service") - self.assertEqual(result["mcp_server"], "http://test.com/mcp") - self.assertIsNone(result["authorization_token"]) - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_with_missing_fields(self, mock_get_record): - """Test MCP record with missing optional fields""" - mock_get_record.return_value = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - # authorization_token missing - "status": True, - "mcp_id": 1 - } - - result = await get_mcp_record_by_id(mcp_id=1, tenant_id="tenant123") - - self.assertIsNotNone(result) - self.assertEqual(result["mcp_name"], "test-service") - self.assertEqual(result["mcp_server"], "http://test.com/mcp") - self.assertIsNone(result["authorization_token"]) # Should be None when missing - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_with_empty_dict(self, mock_get_record): - """Test when database returns empty dict (should not happen but handle gracefully)""" - mock_get_record.return_value = {} - - result = await get_mcp_record_by_id(mcp_id=1, tenant_id="tenant123") - - # Empty dict is falsy, so should return None - self.assertIsNone(result) - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_different_tenant(self, mock_get_record): - """Test getting MCP record with different tenant ID""" - mock_get_record.return_value = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": "token123", - "status": True, - "mcp_id": 1 - } - - result = await get_mcp_record_by_id(mcp_id=1, tenant_id="different_tenant") - - self.assertIsNotNone(result) - mock_get_record.assert_called_once_with(mcp_id=1, tenant_id="different_tenant") - - @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') - async def test_get_mcp_record_returns_only_required_fields(self, mock_get_record): - """Test that function returns only mcp_name, mcp_server, and authorization_token""" - mock_get_record.return_value = { - "mcp_name": "test-service", - "mcp_server": "http://test.com/mcp", - "authorization_token": "token123", - "status": True, - "mcp_id": 1, - "container_id": "container-123", - "created_by": "user123", - "other_field": "should_not_be_included" - } + @patch('backend.services.remote_mcp_service.mcp_server_health') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_full_lifecycle(self, mock_check_name, mock_health, mock_get, mock_create): + mock_check_name.return_value = False + mock_health.return_value = True + await add_remote_mcp_server_list('tid', 'uid', 'http://srv', 'name') - result = await get_mcp_record_by_id(mcp_id=1, tenant_id="tenant123") + mock_get.return_value = [{"mcp_name": "name", "mcp_server": "http://srv", "status": True}] + list_result = await get_remote_mcp_server_list('tid') + self.assertEqual(len(list_result), 1) + self.assertEqual(list_result[0]["remote_mcp_server_name"], "name") - self.assertIsNotNone(result) - # Should only contain the three required fields - self.assertEqual(set(result.keys()), {"mcp_name", "mcp_server", "authorization_token"}) - self.assertNotIn("status", result) - self.assertNotIn("mcp_id", result) - self.assertNotIn("container_id", result) - self.assertNotIn("created_by", result) - self.assertNotIn("other_field", result) + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_duplicate_name_scenario(self, mock_check_name): + mock_check_name.return_value = True + with self.assertRaises(MCPNameIllegal): + await add_remote_mcp_server_list('tid', 'uid', 'http://srv1', 'duplicate_name') if __name__ == '__main__':