From 7731b3280fc2320dc4590832b0920445738352c5 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 1 Sep 2026 05:01:46 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20=E9=A6=96?= =?UTF-8?q?=E5=9B=9E=E5=90=88=20MCP=20=E5=B0=B1=E7=BB=AA=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 DirectProject MCP 首轮可选启动等待配置 在首次 turn/start 前等待已启用第三方 MCP 的 app-server 状态 保留失败和取消 MCP 的非阻塞处理 同步 DirectProject MCP 扩展技术方案与系统提示行为 补充启动参数和隔离配置断言 --- .../src-tauri/src/agent/codex_app_server.rs | 89 ++++++++++++++++++- .../src-tauri/src/agent/direct_runtime.rs | 4 +- ...roject客户端Skill与MCP扩展导入方案-2026-08-31.md | 4 +- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 1b12ec82b..f03e83939 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -7,7 +7,7 @@ use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::{mpsc, oneshot, Mutex, Notify}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -28,6 +28,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000; const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000; const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; +const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000; const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); const DIRECT_CODEX_SHELL_ENVIRONMENT_POLICY: &str = "shell_environment_policy.inherit=\"core\""; @@ -568,6 +569,10 @@ struct CodexAppServerInner { _working_dir: tempfile::TempDir, workspace_path: std::path::PathBuf, workspace_mode: CodexAppServerWorkspaceMode, + client_mcp_server_names: Vec, + client_mcp_startup_statuses: Mutex>, + client_mcp_startup_notify: Notify, + initial_client_mcp_startup_waited: AtomicBool, _provider_proxy: Option, tool_bridge: Option, _skill_roots: Option>, @@ -1132,6 +1137,10 @@ fn configure_game_creator_codex_app_server_command_for_mode( if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_executable = direct_tools_mcp_executable_path()?; command + .arg("-c") + .arg(format!( + "mcp_optional_startup_grace_ms={DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS}" + )) .arg("-c") .arg(format!( "mcp_servers.agc_tools.command={}", @@ -1333,6 +1342,10 @@ fn trust_isolated_game_creator_codex_workspace( ); let mut root = toml::map::Map::new(); root.insert("projects".to_string(), toml::Value::Table(projects)); + root.insert( + "mcp_optional_startup_grace_ms".to_string(), + toml::Value::Integer(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64), + ); if !client_mcp_servers.is_empty() { let mut mcp_servers = toml::map::Map::new(); for server in client_mcp_servers { @@ -1538,6 +1551,10 @@ impl CodexAppServerConnection { } else { Vec::new() }; + let client_mcp_server_names = client_mcp_servers + .iter() + .map(|server| server.name.clone()) + .collect::>(); if workspace_override.is_some() && workspace_mode.allows_workspace_writes() { trust_isolated_game_creator_codex_workspace( &isolated_codex_home, @@ -1684,6 +1701,10 @@ impl CodexAppServerConnection { _working_dir: working_dir, workspace_path, workspace_mode, + client_mcp_server_names, + client_mcp_startup_statuses: Mutex::new(HashMap::new()), + client_mcp_startup_notify: Notify::new(), + initial_client_mcp_startup_waited: AtomicBool::new(false), _provider_proxy: provider_proxy, tool_bridge, _skill_roots: skill_roots, @@ -1760,6 +1781,54 @@ impl CodexAppServerConnection { .map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}")) } + async fn wait_for_initial_client_mcp_startup(&self) { + if self.inner.workspace_mode != CodexAppServerWorkspaceMode::DirectProject + || self.inner.client_mcp_server_names.is_empty() + || self + .inner + .initial_client_mcp_startup_waited + .swap(true, Ordering::AcqRel) + { + return; + } + + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_millis(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS); + loop { + let pending = { + let statuses = self.inner.client_mcp_startup_statuses.lock().await; + self.inner.client_mcp_server_names.iter().any(|name| { + !matches!( + statuses.get(name).map(String::as_str), + Some("ready") | Some("failed") | Some("cancelled") + ) + }) + }; + if !pending { + return; + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + eprintln!( + "agent.direct_codex.client_mcp_startup_wait timed out after {}ms", + DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS + ); + return; + } + tokio::select! { + _ = self.inner.client_mcp_startup_notify.notified() => {} + _ = tokio::time::sleep(remaining) => { + eprintln!( + "agent.direct_codex.client_mcp_startup_wait timed out after {}ms", + DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS + ); + return; + } + } + } + } + async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> { self.write_message(&serde_json::json!({ "method": method, @@ -1981,6 +2050,7 @@ impl CodexAppServerConnection { ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; + self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) @@ -2575,6 +2645,18 @@ async fn read_game_creator_codex_app_server_stdout( .pointer("/params/status") .and_then(serde_json::Value::as_str), ) { + if inner + .client_mcp_server_names + .iter() + .any(|server_name| server_name == name) + { + inner + .client_mcp_startup_statuses + .lock() + .await + .insert(name.to_string(), status.to_string()); + inner.client_mcp_startup_notify.notify_waiters(); + } let _ = crate::client_extensions::record_client_mcp_startup_status(name, status); } continue; @@ -3759,6 +3841,7 @@ mod tests { assert!(joined.contains("mcp_servers.agc_tools.command=")); assert!(joined.contains(DIRECT_TOOLS_MCP_MODE_FLAG)); assert!(joined.contains("mcp_servers.agc_tools.required=true")); + assert!(joined.contains("mcp_optional_startup_grace_ms=120000")); assert!(!joined.contains("mcp_servers={}")); } @@ -3792,6 +3875,10 @@ mod tests { parsed["mcp_servers"]["plugin-search"]["required"].as_bool(), Some(false) ); + assert_eq!( + parsed["mcp_optional_startup_grace_ms"].as_integer(), + Some(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64) + ); assert!(parsed["mcp_servers"].get("agc_tools").is_none()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 798bac52e..177fd24af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -4362,6 +4362,8 @@ mod tests { assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)")); + assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP")); + assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`")); assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index 44567e2c6..b0758c96e 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -326,7 +326,7 @@ MCP · 来自 my-plugin.zip 启动失败 每个 MCP Server 独立启用和禁用。相对路径按来源目录解析,原始 command、args、cwd、env 或 URL 按原生配置保留。 -运行时只转换 Codex 原生能够直接使用的标准字段。STDIO 支持 `command / args / env / env_vars / cwd`;HTTP 支持 `url / bearer_token_env_var / http_headers / env_http_headers`;两类都支持原生启动超时、工具超时和工具筛选字段。客户端启用状态是运行时权威,每个第三方项固定 `enabled=true`、`required=false`,并沿用 DirectProject 无逐工具弹窗的自动批准方式。第三方 MCP 配置写入本次隔离 `CODEX_HOME/config.toml`,避免配置中的环境值或静态 header 出现在进程命令行。 +运行时只转换 Codex 原生能够直接使用的标准字段。STDIO 支持 `command / args / env / env_vars / cwd`;HTTP 支持 `url / bearer_token_env_var / http_headers / env_http_headers`;两类都支持原生启动超时、工具超时和工具筛选字段。客户端启用状态是运行时权威,每个第三方项固定 `enabled=true`、`required=false`,并沿用 DirectProject 无逐工具弹窗的自动批准方式。第三方 MCP 配置写入本次隔离 `CODEX_HOME/config.toml`;DirectProject 启动命令同时以 `-c mcp_optional_startup_grace_ms=120000` 传入同一值,命令行 override 是 Codex 最终生效来源。DirectProject 在首次 `turn/start` 前消费 app-server 已有的 `mcpServer/startupStatus/updated` 通知,等待每个已启用第三方 MCP 进入 `ready`、`failed` 或 `cancelled`;最长等待 `120000` 毫秒,超时后继续对话。ready 的 MCP 进入首轮,失败或超时的 MCP 被跳过并记录状态。该配置只作用于本次 DirectProject 隔离运行,不修改用户全局 Codex 配置。DirectProject 系统提示会声明客户端扩展列表中已启用的第三方 MCP;用户明确指定 Server 或工具时,模型只在当前可用工具中查找,找不到则如实说明,不伪造结果。 用户导入来源中的相对 `cwd` 按该 MCP 配置文件所在目录解析;未声明 `cwd` 的 STDIO Server 默认以该目录启动,从而保留脚本参数的原生相对路径语义。客户端不替用户猜测普通文件如何启动。 @@ -343,6 +343,8 @@ MCP · 来自 my-plugin.zip 启动失败 - 尽量跳过失败项,保留 AGC 内置 Skill 和 `agc_tools`; - 不自动重试、不启动后台修复服务、不删除原始内容。 +DirectProject 首轮使用 Codex 已有的 Eager MCP 启动和 AGC 侧有界 readiness gate:所有已启用第三方 MCP 并行尝试启动,AGC 在首次 `turn/start` 前等待现有 app-server 状态通知,最多等待 `120000` 毫秒;不执行工具探测调用,也不新增外部 MCP 服务。第三方 MCP 仍保持 `required=false`,单项进入 `failed`/`cancelled` 或等待超时后,DirectProject 继续对话;后续回合可重新发现等待窗口结束后才 ready 的 MCP。 + 扩展集合的 fingerprint 纳入现有 DirectProject app-server pool key,扩展集合变化后不复用不匹配的旧运行实例。 ## 9. 客户端接口