Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion backend/contracts/constants/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@
RESOURCE_TYPE_AGENT_REPOSITORY = 'agent_repository'
RESOURCE_TYPE_SKILL_REPOSITORY = 'skill_repository'
RESOURCE_TYPE_MCP_REPOSITORY = 'mcp_repository'
VALID_RESOURCE_TYPES = frozenset({RESOURCE_TYPE_AGENT_REPOSITORY, RESOURCE_TYPE_SKILL_REPOSITORY, RESOURCE_TYPE_MCP_REPOSITORY})
RESOURCE_TYPE_RESOURCE_REVIEW = 'resource_review'
VALID_RESOURCE_TYPES = frozenset({RESOURCE_TYPE_AGENT_REPOSITORY, RESOURCE_TYPE_SKILL_REPOSITORY, RESOURCE_TYPE_MCP_REPOSITORY, RESOURCE_TYPE_RESOURCE_REVIEW})
# Event types (drive title copy / icon)
EVENT_TYPE_REPOSITORY_REVIEW_APPROVED = 'repository_review_approved'
EVENT_TYPE_REPOSITORY_REVIEW_REJECTED = 'repository_review_rejected'
Expand Down
4 changes: 4 additions & 0 deletions backend/contracts/errors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,5 +651,9 @@ class StorageObjectConflictError(ValueError):
"""Raised when an object identity is already bound to different accounting data."""


class ReviewConflictError(Exception):
"""The submitted review state or revision is no longer current."""


class AccountConflictError(Exception):
"""An account email or stable identity conflicts with an existing record."""
129 changes: 129 additions & 0 deletions backend/contracts/review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

"""Contracts for the unified repository review API, independent of legacy APIs."""

from datetime import datetime
from typing import Annotated, Literal

from pydantic import (
BaseModel,
ConfigDict,
Field,
computed_field,
field_validator,
)

ResourceType = Literal["agent", "skill", "mcp"]
ReviewStatus = Literal["pending_review", "approved", "rejected"]
RepositoryId = Annotated[str, Field(pattern=r"^[1-9][0-9]{0,18}$")]


class ReviewListQuery(BaseModel):
"""Use one page across all authorized resource branches."""

model_config = ConfigDict(extra="forbid", populate_by_name=True)

resource_type: Literal["all", "agent", "skill", "mcp"] = Field("all", alias="type")
status: Literal["all", "pending_review", "approved", "rejected"] = "all"
search: str = ""
page: int = Field(1, ge=1)
page_size: int = Field(10, ge=1, le=100)

@field_validator("search")
@classmethod
def trim_search(cls, value: str) -> str:
return value.strip()


class ReviewDecisionRequest(BaseModel):
"""Never accept a client-selected user, tenant, role or query scope."""

model_config = ConfigDict(extra="forbid", populate_by_name=True)

decision: Literal["approve", "reject"]
reason: str | None = None

@field_validator("reason")
@classmethod
def normalize_reason(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if len(value) > 1000:
raise ValueError(
"Review reason must contain at most 1000 Unicode code points"
)
return value or None


class ReviewSubmitter(BaseModel):
"""Display identity must not be substituted with the resource author."""

user_id: str | None = None
display_name: str | None = None
cas_username: str | None = None


class ReviewListItem(BaseModel):
"""Public projection without archives, credentials or mutable source config."""

resource_type: ResourceType
review_id: RepositoryId
source_resource_id: RepositoryId
version_no: int | None = None
repository_id: RepositoryId | None = None
tenant_id: str
tenant_name: str | None = None
name: str
description: str | None = None
version_label: str | None = None
status: ReviewStatus
submitter: ReviewSubmitter
submitted_user_id: str | None = None
submitted_at: datetime | None = None
published_at: datetime | None = None
submit_description: str | None = None
review_comment: str | None = None
reviewed_by: str | None = None
reviewed_at: datetime | None = None
current_repository_status: str | None = None
allowed_actions: list[Literal["approve", "reject"]] = Field(default_factory=list)

@field_validator("review_id", "source_resource_id", "repository_id")
@classmethod
def check_repository_id(cls, value: str) -> str:
if value is not None and int(value) > 9223372036854775807:
raise ValueError("Repository ID exceeds the signed BIGINT range")
return value


class ReviewPagination(BaseModel):
page: int = Field(ge=1)
page_size: int = Field(ge=1, le=100)
total: int = Field(ge=0)

@computed_field
@property
def total_pages(self) -> int:
return (self.total + self.page_size - 1) // self.page_size


class ReviewListResponse(BaseModel):
items: list[ReviewListItem]
pagination: ReviewPagination


class ReviewSubmissionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
resource_type: ResourceType
source_resource_id: RepositoryId
version_no: int | None = Field(None, ge=0)
submit_description: str | None = None
listing_fields: dict = Field(default_factory=dict)

@field_validator("source_resource_id")
@classmethod
def validate_source_id(cls, value):
if int(value) > 2147483647:
raise ValueError("Source ID exceeds supported local identifier range")
return value
10 changes: 10 additions & 0 deletions backend/contracts/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
from pydantic import BaseModel, Field, field_validator


class McpDeleteTarget(BaseModel):
kind: Literal["local", "community"]
id: int = Field(gt=0, strict=True)


class McpBatchDeleteRequest(BaseModel):
items: list[McpDeleteTarget] = Field(min_length=1, max_length=100)


class AgentToolParamsRequest(BaseModel):
"""Request-scoped tool parameter overrides for a single agent."""

Expand Down Expand Up @@ -324,6 +333,7 @@ class CommunityListRequest(BaseModel):
None, description="Filter by transport: url or container"
)
cursor: Optional[str] = Field(None, description="Pagination cursor")
sort_by_published_time: bool = False
page: Optional[int] = Field(None, ge=1, description="Offset pagination page")
limit: int = Field(default=30, ge=1, le=100, description="Items per page")

