diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 3b2e4891f..765c481ed 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -327,7 +327,7 @@ FIELD_DESCRIPTIONS = { "match_field": "Object field used to match one list item during an upsert or removal.", "match_value": "Exact value compared against match_field during a list-item update.", "max_chars": "Maximum number of serialized plugin-data characters to return.", - "max_results": "Maximum number of plugin catalog results to return, from 1 to 200.", + "max_results": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.", "max_rating": "Maximum rating used to filter shared or popular subscriptions.", "media_category": "MoviePilot library category assigned to the media.", "media_category_id": "Stable classification category ID; preserve it separately from the current category path snapshot.", @@ -963,7 +963,19 @@ def _collection_response_contract( query_parameters.get("page"), query_parameters.get("count"), ] - has_existing_window = bool({"limit", "offset", "page_size", "max_results"} & query_parameters.keys()) + native_window_names = {"limit", "offset", "page_size", "max_results"} + has_native_window_default = False + for name in native_window_names: + parameter = query_parameters.get(name) + if not isinstance(parameter, Mapping): + continue + if parameter.get("required", False): + has_native_window_default = True + break + schema = parameter.get("schema") + if isinstance(schema, Mapping) and schema.get("default") is not None: + has_native_window_default = True + break defaults_to_unpaginated = ( all( isinstance(parameter, Mapping) @@ -972,7 +984,7 @@ def _collection_response_contract( and "default" not in parameter["schema"] for parameter in compatibility_parameters ) - and not has_existing_window + and not has_native_window_default ) return { "body_shape": "list", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 9c47b8e9d..9ffef7614 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -8345,7 +8345,7 @@ }, { "additionalProperties": false, - "description": "List installed plugins and their runtime status. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list and the endpoint's documented pagination or limit defaults remain in effect. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.", + "description": "List installed plugins and their runtime status. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.", "properties": { "operation_id": { "const": "plugin.installed", @@ -8377,12 +8377,18 @@ "type": "boolean" }, "max_results": { - "default": 50, - "description": "Maximum number of plugin catalog results to return, from 1 to 200.", - "maximum": 200, - "minimum": 1, - "title": "Max Results", - "type": "integer" + "anyOf": [ + { + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.", + "title": "Max Results" }, "page": { "anyOf": [ @@ -8429,14 +8435,14 @@ "type": "object", "x-moviepilot-collection": { "body_shape": "list", - "default_pagination": "endpoint-defined", + "default_pagination": "unpaginated", "result_count_field": "collection.result_count", "total_count_field": "collection.total_count" } }, { "additionalProperties": false, - "description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list and the endpoint's documented pagination or limit defaults remain in effect. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.", + "description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.", "properties": { "operation_id": { "const": "plugin.market", @@ -8468,12 +8474,18 @@ "type": "boolean" }, "max_results": { - "default": 50, - "description": "Maximum number of plugin catalog results to return, from 1 to 200.", - "maximum": 200, - "minimum": 1, - "title": "Max Results", - "type": "integer" + "anyOf": [ + { + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.", + "title": "Max Results" }, "page": { "anyOf": [ @@ -8520,7 +8532,7 @@ "type": "object", "x-moviepilot-collection": { "body_shape": "list", - "default_pagination": "endpoint-defined", + "default_pagination": "unpaginated", "result_count_field": "collection.result_count", "total_count_field": "collection.total_count" } diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index bb41dc1f9..029478c91 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -183,11 +183,11 @@ async def all_plugins( state: Optional[str] = "all", force: bool = False, query: Optional[str] = None, - max_results: Annotated[int, Query(ge=1, le=200)] = 50, + max_results: Annotated[Optional[int], Query(ge=1, le=200)] = None, page: CompatiblePageParam = None, count: CompatibleCountParam = None, response: Response = None, ) -> List[_SchemaPlugin]: - """查询插件清单,显式分页优先于兼容的 ``max_results`` 限量。""" + """查询插件清单;未指定分页或限量时返回完整清单。""" plugins = await get_plugin_catalog_query().query(state=state or "all", force=force) if query: plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)] @@ -197,6 +197,8 @@ async def all_plugins( page, count = resolve_compatible_pagination(page, count) assert page is not None and count is not None return plugins[(page - 1) * count : page * count] + if max_results is None: + return plugins return plugins[:max_results] diff --git a/docs/architecture/agent-tool-refactor-plan.md b/docs/architecture/agent-tool-refactor-plan.md index a88f23353..02f9bbbc8 100644 --- a/docs/architecture/agent-tool-refactor-plan.md +++ b/docs/architecture/agent-tool-refactor-plan.md @@ -339,7 +339,7 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API - REST 响应继续保持 `Response.data` 为原列表,禁止改成 `{items,total}` 等对象;总数和分页信息使用响应头及 Agent 网关附加元数据表达,避免破坏外部插件和既有客户端 - 对完整列表可报告切片前精确总数;对已经由第三方来源原生分页或限量、且上游没有提供总数的结果,只报告当前返回数量,不强制增加总数,也禁止把当前页数量伪装成全局总数 - 第一版由统一响应路由隐式注入分页参数并在序列化后切片;复核后确认这种实现虽然能生成查询参数,但端点签名不自描述,而且数据库列表仍会全量读取,因此不作为最终方案 -- 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page`、`count`、`limit` 或 `max_results` 的接口保留其既有参数与默认语义 +- 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page`、`count`、`limit` 或 `max_results` 的接口保留其显式参数语义;当原生限量参数默认值为 `None` 时,省略所有窗口参数继续返回完整列表 - 用户、PassKey、活动订阅、Workflow、Site、站点用户数据和站点统计的筛选、稳定排序、`LIMIT/OFFSET` 与精确 `COUNT` 已下推到异步 SQLAlchemy 查询;状态、用户名、名称、触发类型、站点启用状态、站点 ID/域名和日期筛选均在分页前执行,避免空页和错误总数 - 纯内存、配置、缓存、文件系统或运行时目录仍可在响应边界按显式 `page/count` 切片;第三方原生分页且不返回总数的接口只报告当前页数量,不伪造总数 - REST 保持 `data` 原列表;`X-Result-Count` 报告本次返回数量,精确可知时增加 `X-Total-Count`,Agent 网关把这些响应头映射到附加 `collection` 对象。下载历史、订阅历史和插件目录已增加本地精确计数;外部媒体、音乐、推荐和搜索来源未提供总数时不输出 `total_count` diff --git a/docs/mcp-api.md b/docs/mcp-api.md index dbf2b6884..0944dd897 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -96,7 +96,7 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确 查询结果的兼容分页合同如下: - 原先返回完整列表、没有分页参数的接口会在端点签名、OpenAPI、Skill 和 MCP `oneOf` 中显式声明可选 `page` / `count`;`page` 必须不小于 1,`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才分页,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。FastAPI 的 `response` 注入对象不是业务输入,不会出现在 REST、Skill 或 MCP 参数中。 -- 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续保留其旧默认值,显式 `page/count` 的优先级由端点合同说明。 +- 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续支持显式限量;原生限量参数默认值为 `None` 时,省略所有分页和限量参数仍返回完整列表,显式 `page/count` 的优先级由端点合同说明。 - REST 响应的 `data` 保持原列表结构,不改成 `{items,total}`。`X-Result-Count` 报告本次实际返回数量;仅当 MoviePilot 已经取得完整筛选结果时,才增加精确的 `X-Total-Count`。原有结构化分页接口继续在既有 `data.total` 与 `data.items` / `data.list` 中返回总数。 - `moviepilot_api` 把这些响应头投影为响应中的附加 `collection` 对象:`result_count` 为本次返回数量,`total_count` 仅在精确可知时出现,`page` / `count` 在可用时出现。`collection` 是附加元数据,不替换或改写 `data`。 - Agent 仅查询数量或摘要时,应对支持精确总数的列表发送最小窗口;兼容分页接口使用 `page=1,count=1`,然后直接读取 `collection.total_count`。即使列表内容触发 64KB 工具预览截断,网关也会把 `collection` 放在 `data` 前面,确保总数仍可见;不得因为条目被截断就回退到数据库统计。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 8825f24cd..7f5d624e0 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 24 +version: 25 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -713,17 +713,17 @@ Purpose: Install or update one plugin from an approved source. ### `plugin.installed` `GET /api/v1/plugin/`; policy effect: `safe_read`. Purpose: List installed plugins and their runtime status. -- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. For a count-only request, use the smallest valid page and read that metadata instead of querying the database after item truncation. +- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. - `path_params`: none -- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. +- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. - `body`: none ### `plugin.market` `GET /api/v1/plugin/`; policy effect: `safe_read`. Purpose: List plugins available from configured marketplaces. -- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. For a count-only request, use the smallest valid page and read that metadata instead of querying the database after item truncation. +- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. - `path_params`: none -- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries. +- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries. - `body`: none ### `plugin.market.sync_wiki` diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 9bdbde567..8f94a734b 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -172,17 +172,19 @@ def test_mcp_collection_contract_distinguishes_exact_and_unavailable_totals() -> "collection.total_count" ) - for operation_id in ( - "subscription.history", - "download.history.list", - "plugin.installed", - "plugin.market", - ): + for operation_id in ("subscription.history", "download.history.list"): local_page = branches[operation_id]["x-moviepilot-collection"] assert local_page["total_count_field"] == "collection.total_count" assert local_page["default_pagination"] == "endpoint-defined" assert "defaults remain in effect" in branches[operation_id]["description"] + for operation_id in ("plugin.installed", "plugin.market"): + local_page = branches[operation_id]["x-moviepilot-collection"] + assert local_page["total_count_field"] == "collection.total_count" + assert local_page["default_pagination"] == "unpaginated" + assert "omit both page and count" in branches[operation_id]["description"] + assert "default" not in branches[operation_id]["properties"]["query"]["properties"]["max_results"] + media_search = branches["media.search"]["x-moviepilot-collection"] assert media_search["result_count_field"] == "collection.result_count" assert media_search["total_count_field"] is None @@ -221,7 +223,9 @@ def test_plugin_operations_expose_discovery_before_precise_writes() -> None: installed_query = branches["plugin.installed"]["properties"]["query"] assert installed_query["properties"]["state"]["const"] == "installed" assert "query" in installed_query["properties"] - assert installed_query["properties"]["max_results"]["maximum"] == 200 + max_results = installed_query["properties"]["max_results"] + integer_variant = next(item for item in max_results["anyOf"] if item.get("type") == "integer") + assert integer_variant["maximum"] == 200 config_get_path = API_OPERATION_ROUTES["plugin.config.get"].path assert config_get_path == "/api/v1/plugin/form/{plugin_id}" diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 91399460a..b4a6dd1f2 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -110,7 +110,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "command-dispatch": "2", "database-operation": "6", "feedback-issue": "9", - "moviepilot-api": "24", + "moviepilot-api": "25", "moviepilot-update": "5", "organize-files": "5", "transfer-failed-retry": "5", diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index f5c564810..6414223e6 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -261,7 +261,7 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update(): def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None: - """插件列表显式 page/count 应分页,省略时仍保留旧 max_results 行为。""" + """插件列表显式 page/count 应分页,并优先于显式 max_results 限量。""" catalog = MagicMock() catalog.query = AsyncMock( return_value=[ @@ -291,6 +291,36 @@ def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None: assert response.headers["X-Total-Count"] == "3" +def test_all_plugins_without_pagination_or_limit_returns_complete_catalog() -> None: + """插件列表省略分页和限量参数时应返回完整目录。""" + catalog = MagicMock() + catalog.query = AsyncMock( + return_value=[ + schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") + for index in range(1, 52) + ] + ) + + with patch( + "app.api.endpoints.plugin.get_plugin_catalog_query", + return_value=catalog, + ): + response = Response() + result = asyncio.run( + plugin_endpoint.all_plugins( + None, + "all", + False, + response=response, + ) + ) + + assert len(result) == 51 + assert result[0].id == "Plugin1" + assert result[-1].id == "Plugin51" + assert response.headers["X-Total-Count"] == "51" + + def _persistence(identity: PluginIdentity) -> MagicMock: """构造只暴露身份读取合同的异步持久化替身。""" persistence = MagicMock()