合并主分支并融合托管 MCP 更新

同步主分支的 Runner GUI owner 修复与托管 MCP 开发访问支持。
保留角色动作素材分支的现有实现。
This commit is contained in:
2026-08-05 15:43:18 +08:00
7 changed files with 321 additions and 8 deletions
@@ -8,6 +8,7 @@ use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -16,6 +17,76 @@ const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024;
const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024;
const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Default)]
pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState {
generation: u64,
registration: Option<ExternalAgentRunnerGuiOwnerRegistration>,
}
struct ExternalAgentRunnerGuiOwnerRegistration {
generation: u64,
config_dir: PathBuf,
params: ExternalAgentRunnerRequestParams,
attached_boot_id: Option<String>,
}
static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock<
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
> = OnceLock::new();
fn external_agent_runner_gui_owner_attachment_state(
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
.get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()))
}
pub(super) fn register_external_agent_runner_gui_owner_attachment(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
config_dir: &Path,
params: ExternalAgentRunnerRequestParams,
) {
let mut state = lock_unpoisoned(state);
state.generation = state.generation.wrapping_add(1);
let generation = state.generation;
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
generation,
config_dir: config_dir.to_path_buf(),
params,
attached_boot_id: None,
});
}
pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F>(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint,
attach: F,
) -> Result<(), String>
where
F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>,
{
let Some((generation, params)) = ({
let state = lock_unpoisoned(state);
state.registration.as_ref().and_then(|registration| {
(registration.config_dir == config_dir
&& registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()))
.then(|| (registration.generation, registration.params.clone()))
})
}) else {
return Ok(());
};
attach(endpoint, params)?;
let mut state = lock_unpoisoned(state);
if let Some(registration) = state.registration.as_mut() {
if registration.generation == generation && registration.config_dir == config_dir {
registration.attached_boot_id = Some(endpoint.boot_id.clone());
}
}
Ok(())
}
fn redact_url_queries(line: &str) -> String {
line.split_whitespace()
.map(|token| {
@@ -914,14 +985,22 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
let endpoint = ensure_external_agent_runner(&config_dir)?;
let result = send_external_agent_runner_request(
&endpoint,
"runner.attach_gui_owner",
register_external_agent_runner_gui_owner_attachment(
external_agent_runner_gui_owner_attachment_state(),
&config_dir,
ExternalAgentRunnerRequestParams::default(),
)?;
);
ensure_external_agent_runner(&config_dir).map(|_| ())
}
fn attach_external_agent_runner_gui_owner_at(
endpoint: &ExternalAgentRunnerEndpoint,
params: ExternalAgentRunnerRequestParams,
) -> Result<(), String> {
let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?;
if result.get("attached").and_then(Value::as_bool) == Some(true) {
Ok(())
} else {
@@ -929,6 +1008,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
}
}
fn attach_registered_external_agent_runner_gui_owner_if_needed(
config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint,
) -> Result<(), String> {
attach_registered_external_agent_runner_gui_owner_if_needed_with(
external_agent_runner_gui_owner_attachment_state(),
config_dir,
endpoint,
attach_external_agent_runner_gui_owner_at,
)
}
pub(super) fn shutdown_external_agent_runner_for_client_exit_at(
config_dir: &Path,
) -> Result<bool, String> {
@@ -1024,6 +1115,9 @@ pub(super) fn ensure_external_agent_runner(
match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) {
ExternalAgentRunnerReuseDecision::Reuse => {
if ping_external_agent_runner(&endpoint).is_ok() {
attach_registered_external_agent_runner_gui_owner_if_needed(
config_dir, &endpoint,
)?;
return Ok(endpoint);
}
}
@@ -1057,6 +1151,7 @@ pub(super) fn ensure_external_agent_runner(
let _ = launched.child.wait();
})
.map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?;
attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?;
Ok(endpoint)
}
Err(error) => {
@@ -10,6 +10,7 @@ use std::io::{self, Cursor};
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -551,6 +552,170 @@ fn runner_endpoint_rejects_hard_links() {
assert!(error.contains("硬链接"));
}
#[test]
fn gui_owner_registration_replays_once_for_each_runner_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("registered-gui-appdata");
let params = ExternalAgentRunnerRequestParams {
action_id: Some("registered-owner-params".to_string()),
..ExternalAgentRunnerRequestParams::default()
};
register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params);
let calls = std::cell::RefCell::new(Vec::new());
let endpoint_a = test_endpoint(
"gui-owner-replay-token-gui-owner-replay-token",
"gui-owner-boot-a",
31318,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_a,
|endpoint, params| {
calls.borrow_mut().push((
endpoint.boot_id.clone(),
params.action_id.expect("registered params are retained"),
));
Ok(())
},
)
.expect("first boot attaches");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_a,
|_, _| panic!("same boot must not attach twice"),
)
.expect("same boot is idempotent");
let endpoint_b = test_endpoint(
"gui-owner-replay-token-gui-owner-replay-token",
"gui-owner-boot-b",
31319,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_b,
|endpoint, params| {
calls.borrow_mut().push((
endpoint.boot_id.clone(),
params.action_id.expect("registered params are replayed"),
));
Ok(())
},
)
.expect("replacement boot reattaches");
assert_eq!(
calls.into_inner(),
vec![
(
"gui-owner-boot-a".to_string(),
"registered-owner-params".to_string()
),
(
"gui-owner-boot-b".to_string(),
"registered-owner-params".to_string()
),
]
);
}
#[test]
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("retry-gui-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerRequestParams::default(),
);
let endpoint = test_endpoint(
"gui-owner-retry-token-gui-owner-retry-token",
"gui-owner-retry-boot",
31320,
);
let attempts = std::cell::Cell::new(0_u32);
let error = attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
Err("injected attach failure".to_string())
},
)
.expect_err("failed attach must remain pending");
assert_eq!(error, "injected attach failure");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
Ok(())
},
)
.expect("same boot retries after failure");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| panic!("successful retry must mark the boot attached"),
)
.expect("successful retry is idempotent");
assert_eq!(attempts.get(), 2);
}
#[test]
fn gui_owner_registration_does_not_cross_config_dirs() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let registered_config_dir = PathBuf::from("registered-gui-appdata");
let other_config_dir = PathBuf::from("other-gui-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&registered_config_dir,
ExternalAgentRunnerRequestParams::default(),
);
let endpoint = test_endpoint(
"gui-owner-config-token-gui-owner-config-token",
"gui-owner-config-boot",
31321,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&other_config_dir,
&endpoint,
|_, _| panic!("GUI owner registration must stay bound to its AppData"),
)
.expect("other AppData remains unattached");
let calls = std::cell::Cell::new(0_u32);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&registered_config_dir,
&endpoint,
|_, _| {
calls.set(calls.get() + 1);
Ok(())
},
)
.expect("registered AppData attaches");
assert_eq!(calls.get(), 1);
let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&unregistered,
&registered_config_dir,
&endpoint,
|_, _| panic!("CLI state without GUI registration must not attach"),
)
.expect("unregistered CLI state remains unchanged");
}
#[test]
fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() {
let directory = unique_test_directory();
File diff suppressed because one or more lines are too long
@@ -4127,3 +4127,11 @@
- 风险:Provider 工具约束不是本地安全边界;特别是 `writes + readOnlyHint=true` 自动放行的工具,schema 外字段可能改变外部副作用而不进入预期确认路径。
- 处理:使用完整 JSON Schema validator 校验原始 catalog schema,不手写 required/type 子集;native parser、fingerprint enrichment 与实际 MCP 调用边界复用同一校验器。enrichment 错误必须映射回 classified `arguments-schema` repair,不能以普通字符串直接终止 run;执行点重验用于阻断升级前已经落盘的 schema 外 pending。关闭网络和文件 `$ref` 解析,schema 无法安全编译时不广告或不执行。`serde` 类型错误会包含实际字符串值,catalog miss 也会包含模型提交的 server/tool,因此这两类错误同样只能返回稳定类别,不能拼接原始错误、参数值或 schema 内容。
- 验证:覆盖 required、additionalProperties、type、enum、本地 `$defs/$ref`、HTTP/file 外部引用、无效 schema、错误脱敏,证明 legacy wrapper 在注入 fingerprint 前进入 repair,并证明带旧有效 fingerprint 的历史 pending 在实际调用前仍被 schema 拒绝。
## 托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05)
- 现象:`https://dev.genarrative.world/api/external/v1/mcp` 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 `initialize` 返回 `403 FORBIDDEN`;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。
- 原因:`rmcp` Streamable HTTP transport 自带 DNS rebinding 防护。公网网关已经接入 dev 域名,但 `external_mcp::service()``allowed_hosts` / `allowed_origins` 仍只登记正式域名和 localhost,因此请求在 MCP 协议处理前被 transport 拒绝。
- 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。
- 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize``tools/list``resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。
- 关联:`server-rs/crates/api-server/src/external_mcp.rs``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
@@ -271,7 +271,7 @@ Agent Runtime 负责:
- 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。
- 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。
- 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`
- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach ownerRunner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`
- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach ownerRunner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`
- 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。
- 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/<agentId>.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。
- 2026-07-10 补充,2026-08-01 更新:默认 `agent.resume=confirm` 时,客户端自动恢复命令先做只读 recovery preflight。全新项目和已完全终态且没有 task / retry / handoff / finalization / pending action / reconciliation 等 durable recovery work 的项目直接返回空结果,不显示虚假的 `agent.resume` 确认条。确实存在可恢复工作时,自动命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。
@@ -79,6 +79,8 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原
`/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize``notifications/initialized``ping``tools/list``tools/call``resources/list``resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。
MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host``Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。
MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。
MCP 暴露下列稳定文本资源:
@@ -83,6 +83,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService {
.with_allowed_hosts([
"www.genarrative.world",
"genarrative.world",
"dev.genarrative.world",
"localhost",
"127.0.0.1",
"::1",
@@ -90,6 +91,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService {
.with_allowed_origins([
"https://www.genarrative.world",
"https://genarrative.world",
"https://dev.genarrative.world",
"http://localhost:3000",
"http://127.0.0.1:3000",
]);
@@ -603,7 +605,7 @@ mod tests {
use axum::{
http::{
StatusCode,
header::{ACCEPT, HOST},
header::{ACCEPT, HOST, ORIGIN},
},
middleware,
};
@@ -759,6 +761,47 @@ mod tests {
);
}
#[tokio::test]
async fn streamable_http_accepts_dev_host_and_origin_without_weakening_guards() {
for (host, origin, expected_status) in [
("dev.genarrative.world", None, StatusCode::OK),
(
"dev.genarrative.world",
Some("https://dev.genarrative.world"),
StatusCode::OK,
),
("untrusted.example", None, StatusCode::FORBIDDEN),
(
"dev.genarrative.world",
Some("https://untrusted.example"),
StatusCode::FORBIDDEN,
),
] {
let mut request = Request::builder()
.method(Method::POST)
.uri("/api/external/v1/mcp")
.header(HOST, host)
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json, text/event-stream");
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
let request = request
.body(Body::from(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dev-host-test-agent","version":"1.0"}}}"#,
))
.expect("development initialize request should build");
let response = service()
.oneshot(request)
.await
.expect("MCP service should be infallible");
assert_eq!(response.status(), expected_status, "host={host}");
assert!(response.headers().get("mcp-session-id").is_none());
}
}
#[tokio::test]
async fn streamable_http_initialize_is_stateless_json() {
let request = Request::builder()