Expand Down
6 changes: 5 additions & 1 deletion backend/management/api/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,11 @@ async def list_agent_page_api(
kwargs[key] = [TagAssignmentFilter.model_validate(item) for item in parsed]
if additional_tenant_id:
kwargs["additional_tenant_id"] = additional_tenant_id
return await list_agent_page_impl(**kwargs)
result = await list_agent_page_impl(**kwargs)
if include_repository_info:
from management.services.repository.reviews import attach_latest_reviews
attach_latest_reviews(result["items"], "agent", "agent_id", user_id, resolved_tenant_id)
return result
except ValueError as error:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(error)) from error
except Exception as error:
Expand Down
11 changes: 10 additions & 1 deletion backend/management/api/agent/agent_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,26 @@ async def list_agent_repository_listings_api(
),
tag: Optional[str] = Query(None, description="Filter by an exact tag"),
sort_by_update_time: bool = Query(False, description="Sort by repository publish time descending"),
sort_by_published_time: bool = Query(False, description="Sort by actual approval time descending"),
sort_by_downloads: bool = Query(False, description="Sort by cumulative agent copies descending"),
authorization: str = Header(None),
):
"""List all marketplace repository listings with optional status filter."""
try:
_, tenant_id = get_current_user_id(authorization)
user_id, tenant_id = get_current_user_id(authorization)
filters = {
"status": status,
"agent_id": agent_id,
"page": page,
"page_size": page_size,
"search": search,
"sort_by_update_time": sort_by_update_time,
"user_id": user_id,
}
if sort_by_published_time:
filters["sort_by_published_time"] = True
if sort_by_downloads:
filters["sort_by_downloads"] = True
search_predicates = _parse_tag_predicates(search_tag_predicates)
if search_predicates:
filters["search_tag_predicates"] = search_predicates
Expand Down Expand Up @@ -271,6 +278,8 @@ async def list_my_editable_agents_api(
if search_predicates:
filters["search_tag_predicates"] = search_predicates
result = await list_my_editable_agents_impl(**filters)
from management.services.repository.reviews import attach_latest_reviews
attach_latest_reviews(result["items"], "agent", "agent_id", user_id, tenant_id)
return JSONResponse(status_code=HTTPStatus.OK, content=result)
except UnauthorizedError as e:
logger.warning(f"Unauthorized my editable agents access attempt: {str(e)}")
Expand Down
2 changes: 2 additions & 0 deletions backend/management/api/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from infrastructure.concurrency import config_thread_manager
from management.api.a2a.a2a_client import router as a2a_client_router
from management.api.a2a.a2a_server import router as a2a_server_router
from management.api.repository.reviews import router as repository_reviews_router
from management.api.agent.agent import agent_config_router as agent_router
from management.api.agent.agent_repository import agent_repository_router
from management.api.configuration.config_sync import router as config_sync_router
Expand Down Expand Up @@ -160,6 +161,7 @@ async def thread_capacity():
return config_thread_manager.snapshot()


app.include_router(repository_reviews_router)
app.include_router(model_manager_router)
app.include_router(config_sync_router)
app.include_router(agent_router)
Expand Down
49 changes: 46 additions & 3 deletions backend/management/api/mcp/mcp_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
delete_community_mcp_service,
)
from management.db.mcp.market import increment_mcp_market_download_count
from contracts.errors.exceptions import ForbiddenError
from contracts.tool import McpBatchDeleteRequest
from management.services.mcp.service import delete_mcp_service
from management.services.monitor.audit import record_security_event
from gateway.authentication.auth import get_current_user_info

Expand Down Expand Up @@ -53,6 +56,8 @@
cursor=query.cursor,
limit=query.limit,
)
if query.sort_by_published_time:
list_kwargs["sort_by_published_time"] = True
if query.page is not None:
list_kwargs["page"] = query.page
data = await list_community_mcp_services(**list_kwargs)
Expand Down Expand Up @@ -183,7 +188,7 @@
)
return JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "success", "data": {"market_id": market_id}},
content={"status": "success", "data": market_id},
)
except McpNotFoundError as exc:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc))
Expand Down Expand Up @@ -247,6 +252,44 @@
)


