合并主线更新内容
Project CI / Native shell tests (pull_request) Successful in 13m36s
Project CI / Backend tests (pull_request) Successful in 4m3s
Project CI / Repository checks (pull_request) Successful in 48s
Project CI / Frontend tests (pull_request) Successful in 4m53s

- 合并外部 MCP 项目选择修复:紧凑项目列表、封面稳定引用、请求体参数提示与回归测试
- 同步 External v1 契约、Skill 与项目记忆
- 解决 external_editor_api.rs 测试模块合并冲突,保留两侧新增测试代码
This commit is contained in:
2026-08-07 19:55:18 +08:00
6 changed files with 675 additions and 37 deletions
@@ -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 |
@@ -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": [
@@ -4224,6 +4224,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 文件。
@@ -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`:关键工作流和异步轮询规则。
@@ -258,6 +260,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 控制面。
@@ -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,
};
@@ -66,6 +66,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")]
@@ -169,6 +185,31 @@ pub struct ExternalEditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectSummary {
project_id: String,
title: String,
updated_at: String,
cover: Option<ExternalEditorProjectCoverSummary>,
}
#[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<ExternalEditorProjectSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectDeleteResponse {
@@ -241,6 +282,7 @@ pub async fn create_external_editor_project(
pub async fn list_external_editor_projects(
State(state): State<AppState>,
Query(query): Query<ExternalEditorProjectListQuery>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
@@ -249,15 +291,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(
@@ -1028,8 +1120,77 @@ fn serialize_external_editor_image_sequence_frames(
mod tests {
use super::*;
use axum::{Router, body::Body, routing::post};
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
use tower::ServiceExt;
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<EditorProjectResourceRecord>,
) -> 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",
"ExternalEditorProjectResourceCreateRequest",
@@ -1076,6 +1237,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::<ExternalEditorProjectListQuery>::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::<ExternalEditorProjectListQuery>::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::<ExternalEditorProjectListQuery>::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::<ExternalEditorProjectListQuery>::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::<Vec<_>>();
let full = serde_json::to_vec(
&projects
.iter()
.cloned()
.map(editor_project_payload_from_record)
.collect::<Vec<_>>(),
)
.expect("完整项目列表应可序列化");
let summaries = projects
.into_iter()
.map(external_editor_project_summary_from_record)
.collect::<Vec<_>>();
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::<ExternalEditorCanvasSaveRequest>(json!({
+224 -23
View File
@@ -262,11 +262,16 @@ fn build_mcp_operations() -> Vec<McpOperation> {
.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<String, Value> {
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<String, Value>,
context: &McpRequestContext<RoleServer>,
) -> Result<Value, Value> {
validate_required_body(operation, &arguments)?;
let parts = context
.extensions
.get::<axum::http::request::Parts>()
@@ -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<String, Value>,
) -> 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::<Vec<_>>();
if missing_fields.is_empty() {
Ok(())
} else {
Err(json!({
"error": "缺少必填字段",
"missingFields": missing_fields,
}))
}
}
fn append_query_parameters(path: &mut String, query: &Map<String, Value>) {
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<String, Value>,
) {
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