diff --git a/backend/apps/mcp_management_app.py b/backend/apps/mcp_management_app.py index f94e03e1f..3c2c78a3d 100644 --- a/backend/apps/mcp_management_app.py +++ b/backend/apps/mcp_management_app.py @@ -90,6 +90,7 @@ async def list_community_mcp_services_api( user_id, tenant_id, _ = get_current_user_info(authorization, http_request) data = await list_community_mcp_services( tenant_id=tenant_id, + user_id=user_id, search=query.search, tag=query.tag, transport_type=query.transport_type, @@ -214,6 +215,9 @@ async def publish_community_mcp_service_api( tags=payload.tags, mcp_server=payload.mcp_server, config_json=payload.config_json, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse( status_code=HTTPStatus.OK, @@ -257,6 +261,9 @@ async def update_community_mcp_service_legacy_api( mcp_server=payload.mcp_server, config_json=payload.config_json, transport_type=payload.transport_type, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse(status_code=HTTPStatus.OK, content={"status": "success"}) except McpNotFoundError as exc: @@ -386,6 +393,9 @@ async def create_community_mcp_service_api( tags=payload.tags, mcp_server=payload.mcp_server, config_json=payload.config_json, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse( status_code=HTTPStatus.OK, @@ -430,6 +440,9 @@ async def update_community_mcp_service_api( mcp_server=payload.mcp_server, config_json=payload.config_json, transport_type=payload.transport_type, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse(status_code=HTTPStatus.OK, content={"status": "success"}) except McpNotFoundError as exc: diff --git a/backend/apps/remote_mcp_app.py b/backend/apps/remote_mcp_app.py index 0c1f41e17..04edc5cc6 100644 --- a/backend/apps/remote_mcp_app.py +++ b/backend/apps/remote_mcp_app.py @@ -174,6 +174,9 @@ async def add_mcp_service_endpoint( config_json=payload.config_json, market_id=payload.market_id, enabled=payload.enabled if payload.enabled is not None else False, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse( @@ -225,6 +228,9 @@ async def add_container_mcp_service_endpoint( market_id=payload.market_id, port=payload.port, mcp_config=payload.mcp_config, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse( @@ -296,6 +302,9 @@ async def update_mcp_service_endpoint( config_json=payload.config_json, tags=payload.tags, market_id=payload.market_id, + group_ids=payload.group_ids, + ingroup_permission=payload.ingroup_permission, + shared_fields=payload.shared_fields, ) return JSONResponse( diff --git a/backend/consts/model.py b/backend/consts/model.py index d75268315..9b29d8cec 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1381,6 +1381,9 @@ class AddMcpServiceRequest(BaseModel): config_json: Optional[Dict[str, Any]] = Field(None, description="MCP configuration JSON (e.g. OpenAPI spec for API-type MCP)") market_id: Optional[int] = Field(None, gt=0, description="Linked market record ID") enabled: Optional[bool] = Field(default=False, description="Whether the MCP is enabled after creation") + group_ids: Optional[str] = Field(None, description="Comma-separated group IDs that can access this MCP") + ingroup_permission: Optional[str] = Field(None, description="Permission level: EDIT, READ_ONLY, PRIVATE") + shared_fields: Optional[Dict[str, Any]] = Field(None, description="JSON object of field-level sharing flags") @field_validator("name", "server_url", "description", "authorization_token", mode="before") @classmethod @@ -1401,6 +1404,9 @@ class AddContainerMcpServiceRequest(BaseModel): market_id: Optional[int] = Field(None, gt=0, description="Linked market record ID") port: int = Field(..., ge=1, le=65535, description="Host port for the container") mcp_config: MCPConfigRequest = Field(..., description="MCP server configuration") + group_ids: Optional[str] = Field(None, description="Comma-separated group IDs that can access this MCP") + ingroup_permission: Optional[str] = Field(None, description="Permission level: EDIT, READ_ONLY, PRIVATE") + shared_fields: Optional[Dict[str, Any]] = Field(None, description="JSON object of field-level sharing flags") @field_validator("name", "description", "authorization_token", mode="before") @classmethod @@ -1422,6 +1428,9 @@ class UpdateMcpServiceRequest(BaseModel): config_json: Optional[Dict[str, Any]] = Field(None, description="MCP configuration JSON") version: Optional[str] = Field(None, description="MCP version") market_id: Optional[int] = Field(None, gt=0, description="Linked market record ID") + group_ids: Optional[str] = Field(None, description="Comma-separated group IDs that can access this MCP") + ingroup_permission: Optional[str] = Field(None, description="Permission level: EDIT, READ_ONLY, PRIVATE") + shared_fields: Optional[Dict[str, Any]] = Field(None, description="JSON object of field-level sharing flags") @field_validator("name", "server_url", "description", "authorization_token", "version", mode="before") @classmethod @@ -1538,6 +1547,9 @@ class CommunityPublishRequest(BaseModel): 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") + group_ids: Optional[List[int]] = Field(None, description="Group IDs that can access this MCP") + ingroup_permission: Optional[str] = Field(None, description="Permission level: EDIT, READ_ONLY, PRIVATE") + shared_fields: Optional[Dict[str, Any]] = Field(None, description="JSON object of field-level sharing flags") @field_validator("name", "description", "mcp_server", mode="before") @classmethod @@ -1561,6 +1573,9 @@ class CommunityUpdateRequest(BaseModel): None, description="Container MCP configuration JSON (omit to leave unchanged)", ) + group_ids: Optional[List[int]] = Field(None, description="Group IDs that can access this MCP") + ingroup_permission: Optional[str] = Field(None, description="Permission level: EDIT, READ_ONLY, PRIVATE") + shared_fields: Optional[Dict[str, Any]] = Field(None, description="JSON object of field-level sharing flags") @field_validator("name", "description", "mcp_server", "transport_type", mode="before") @classmethod diff --git a/backend/database/db_models.py b/backend/database/db_models.py index fccef1d92..4fb93b97d 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -799,6 +799,11 @@ class McpRecord(TableBase): enabled = Column(Boolean, default=True, doc="Enabled") tags = Column(ARRAY(Text), doc="Tags") description = Column(Text, doc="Description") + group_ids = Column(String, doc="Comma-separated group IDs that can access this MCP") + ingroup_permission = Column(String(30), default="READ_ONLY", + doc="In-group permission: EDIT, READ_ONLY, PRIVATE") + shared_fields = Column(JSON, default=None, + doc="JSON object of field-level sharing flags (e.g. {\"serverUrl\": true, \"authorizationToken\": false})") class McpCommunityRecord(TableBase): @@ -859,6 +864,11 @@ class McpMarketRecord(TableBase): doc="Listing status: not_shared / pending_review / rejected / shared") submitted_by = Column(String(100), doc="Submitter email when listing enters pending_review") source_mcp_id = Column(Integer, doc="Local MCP record ID that created this market record") + group_ids = Column(String, doc="Comma-separated group IDs that can access this MCP") + ingroup_permission = Column(String(30), default="READ_ONLY", + doc="In-group permission: EDIT, READ_ONLY, PRIVATE") + shared_fields = Column(JSON, default=None, + doc="Snapshot of shared_fields at submission time") class UserTenant(TableBase): diff --git a/backend/database/market_mcp_db.py b/backend/database/market_mcp_db.py index a27b139b1..3e1238356 100644 --- a/backend/database/market_mcp_db.py +++ b/backend/database/market_mcp_db.py @@ -1,7 +1,7 @@ import logging -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional -from sqlalchemy import func, or_ +from sqlalchemy import func, or_, text as sa_text from database.client import as_dict, filter_property, get_db_session from database.db_models import McpMarketRecord @@ -9,6 +9,29 @@ logger = logging.getLogger("market_mcp_db") +def _apply_group_permission_filter(query, user_id: str, user_group_ids: List[int]): + """Apply group-based visibility filter to a market record query. + + Users see MCPs where: + - They are the creator, OR + - The MCP has no group restriction (group_ids IS NULL/empty), OR + - They belong to at least one of the MCP's allowed groups + """ + conditions = [ + McpMarketRecord.user_id == user_id, + McpMarketRecord.group_ids.is_(None), + McpMarketRecord.group_ids == "", + ] + if user_group_ids: + group_ids_str = ",".join(str(g) for g in user_group_ids) + conditions.append( + sa_text( + f"string_to_array(group_ids, ',') && ARRAY[{group_ids_str}]::text[]" + ) + ) + return query.filter(or_(*conditions)) + + def get_mcp_market_records( *, tenant_id: str | None = None, @@ -17,6 +40,8 @@ def get_mcp_market_records( transport_type: str | None = None, cursor: str | None = None, limit: int = 30, + user_id: str | None = None, + user_group_ids: Optional[List[int]] = None, ) -> Dict[str, Any]: """Cursor-paginated listing of shared (approved) market records scoped to a tenant.""" with get_db_session() as session: @@ -54,6 +79,9 @@ def get_mcp_market_records( if cursor_id is not None: query = query.filter(McpMarketRecord.market_id < cursor_id) + if user_id is not None and user_group_ids is not None: + query = _apply_group_permission_filter(query, user_id, user_group_ids) + rows: List[McpMarketRecord] = ( query.order_by(McpMarketRecord.market_id.desc()) .limit(limit + 1) @@ -148,6 +176,9 @@ def update_mcp_market_record( mcp_server: str | None = None, config_json: Dict[str, Any] | None = None, transport_type: str | None = None, + group_ids: str | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> None: """Update editable fields on a market record (does not change status).""" update_fields: Dict[str, Any] = {"updated_by": user_id} @@ -165,6 +196,12 @@ def update_mcp_market_record( update_fields["config_json"] = config_json if transport_type is not None: update_fields["transport_type"] = transport_type + if group_ids is not None: + update_fields["group_ids"] = group_ids + if ingroup_permission is not None: + update_fields["ingroup_permission"] = ingroup_permission + if shared_fields is not None: + update_fields["shared_fields"] = shared_fields with get_db_session() as session: session.query(McpMarketRecord).filter( diff --git a/backend/database/remote_mcp_db.py b/backend/database/remote_mcp_db.py index d6223628f..4359f465d 100644 --- a/backend/database/remote_mcp_db.py +++ b/backend/database/remote_mcp_db.py @@ -20,7 +20,8 @@ def create_mcp_record(mcp_data: Dict[str, Any], tenant_id: str, user_id: str): allowed_fields = { 'mcp_name', 'mcp_server', 'status', 'container_id', 'container_port', 'authorization_token', 'custom_headers', 'source', 'market_id', - 'registry_json', 'config_json', 'enabled', 'tags', 'description' + 'registry_json', 'config_json', 'enabled', 'tags', 'description', + 'group_ids', 'ingroup_permission', 'shared_fields' } filtered_data = {k: v for k, v in mcp_data.items() if k in allowed_fields and v is not None} @@ -139,26 +140,34 @@ def update_mcp_record_manage_fields_by_id( custom_headers: Dict[str, Any] | None, config_json: Dict[str, Any] | None, market_id: int | None, + group_ids: str | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> None: with get_db_session() as session: + update_data = { + "mcp_name": name, + "mcp_server": server_url, + "description": description, + "tags": tags or [], + "source": source, + "authorization_token": authorization_token, + "custom_headers": custom_headers, + "config_json": config_json, + "market_id": market_id, + "updated_by": user_id, + } + if group_ids is not None: + update_data["group_ids"] = group_ids + if ingroup_permission is not None: + update_data["ingroup_permission"] = ingroup_permission + if shared_fields is not None: + update_data["shared_fields"] = shared_fields 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, - "custom_headers": custom_headers, - "config_json": config_json, - "market_id": market_id, - "updated_by": user_id, - } - ) + ).update(update_data) def update_mcp_record_market_id_by_id( diff --git a/backend/services/mcp_management_service.py b/backend/services/mcp_management_service.py index e67d265cc..5929e12fc 100644 --- a/backend/services/mcp_management_service.py +++ b/backend/services/mcp_management_service.py @@ -5,6 +5,7 @@ import aiohttp +from consts.const import CAN_EDIT_ALL_USER_ROLES from consts.exceptions import ( MCPConnectionError, McpNameConflictError, @@ -36,8 +37,11 @@ clear_mcp_record_market_id, get_mcp_record_by_id_and_tenant, update_mcp_record_market_id_by_id, + update_mcp_record_manage_fields_by_id, ) from database.user_tenant_db import get_user_tenant_by_user_id +from database.group_db import query_group_ids_by_user +from utils.str_utils import convert_list_to_string, convert_string_to_list logger = logging.getLogger("mcp_management_service") @@ -110,11 +114,27 @@ def _to_community_card(row: Dict[str, Any]) -> Dict[str, Any]: STATUS_SHARED: "approved", STATUS_REJECTED: "rejected", } + # Look up authorization_token and custom_headers from the source MCP record + source_authorization_token = None + source_custom_headers = None + source_mcp_id = row.get("source_mcp_id") + if source_mcp_id is not None: + try: + from database.remote_mcp_db import get_mcp_record_by_id_and_tenant + mcp_record = get_mcp_record_by_id_and_tenant(mcp_id=source_mcp_id, tenant_id=row.get("tenant_id", "")) + if mcp_record: + source_authorization_token = mcp_record.get("authorization_token") + source_custom_headers = mcp_record.get("custom_headers") + except Exception: + pass return { "communityId": row.get("market_id"), "marketId": row.get("market_id"), "reviewId": row.get("market_id"), - "sourceMcpId": row.get("source_mcp_id"), + "sourceMcpId": source_mcp_id, + "sharedFields": row.get("shared_fields"), + "authorizationToken": source_authorization_token, + "customHeaders": source_custom_headers, "name": row.get("mcp_name"), "description": row.get("description"), "status": "active" if raw_status == STATUS_SHARED else "inactive", @@ -130,6 +150,8 @@ def _to_community_card(row: Dict[str, Any]) -> Dict[str, Any]: "reviewType": "initial_listing", "installCount": row.get("download_count") or 0, "authorDisplayName": _resolve_author_display_name(row.get("user_id")), + "groupIds": row.get("group_ids"), + "ingroupPermission": row.get("ingroup_permission"), } @@ -192,13 +214,22 @@ def _validate_market_status_transition( async def list_community_mcp_services( *, tenant_id: str, + user_id: str, search: str | None = None, tag: str | None = None, transport_type: str | None = None, cursor: str | None = None, limit: int = 30, ) -> Dict[str, Any]: - """List shared (approved) community MCP services scoped to a tenant.""" + """List shared (approved) community MCP services scoped to a tenant with permission filtering.""" + user_role = _get_user_role(user_id) + user_group_ids = None + if user_role not in CAN_EDIT_ALL_USER_ROLES: + try: + user_group_ids = list(query_group_ids_by_user(user_id) or []) + except Exception as e: + logger.warning(f"Failed to query user group ids: user_id={user_id}, err={e}") + db_result = get_mcp_market_records( tenant_id=tenant_id, search=search, @@ -206,6 +237,8 @@ async def list_community_mcp_services( transport_type=transport_type, cursor=cursor, limit=limit, + user_id=user_id if user_role not in CAN_EDIT_ALL_USER_ROLES else None, + user_group_ids=user_group_ids, ) return { "count": db_result.get("count", 0), @@ -228,6 +261,9 @@ async def publish_community_mcp_service( tags: List[str] | None = None, mcp_server: str | None = None, config_json: Dict[str, Any] | None = None, + group_ids: List[int] | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> int: """Submit a local MCP service for review. @@ -273,10 +309,32 @@ async def publish_community_mcp_service( "submitted_by": _resolve_user_email(user_id), "tags": final_tags, "description": final_description, + "group_ids": convert_list_to_string(group_ids) if group_ids else None, + "ingroup_permission": ingroup_permission, + "shared_fields": shared_fields, }, tenant_id=tenant_id, user_id=user_id, ) + + # Update shared_fields on the source MCP record + if shared_fields is not None: + update_mcp_record_manage_fields_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + name=source_record.get("mcp_name", ""), + server_url=source_record.get("mcp_server", ""), + description=source_record.get("description") or "", + tags=source_record.get("tags"), + source=source_record.get("source") or "local", + authorization_token=source_record.get("authorization_token"), + custom_headers=source_record.get("custom_headers"), + config_json=source_record.get("config_json"), + market_id=source_record.get("market_id"), + shared_fields=shared_fields, + ) + return market_id @@ -292,6 +350,9 @@ async def update_community_mcp_service( mcp_server: str | None = None, config_json: Dict[str, Any] | None = None, transport_type: str | None = None, + group_ids: List[int] | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> None: """Update a published market MCP and set it back to pending_review for re-approval.""" current = get_mcp_market_record_by_id(market_id=market_id) @@ -326,6 +387,9 @@ async def update_community_mcp_service( mcp_server=mcp_server, config_json=next_config_json, transport_type=next_transport_type, + group_ids=convert_list_to_string(group_ids) if group_ids else None, + ingroup_permission=ingroup_permission, + shared_fields=shared_fields, ) # Set back to pending_review for re-approval @@ -336,6 +400,24 @@ async def update_community_mcp_service( submitted_by=_resolve_user_email(user_id), ) + # Update shared_fields on the source MCP record + if shared_fields is not None and current.get("source_mcp_id"): + update_mcp_record_manage_fields_by_id( + mcp_id=current["source_mcp_id"], + tenant_id=tenant_id, + user_id=user_id, + name=current.get("mcp_name") or "", + server_url=current.get("mcp_server") or "", + description=current.get("description") or "", + tags=current.get("tags"), + source="local", + authorization_token=None, + custom_headers=None, + config_json=current.get("config_json"), + market_id=market_id, + shared_fields=shared_fields, + ) + async def change_mcp_market_status( *, diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index a64f99607..b4b2ed6a4 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -36,6 +36,7 @@ get_mcp_custom_headers_by_name_and_url, ) from database.user_tenant_db import get_user_tenant_by_user_id +from database.group_db import query_group_ids_by_user from services.mcp_container_service import MCPContainerManager from utils.http_client_utils import create_httpx_client @@ -210,7 +211,11 @@ def _is_container_record(record: dict | None) -> bool: """ if not record: return False - return record.get("container_id") is not None or record.get("config_json") is not None + config_json = record.get("config_json") + # API-type MCPs store OpenAPI JSON in config_json, not container config + if isinstance(config_json, dict) and "openapi" in config_json: + return False + return record.get("container_id") is not None or config_json is not None # --------------------------------------------------------------------------- @@ -387,6 +392,9 @@ async def add_mcp_service( enabled: bool = False, container_id: str | None = None, container_port: int | None = None, + group_ids: str | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> None: """Add an MCP service record. @@ -407,6 +415,8 @@ async def add_mcp_service( enabled: Whether the MCP is enabled container_id: Docker container ID container_port: Container port + group_ids: Comma-separated group IDs that can access this MCP + ingroup_permission: Permission level: EDIT, READ_ONLY, PRIVATE """ status: bool | None = None normalized_container_id = container_id if isinstance(container_id, str) and container_id else None @@ -414,15 +424,67 @@ async def add_mcp_service( resolved_config_json = container_config if is_container and isinstance(container_config, dict) else config_json 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") + # If the name exists but the existing MCP has group restrictions that make it + # invisible to this user, allow the installation anyway. + from database.remote_mcp_db import get_mcp_records_by_tenant + existing = [r for r in get_mcp_records_by_tenant(tenant_id) if r.get("mcp_name") == name] + if existing: + existing_rec = existing[0] + existing_group_ids = (existing_rec.get("group_ids") or "").strip() + if existing_group_ids: + created_by = str(existing_rec.get("created_by") or existing_rec.get("user_id") or "") + if created_by != user_id: + from database.group_db import query_group_ids_by_user + user_grps = [str(g) for g in (query_group_ids_by_user(user_id) or [])] + allowed = [g.strip() for g in existing_group_ids.split(",") if g.strip()] + if any(g in allowed for g in user_grps): + logger.error(f"MCP name already exists: {name}") + raise MCPNameIllegal("MCP name already exists") + else: + logger.error(f"MCP name already exists: {name}") + raise MCPNameIllegal("MCP name already exists") + else: + logger.error(f"MCP name already exists: {name}") + raise MCPNameIllegal("MCP name already exists") resolved_registry_json = registry_json or {} if server_url: - headers = _build_mcp_headers(authorization_token, custom_headers) - tool_names = await _check_mcp_connectivity(server_url, headers, is_container, name) - if tool_names: - resolved_registry_json["_toolNames"] = tool_names + # API-type MCPs use OpenAPI JSON, not MCP protocol + is_api = isinstance(resolved_config_json, dict) and "openapi" in resolved_config_json + if is_api: + # Register OpenAPI service (same as agent config flow) + try: + from services.tool_configuration_service import import_openapi_service, _refresh_openapi_services_in_mcp + import_openapi_service( + service_name=name, + openapi_json=resolved_config_json, + server_url=server_url, + tenant_id=tenant_id, + user_id=user_id, + service_description=description, + headers_template=custom_headers, + force_update=True, + ) + _refresh_openapi_services_in_mcp(tenant_id) + except Exception as exc: + logger.warning(f"Failed to register OpenAPI service '{name}': {exc}") + # Extract tool names from OpenAPI spec for display + api_tools = [] + paths = resolved_config_json.get("paths", {}) or {} + for path, methods in paths.items(): + if isinstance(methods, dict): + for method_name, detail in methods.items(): + if isinstance(detail, dict): + tool_name = detail.get("operationId") or detail.get("summary") or "" + if tool_name: + api_tools.append(tool_name) + if api_tools: + resolved_registry_json["_toolNames"] = api_tools + else: + headers = _build_mcp_headers(authorization_token, custom_headers) + tool_names = await _check_mcp_connectivity(server_url, headers, is_container, name) + if tool_names: + resolved_registry_json["_toolNames"] = tool_names if enabled: status = True @@ -443,6 +505,9 @@ async def add_mcp_service( "tags": tags, "description": description, "config_json": resolved_config_json, + "group_ids": group_ids, + "ingroup_permission": ingroup_permission, + "shared_fields": shared_fields, }, tenant_id=tenant_id, user_id=user_id, @@ -462,6 +527,9 @@ async def add_container_mcp_service( market_id: int | None, port: int, mcp_config: MCPConfigRequest, + group_ids: str | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> dict: """Add a container-based MCP service. @@ -477,6 +545,8 @@ async def add_container_mcp_service( community_id: Linked community record ID port: Host port for the container mcp_config: MCP server configuration + group_ids: Comma-separated group IDs that can access this MCP + ingroup_permission: Permission level: EDIT, READ_ONLY, PRIVATE Returns: Container information dictionary @@ -549,6 +619,8 @@ async def add_container_mcp_service( enabled=True, container_id=container_info.get("container_id"), container_port=container_info.get("host_port"), + group_ids=group_ids, + ingroup_permission=ingroup_permission, ) except Exception as exc: logger.warning(f"Failed to start container MCP service: {exc}") @@ -622,6 +694,9 @@ def update_mcp_service( config_json: dict | None, tags: list | None, market_id: int | None, + group_ids: str | None = None, + ingroup_permission: str | None = None, + shared_fields: dict | None = None, ) -> None: """Update an MCP service record by ID. @@ -637,6 +712,8 @@ def update_mcp_service( config_json: MCP configuration JSON tags: MCP tags market_id: Linked market record ID + group_ids: Comma-separated group IDs that can access this MCP + ingroup_permission: Permission level: EDIT, READ_ONLY, PRIVATE Raises: McpNotFoundError: If MCP record is not found @@ -663,6 +740,9 @@ def update_mcp_service( config_json=next_config_json, tags=tags, market_id=next_market_id, + group_ids=group_ids, + ingroup_permission=ingroup_permission, + shared_fields=shared_fields, ) @@ -827,19 +907,30 @@ async def update_mcp_service_enabled( ) elif enabled: server_url = current_record.get("mcp_server") - health_ok = await mcp_server_health( - remote_mcp_server=server_url, - authorization_token=authorization_token, - custom_headers=custom_headers, - ) - 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") + # Skip MCP protocol check for API-type MCPs + config_json = current_record.get("config_json") + api_type = isinstance(config_json, dict) and "openapi" in config_json + if api_type: + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=True, + ) + else: + health_ok = await mcp_server_health( + remote_mcp_server=server_url, + authorization_token=authorization_token, + custom_headers=custom_headers, + ) + 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, @@ -920,10 +1011,42 @@ async def get_remote_mcp_server_list( mcp_records = get_mcp_records_by_tenant(tenant_id=tenant_id) mcp_records_list = [] can_edit_all = False + user_groups: list[str] | None = None if user_id: user_tenant_record = get_user_tenant_by_user_id(user_id) or {} user_role = str(user_tenant_record.get("user_role") or "").upper() can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + try: + raw_groups = query_group_ids_by_user(user_id) or [] + user_groups = [str(g) for g in raw_groups] + except Exception: + user_groups = [] + + if user_groups is not None: + filtered_records = [] + for record in mcp_records: + # NULL group_ids means public (backward compatible with pre-PR data) + if record.get("group_ids") is None: + filtered_records.append(record) + continue + record_group_ids = (record.get("group_ids") or "").strip() + # User can see MCPs they created + if str(record.get("created_by") or record.get("user_id") or "") == user_id: + filtered_records.append(record) + continue + # User can see MCPs where they belong to at least one allowed group + if user_groups: + allowed = [g.strip() for g in record_group_ids.split(",") if g.strip()] + if any(g in allowed for g in user_groups): + # Hide PRIVATE MCPs from non-creator group members (like agent behavior) + ingroup_perm = (record.get("ingroup_permission") or "").upper() + if ingroup_perm == "PRIVATE": + continue + filtered_records.append(record) + continue + logger.info(f"[MCP group filter] user_id={user_id}, groups={user_groups}, " + f"total={len(mcp_records)}, filtered={len(filtered_records)}") + mcp_records = filtered_records container_status_map = {} try: @@ -946,6 +1069,18 @@ async def get_remote_mcp_server_list( permission = PERMISSION_READ else: permission = PERMISSION_EDIT if can_edit_all or str(created_by) == str(user_id) else PERMISSION_READ + # Public MCPs (NULL group_ids) are editable by all users + if record.get("group_ids") is None: + permission = PERMISSION_EDIT + # For group-shared MCPs, respect ingroup_permission + if permission == PERMISSION_READ and user_groups: + record_group_ids = (record.get("group_ids") or "").strip() + if record_group_ids: + allowed = [g.strip() for g in record_group_ids.split(",") if g.strip()] + if any(g in allowed for g in user_groups): + ingroup_perm = (record.get("ingroup_permission") or "READ_ONLY").upper() + if ingroup_perm == "EDIT": + permission = PERMISSION_EDIT config_json = record.get("config_json") container_id = record.get("container_id") @@ -977,6 +1112,9 @@ async def get_remote_mcp_server_list( "market_id": record.get("market_id"), "is_listed_in_repository": record.get("market_id") is not None, "container_status": container_status, + "group_ids": record.get("group_ids"), + "ingroup_permission": record.get("ingroup_permission"), + "shared_fields": record.get("shared_fields"), } if is_need_auth: record_dict["authorization_token"] = record.get("authorization_token") @@ -1246,6 +1384,11 @@ async def refresh_mcp_service_tool_count( authorization_token = record.get("authorization_token") custom_headers = record.get("custom_headers") + # Skip MCP protocol check for API-type MCPs (they use OpenAPI JSON, not MCP) + config_json = record.get("config_json") + if isinstance(config_json, dict) and "openapi" in config_json: + return + headers = {} if authorization_token: headers["Authorization"] = authorization_token diff --git a/deploy/sql/migrations/v2.3.0_0716_add_mcp_permissions_and_sharing.sql b/deploy/sql/migrations/v2.3.0_0716_add_mcp_permissions_and_sharing.sql new file mode 100644 index 000000000..72d6a6610 --- /dev/null +++ b/deploy/sql/migrations/v2.3.0_0716_add_mcp_permissions_and_sharing.sql @@ -0,0 +1,64 @@ +-- Migration: Add permission and sharing fields to MCP tables +-- Date: 2026-07-16 +-- Description: +-- - mcp_record_t: add group_ids, ingroup_permission, shared_fields +-- - mcp_market_record_t: add group_ids, ingroup_permission, shared_fields +-- Both target the same commit to keep the migration atomic. + +SET search_path TO nexent; + +BEGIN; + +-- ------------------------------------------------------------------------- +-- mcp_market_record_t — group-based access control +-- ------------------------------------------------------------------------- +ALTER TABLE nexent.mcp_market_record_t + ADD COLUMN IF NOT EXISTS group_ids VARCHAR, + ADD COLUMN IF NOT EXISTS ingroup_permission VARCHAR(30) DEFAULT 'READ_ONLY'; + +COMMENT ON COLUMN nexent.mcp_market_record_t.group_ids IS + 'Comma-separated group IDs that can access this MCP'; +COMMENT ON COLUMN nexent.mcp_market_record_t.ingroup_permission IS + 'In-group permission: EDIT, READ_ONLY, PRIVATE'; + +-- ------------------------------------------------------------------------- +-- mcp_market_record_t — shared-fields snapshot at submission time +-- ------------------------------------------------------------------------- +ALTER TABLE nexent.mcp_market_record_t + ADD COLUMN IF NOT EXISTS shared_fields JSON; + +COMMENT ON COLUMN nexent.mcp_market_record_t.shared_fields IS + 'Snapshot of shared_fields at submission time'; + +-- ------------------------------------------------------------------------- +-- mcp_record_t — group-based access control +-- ------------------------------------------------------------------------- +ALTER TABLE nexent.mcp_record_t + ADD COLUMN IF NOT EXISTS group_ids VARCHAR, + ADD COLUMN IF NOT EXISTS ingroup_permission VARCHAR(30) DEFAULT 'READ_ONLY'; + +COMMENT ON COLUMN nexent.mcp_record_t.group_ids IS + 'Comma-separated group IDs that can access this MCP'; +COMMENT ON COLUMN nexent.mcp_record_t.ingroup_permission IS + 'In-group permission: EDIT, READ_ONLY, PRIVATE'; + +-- ------------------------------------------------------------------------- +-- mcp_record_t — field-level sharing flags +-- ------------------------------------------------------------------------- +ALTER TABLE nexent.mcp_record_t + ADD COLUMN IF NOT EXISTS shared_fields JSON; + +COMMENT ON COLUMN nexent.mcp_record_t.shared_fields IS + 'JSON object of field-level sharing flags (e.g. {"serverUrl": true, "authorizationToken": false})'; + +-- ------------------------------------------------------------------------- +-- Grant EDIT permission to existing public MCPs +-- Existing MCPs with NULL group_ids have no group restrictions +-- and should be editable by all tenant users. +-- ------------------------------------------------------------------------- +UPDATE nexent.mcp_record_t +SET ingroup_permission = 'EDIT' +WHERE group_ids IS NULL + AND delete_flag != 'Y'; + +COMMIT; diff --git a/frontend/app/[locale]/mcp-space/components/McpServiceDetailModal.tsx b/frontend/app/[locale]/mcp-space/components/McpServiceDetailModal.tsx index 1d922321f..201e86f7f 100644 --- a/frontend/app/[locale]/mcp-space/components/McpServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/McpServiceDetailModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Alert, App, Button, Form, Input, Modal } from "antd"; +import { Alert, App, Button, Form, Input, Modal, Select } from "antd"; import { ApiOutlined, CloudOutlined, ContainerOutlined, LinkOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { @@ -13,6 +13,9 @@ import type { McpServiceItem } from "@/types/mcpTools"; import { resolveDeploymentType, toPrettyRegistryJson } from "@/lib/mcpTools"; import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; import { useMcpServiceDetail } from "@/hooks/mcpTools/useMcpServiceDetail"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { Can } from "@/components/permission/Can"; import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; import McpToolListModal from "@/components/mcp/McpToolListModal"; import ContainerPortField from "./shared/ContainerPortField"; @@ -68,7 +71,11 @@ export default function McpServiceDetailModal({ const [containerPort, setContainerPort] = useState(); const detail = useMcpServiceDetail({ selectedService, onClose }); - const { draft } = detail; + const { user } = useAuthorizationContext(); + const tenantId = user?.tenantId || null; + const { data: groupData } = useGroupList(tenantId); + const groups = groupData?.groups || []; + const { draft, setDraft } = detail; const originalDeploymentType = useMemo( () => @@ -104,6 +111,8 @@ export default function McpServiceDetailModal({ openApiJson: toPrettyRegistryJson(draft.configJson), containerConfigJson: toPrettyRegistryJson(draft.configJson), containerPort: draft.containerPort, + group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number).filter(Boolean) : undefined, + ingroup_permission: draft.ingroupPermission || "READ_ONLY", }); }, [draft, form]); @@ -114,9 +123,11 @@ export default function McpServiceDetailModal({ const isRemoteLink = deploymentType === McpDeploymentType.REMOTE_LINK; const isContainer = deploymentType === McpDeploymentType.CONTAINER; const isApi = deploymentType === McpDeploymentType.API; + const isLocalImage = deploymentType === McpDeploymentType.LOCAL_IMAGE; const isUnsupported = deploymentType === McpDeploymentType.LOCAL_IMAGE || deploymentType !== originalDeploymentType; + const isReadOnly = selectedService?.permission === "READ_ONLY"; const hasRegistryJson = Boolean(draft.registryJson); const hasConfigJson = Boolean(draft.configJson); @@ -131,7 +142,7 @@ export default function McpServiceDetailModal({ }; const handleSave = async () => { - if (isUnsupported) return; + if (isUnsupported || isReadOnly) return; try { await form.validateFields(); } catch { @@ -221,29 +232,23 @@ export default function McpServiceDetailModal({ -
- {DEPLOYMENT_OPTIONS.map(({ value, labelKey, Icon }) => { - const selected = deploymentType === value; - return ( - - ); - })} +
+
+ {(() => { + const opt = DEPLOYMENT_OPTIONS.find(o => o.value === originalDeploymentType) || DEPLOYMENT_OPTIONS[0]; + const Icon = opt.Icon; + return ( +
+ + {t(opt.labelKey)} +
+ ); + })()} +
- {isUnsupported ? ( + {isLocalImage ? ( - + @@ -277,6 +282,7 @@ export default function McpServiceDetailModal({ @@ -291,45 +297,93 @@ export default function McpServiceDetailModal({ - - - +
+ + + + +
- - - +
+ + + + +
- - - +
+ + + + +
@@ -353,6 +407,7 @@ export default function McpServiceDetailModal({ @@ -366,6 +421,7 @@ export default function McpServiceDetailModal({ rows={2} className="w-full rounded-md" placeholder={t("mcpConfig.addServer.customHeadersPlaceholder")} + disabled={isReadOnly} /> @@ -379,6 +435,7 @@ export default function McpServiceDetailModal({ rows={6} className="w-full rounded-md" placeholder={t("mcpConfig.openApiToMcp.jsonPlaceholder")} + disabled={isReadOnly} /> @@ -396,13 +453,29 @@ export default function McpServiceDetailModal({ - - - +
+ + + + +
@@ -420,6 +493,58 @@ export default function McpServiceDetailModal({ ) : null} + +
+ + { + setDraft((prev) => prev ? { ...prev, ingroupPermission: value as "EDIT" | "READ_ONLY" | "PRIVATE" } : prev); + form.setFieldValue("ingroup_permission", value); + }} + options={[ + { value: "READ_ONLY", label: t("knowledgeBase.ingroup.permission.READ_ONLY") }, + { value: "EDIT", label: t("knowledgeBase.ingroup.permission.EDIT") }, + { value: "PRIVATE", label: t("knowledgeBase.ingroup.permission.PRIVATE") }, + ]} + /> + + +
+
+ {isApi ? ( +

此添加方式不支持分组和权限设置

+ ) : null} +
- {t("mcpTools.detail.save")} + {isReadOnly ? "无编辑权限" : t("mcpTools.detail.save")}
@@ -504,6 +629,7 @@ export default function McpServiceDetailModal({ open={publishConfirmOpen} source={selectedService} publishing={detail.publishing} + tenantId={tenantId} onCancel={() => setPublishConfirmOpen(false)} onConfirm={async (override) => { const ok = await detail.publish(override); diff --git a/frontend/app/[locale]/mcp-space/components/McpToolsSearchFilterBar.tsx b/frontend/app/[locale]/mcp-space/components/McpToolsSearchFilterBar.tsx index f3065bef3..b3d7842d9 100644 --- a/frontend/app/[locale]/mcp-space/components/McpToolsSearchFilterBar.tsx +++ b/frontend/app/[locale]/mcp-space/components/McpToolsSearchFilterBar.tsx @@ -61,14 +61,16 @@ export default function McpToolsSearchFilterBar({ key={item.value} type="button" onClick={onClick} - className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs transition ${ + className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${ selected - ? "border-blue-500 bg-blue-500 font-medium text-white shadow-sm" - : "border-slate-200 bg-slate-50 text-slate-600 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700" + ? "bg-primary text-white" + : "bg-slate-100 text-slate-700 hover:bg-slate-200" }`} > {item.label} - + {item.count} diff --git a/frontend/app/[locale]/mcp-space/components/MineMcpServiceCard.tsx b/frontend/app/[locale]/mcp-space/components/MineMcpServiceCard.tsx index 154f979cd..0473d0de6 100644 --- a/frontend/app/[locale]/mcp-space/components/MineMcpServiceCard.tsx +++ b/frontend/app/[locale]/mcp-space/components/MineMcpServiceCard.tsx @@ -1,7 +1,7 @@ import { Button, Dropdown, type MenuProps } from "antd"; import { ArrowDownFromLine, Clock, Edit3, MoreHorizontal, Power, RefreshCw, Share2, Trash2, Upload } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { McpServiceStatus, McpSource } from "@/const/mcpTools"; +import { McpServiceStatus, McpDeploymentType } from "@/const/mcpTools"; import type { CommunityMcpCard, McpServiceItem } from "@/types/mcpTools"; import { formatRegistryDate, @@ -65,7 +65,10 @@ export default function MineMcpServiceCard({ const reviewStatus = onlineService?.reviewStatus || service.reviewStatus; const isPending = reviewStatus === "pending"; const isInRepository = isLocal - ? Boolean(localService?.isListedInRepository) + ? Boolean(onlineService) && onlineService?.reviewStatus === "approved" + : reviewStatus === "approved"; + const hasOnlineRecord = isLocal + ? Boolean(onlineService) && (onlineService?.reviewStatus === "approved" || onlineService?.reviewStatus === "pending") : reviewStatus === "approved"; const reviewBadge = getMineCardReviewBadge(item, onlineService); const updatedAt = formatRegistryDate(service.updatedAt || ""); @@ -73,9 +76,7 @@ export default function MineMcpServiceCard({ // Owned = user-created MCP can be published/updated; community-installed // or registry-installed MCPs only permit deletion. - const isOwned = item.kind === "community" || ( - localService?.permission === "EDIT" && localService?.source === McpSource.LOCAL - ); + const isOwned = item.kind === "community" || localService?.permission === "EDIT"; const actionItems: MenuProps["items"] = (() => { if (!isOwned) { @@ -99,16 +100,11 @@ export default function MineMcpServiceCard({ icon: , onClick: () => onViewReviewProgress?.(item, onlineService), }); - } else if (reviewStatus === "approved") { - items.push({ - key: "submit-version-update", - label: t("mcpTools.mine.submitVersionUpdate"), - icon: , - disabled: publishing, - onClick: () => onSubmitVersionUpdate(item, onlineService), - }); - } else { - // never submitted, rejected, or offline → apply for listing + } else if ( + deploymentType === McpDeploymentType.REMOTE_LINK || + deploymentType === McpDeploymentType.CONTAINER + ) { + // only remote link and container MCPs can be published to community items.push({ key: "apply-for-listing", label: t("mcpTools.mine.applyForListing"), @@ -118,10 +114,10 @@ export default function MineMcpServiceCard({ }); } - if (isInRepository) { + if (hasOnlineRecord) { items.push({ key: "unpublish-online-version", - label: t("mcpTools.mine.unpublishOnlineVersion"), + label: isPending ? "撤回审核" : t("mcpTools.mine.unpublishOnlineVersion"), icon: , danger: true, disabled: unpublishing, diff --git a/frontend/app/[locale]/mcp-space/components/PublishConfirmModal.tsx b/frontend/app/[locale]/mcp-space/components/PublishConfirmModal.tsx index 7d5d8a0b7..1bf455a26 100644 --- a/frontend/app/[locale]/mcp-space/components/PublishConfirmModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/PublishConfirmModal.tsx @@ -1,9 +1,13 @@ +"use client"; + import { useEffect, useState } from "react"; -import { Form, Input, Modal } from "antd"; +import { Form, Input, Modal, Select } from "antd"; import { useTranslation } from "react-i18next"; import { McpTransportType } from "@/const/mcpTools"; import type { McpServiceItem } from "@/types/mcpTools"; import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { Can } from "@/components/permission/Can"; import TagEditor from "./shared/TagEditor"; export interface PublishOverride { @@ -15,12 +19,19 @@ export interface PublishOverride { serverUrl: string; /** Container config JSON text; only used when publishing a container MCP. */ containerConfigJson?: string; + /** Group IDs that can access this MCP. */ + groupIds?: number[]; + /** Permission level: EDIT, READ_ONLY, PRIVATE. */ + ingroupPermission?: "EDIT" | "READ_ONLY" | "PRIVATE"; + /** Field-level sharing flags. */ + sharedFields?: Record; } interface PublishConfirmModalProps { open: boolean; source: McpServiceItem | null; publishing: boolean; + tenantId: string | null; onCancel: () => void; onConfirm: (override: PublishOverride) => Promise | void; } @@ -33,6 +44,7 @@ export default function PublishConfirmModal({ open, source, publishing, + tenantId, onCancel, onConfirm, }: PublishConfirmModalProps) { @@ -46,8 +58,14 @@ export default function PublishConfirmModal({ tags: [], serverUrl: "", containerConfigJson: "", + groupIds: [], + ingroupPermission: "READ_ONLY", + sharedFields: {}, }); + const { data: groupData } = useGroupList(tenantId); + const groups = groupData?.groups || []; + useEffect(() => { if (!open || !source) return; const containerConfigJson = @@ -61,6 +79,9 @@ export default function PublishConfirmModal({ tags: source.tags || [], serverUrl: source.serverUrl || "", containerConfigJson, + groupIds: [], + ingroupPermission: "READ_ONLY", + sharedFields: {}, }; setDraft(next); form.setFieldsValue(next); @@ -70,6 +91,15 @@ export default function PublishConfirmModal({ setDraft((prev) => ({ ...prev, ...partial })); }; + const handlePermissionChange = (value: string) => { + const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; + patch({ ingroupPermission: permission }); + if (permission === "PRIVATE") { + patch({ groupIds: [] }); + form.setFieldsValue({ group_ids: [] }); + } + }; + const handleOk = async () => { if (!source) return; try { @@ -77,6 +107,8 @@ export default function PublishConfirmModal({ } catch { return; } + + const isPrivate = draft.ingroupPermission === "PRIVATE"; await onConfirm({ name: draft.name.trim(), description: draft.description, @@ -90,9 +122,14 @@ export default function PublishConfirmModal({ source?.transportType === McpTransportType.CONTAINER ? draft.containerConfigJson?.trim() ?? "" : undefined, + groupIds: isPrivate ? [] : draft.groupIds, + ingroupPermission: draft.ingroupPermission, + sharedFields: draft.sharedFields, }); }; + const isGroupSelectDisabled = draft.ingroupPermission === "PRIVATE"; + return ( {source?.transportType !== McpTransportType.CONTAINER ? ( - - { - patch({ serverUrl: event.target.value }); - form.setFieldValue("serverUrl", event.target.value); - }} - className="rounded-md" - /> - +
+ + { + patch({ serverUrl: event.target.value }); + form.setFieldValue("serverUrl", event.target.value); + }} + className="w-full 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")} - /> - +
+ + { + patch({ containerConfigJson: event.target.value }); + form.setFieldValue("containerConfigJson", event.target.value); + }} + rows={6} + className="mt-2 w-full rounded-md font-mono text-sm" + placeholder={t("mcpTools.addModal.containerConfigPlaceholder")} + /> + + +
) : null} + + + + ({ + label: group.group_name, + value: group.group_id, + }))} + disabled={isGroupSelectDisabled} + onChange={(values: number[]) => patch({ groupIds: values })} + className="rounded-md" + /> + +
); diff --git a/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx b/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx index 4995cda1f..1fdafea56 100644 --- a/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { App, Button, Form, Input, Modal } from "antd"; +import { App, Button, Form, Input, Modal, Select } from "antd"; import { useTranslation } from "react-i18next"; import { Globe, @@ -33,6 +33,9 @@ import { import TransportIcon from "./shared/TransportIcon"; import JsonPreviewModal from "./shared/JsonPreviewModal"; import TagEditor from "./shared/TagEditor"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { Can } from "@/components/permission/Can"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; interface PublishedServiceDetailModalProps { open: boolean; @@ -60,6 +63,10 @@ export default function PublishedServiceDetailModal({ edit; const [showServerJsonModal, setShowServerJsonModal] = useState(false); const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + const { user } = useAuthorizationContext(); + const tenantId = user?.tenantId || null; + const { data: groupData } = useGroupList(tenantId); + const groups = groupData?.groups || []; const { websiteUrl, repositoryUrl } = extractRegistryLinks( (service?.registryJson || undefined) as Record | undefined @@ -91,6 +98,8 @@ export default function PublishedServiceDetailModal({ name: draft.name, description: draft.description, version: draft.version, + ingroup_permission: draft.ingroupPermission ?? "READ_ONLY", + group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number) : [], }); }, [open, draft, form]); @@ -100,6 +109,8 @@ export default function PublishedServiceDetailModal({ name: draft.name, description: draft.description, version: draft.version, + ingroup_permission: draft.ingroupPermission ?? "READ_ONLY", + group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number) : [], }); setIsEditing(true); }; @@ -114,6 +125,15 @@ export default function PublishedServiceDetailModal({ setIsEditing(false); }; + const handlePermissionChange = (value: string) => { + const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; + updateDraft({ ingroupPermission: permission }); + if (permission === "PRIVATE") { + updateDraft({ groupIds: "" }); + form.setFieldsValue({ group_ids: [] }); + } + }; + const handleSave = async () => { try { await form.validateFields(); @@ -383,6 +403,62 @@ export default function PublishedServiceDetailModal({ loading={edit.tagSaving} /> + + {/* Permissions Section */} +
+

+ + {t("tenantResources.knowledgeBase.permission")} +

+ {isEditing ? ( +
+ + + ({ + label: g.group_name, + value: g.group_id, + }))} + disabled={draft.ingroupPermission === "PRIVATE"} + onChange={(values: number[]) => updateDraft({ groupIds: values.join(",") })} + /> + + +
+ ) : ( +
+ {draft.ingroupPermission === "PRIVATE" ? ( + + {t("knowledgeBase.ingroup.permission.PRIVATE")} + + ) : draft.groupIds ? ( + + {t("knowledgeBase.ingroup.permission.READ_ONLY")} + + ) : ( + + {t("knowledgeBase.ingroup.permission.READ_ONLY")} + + )} +
+ )} +
diff --git a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx index a87a479a6..4a288251d 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx @@ -141,15 +141,69 @@ export default function McpCommunityDetailModal({ - {/* Server URL Section */} - {!service.configJson && ( + {/* Service Config Section */} + {(!service.configJson || service.authorizationToken || service.customHeaders) && (

- - {t("mcpTools.detail.serverUrl")} + + 服务配置

-
- {service.serverUrl || "-"} +
+ {!service.configJson ? ( +
+ +
+
+ {service.serverUrl || "-"} +
+ + {service.sharedFields?.serverUrl ? "已共享" : "未共享"} + +
+
+ ) : null} + + {service.authorizationToken ? ( +
+ +
+
+ {service.authorizationToken} +
+ + {service.sharedFields?.authorizationToken ? "已共享" : "未共享"} + +
+
+ ) : null} + + {service.customHeaders ? ( +
+ +
+
+ {typeof service.customHeaders === "string" + ? service.customHeaders + : JSON.stringify(service.customHeaders, null, 2)} +
+ + {service.sharedFields?.customHeaders ? "已共享" : "未共享"} + +
+
+ ) : null}
)} diff --git a/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx index f75bba69c..dd66874c8 100644 --- a/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Alert, Button, Form, Input, Upload } from "antd"; +import { Alert, Button, Form, Input, Select, Upload } from "antd"; import type { UploadFile } from "antd"; import { ApiOutlined, CloudOutlined, ContainerOutlined, LinkOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; @@ -7,6 +7,9 @@ import { McpDeploymentType, McpTransportType } from "@/const/mcpTools"; import type { LocalAddMcpDraft } from "@/types/mcpTools"; import { useMcpAddLocal } from "@/hooks/mcpTools/useMcpAddLocal"; import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { Can } from "@/components/permission/Can"; import ContainerPortField from "../../shared/ContainerPortField"; import TagEditor from "../../shared/TagEditor"; @@ -46,6 +49,8 @@ const createInitialDraft = (): LocalAddMcpDraft => ({ containerPort: undefined, uploadImageFile: null, tags: [], + groupIds: [], + ingroupPermission: "READ_ONLY", }); interface AddMcpServiceLocalSectionProps { @@ -66,6 +71,10 @@ export default function AddMcpServiceLocalSection({ const [deploymentType, setDeploymentType] = useState( McpDeploymentType.REMOTE_LINK ); + const { user } = useAuthorizationContext(); + const tenantId = user?.tenantId || null; + const { data: groupData } = useGroupList(tenantId); + const groups = groupData?.groups || []; const { submit, submitting } = useMcpAddLocal({ onSuccess: () => { setDraft(createInitialDraft()); @@ -120,6 +129,15 @@ export default function AddMcpServiceLocalSection({ patchDraft({ tags: draft.tags.filter((_, i) => i !== index) }); }; + const handlePermissionChange = (value: string) => { + const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; + patchDraft({ ingroupPermission: permission }); + if (permission === "PRIVATE") { + patchDraft({ groupIds: [] }); + form.setFieldValue("group_ids", []); + } + }; + const handleSubmit = async () => { try { await form.validateFields(); @@ -135,6 +153,7 @@ export default function AddMcpServiceLocalSection({ const isContainer = deploymentType === McpDeploymentType.CONTAINER; const isApi = deploymentType === McpDeploymentType.API; const isLocalImage = deploymentType === McpDeploymentType.LOCAL_IMAGE; + const isGroupSelectDisabled = draft.ingroupPermission === "PRIVATE" || isApi; return (
@@ -163,6 +182,7 @@ export default function AddMcpServiceLocalSection({ value === McpDeploymentType.LOCAL_IMAGE ? McpTransportType.CONTAINER : McpTransportType.URL; + const nextPermission = value === McpDeploymentType.API ? "PRIVATE" : "READ_ONLY"; patchDraft({ deploymentType: value, transportType: nextTransport, @@ -170,7 +190,11 @@ export default function AddMcpServiceLocalSection({ value === McpDeploymentType.LOCAL_IMAGE ? draft.uploadImageFile : null, + groupIds: nextPermission === "PRIVATE" ? [] : draft.groupIds, + ingroupPermission: nextPermission as "EDIT" | "READ_ONLY" | "PRIVATE", }); + form.setFieldValue("ingroup_permission", nextPermission); + form.setFieldValue("group_ids", []); form.setFieldValue("transportType", nextTransport); }} className={`flex h-20 flex-col items-center justify-center gap-2 rounded-xl border text-sm transition ${ @@ -219,40 +243,82 @@ export default function AddMcpServiceLocalSection({ - - - +
+ + + + +
- - - +
+ + + + +
- - - +
+ + + + +
@@ -266,14 +332,28 @@ export default function AddMcpServiceLocalSection({ - - - +
+ + + + +
@@ -424,6 +504,54 @@ export default function AddMcpServiceLocalSection({ ) : null} + +
+ + + + +
+
+ {isApi ? ( +

此添加方式不支持分组和权限设置

+ ) : null} +
string ): Array<{ value: DeploymentFilter; label: string; count: number }> { + const hasLocalImage = items.some( + (item) => resolveDeploymentType(item) === McpDeploymentType.LOCAL_IMAGE + ); return [ { value: FILTER_ALL, label: t("mcpTools.deploymentType.all"), count: items.length, }, - ...deploymentCategories.map((deploymentType) => ({ - value: deploymentType, - label: t(getDeploymentTypeLabelKey(deploymentType)), - count: items.filter( - (item) => resolveDeploymentType(item) === deploymentType - ).length, - })), + ...deploymentCategories + .filter((dt) => dt !== McpDeploymentType.LOCAL_IMAGE || hasLocalImage) + .map((deploymentType) => ({ + value: deploymentType, + label: t(getDeploymentTypeLabelKey(deploymentType)), + count: items.filter( + (item) => resolveDeploymentType(item) === deploymentType + ).length, + })), ]; } @@ -223,7 +228,7 @@ export default function McpToolsPage() { icon={} onClick={openAddModal} > - 添加 MCP + {t("mcpTools.addModal.title")} ) : null; @@ -390,11 +395,6 @@ function RepositoryView({ const [deploymentType, setDeploymentType] = useState(FILTER_ALL); - const categoryStats = useMemo( - () => getDeploymentCategoryStats(browser.services, t), - [browser.services, t] - ); - const filteredServices = useMemo(() => { return filterByDeploymentType(browser.services, deploymentType).filter( (item) => matchesNameOrTag(item, browser.filters.search) @@ -417,13 +417,14 @@ function RepositoryView({
browser.updateFilter("search", value)} - onDeploymentTypeChange={setDeploymentType} /> +

+ {t("mcpTools.repository.installHint")} +

+ {browser.loading ? ( @@ -585,6 +586,11 @@ function MineView({ item: MineMcpCardItem, onlineService?: CommunityMcpCard ) => { + const sharedFields = item.service.sharedFields; + if (!sharedFields || !Object.values(sharedFields).some(Boolean)) { + message.warning("未勾选共享配置信息"); + return; + } if (item.kind === "community") { // Community items: submit directly doSubmitVersionUpdate(item, onlineService); @@ -620,6 +626,7 @@ function MineView({ version: (service.version || "").trim(), tags: service.tags || [], registry_json: service.registryJson, + shared_fields: item.service.sharedFields, }); } else if (onlineService?.marketId) { const service = item.service; @@ -636,6 +643,7 @@ function MineView({ ? McpTransportType.CONTAINER : McpTransportType.URL, config_json: configJson, + shared_fields: item.service.sharedFields, }); } else if (item.kind === "local") { const service = item.service; @@ -648,14 +656,20 @@ function MineView({ tags: service.tags || [], mcp_server: configJson ? undefined : service.serverUrl, config_json: configJson, + shared_fields: item.service.sharedFields, }); } - message.success(t("mcpTools.mine.submitVersionUpdateSuccess")); + const isInitialPublish = item.kind === "local" && !onlineService?.marketId; + message.success( + isInitialPublish + ? "上架申请成功" + : "上架申请成功" + ); // Optimistically update local cache to show pending status updateLocalReviewStatus(item, "pending"); await refreshMineData(); } catch { - message.error(t("mcpTools.mine.submitVersionUpdateFailed")); + message.error("上架申请失败"); } finally { setPublishingKey(null); } @@ -682,12 +696,15 @@ function MineView({ onlineService: CommunityMcpCard ) => { if (!onlineService.communityId) return; + const isPendingReview = onlineService.reviewStatus === "pending"; modal.confirm({ - title: t("mcpTools.mine.unpublishOnlineVersionTitle"), - content: t("mcpTools.mine.unpublishOnlineVersionDescription", { - name: onlineService.name || item.service.name, - }), - okText: t("mcpTools.mine.unpublishOnlineVersion"), + title: isPendingReview ? "确认撤回审核?" : t("mcpTools.mine.unpublishOnlineVersionTitle"), + content: isPendingReview + ? t("mcpTools.mine.reviewModal.cancelApply") + : t("mcpTools.mine.unpublishOnlineVersionDescription", { + name: onlineService.name || item.service.name, + }), + okText: isPendingReview ? t("mcpTools.mine.reviewModal.cancelApply") : t("mcpTools.mine.unpublishOnlineVersion"), cancelText: t("common.cancel"), okButtonProps: { danger: true }, centered: true, @@ -696,10 +713,18 @@ function MineView({ setUnpublishingKey(key); try { await deleteCommunityMcpTool(onlineService.communityId!); - message.success(t("mcpTools.mine.unpublishOnlineVersionSuccess")); + message.success( + isPendingReview + ? t("mcpTools.mine.cancelApplySuccess") + : t("mcpTools.mine.unpublishOnlineVersionSuccess") + ); await refreshMineData(); } catch { - message.error(t("mcpTools.mine.unpublishOnlineVersionFailed")); + message.error( + isPendingReview + ? t("mcpTools.mine.cancelApplyFailed") + : t("mcpTools.mine.unpublishOnlineVersionFailed") + ); } finally { setUnpublishingKey(null); } @@ -790,6 +815,10 @@ function MineView({ onDeploymentTypeChange={setDeploymentType} /> +

+ {t("mcpTools.mine.publishHint")} +

+ {loading ? ( @@ -897,9 +926,9 @@ function getDeduplicatedMineItems( localServices: McpServiceItem[], publishedServices: CommunityMcpCard[] ): MineMcpCardItem[] { - // Only show local MCPs that belong to the current user + // Only show local MCPs that belong to the current user or are shared via groups const myLocalServices = localServices.filter( - (s) => s.permission === "EDIT" + (s) => s.permission === "EDIT" || s.groupIds ); const linkedCommunityIds = new Set(); const localNames = new Set(); @@ -980,29 +1009,8 @@ function ReviewCenterView({ }) { const { t } = useTranslation("common"); const { message } = App.useApp(); - const [statusFilter, setStatusFilter] = useState(FILTER_ALL); const [reviewingId, setReviewingId] = useState(null); - const statusTabs = useMemo(() => { - const items = browser.services; - const counts: Record = {}; - for (const s of items) { - const st = s.reviewStatus || "pending"; - counts[st] = (counts[st] || 0) + 1; - } - return [ - { value: FILTER_ALL, label: t("mcpTools.review.status.all"), count: items.length }, - { value: "pending", label: t("mcpTools.review.status.pending"), count: counts.pending || 0 }, - { value: "approved", label: t("mcpTools.review.status.approved"), count: counts.approved || 0 }, - { value: "rejected", label: t("mcpTools.review.status.rejected"), count: counts.rejected || 0 }, - ]; - }, [browser.services, t]); - - const filteredServices = useMemo(() => { - if (statusFilter === FILTER_ALL) return browser.services; - return browser.services.filter((s) => (s.reviewStatus || "pending") === statusFilter); - }, [browser.services, statusFilter]); - const handleReview = async ( service: CommunityMcpCard, action: "approve" | "reject" @@ -1027,20 +1035,11 @@ function ReviewCenterView({ return (
- browser.updateFilter("search", value)} - filterTabs={statusTabs} - activeFilterTab={statusFilter} - onFilterTabChange={(value) => setStatusFilter(value)} - /> - {browser.loading ? ( - ) : filteredServices.length === 0 ? ( + ) : browser.services.length === 0 ? ( @@ -1067,7 +1066,7 @@ function ReviewCenterView({ - {filteredServices.map((service) => ( + {browser.services.map((service) => ( { @@ -220,14 +222,12 @@ export function useMcpServiceDetail({ authorization_token: nextToken || undefined, custom_headers: currentDraft.customHeaders, config_json: currentDraft.configJson, + group_ids: currentDraft.groupIds ?? undefined, + ingroup_permission: currentDraft.ingroupPermission ?? undefined, + shared_fields: currentDraft.sharedFields ?? 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")); @@ -270,6 +270,9 @@ export function useMcpServiceDetail({ tags?: string[]; serverUrl?: string; containerConfigJson?: string; + groupIds?: number[]; + ingroupPermission?: string; + sharedFields?: Record; }) => { if (!selectedService || selectedService.mcpId < 0) return false; setPublishing(true); @@ -306,6 +309,9 @@ export function useMcpServiceDetail({ tags: editedTags, ...(!isContainer ? { mcp_server: editedServerUrl } : {}), ...(parsedConfig ? { config_json: parsedConfig } : {}), + ...(override?.groupIds !== undefined ? { group_ids: override.groupIds } : {}), + ...(override?.ingroupPermission !== undefined ? { ingroup_permission: override.ingroupPermission } : {}), + ...(override?.sharedFields !== undefined ? { shared_fields: override.sharedFields } : {}), }); message.success(t("mcpTools.community.publishSuccess")); diff --git a/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts b/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts index 5741678ee..e4ec5bcff 100644 --- a/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts +++ b/frontend/hooks/mcpTools/usePublishedServiceDetailEdit.ts @@ -18,6 +18,9 @@ export interface PublishedServiceEditDraft { description: string; version: string; tags: string[]; + groupIds?: string; + ingroupPermission?: "EDIT" | "READ_ONLY" | "PRIVATE"; + sharedFields?: Record; } const draftFromItem = ( @@ -30,6 +33,9 @@ const draftFromItem = ( description: item.description || "", version: item.version || "", tags: item.tags || [], + groupIds: item.groupIds, + ingroupPermission: item.ingroupPermission, + sharedFields: item.sharedFields, }; }; @@ -81,6 +87,9 @@ export function usePublishedServiceDetailEdit( description: currentDraft.description.trim(), version: currentDraft.version.trim(), tags: newTags, + group_ids: currentDraft.groupIds ? currentDraft.groupIds.split(",").map(Number).filter(Boolean) : undefined, + ingroup_permission: currentDraft.ingroupPermission, + shared_fields: currentDraft.sharedFields ?? undefined, }); // Update local state setDraft((prev) => { @@ -132,6 +141,9 @@ export function usePublishedServiceDetailEdit( description: currentDraft.description.trim(), version: currentDraft.version.trim(), tags: currentDraft.tags, + group_ids: currentDraft.groupIds ? currentDraft.groupIds.split(",").map(Number).filter(Boolean) : undefined, + ingroup_permission: currentDraft.ingroupPermission, + shared_fields: currentDraft.sharedFields ?? undefined, }); message.success(t("mcpTools.service.saveSuccess")); queryClient.invalidateQueries({ diff --git a/frontend/lib/mcpTools.ts b/frontend/lib/mcpTools.ts index c3a2bfd2b..869a7530c 100644 --- a/frontend/lib/mcpTools.ts +++ b/frontend/lib/mcpTools.ts @@ -61,6 +61,9 @@ export const resolveDeploymentType = (item: { serverUrl?: string; }): McpDeploymentType => { if (item.deploymentType) return item.deploymentType; + if (item.configJson && typeof item.configJson === "object" && "openapi" in item.configJson) { + return McpDeploymentType.API; + } if (item.transportType === McpTransportType.CONTAINER || item.configJson) { return McpDeploymentType.CONTAINER; } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index b9c3a1714..2ffb34633 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -2591,6 +2591,7 @@ "mcpTools.repository.downloadCount": "Downloads", "mcpTools.repository.empty": "No matching repository MCP tools found.", + "mcpTools.repository.installHint": "MCP services in the same tenant must be \"installed\" before they can be enabled.", "mcpTools.repository.install": "Install", "mcpTools.repository.installed": "Installed", "mcpTools.repository.offline": "Take Down", @@ -2644,6 +2645,7 @@ "mcpTools.mine.confirmApplyListing": "Are you sure you want to submit {{name}} for listing?", "mcpTools.mine.deleteSuccess": "MCP service deleted", "mcpTools.mine.deleteFailed": "Failed to delete MCP service", + "mcpTools.mine.publishHint": "MCP services added via remote link or container can be published to the repository", "mcpTools.mine.deleteConfirmTitle": "Delete this MCP service?", "mcpTools.mine.deleteConfirmDescription": "After deletion, {{name}} will no longer appear in your list.", "mcpTools.mine.submitVersionUpdateSuccess": "Version update has been submitted for review", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index d02e79fd9..f077074d9 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -3419,6 +3419,7 @@ "mcpTools.deploymentType.localImage": "本地镜像", "mcpTools.deploymentType.label": "部署方式", "mcpTools.repository.empty": "未找到匹配的仓库 MCP 工具。", + "mcpTools.repository.installHint": "同租户内的MCP需先「安装MCP」后才能启用", "mcpTools.repository.install": "安装", "mcpTools.repository.details": "查看详情", "mcpTools.repository.offline": "下架", @@ -3453,6 +3454,7 @@ "mcpTools.mine.deleteConfirmDescription": "删除后,{{name}} 将不再显示在您的列表中。", "mcpTools.mine.deleteSuccess": "MCP 服务已删除", "mcpTools.mine.deleteFailed": "删除 MCP 服务失败", + "mcpTools.mine.publishHint": "通过远程链接和容器化部署方式添加的MCP服务可以上架到仓库", "mcpTools.mine.reviewProgressTitle": "版本更新审核进度", "mcpTools.mine.reviewProgressService": "MCP 服务", "mcpTools.mine.reviewProgressVersion": "版本", diff --git a/frontend/services/mcpService.ts b/frontend/services/mcpService.ts index a7926bfa0..23f2de47b 100644 --- a/frontend/services/mcpService.ts +++ b/frontend/services/mcpService.ts @@ -55,6 +55,9 @@ export const getMcpServerList = async (tenantId?: string | null) => { version: server.version, market_id: server.market_id, is_listed_in_repository: server.is_listed_in_repository, + group_ids: server.group_ids, + ingroup_permission: server.ingroup_permission, + shared_fields: server.shared_fields, }; }); diff --git a/frontend/services/mcpToolsService.ts b/frontend/services/mcpToolsService.ts index 9e9b451d5..0a91a49d4 100644 --- a/frontend/services/mcpToolsService.ts +++ b/frontend/services/mcpToolsService.ts @@ -50,6 +50,9 @@ type AddContainerMcpToolPayload = { market_id?: number; port: number; mcp_config: McpContainerConfigPayload; + group_ids?: string; + ingroup_permission?: string; + shared_fields?: Record; }; type PortConflictResult = { @@ -296,11 +299,13 @@ export const listMcpTools = async (params?: { tag?: string }) => { updatedAt: s.update_time, tags: s.tags || [], transportType: - (s.config_json !== undefined && s.config_json !== null) || - (s.container_id !== undefined && s.container_id !== null) || - (s.container_port !== undefined && s.container_port !== null) - ? McpTransportType.CONTAINER - : McpTransportType.URL, + s.config_json && typeof s.config_json === "object" && "openapi" in s.config_json + ? McpTransportType.URL + : (s.config_json !== undefined && s.config_json !== null) || + (s.container_id !== undefined && s.container_id !== null) || + (s.container_port !== undefined && s.container_port !== null) + ? McpTransportType.CONTAINER + : McpTransportType.URL, serverUrl: s.mcp_url, version: s.version ?? undefined, registryJson: s.registry_json ?? undefined, @@ -315,6 +320,9 @@ export const listMcpTools = async (params?: { tag?: string }) => { communityId: s.market_id ?? undefined, isListedInRepository: s.is_listed_in_repository ?? undefined, permission: s.permission ?? undefined, + groupIds: s.group_ids ?? undefined, + ingroupPermission: s.ingroup_permission ?? undefined, + sharedFields: s.shared_fields ?? undefined, } as McpServiceItem; }); return { success: true, data: items } as McpToolsApiResult; @@ -505,6 +513,9 @@ export type PublishCommunityMcpToolPayload = { tags?: string[]; mcp_server?: string; config_json?: McpContainerConfigPayload; + group_ids?: number[]; + ingroup_permission?: string; + shared_fields?: Record; }; export const publishCommunityMcpTool = async ( @@ -562,6 +573,9 @@ export const updateCommunityMcpTool = async (payload: { mcp_server?: string; transport_type?: McpTransportType; config_json?: McpContainerConfigPayload; + group_ids?: number[]; + ingroup_permission?: string; + shared_fields?: Record; }) => { try { const response = await fetchWithAuth( diff --git a/frontend/types/mcpTools.ts b/frontend/types/mcpTools.ts index 439691fa8..ac17a0fc3 100644 --- a/frontend/types/mcpTools.ts +++ b/frontend/types/mcpTools.ts @@ -150,6 +150,11 @@ export interface CommunityMcpCard { reviewType?: "initial_listing" | "version_update"; previousVersion?: string; pendingVersion?: string; + groupIds?: string; + ingroupPermission?: "EDIT" | "READ_ONLY" | "PRIVATE"; + sharedFields?: Record; + authorizationToken?: string; + customHeaders?: Record; } export interface McpServiceItem { @@ -179,6 +184,9 @@ export interface McpServiceItem { reviewStatus?: "pending" | "approved" | "rejected" | "offline"; reviewType?: "initial_listing" | "version_update"; permission?: "EDIT" | "READ_ONLY"; + groupIds?: string; + ingroupPermission?: "EDIT" | "READ_ONLY" | "PRIVATE"; + sharedFields?: Record; } export interface McpTagStat { @@ -201,6 +209,9 @@ export interface AddMcpServicePayload { registry_json?: Record; enabled?: boolean; market_id?: number; + group_ids?: string; + ingroup_permission?: string; + shared_fields?: Record; } export interface UpdateMcpServicePayload { @@ -214,6 +225,9 @@ export interface UpdateMcpServicePayload { config_json?: Record; version?: string; community_id?: number; + group_ids?: string; + ingroup_permission?: string; + shared_fields?: Record; } export interface ToggleMcpServicePayload { @@ -259,6 +273,9 @@ export interface LocalAddMcpDraft { uploadImageFile?: File | null; tags: string[]; version?: string; + groupIds?: number[]; + ingroupPermission?: "EDIT" | "READ_ONLY" | "PRIVATE"; + sharedFields?: Record; } /** diff --git a/test/backend/app/test_mcp_management_app.py b/test/backend/app/test_mcp_management_app.py index 063706d51..50444e4df 100644 --- a/test/backend/app/test_mcp_management_app.py +++ b/test/backend/app/test_mcp_management_app.py @@ -108,7 +108,7 @@ def test_list_success(self, mock_list, mock_auth): assert resp.status_code == HTTPStatus.OK assert resp.json()["status"] == "success" mock_list.assert_called_once_with( - tenant_id="tid", search=None, tag=None, + tenant_id="tid", user_id="uid", search=None, tag=None, transport_type=None, cursor=None, limit=30, ) diff --git a/test/backend/database/test_market_mcp_db.py b/test/backend/database/test_market_mcp_db.py index be1b52720..d3f45d540 100644 --- a/test/backend/database/test_market_mcp_db.py +++ b/test/backend/database/test_market_mcp_db.py @@ -55,6 +55,7 @@ def __ge__(self, other): return MagicMock() def __add__(self, other): return MagicMock() def __radd__(self, other): return MagicMock() def __hash__(self): return 0 + def is_(self, other): return MagicMock() def desc(self): return MagicMock() def ilike(self, key): return True def any(self, val): return MagicMock() @@ -74,6 +75,7 @@ def __init__(self, **kwargs): 'mcp_server', 'registry_json', 'config_json', 'source', 'created_by', 'updated_by', 'review_status', 'submitted_by', 'source_mcp_id', + 'group_ids', 'ingroup_permission', ]: setattr(_MockMcpMarketRecord, _col, _MockColumn()) @@ -92,6 +94,7 @@ def __init__(self, **kwargs): list_mcp_market_records_by_tenant_and_user, increment_mcp_market_download_count, get_mcp_market_tag_stats_by_tenant, + _apply_group_permission_filter, update_mcp_market_status, list_mcp_market_records_by_status, ) @@ -330,6 +333,73 @@ def test_update_all_fields(self, mock_session): config_json={"cfg": "val"}, transport_type="url", ) + @patch('backend.database.market_mcp_db.get_db_session') + def test_update_with_shared_fields(self, mock_session): + """Test update_mcp_market_record with shared_fields.""" + session = MockSession() + session.update = MagicMock() + mock_session.return_value = session + + update_mcp_market_record( + market_id=1, user_id="uid", + shared_fields={"serverUrl": True, "authorizationToken": False}, + ) + + @patch('backend.database.market_mcp_db.get_db_session') + def test_update_with_group_permissions(self, mock_session): + """Test update_mcp_market_record with group_ids and ingroup_permission.""" + session = MockSession() + session.update = MagicMock() + mock_session.return_value = session + + update_mcp_market_record( + market_id=1, user_id="uid", + group_ids="2,4", + ingroup_permission="EDIT", + ) + + +class TestApplyGroupPermissionFilter: + """Test _apply_group_permission_filter directly with mocked or_.""" + + @patch('backend.database.market_mcp_db.or_') + def test_without_user_groups(self, mock_or): + """_apply_group_permission_filter should build 3 conditions when user_group_ids is empty.""" + from backend.database.market_mcp_db import _apply_group_permission_filter + query = MagicMock() + result = _apply_group_permission_filter(query, "uid", []) + # filter should be called once with or_ result + assert query.filter.called + + @patch('backend.database.market_mcp_db.or_') + def test_with_user_groups(self, mock_or): + """_apply_group_permission_filter should handle user_group_ids provided.""" + from backend.database.market_mcp_db import _apply_group_permission_filter + query = MagicMock() + result = _apply_group_permission_filter(query, "uid", [2, 4]) + assert query.filter.called + + def test_without_user_id_skipped(self): + """get_mcp_market_records should skip filter when user_id is None.""" + from backend.database.market_mcp_db import get_mcp_market_records + with patch('backend.database.market_mcp_db.get_db_session') as mock_session: + session = MockSession() + mock_session.return_value = session + result = get_mcp_market_records(tenant_id="tid") + assert result is not None + + @patch('backend.database.market_mcp_db.or_') + def test_with_user_id_and_groups(self, mock_or): + """get_mcp_market_records should apply filter when user_id and user_group_ids provided.""" + from backend.database.market_mcp_db import get_mcp_market_records + with patch('backend.database.market_mcp_db.get_db_session') as mock_session: + session = MockSession() + mock_session.return_value = session + result = get_mcp_market_records( + tenant_id="tid", user_id="uid", user_group_ids=[2, 4], + ) + assert result is not None + class TestDeleteMcpMarketRecord: """Test delete_mcp_market_record_by_id (soft delete).""" diff --git a/test/backend/database/test_remote_mcp_db.py b/test/backend/database/test_remote_mcp_db.py index 824c72539..9f3b5e187 100644 --- a/test/backend/database/test_remote_mcp_db.py +++ b/test/backend/database/test_remote_mcp_db.py @@ -429,6 +429,62 @@ def test_update_mcp_record_manage_fields_by_id_with_custom_headers(monkeypatch, assert call_args["authorization_token"] == "new_token" +def test_update_mcp_record_manage_fields_by_id_with_group_permissions(monkeypatch, mock_session): + """Test group_ids and ingroup_permission in update_mcp_record_manage_fields_by_id.""" + 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_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, + custom_headers=None, config_json=None, + market_id=None, + group_ids="2,4", + ingroup_permission="EDIT", + ) + call_args = mock_update.call_args[0][0] + assert call_args["group_ids"] == "2,4" + assert call_args["ingroup_permission"] == "EDIT" + + +def test_update_mcp_record_manage_fields_by_id_with_shared_fields(monkeypatch, mock_session): + """Test shared_fields in update_mcp_record_manage_fields_by_id.""" + 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) + + shared = {"serverUrl": True, "authorizationToken": False} + 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, + custom_headers=None, config_json=None, + market_id=None, + group_ids="2", ingroup_permission="READ_ONLY", + shared_fields=shared, + ) + call_args = mock_update.call_args[0][0] + assert call_args["group_ids"] == "2" + assert call_args["ingroup_permission"] == "READ_ONLY" + assert call_args["shared_fields"] == shared + + # ============================================================================ # update_mcp_record_enabled_by_id (NEW) # ============================================================================ diff --git a/test/backend/services/test_mcp_management_service.py b/test/backend/services/test_mcp_management_service.py index 907ead1f0..cb7c6c992 100644 --- a/test/backend/services/test_mcp_management_service.py +++ b/test/backend/services/test_mcp_management_service.py @@ -154,6 +154,29 @@ def test_author_display_name_no_user(self): card = _to_community_card({"user_id": None}) self.assertIsNone(card["authorDisplayName"]) + def test_shared_fields_included(self): + """to_community_card should include shared_fields from the market record row.""" + card = _to_community_card({ + **MARKET_RECORD, + "group_ids": "2,4", + "ingroup_permission": "EDIT", + "shared_fields": {"serverUrl": True, "authorizationToken": False}, + }) + self.assertEqual(card["groupIds"], "2,4") + self.assertEqual(card["ingroupPermission"], "EDIT") + self.assertEqual(card["sharedFields"], {"serverUrl": True, "authorizationToken": False}) + + @patch('backend.services.mcp_management_service.get_mcp_record_by_id_and_tenant', side_effect=Exception('DB error')) + def test_source_mcp_lookup_exception_handled(self, mock_get): + """_to_community_card should handle get_mcp_record_by_id_and_tenant exception gracefully.""" + card = _to_community_card({ + **MARKET_RECORD, + "source_mcp_id": 10, + }) + # Should not raise - exception is caught silently + self.assertIsNotNone(card) + self.assertEqual(card["sharedFields"], None) + class TestGetMcpReviewAdminScope(unittest.TestCase): """Test _get_mcp_review_admin_scope checks user role.""" @@ -355,11 +378,12 @@ class TestListCommunityMcpServices(unittest.IsolatedAsyncioTestCase): @patch('backend.services.mcp_management_service.get_mcp_market_records') async def test_list_empty(self, mock_get): mock_get.return_value = {"count": 0, "nextCursor": None, "items": []} - result = await list_community_mcp_services(tenant_id="tid", limit=30) + result = await list_community_mcp_services(tenant_id="tid", user_id="uid", limit=30) self.assertEqual(result["count"], 0) mock_get.assert_called_once_with( tenant_id="tid", search=None, tag=None, transport_type=None, cursor=None, limit=30, + user_id="uid", user_group_ids=[], ) @patch('backend.services.mcp_management_service.get_mcp_market_records') @@ -368,7 +392,7 @@ async def test_list_with_items(self, mock_get): "count": 1, "nextCursor": None, "items": [MARKET_RECORD], } - result = await list_community_mcp_services(tenant_id="tid") + result = await list_community_mcp_services(tenant_id="tid", user_id="uid") self.assertEqual(result["count"], 1) self.assertEqual(result["items"][0]["name"], "svc1") self.assertEqual(result["items"][0]["marketId"], 1) @@ -377,12 +401,13 @@ async def test_list_with_items(self, mock_get): async def test_list_with_filters(self, mock_get): mock_get.return_value = {"count": 0, "nextCursor": None, "items": []} await list_community_mcp_services( - tenant_id="tid", search="key", tag="python", + tenant_id="tid", user_id="uid", search="key", tag="python", transport_type="url", cursor="10", limit=20, ) mock_get.assert_called_once_with( tenant_id="tid", search="key", tag="python", transport_type="url", cursor="10", limit=20, + user_id="uid", user_group_ids=[], ) @@ -390,6 +415,62 @@ async def test_list_with_filters(self, mock_get): # list_community_mcp_tag_stats # ============================================================================ + @patch('backend.services.mcp_management_service.query_group_ids_by_user') + @patch('backend.services.mcp_management_service.get_user_tenant_by_user_id') + @patch('backend.services.mcp_management_service.get_mcp_market_records') + async def test_list_with_user_group_ids(self, mock_get, mock_tenant, mock_groups): + """list_community_mcp_services should pass user_group_ids to get_mcp_market_records.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2, 4] + mock_get.return_value = {"count": 0, "nextCursor": None, "items": []} + + result = await list_community_mcp_services( + tenant_id="tid", user_id="uid", limit=30, + ) + self.assertEqual(result["count"], 0) + mock_get.assert_called_once_with( + tenant_id="tid", search=None, tag=None, + transport_type=None, cursor=None, limit=30, + user_id="uid", user_group_ids=[2, 4], + ) + + @patch('backend.services.mcp_management_service.query_group_ids_by_user', side_effect=Exception('query failed')) + @patch('backend.services.mcp_management_service.get_user_tenant_by_user_id') + @patch('backend.services.mcp_management_service.get_mcp_market_records') + async def test_list_handles_group_query_failure(self, mock_get, mock_tenant, mock_groups): + """list_community_mcp_services should handle query_group_ids_by_user failure gracefully.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_get.return_value = {"count": 0, "nextCursor": None, "items": []} + + result = await list_community_mcp_services( + tenant_id="tid", user_id="uid", limit=30, + ) + self.assertEqual(result["count"], 0) + mock_get.assert_called_once_with( + tenant_id="tid", search=None, tag=None, + transport_type=None, cursor=None, limit=30, + user_id="uid", user_group_ids=None, + ) + + @patch('backend.services.mcp_management_service.query_group_ids_by_user') + @patch('backend.services.mcp_management_service.get_user_tenant_by_user_id') + @patch('backend.services.mcp_management_service.get_mcp_market_records') + async def test_list_skips_group_filter_for_admin(self, mock_get, mock_tenant, mock_groups): + """Admin users should skip group filtering (user_id None, user_group_ids None).""" + mock_tenant.return_value = {"user_role": "ADMIN"} + mock_get.return_value = {"count": 0, "nextCursor": None, "items": []} + + result = await list_community_mcp_services( + tenant_id="tid", user_id="uid", limit=30, + ) + self.assertEqual(result["count"], 0) + mock_get.assert_called_once_with( + tenant_id="tid", search=None, tag=None, + transport_type=None, cursor=None, limit=30, + user_id=None, user_group_ids=None, + ) + + class TestListCommunityMcpTagStats(unittest.TestCase): @patch('backend.services.mcp_management_service.get_mcp_market_tag_stats_by_tenant') @@ -464,6 +545,42 @@ async def test_publish_success(self, mock_get, mock_check, mock_email, mock_crea self.assertEqual(market_id, 42) mock_check.assert_called_once_with("svc") + @patch('backend.services.mcp_management_service.create_mcp_market_record') + @patch('backend.services.mcp_management_service._resolve_user_email') + @patch('backend.services.mcp_management_service.check_mcp_market_name_exists') + @patch('backend.services.mcp_management_service.get_mcp_record_by_id_and_tenant') + @patch('backend.services.mcp_management_service.update_mcp_record_manage_fields_by_id') + async def test_publish_with_shared_fields(self, mock_upd, mock_get, mock_check, mock_email, mock_create): + """Publish should pass shared_fields to create_mcp_market_record and update source MCP.""" + mock_get.return_value = { + "mcp_id": 1, "mcp_name": "svc", "mcp_server": "http://srv", + "description": "desc", "tags": ["a"], + "registry_json": None, "config_json": None, + "transport_type": "url", + } + mock_check.return_value = False + mock_email.return_value = "user@test.com" + mock_create.return_value = 7 + + shared = {"serverUrl": True, "authorizationToken": False} + await publish_community_mcp_service( + tenant_id="tid", user_id="uid", mcp_id=1, + group_ids=[2, 4], + ingroup_permission="EDIT", + shared_fields=shared, + ) + + # Verify shared_fields in market record + call_data = mock_create.call_args[1]["mcp_data"] + self.assertEqual(call_data["group_ids"], "2,4") + self.assertEqual(call_data["ingroup_permission"], "EDIT") + self.assertEqual(call_data["shared_fields"], shared) + + # Verify shared_fields updated on source MCP record + mock_upd.assert_called_once() + upd_kwargs = mock_upd.call_args[1] + self.assertEqual(upd_kwargs["shared_fields"], shared) + @patch('backend.services.mcp_management_service.get_mcp_record_by_id_and_tenant') async def test_publish_not_found(self, mock_get): mock_get.return_value = None @@ -583,6 +700,32 @@ async def test_update_same_name_skips_check(self, mock_get, mock_check, mock_ema ) mock_check.assert_not_called() + @patch('backend.services.mcp_management_service.update_mcp_market_status') + @patch('backend.services.mcp_management_service.update_mcp_market_record') + @patch('backend.services.mcp_management_service._resolve_user_email') + @patch('backend.services.mcp_management_service.get_mcp_market_record_by_id') + @patch('backend.services.mcp_management_service.update_mcp_record_manage_fields_by_id') + async def test_update_with_shared_fields(self, mock_upd_mcp, mock_get, mock_email, mock_update_record, mock_update_status): + """update_community_mcp_service should update shared_fields on source MCP record.""" + mock_get.return_value = { + "market_id": 1, "mcp_name": "svc", "mcp_server": "http://srv", + "config_json": None, "registry_json": None, + "source_mcp_id": 10, "tags": [], + } + mock_email.return_value = "user@test.com" + + shared = {"serverUrl": True} + await update_community_mcp_service( + tenant_id="tid", user_id="uid", market_id=1, + name="svc", description="d", tags=[], + registry_json=None, + shared_fields=shared, + ) + mock_upd_mcp.assert_called_once() + kwargs = mock_upd_mcp.call_args[1] + self.assertEqual(kwargs["mcp_id"], 10) + self.assertEqual(kwargs["shared_fields"], shared) + @patch('backend.services.mcp_management_service.update_mcp_market_status') @patch('backend.services.mcp_management_service.update_mcp_market_record') @patch('backend.services.mcp_management_service._resolve_user_email') diff --git a/test/backend/services/test_remote_mcp_service.py b/test/backend/services/test_remote_mcp_service.py index 353c2f78a..b2617690e 100644 --- a/test/backend/services/test_remote_mcp_service.py +++ b/test/backend/services/test_remote_mcp_service.py @@ -111,6 +111,7 @@ McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, ) +from backend.consts.const import PERMISSION_READ, PERMISSION_EDIT from backend.consts.model import MCPConfigRequest # Functions to test @@ -518,6 +519,420 @@ async def test_add_with_none_custom_headers(self, mock_check_name, mock_health_c self.assertIsNone(call_data['custom_headers']) +# ============================================================================ +# add_mcp_service - name conflict with group visibility tests +# ============================================================================ + +class TestAddMcpServiceNameConflictGroupVisibility(unittest.IsolatedAsyncioTestCase): + """Test add_mcp_service name conflict logic with group restrictions.""" + + @patch('backend.services.remote_mcp_service.create_mcp_record') + @patch('backend.services.remote_mcp_service._mcp_protocol_health_check') + @patch('database.group_db.query_group_ids_by_user') + @patch('database.remote_mcp_db.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_name_conflict_allowed_when_existing_mcp_invisible( + self, mock_check_name, mock_get_records, mock_query_groups, mock_health, mock_create + ): + """Name conflict should be allowed when existing MCP has group_ids not overlapping user's groups.""" + mock_check_name.return_value = True # name exists + mock_get_records.return_value = [ + {"mcp_name": "test-svc", "group_ids": "2", "created_by": "other-user"} + ] + mock_query_groups.return_value = [4] # user is in group 4, not group 2 + mock_health.return_value = ["tool1"] + + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='https://srv/mcp', + tags=[], authorization_token=None, + custom_headers=None, container_config=None, registry_json=None, + enabled=False, config_json=None, market_id=None, + ) + # Should not raise - installation allowed + mock_create.assert_called_once() + + @patch('backend.services.remote_mcp_service.create_mcp_record') + @patch('backend.services.remote_mcp_service._mcp_protocol_health_check') + @patch('database.group_db.query_group_ids_by_user') + @patch('database.remote_mcp_db.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_name_conflict_blocks_when_user_in_allowed_group( + self, mock_check_name, mock_get_records, mock_query_groups, mock_health, mock_create + ): + """Name conflict should block when existing MCP's group_ids include user's group.""" + mock_check_name.return_value = True + mock_get_records.return_value = [ + {"mcp_name": "test-svc", "group_ids": "2,4", "created_by": "other-user"} + ] + mock_query_groups.return_value = [4] # user is in group 4 + mock_health.return_value = ["tool1"] + + with self.assertRaises(MCPNameIllegal): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='https://srv/mcp', + tags=[], authorization_token=None, + custom_headers=None, container_config=None, registry_json=None, + enabled=False, config_json=None, market_id=None, + ) + + @patch('backend.services.remote_mcp_service.create_mcp_record') + @patch('backend.services.remote_mcp_service._mcp_protocol_health_check') + @patch('database.group_db.query_group_ids_by_user') + @patch('database.remote_mcp_db.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_name_conflict_blocks_when_same_creator( + self, mock_check_name, mock_get_records, mock_query_groups, mock_health, mock_create + ): + """Name conflict should block when existing MCP is created by the same user.""" + mock_check_name.return_value = True + mock_get_records.return_value = [ + {"mcp_name": "test-svc", "group_ids": "2", "created_by": "uid"} + ] + mock_query_groups.return_value = [4] + + with self.assertRaises(MCPNameIllegal): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='https://srv/mcp', + tags=[], authorization_token=None, + custom_headers=None, container_config=None, registry_json=None, + enabled=False, config_json=None, market_id=None, + ) + + @patch('backend.services.remote_mcp_service.create_mcp_record') + @patch('backend.services.remote_mcp_service._mcp_protocol_health_check') + @patch('database.remote_mcp_db.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_name_conflict_blocks_when_no_group_ids( + self, mock_check_name, mock_get_records, mock_health, mock_create + ): + """Name conflict should block when existing MCP has no group restriction.""" + mock_check_name.return_value = True # name exists + mock_get_records.return_value = [ + {"mcp_name": "test-svc", "group_ids": "", "created_by": "other"} + ] + + with self.assertRaises(MCPNameIllegal): + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-svc', + description='desc', source='local', server_url='https://srv/mcp', + tags=[], authorization_token=None, + custom_headers=None, container_config=None, registry_json=None, + enabled=False, config_json=None, market_id=None, + ) + + +# ============================================================================ +# add_mcp_service - API-type (OpenAPI) tests +# ============================================================================ + +class TestAddMcpServiceApiType(unittest.IsolatedAsyncioTestCase): + """Test add_mcp_service with API-type (OpenAPI JSON) config.""" + + @patch('backend.services.remote_mcp_service.create_mcp_record') + @patch('backend.services.remote_mcp_service.check_mcp_name_exists') + async def test_api_type_skips_mcp_protocol_and_extracts_tools( + self, mock_check_name, mock_create + ): + """API-type MCP should skip MCP protocol check and extract tool names from OpenAPI.""" + mock_check_name.return_value = False + + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/ping": {"get": {"operationId": "ping", "summary": "Health check"}}, + "/echo": {"post": {"operationId": "echo", "summary": "Echo message"}}, + }, + } + + await add_mcp_service( + tenant_id='tid', user_id='uid', name='test-api', + description='desc', source='local', server_url='https://api.test', + tags=[], authorization_token=None, + custom_headers=None, container_config=None, registry_json=None, + enabled=False, config_json=openapi_spec, market_id=None, + ) + + # Verify tool names were extracted from OpenAPI paths + call_data = mock_create.call_args[1]['mcp_data'] + self.assertEqual( + call_data['registry_json']['_toolNames'], + ["ping", "echo"], + ) + + +# ============================================================================ +# get_remote_mcp_server_list - group visibility filtering tests +# ============================================================================ + +class TestGetRemoteMcpServerListGroupFilter(unittest.IsolatedAsyncioTestCase): + """Test get_remote_mcp_server_list group-based visibility filtering.""" + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_user_sees_only_own_and_group_mcps( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """Non-admin user should only see own MCPs and MCPs shared with their groups.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "my-mcp", "group_ids": "", "created_by": "uid", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None}, + {"mcp_name": "shared-mcp", "group_ids": "2", "created_by": "other", + "mcp_id": 2, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None, + "ingroup_permission": "READ_ONLY"}, + {"mcp_name": "private-mcp", "group_ids": "3", "created_by": "other", + "mcp_id": 3, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + + names = [r['remote_mcp_server_name'] for r in result] + self.assertIn("my-mcp", names) # own MCP + self.assertIn("shared-mcp", names) # shared with group 2 + self.assertNotIn("private-mcp", names) # group 3, not visible + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_null_group_ids_visible_to_all( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """MCPs with NULL group_ids should be visible to all users (backward compatible).""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "public-mcp", "group_ids": None, "created_by": "other", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + names = [r['remote_mcp_server_name'] for r in result] + self.assertIn("public-mcp", names) + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_null_group_ids_editable_by_all( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """MCPs with NULL group_ids should be editable by all users.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "public-mcp", "group_ids": None, "created_by": "other", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + self.assertEqual(result[0]['permission'], PERMISSION_EDIT) + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_empty_string_group_ids_hidden_from_non_creator( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """MCPs with empty string group_ids should be hidden from non-creator users.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "empty-group", "group_ids": "", "created_by": "other", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + names = [r['remote_mcp_server_name'] for r in result] + self.assertNotIn("empty-group", names) + + @patch('backend.services.remote_mcp_service.query_group_ids_by_user', side_effect=Exception('query failed')) + @patch('backend.services.remote_mcp_service.MCPContainerManager') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + async def test_group_query_failure_falls_back_gracefully( + self, mock_tenant, mock_records, mock_mgr, mock_groups + ): + """get_remote_mcp_server_list should handle query_group_ids_by_user failure gracefully.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + self.assertEqual(len(result), 0) + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_private_mcp_hidden_from_non_creator( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """PRIVATE MCPs should be hidden from non-creator group members.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "private-svc", "group_ids": "2", "created_by": "other", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None, + "ingroup_permission": "PRIVATE"}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + + names = [r['remote_mcp_server_name'] for r in result] + self.assertNotIn("private-svc", names) + + +# ============================================================================ +# _is_container_record - API type tests +# ============================================================================ + +class TestIsContainerRecordApiType(unittest.TestCase): + """Test _is_container_record with API-type config.""" + + def test_api_type_config_returns_false(self): + """_is_container_record should return False for API-type MCPs (config_json has openapi).""" + record = {"config_json": {"openapi": "3.0.0", "info": {"title": "Test"}}} + self.assertFalse(_is_container_record(record)) + + def test_none_record_returns_false(self): + """_is_container_record should return False for None record.""" + self.assertFalse(_is_container_record(None)) + + def test_empty_record_returns_false(self): + """_is_container_record should return False for empty record.""" + self.assertFalse(_is_container_record({})) + + def test_container_config_returns_true(self): + """_is_container_record should return True for container MCPs.""" + record = {"config_json": {"mcpServers": {"s": {"command": "echo"}}}} + self.assertTrue(_is_container_record(record)) + + def test_container_id_returns_true(self): + """_is_container_record should return True when container_id is set.""" + record = {"container_id": "abc123", "config_json": None} + self.assertTrue(_is_container_record(record)) + + +# ============================================================================ +# update_mcp_service_enabled - API type tests +# ============================================================================ + +class TestUpdateMcpServiceEnabledApiType(unittest.IsolatedAsyncioTestCase): + """Test update_mcp_service_enabled with API-type MCP.""" + + def _make_api_record(self, **overrides): + base = { + "mcp_id": 1, "mcp_name": "test-api", "mcp_server": "http://localhost:8765", + "container_id": None, "container_port": None, + "config_json": {"openapi": "3.0.0", "info": {"title": "Test"}}, + "authorization_token": None, "custom_headers": None, + "enabled": False, "source": "local", + } + base.update(overrides) + return base + + @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.get_mcp_record_by_id_and_tenant') + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + async def test_api_enable_skips_health_check( + self, mock_records, mock_get, mock_status, mock_enabled + ): + """Enabling API-type MCP should skip MCP health check and set status=True.""" + mock_get.return_value = self._make_api_record() + mock_records.return_value = [] + + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) + + # Should update status to True directly without health check + mock_status.assert_called_once_with( + mcp_id=1, tenant_id='tid', user_id='uid', status=True, + ) + mock_enabled.assert_called_once() + + +# ============================================================================ +# get_remote_mcp_server_list - ingroup_permission tests +# ============================================================================ + +class TestGetRemoteMcpServerListPermission(unittest.IsolatedAsyncioTestCase): + """Test get_remote_mcp_server_list permission computation with ingroup_permission.""" + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_ingroup_edit_grants_edit_permission( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """Group member should get EDIT permission when ingroup_permission is EDIT.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "editable", "group_ids": "2", "created_by": "other", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None, + "ingroup_permission": "EDIT"}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + + self.assertEqual(result[0]['permission'], PERMISSION_EDIT) + + @patch('backend.services.remote_mcp_service.get_mcp_records_by_tenant') + @patch('backend.services.remote_mcp_service.query_group_ids_by_user') + @patch('backend.services.remote_mcp_service.get_user_tenant_by_user_id') + @patch('backend.services.remote_mcp_service.MCPContainerManager') + async def test_ingroup_readonly_grants_read_permission( + self, mock_mgr, mock_tenant, mock_groups, mock_records + ): + """Group member should get READ permission when ingroup_permission is READ_ONLY.""" + mock_tenant.return_value = {"user_role": "DEV"} + mock_groups.return_value = [2] + mock_mgr.return_value.list_mcp_containers.return_value = [] + mock_records.return_value = [ + {"mcp_name": "readonly", "group_ids": "2", "created_by": "other", + "mcp_id": 1, "mcp_server": "", "status": None, "enabled": False, + "source": "local", "update_time": "", "tags": [], "container_port": None, + "registry_json": None, "config_json": None, "market_id": None, + "ingroup_permission": "READ_ONLY"}, + ] + + result = await get_remote_mcp_server_list(tenant_id='tid', user_id='uid') + + self.assertEqual(result[0]['permission'], PERMISSION_READ) + + # ============================================================================ # update_remote_mcp_server_list - custom_headers tests (lines 418, 423-424) # ============================================================================ @@ -621,6 +1036,64 @@ def test_update_with_none_custom_headers(self, mock_get, mock_update): call_kwargs = mock_update.call_args[1] self.assertIsNone(call_kwargs['custom_headers']) + @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_with_group_ids(self, mock_get, mock_update): + """Test update_mcp_service passes group_ids to database 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='n', description='d', + server_url='http://srv', authorization_token=None, + custom_headers=None, tags=None, + config_json=None, market_id=None, + group_ids="2,4", + ) + + call_kwargs = mock_update.call_args[1] + self.assertEqual(call_kwargs['group_ids'], "2,4") + + @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_with_ingroup_permission(self, mock_get, mock_update): + """Test update_mcp_service passes ingroup_permission to database 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='n', description='d', + server_url='http://srv', authorization_token=None, + custom_headers=None, tags=None, + config_json=None, market_id=None, + ingroup_permission="READ_ONLY", + ) + + call_kwargs = mock_update.call_args[1] + self.assertEqual(call_kwargs['ingroup_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_with_shared_fields(self, mock_get, mock_update): + """Test update_mcp_service passes shared_fields to database update.""" + mock_get.return_value = {"mcp_id": 1, "source": "local", "config_json": None} + + shared = {"serverUrl": True, "authorizationToken": False} + update_mcp_service( + tenant_id='tid', user_id='uid', mcp_id=1, + new_name='n', description='d', + server_url='http://srv', authorization_token=None, + custom_headers=None, tags=None, + config_json=None, market_id=None, + group_ids="2", ingroup_permission="READ_ONLY", + shared_fields=shared, + ) + + call_kwargs = mock_update.call_args[1] + self.assertEqual(call_kwargs['group_ids'], "2") + self.assertEqual(call_kwargs['ingroup_permission'], "READ_ONLY") + self.assertEqual(call_kwargs['shared_fields'], shared) + # ============================================================================ # update_mcp_service_enabled - custom_headers tests (lines 530, 599, 656) @@ -687,6 +1160,25 @@ async def test_non_container_enable_without_custom_headers( custom_headers=None, ) + @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', return_value=False) + @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_non_container_enable_health_fail_raises_error( + self, mock_records, mock_get, mock_health, mock_status, mock_enabled + ): + """Non-container enable with health check failure should raise MCPConnectionError.""" + mock_get.return_value = self._make_record() + mock_records.return_value = [] + + with self.assertRaises(MCPConnectionError): + await update_mcp_service_enabled(tenant_id='tid', user_id='uid', mcp_id=1, enabled=True) + + mock_status.assert_called_once_with( + mcp_id=1, tenant_id='tid', user_id='uid', status=False, + ) + @patch('backend.services.remote_mcp_service.check_runtime_host_port_available', return_value=True) @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')