From e099a519bac46d380ab6162999ce855afdd32c37 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 7 Aug 2026 19:21:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=96=E9=83=A8=20MCP=20?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=80=89=E6=8B=A9=E4=B8=8E=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目列表新增紧凑摘要和最新封面稳定引用 MCP 固定摘要视图并前置校验必填请求体 同步 External v1 OpenAPI、Skill 与项目记忆 补充十九项目超限和四个 415 工具回归测试 --- .../references/api-operations.md | 9 +- .../genarrative-external-v1.openapi.json | 108 +++++- docs/project-memory/shared-memory/pitfalls.md | 7 + ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 6 +- .../api-server/src/external_editor_api.rs | 335 +++++++++++++++++- .../crates/api-server/src/external_mcp.rs | 247 +++++++++++-- 6 files changed, 675 insertions(+), 37 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 1597a2a12..15ab6db75 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -8,7 +8,7 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S | Operation | Method and path | Minimum input | | --- | --- | --- | -| List projects | `GET /api/external/v1/editor/projects` | Authentication | +| List projects | `GET /api/external/v1/editor/projects` | Authentication; optional `view=full\|summary` (default `full`) | | Create project | `POST /api/external/v1/editor/projects` | Optional `title` | | Load recent project | `GET /api/external/v1/editor/projects/recent` | Authentication | | Get project | `GET /api/external/v1/editor/projects/{projectId}` | `projectId` | @@ -19,6 +19,13 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S Canvas save uses optimistic revision control. Pass the last authoritative `expectedRevision`; on conflict, reload instead of replaying a stale full layout. +Project listing supports two views: + +- `view=full` is the REST default and returns the complete project, canvas, layers, and resources. +- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot. +- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required. +- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references. + ## Asset and Upload Operations | Operation | Method and path | Minimum input | diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 659c13a6f..5eca4ff6f 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -365,13 +365,36 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "name": "view", + "in": "query", + "required": false, + "description": "返回视图。full 返回完整项目、画布、图层与资源;summary 只返回项目选择所需元数据和封面稳定引用。MCP 的 list_editor_projects 工具固定使用 summary。", + "schema": { + "type": "string", + "enum": [ + "full", + "summary" + ], + "default": "full" + } + } + ], "responses": { "200": { - "description": "项目列表", + "description": "项目列表。view=full 返回完整项目列表;view=summary 返回紧凑项目摘要列表。", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalEditorProjectListResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalEditorProjectListResponse" + }, + { + "$ref": "#/components/schemas/ExternalEditorProjectSummaryListResponse" + } + ] } } } @@ -2431,6 +2454,87 @@ } } }, + "ExternalEditorProjectSummaryListResponse": { + "type": "object", + "required": [ + "projects" + ], + "properties": { + "projects": { + "type": "array", + "description": "用于展示、查找、同名确认和安全选择目标的紧凑项目摘要;不包含 canvas、viewport、layers 或 resources。", + "items": { + "$ref": "#/components/schemas/EditorProjectSummary" + } + } + }, + "additionalProperties": false + }, + "EditorProjectSummary": { + "type": "object", + "required": [ + "projectId", + "title", + "updatedAt", + "cover" + ], + "properties": { + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "cover": { + "description": "项目最新封面快照的稳定引用;项目没有封面时为 null。需要展示时使用 objectKey 调用 /assets/read-url 获取临时签名 URL。", + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectSummaryCover" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "EditorProjectSummaryCover": { + "type": "object", + "required": [ + "resourceId", + "objectKey", + "width", + "height", + "updatedAt" + ], + "properties": { + "resourceId": { + "type": "string" + }, + "objectKey": { + "type": "string", + "description": "封面对象的稳定引用,不是图片正文、Data URL 或临时签名 URL。" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, "ExternalEditorProjectDeleteResponse": { "type": "object", "required": [ diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 75a7464b7..1ed4ef4d3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4200,6 +4200,13 @@ - 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 - 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 +## MCP 列表不能透传完整项目快照(2026-08-07) + +- 现象:账号项目数量增长后,`list_editor_projects` 把每个项目的 `canvas / layers / resources` 全量透传,REST 响应超过 MCP 4 MiB 上限,Agent 因整批失败而无法展示、查重或安全选择项目;缺少必填请求体时,内部 Axum JSON extractor 的文本 `415` 又会被泛化成“非 JSON 响应”。 +- 处理:项目列表 REST 保持默认 `view=full` 兼容,并提供 `view=summary`;MCP 固定使用 summary 且不向 Agent 暴露或接受 `view=full`。摘要只返回 `projectId / title / updatedAt / cover`,封面取最新且存在稳定 `objectKey` 的 `project-cover-snapshot`,展示时再调用 `/assets/read-url`,不在列表内嵌图片或签名 URL。MCP 在构造内部 REST 请求前按 OpenAPI schema 校验 required body;缺正文和缺字段分别返回结构化错误,不进入写入、上传票据或计费路径。 +- 验证:用 19 个完整序列化后超过 4 MiB 的项目 fixture 证明摘要仍低于上限且不含大型布局;覆盖四个历史 `415` 工具的缺正文、空对象和非对象输入,并断言项目列表工具固定 summary、调用方不能通过 query 覆盖。 +- 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`server-rs/crates/api-server/src/external_editor_api.rs`、`docs/openapi/genarrative-external-v1.openapi.json`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 + ## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31) - 现象:本地 `cargo test` 可以编译 MCP 与 Skill 下载模块,但 api-server 镜像在 Rust 编译阶段报 `include_str!` 找不到 OpenAPI 或 Skill 文件。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 4c21135d2..857e6ee62 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -17,7 +17,7 @@ v1 只开放以下能力: - `POST /api/external/v1/assets/direct-upload-tickets`:创建素材直传 OSS 凭证。 - `POST /api/external/v1/assets/objects/confirm`:确认已上传素材对象,`ownerUserId` 固定为 API Key 所属账号。 - `GET /api/external/v1/assets/read-url`:获取私有素材读取签名 URL。 -- `GET /api/external/v1/editor/projects`:列出当前 API Key 所属账号的图片画布项目。 +- `GET /api/external/v1/editor/projects`:列出当前 API Key 所属账号的图片画布项目;`view=full|summary`,REST 默认 `full`,MCP 固定使用 `summary`。 - `POST /api/external/v1/editor/projects`:创建图片画布项目。 - `GET /api/external/v1/editor/projects/recent`:读取当前账号最近图片画布项目。 - `GET /api/external/v1/editor/projects/{projectId}`:读取项目与默认画布。 @@ -83,6 +83,8 @@ MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarr MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 +`list_editor_projects` 是项目选择工具,服务端固定以 `view=summary` 调用项目列表,不允许因 OpenAPI 的 REST 默认值退回完整视图。摘要逐项目只返回 `projectId`、`title`、`updatedAt` 和可空 `cover`,不携带 `canvas`、`viewport`、`layers`、`resources` 或图片正文;选定目标后再用 `get_editor_project` 读取完整权威状态。`cover` 只包含最新项目封面快照的 `resourceId`、稳定 `objectKey`、尺寸与 `updatedAt`,没有封面时为 `null`。需要展示封面时,以 `objectKey` 调用 `/api/external/v1/assets/read-url` 获取短期签名 URL;列表不得内嵌 Data URL、图片二进制或临时签名 URL,也不得把签名 URL 当作持久引用。 + MCP 暴露下列稳定文本资源: - `genarrative://external-editor/usage`:关键工作流和异步轮询规则。 @@ -257,6 +259,8 @@ docs/openapi/genarrative-external-v1.openapi.json - 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 - 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 +- 项目列表 REST 默认 `view=full` 并保持完整响应兼容;`view=summary` 只返回项目选择元数据和可空封面稳定引用,MCP `list_editor_projects` 固定使用该摘要视图,不因完整项目数据量增长触发返回体上限。 +- 摘要封面不内嵌图片或签名 URL;使用 `cover.objectKey` 调 `/assets/read-url` 后才能临时展示。 - OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。 - `agent-integration.json` 能发现 MCP、OpenAPI、Skill entry/archive;下载 archive 的 SHA-256 与 manifest 一致,ZIP 包含 `SKILL.md`、四篇 references、Python helper 和 `agents/openai.yaml` 七个声明文件且不含凭据。 - MCP 在无 Bearer、Bearer 格式错误或 Key 无效时返回相同的 `401 + WWW-Authenticate + details.guide` 鉴权引导,且不暴露 tools/resources/owner;合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;resource catalog 必须包含 usage、OpenAPI、`skill` 主入口和当前全部 Skill references,当前精确为 `skill/references/capability-routing.md`、`skill/references/api-operations.md`、`skill/references/authentication-and-safety.md` 与 `skill/references/requests-and-outputs.md`,且不包含 CLI 脚本、测试或 workflow;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。 diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 0fa3734ed..96c3761e7 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Extension, Path, State, rejection::JsonRejection}, + extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; @@ -16,7 +16,7 @@ use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectRenameRecordInput, + EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; @@ -65,6 +65,22 @@ const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500; const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; +const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot"; + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum ExternalEditorProjectListView { + #[default] + Full, + Summary, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectListQuery { + #[serde(default)] + view: ExternalEditorProjectListView, +} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -168,6 +184,31 @@ pub struct ExternalEditorProjectListResponse { projects: Vec, } +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectSummary { + project_id: String, + title: String, + updated_at: String, + cover: Option, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectCoverSummary { + resource_id: String, + object_key: String, + width: u32, + height: u32, + updated_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectSummaryListResponse { + projects: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectDeleteResponse { @@ -240,6 +281,7 @@ pub async fn create_external_editor_project( pub async fn list_external_editor_projects( State(state): State, + Query(query): Query, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { @@ -248,15 +290,65 @@ pub async fn list_external_editor_projects( .spacetime_client() .list_editor_projects(principal.owner_user_id().to_string()) .await - .map_err(map_editor_project_error)? - .into_iter() - .map(editor_project_payload_from_record) - .collect(); + .map_err(map_editor_project_error)?; - Ok(json_success_body( - Some(&request_context), - ExternalEditorProjectListResponse { projects }, - )) + match query.view { + ExternalEditorProjectListView::Full => Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectListResponse { + projects: projects + .into_iter() + .map(editor_project_payload_from_record) + .collect(), + }, + )), + ExternalEditorProjectListView::Summary => Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectSummaryListResponse { + projects: projects + .into_iter() + .map(external_editor_project_summary_from_record) + .collect(), + }, + )), + } +} + +fn external_editor_project_summary_from_record( + record: EditorProjectRecord, +) -> ExternalEditorProjectSummary { + let cover = record + .resources + .iter() + .filter_map(|resource| { + (resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND)) + .then_some(resource) + .zip( + resource + .object_key + .as_deref() + .filter(|object_key| !object_key.trim().is_empty()), + ) + }) + .max_by(|(left, _), (right, _)| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.resource_id.cmp(&right.resource_id)) + }) + .map(|(resource, object_key)| ExternalEditorProjectCoverSummary { + resource_id: resource.resource_id.clone(), + object_key: object_key.to_string(), + width: resource.width, + height: resource.height, + updated_at: resource.updated_at.clone(), + }); + + ExternalEditorProjectSummary { + project_id: record.project_id, + title: record.title, + updated_at: record.updated_at, + cover, + } } pub async fn load_recent_external_editor_project( @@ -1025,6 +1117,75 @@ fn serialize_external_editor_image_sequence_frames( #[cfg(test)] mod tests { use super::*; + use spacetime_client::{ + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord, + }; + + fn external_editor_project_resource_fixture( + resource_id: &str, + asset_kind: Option<&str>, + object_key: Option<&str>, + updated_at: &str, + ) -> EditorProjectResourceRecord { + serde_json::from_value(json!({ + "resource_id": resource_id, + "project_id": "proj-summary-test", + "owner_user_id": "user-1", + "asset_object_id": format!("asset-object-{resource_id}"), + "image_src": format!("/api/assets/{resource_id}"), + "object_key": object_key, + "width": 320, + "height": 240, + "source_type": "uploaded", + "prompt": null, + "actual_prompt": null, + "model": null, + "provider": null, + "task_id": null, + "source_resource_id": null, + "asset_kind": asset_kind, + "generation_inputs": null, + "public_showcase_enabled": false, + "created_at": updated_at, + "updated_at": updated_at + })) + .expect("项目资源 fixture 应兼容可选字段扩展") + } + + fn external_editor_project_record_fixture( + resources: Vec, + ) -> EditorProjectRecord { + EditorProjectRecord { + project_id: "proj-summary-test".to_string(), + owner_user_id: "user-1".to_string(), + title: "摘要项目".to_string(), + canvas: EditorCanvasRecord { + canvas_id: "canvas-summary-test".to_string(), + project_id: "proj-summary-test".to_string(), + title: "摘要项目".to_string(), + viewport: EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers: json!([{"large": "canvas payload must not enter summary"}]), + revision: 3, + layout_storage_version: 1, + background_color: None, + created_at: "2026-08-01T00:00:00Z".to_string(), + updated_at: "2026-08-07T00:00:00Z".to_string(), + }, + viewport: EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers: json!([{"large": "project payload must not enter summary"}]), + resources, + created_at: "2026-08-01T00:00:00Z".to_string(), + updated_at: "2026-08-07T00:00:00Z".to_string(), + } + } const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [ "ExternalEditorAssetCreateRequest", @@ -1072,6 +1233,160 @@ mod tests { ) } + #[test] + fn external_editor_project_list_query_defaults_to_full_and_accepts_summary() { + let without_view = "http://localhost/api/external/v1/editor/projects" + .parse() + .expect("测试 URI 应合法"); + let Query(query) = Query::::try_from_uri(&without_view) + .expect("缺省 view 应保持 full 兼容语义"); + assert_eq!(query.view, ExternalEditorProjectListView::Full); + + let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full" + .parse() + .expect("测试 URI 应合法"); + let Query(query) = Query::::try_from_uri(&explicit_full) + .expect("显式 full 应合法"); + assert_eq!(query.view, ExternalEditorProjectListView::Full); + + let summary = "http://localhost/api/external/v1/editor/projects?view=summary" + .parse() + .expect("测试 URI 应合法"); + let Query(query) = Query::::try_from_uri(&summary) + .expect("summary 应合法"); + assert_eq!(query.view, ExternalEditorProjectListView::Summary); + + let unknown = "http://localhost/api/external/v1/editor/projects?view=compact" + .parse() + .expect("测试 URI 应合法"); + assert!( + Query::::try_from_uri(&unknown).is_err(), + "未知 view 不得静默回落到 full" + ); + } + + #[test] + fn external_editor_project_summary_selects_latest_persisted_cover() { + let project = external_editor_project_record_fixture(vec![ + external_editor_project_resource_fixture( + "resource-cover-old", + Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), + Some("editor/project-covers/old.webp"), + "2026-08-02T00:00:00Z", + ), + external_editor_project_resource_fixture( + "resource-other-newer", + Some("editor_generated_image"), + Some("editor/generated/newer.webp"), + "2026-08-06T00:00:00Z", + ), + external_editor_project_resource_fixture( + "resource-cover-empty-key", + Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), + Some(" "), + "2026-08-07T00:00:00Z", + ), + external_editor_project_resource_fixture( + "resource-cover-latest", + Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), + Some("editor/project-covers/latest.webp"), + "2026-08-05T00:00:00Z", + ), + ]); + + let summary = external_editor_project_summary_from_record(project); + let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化"); + + assert_eq!( + serialized, + json!({ + "projectId": "proj-summary-test", + "title": "摘要项目", + "updatedAt": "2026-08-07T00:00:00Z", + "cover": { + "resourceId": "resource-cover-latest", + "objectKey": "editor/project-covers/latest.webp", + "width": 320, + "height": 240, + "updatedAt": "2026-08-05T00:00:00Z" + } + }) + ); + assert!(serialized.get("canvas").is_none()); + assert!(serialized.get("layers").is_none()); + assert!(serialized.get("resources").is_none()); + } + + #[test] + fn external_editor_project_summary_returns_null_without_persisted_cover() { + let project = external_editor_project_record_fixture(vec![ + external_editor_project_resource_fixture( + "resource-non-cover", + Some("editor_generated_image"), + Some("editor/generated/image.webp"), + "2026-08-06T00:00:00Z", + ), + external_editor_project_resource_fixture( + "resource-cover-without-object", + Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), + None, + "2026-08-07T00:00:00Z", + ), + ]); + + let summary = external_editor_project_summary_from_record(project); + assert_eq!(summary.cover, None); + assert_eq!( + serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"], + Value::Null + ); + } + + #[test] + fn nineteen_large_projects_stay_below_mcp_limit_in_summary_view() { + const MCP_LIMIT_BYTES: usize = 4 * 1024 * 1024; + let projects = (0..19) + .map(|index| { + let mut project = external_editor_project_record_fixture(Vec::new()); + project.project_id = format!("proj-summary-{index}"); + project.canvas.project_id = project.project_id.clone(); + project.title = format!("项目 {index}"); + project.layers = json!({"largePayload": "x".repeat(160_000)}); + project.canvas.layers = json!({"largePayload": "x".repeat(160_000)}); + project + }) + .collect::>(); + + let full = serde_json::to_vec( + &projects + .iter() + .cloned() + .map(editor_project_payload_from_record) + .collect::>(), + ) + .expect("完整项目列表应可序列化"); + let summaries = projects + .into_iter() + .map(external_editor_project_summary_from_record) + .collect::>(); + let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化"); + + assert!( + full.len() > MCP_LIMIT_BYTES, + "测试数据必须先复现完整列表超过 MCP 上限" + ); + assert_eq!(summaries.len(), 19); + assert!( + summary.len() < MCP_LIMIT_BYTES, + "同一批项目的摘要必须低于 MCP 上限" + ); + assert!( + !summary + .windows(b"largePayload".len()) + .any(|window| { window == b"largePayload" }) + ); + } + #[test] fn external_editor_canvas_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 5f9a056b9..a01707084 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -262,11 +262,16 @@ fn build_mcp_operations() -> Vec { .or_else(|| operation.get("summary")) .and_then(Value::as_str) .unwrap_or("调用陶泥儿外部编辑器 API"); + let path_template = if operation_id == "listEditorProjects" { + format!("{path}?view=summary") + } else { + path.clone() + }; operations.push(McpOperation { tool_name: camel_to_snake(operation_id), operation_id: operation_id.to_string(), method, - path_template: path.clone(), + path_template, description: format!("{description}({} {path})", method_name.to_uppercase()), input_schema: Arc::new(build_operation_input_schema( &openapi, @@ -289,6 +294,8 @@ fn build_operation_input_schema( requires_idempotency_key: bool, ) -> Map { let mut properties = Map::new(); + let fixes_project_list_to_summary = + operation.get("operationId").and_then(Value::as_str) == Some("listEditorProjects"); let parameters = path_item .get("parameters") .and_then(Value::as_array) @@ -314,6 +321,9 @@ fn build_operation_input_schema( let Some(name) = parameter.get("name").and_then(Value::as_str) else { continue; }; + if fixes_project_list_to_summary && location == "query" && name == "view" { + continue; + } parameter_properties.insert( name.to_string(), parameter @@ -447,6 +457,8 @@ async fn dispatch_operation( arguments: Map, context: &McpRequestContext, ) -> Result { + validate_required_body(operation, &arguments)?; + let parts = context .extensions .get::() @@ -479,28 +491,7 @@ async fn dispatch_operation( return Err(json!({"error": "缺少必填路径参数"})); } if let Some(query) = arguments.get("queryParameters").and_then(Value::as_object) { - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - for (name, value) in query { - match value { - Value::Array(values) => { - for value in values { - if let Some(value) = json_scalar_string(value) { - serializer.append_pair(name, &value); - } - } - } - value => { - if let Some(value) = json_scalar_string(value) { - serializer.append_pair(name, &value); - } - } - } - } - let query = serializer.finish(); - if !query.is_empty() { - path.push('?'); - path.push_str(&query); - } + append_operation_query_parameters(operation, &mut path, query); } let body = arguments.get("body").cloned().unwrap_or(Value::Null); @@ -566,6 +557,95 @@ async fn dispatch_operation( } } +fn validate_required_body( + operation: &McpOperation, + arguments: &Map, +) -> Result<(), Value> { + let body_is_required = operation + .input_schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|required| required.iter().any(|name| name.as_str() == Some("body"))); + if !body_is_required { + return Ok(()); + } + + let required_fields = operation.input_schema["properties"]["body"] + .get("required") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let Some(body) = arguments.get("body").filter(|body| !body.is_null()) else { + return Err(json!({ + "error": "缺少请求体", + "requiredFields": required_fields, + })); + }; + + let Some(body) = body.as_object() else { + return Err(json!({ + "error": "请求体必须是 JSON 对象", + "expectedType": "object", + })); + }; + let missing_fields = required_fields + .iter() + .filter(|field| { + field + .as_str() + .is_some_and(|field| !body.contains_key(field)) + }) + .cloned() + .collect::>(); + if missing_fields.is_empty() { + Ok(()) + } else { + Err(json!({ + "error": "缺少必填字段", + "missingFields": missing_fields, + })) + } +} + +fn append_query_parameters(path: &mut String, query: &Map) { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in query { + match value { + Value::Array(values) => { + for value in values { + if let Some(value) = json_scalar_string(value) { + serializer.append_pair(name, &value); + } + } + } + value => { + if let Some(value) = json_scalar_string(value) { + serializer.append_pair(name, &value); + } + } + } + } + let query = serializer.finish(); + if !query.is_empty() { + path.push(if path.contains('?') { '&' } else { '?' }); + path.push_str(&query); + } +} + +fn append_operation_query_parameters( + operation: &McpOperation, + path: &mut String, + query: &Map, +) { + if operation.operation_id == "listEditorProjects" { + let mut query = query.clone(); + query.remove("view"); + append_query_parameters(path, &query); + } else { + append_query_parameters(path, query); + } +} + fn unwrap_external_api_success_payload(payload: Value) -> Value { payload .get("data") @@ -674,6 +754,127 @@ mod tests { assert_eq!(create_project.method, Method::POST); } + #[test] + fn project_list_tool_always_requests_summary_view() { + let operation = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == "list_editor_projects") + .expect("project list tool should exist"); + assert_eq!( + operation.path_template, + "/api/external/v1/editor/projects?view=summary" + ); + assert!( + operation.input_schema["properties"] + .get("queryParameters") + .is_none(), + "MCP 项目列表固定 summary 后不得再向 Agent 暴露 REST view 参数" + ); + + let mut path = operation.path_template.clone(); + append_operation_query_parameters( + operation, + &mut path, + &Map::from_iter([ + ("limit".to_string(), json!(20)), + ("view".to_string(), json!("full")), + ]), + ); + assert_eq!( + path, + "/api/external/v1/editor/projects?view=summary&limit=20" + ); + + let mut path = "/api/external/v1/editor/assets".to_string(); + append_query_parameters( + &mut path, + &Map::from_iter([("folderId".to_string(), json!("folder-1"))]), + ); + assert_eq!(path, "/api/external/v1/editor/assets?folderId=folder-1"); + } + + #[test] + fn required_request_body_errors_are_derived_from_tool_schema() { + for (tool_name, required_fields) in [ + ( + "confirm_external_asset_object", + vec![json!("objectKey"), json!("assetKind")], + ), + ( + "create_editor_asset", + vec![ + json!("folderId"), + json!("label"), + json!("imageSrc"), + json!("width"), + json!("height"), + json!("sourceType"), + ], + ), + ("create_editor_asset_folder", vec![json!("label")]), + ( + "create_external_direct_upload_ticket", + vec![json!("legacyPrefix"), json!("fileName")], + ), + ] { + let operation = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == tool_name) + .unwrap_or_else(|| panic!("{tool_name} should exist")); + for arguments in [ + Map::new(), + Map::from_iter([("body".to_string(), Value::Null)]), + ] { + assert_eq!( + validate_required_body(operation, &arguments), + Err(json!({ + "error": "缺少请求体", + "requiredFields": required_fields, + })), + "{tool_name}" + ); + } + assert_eq!( + validate_required_body( + operation, + &Map::from_iter([("body".to_string(), json!({}))]), + ), + Err(json!({ + "error": "缺少必填字段", + "missingFields": required_fields, + })), + "{tool_name}" + ); + assert_eq!( + validate_required_body( + operation, + &Map::from_iter([("body".to_string(), json!([]))]), + ), + Err(json!({ + "error": "请求体必须是 JSON 对象", + "expectedType": "object", + })), + "{tool_name}" + ); + } + } + + #[test] + fn optional_request_body_is_not_rejected() { + let operation = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == "create_editor_project") + .expect("project create tool should exist"); + assert_eq!(validate_required_body(operation, &Map::new()), Ok(())); + assert_eq!( + validate_required_body( + operation, + &Map::from_iter([("body".to_string(), Value::Null)]), + ), + Ok(()) + ); + } + #[test] fn generation_tools_require_idempotency_key() { let operation = MCP_OPERATIONS