Files
Genarrative/server-rs/crates/api-server/src/external_skill_api.rs
T
kdletters d1a657b5d2 为未鉴权MCP返回接入引导
保持401与Bearer挑战并提供机器可读的Key配置步骤

统一缺失格式错误和无效Key响应以避免凭据枚举

保留请求元信息并同步OpenAPI集成清单与架构文档

补充鉴权脱敏契约测试并完成本地dev栈验证
2026-08-03 12:03:50 +08:00

181 lines
6.1 KiB
Rust

use std::io::{Cursor, Write};
use axum::{
Json,
body::Body,
http::{
HeaderValue, StatusCode,
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use zip::{ZipWriter, write::SimpleFileOptions};
use crate::http_error::AppError;
const SKILL_ROOT: &str = "genarrative-external-editor-api";
const SKILL_FILES: [(&str, &str); 7] = [
(
"SKILL.md",
include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"),
),
(
"references/capability-routing.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md"
),
),
(
"references/api-operations.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md"
),
),
(
"references/authentication-and-safety.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md"
),
),
(
"references/requests-and-outputs.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md"
),
),
(
"scripts/genarrative_external_api.py",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py"
),
),
(
"agents/openai.yaml",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/agents/openai.yaml"
),
),
];
pub async fn get_external_skill_entry() -> Response {
let mut response = Body::from(SKILL_FILES[0].1).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
response
}
pub async fn download_external_skill_archive() -> Result<Response, AppError> {
let bytes = build_external_skill_archive()?;
let mut response = Body::from(bytes).into_response();
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/zip"));
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_static(
"attachment; filename=\"genarrative-external-editor-api.skill.zip\"",
),
);
Ok(response)
}
pub async fn get_external_agent_integration_manifest() -> Result<Json<Value>, AppError> {
let archive = build_external_skill_archive()?;
let sha256 = format!("{:x}", Sha256::digest(&archive));
Ok(Json(json!({
"name": SKILL_ROOT,
"version": env!("CARGO_PKG_VERSION"),
"mcp": {
"transport": "streamable-http",
"url": "/api/external/v1/mcp",
"authentication": "bearer-api-key",
"credentialSetup": {
"action": "CONFIGURE_BEARER_API_KEY",
"authorizationValueFormat": "Bearer <tnr_sk_...>",
"navigationLabel": "开发者 API Key",
"guide": "/api/external/v1/skill/SKILL.md"
}
},
"openapi": "/api/external/v1/openapi.json",
"skill": {
"entry": "/api/external/v1/skill/SKILL.md",
"archive": "/api/external/v1/skill.zip",
"archiveSha256": sha256,
"files": SKILL_FILES.map(|(path, _)| format!("{SKILL_ROOT}/{path}")),
}
})))
}
fn build_external_skill_archive() -> Result<Vec<u8>, AppError> {
let cursor = Cursor::new(Vec::new());
let mut archive = ZipWriter::new(cursor);
let options = SimpleFileOptions::default().unix_permissions(0o644);
for (path, contents) in SKILL_FILES {
archive
.start_file(format!("{SKILL_ROOT}/{path}"), options)
.map_err(skill_archive_error)?;
archive
.write_all(contents.as_bytes())
.map_err(skill_archive_error)?;
}
archive
.finish()
.map(Cursor::into_inner)
.map_err(skill_archive_error)
}
fn skill_archive_error(error: impl std::fmt::Display) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "external-skill-archive",
"message": format!("构建外部 Skill 包失败:{error}"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_contains_complete_skill_bundle() {
let bytes = build_external_skill_archive().expect("skill archive should build");
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("archive should parse");
assert_eq!(archive.len(), SKILL_FILES.len());
for (path, contents) in SKILL_FILES {
let name = format!("{SKILL_ROOT}/{path}");
let mut file = archive.by_name(&name).expect("skill file should exist");
let mut actual = String::new();
std::io::Read::read_to_string(&mut file, &mut actual).expect("skill file should read");
assert_eq!(actual, contents);
}
}
#[tokio::test]
async fn integration_manifest_matches_complete_skill_archive() {
let bytes = build_external_skill_archive().expect("skill archive should build");
let Json(manifest) = get_external_agent_integration_manifest()
.await
.expect("integration manifest should build");
let expected_files = SKILL_FILES
.map(|(path, _)| format!("{SKILL_ROOT}/{path}"))
.to_vec();
assert_eq!(manifest["skill"]["files"], json!(expected_files));
assert_eq!(
manifest["mcp"]["credentialSetup"],
json!({
"action": "CONFIGURE_BEARER_API_KEY",
"authorizationValueFormat": "Bearer <tnr_sk_...>",
"navigationLabel": "开发者 API Key",
"guide": "/api/external/v1/skill/SKILL.md"
})
);
assert_eq!(
manifest["skill"]["archiveSha256"],
json!(format!("{:x}", Sha256::digest(&bytes)))
);
}
}