@router.post("/batch_delete")
async def batch_delete_mcp_services_api(
payload: McpBatchDeleteRequest,
authorization: Optional[str] = Header(None),
http_request: Request = None,
):
"""Delete selected local services and community listings independently."""
try:
user_id, tenant_id, _ = get_current_user_info(authorization, http_request)
except UnauthorizedError as exc:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) from exc

results = []
for kind, target_id in dict.fromkeys((item.kind, item.id) for item in payload.items):
result = {"kind": kind, "id": target_id}
try:
if kind == "local":
await delete_mcp_service(tenant_id=tenant_id, user_id=user_id, mcp_id=target_id)
else:
await delete_community_mcp_service(tenant_id=tenant_id, user_id=user_id, market_id=target_id)
except (McpNotFoundError, ForbiddenError, UnauthorizedError) as exc:
results.append({**result, "success": False, "message": str(exc)})
except Exception:
logger.exception("Failed to batch delete MCP %s %s", kind, target_id)
results.append({**result, "success": False, "message": "Failed to delete MCP service"})
else:
record_security_event(
"mcp_delete" if kind == "local" else "mcp_market_delete",
request=http_request,
user_id=user_id,
tenant_id=tenant_id,
details={"mcp_id" if kind == "local" else "market_id": target_id},
)
results.append({**result, "success": True})

return JSONResponse(status_code=HTTPStatus.OK, content={"status": "success", "data": results})


@router.delete("/community/delete")
async def delete_community_mcp_service_legacy_api(
market_id: int = Query(gt=0),
Expand Down Expand Up @@ -371,11 +414,11 @@
request=http_request,
user_id=user_id,
tenant_id=tenant_id,
details={"market_id": market_id, "mcp_id": payload.mcp_id, "mcp_server": payload.mcp_server},
details={"review_id": market_id.get("review_id"), "mcp_id": payload.mcp_id},
)
return JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "success", "data": {"market_id": market_id}},
content={"status": "success", "data": market_id},
)
except McpNotFoundError as exc:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc))
Expand Down
13 changes: 8 additions & 5 deletions backend/management/api/mcp/remote_mcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError
from contracts.tool import MCPConfigRequest, AddMcpServiceRequest, AddContainerMcpServiceRequest, UpdateMcpServiceRequest, EnableMcpServiceRequest, DisableMcpServiceRequest, HealthcheckMcpServiceRequest, TestMcpConnectionRequest, ListMcpServicesQuery
import logging
import json
import asyncio
Expand All @@ -12,7 +10,7 @@

from contracts.constants.environment import ENABLE_UPLOAD_IMAGE
from contracts.errors.codes import ErrorCode
from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError
from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError, ForbiddenError
from contracts.tool import MCPConfigRequest, AddMcpServiceRequest, AddContainerMcpServiceRequest, UpdateMcpServiceRequest, EnableMcpServiceRequest, DisableMcpServiceRequest, HealthcheckMcpServiceRequest, TestMcpConnectionRequest, ListMcpServicesQuery
from permissions.resource_access import resource_read_tenant
from management.services.mcp.service import get_remote_mcp_server_list, check_mcp_health_and_update_db, delete_mcp_by_container_id, upload_and_start_mcp_image, update_remote_mcp_server_list, attach_mcp_container_permissions, get_mcp_record_by_id, list_mcp_service_tools_by_id, refresh_mcp_service_tool_count, add_container_mcp_service, update_mcp_service, update_mcp_service_enabled, delete_mcp_service, check_mcp_service_health, test_mcp_connection, check_container_port_conflict, suggest_container_port
Expand Down Expand Up @@ -436,6 +434,8 @@ async def delete_mcp_by_id(
)
except McpNotFoundError as e:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e))
except ForbiddenError as e:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete MCP service: {e}")
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to delete MCP service")
Expand Down Expand Up @@ -516,15 +516,18 @@ async def get_mcp_list(

if authorized_tenant := resource_read_tenant(http_request, shared=True):
remote_mcp_list = await get_remote_mcp_server_list(
tenant_id=authorized_tenant, is_need_auth=False,
tenant_id=authorized_tenant, is_need_auth=False, include_author=True,
)
for record in remote_mcp_list:
record.update(permission="READ_ONLY", registry_json=None, config_json=None, shared_fields=None)
else:
remote_mcp_list = await get_remote_mcp_server_list(
tenant_id=effective_tenant_id, user_id=user_id, is_need_auth=True,
tenant_id=effective_tenant_id, user_id=user_id, is_need_auth=True, include_author=True,
)

from management.services.repository.reviews import attach_latest_reviews
attach_latest_reviews(remote_mcp_list, "mcp", "mcp_id", user_id, effective_tenant_id)

return JSONResponse(
status_code=HTTPStatus.OK,
content={
Expand Down
Loading
Loading