合并远端主分支
同步外部 MCP 项目选择与 API 契约修复 同步 Gitea CI 网络稳定性与构建镜像配置
This commit is contained in:
@@ -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<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 {
|
||||
@@ -240,6 +281,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> {
|
||||
@@ -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(
|
||||
@@ -1026,6 +1118,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<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",
|
||||
@@ -1073,6 +1234,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!({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user